| 1 | pragma Singleton |
| 2 | import QtQuick |
| 3 | import Quickshell |
| 4 | import Quickshell.Io |
| 5 | |
| 6 | // The settings app's sections and its search index. Like Android indexes each preference XML, the rows are parsed out |
| 7 | // of the page files themselves: Group / SectionLabel (a group: its literal `title:` / `text:`) and Switch / Knob / |
| 8 | // Choice / Field / Timeout with a literal `label:`, plus their `hint:`, `keywords:` (synonyms, read only here) and a |
| 9 | // Choice's option labels. IconBtn only with `keywords:` (opt-in). Non-literal labels aren't indexed. `extras` covers rows without a label (wi-fi, bluetooth power). |
| 10 | // Editing a page hot-reloads the shell, which re-parses it. |
| 11 | Singleton { |
| 12 | id: idx |
| 13 | |
| 14 | // files: parsed in this order (a page's composite rows like BatteryInfo / GpuSwitch count as the page) |
| 15 | readonly property var sections: [ |
| 16 | { id: "network", icon: Theme.iWifi, word: "network", caption: "network · wi-fi, wired, tethering", files: ["NetPage.qml"], |
| 17 | keywords: "wifi internet ethernet" }, |
| 18 | { id: "bluetooth", icon: Theme.iBt, word: "bluetooth", caption: "bluetooth · devices, pairing", files: ["BtPage.qml"], keywords: "bt" }, |
| 19 | { id: "sound", icon: Theme.iVolHigh, word: "sound", caption: "sound · levels, devices, apps", files: ["AudioPage.qml"], |
| 20 | keywords: "audio volume speakers microphone" }, |
| 21 | { id: "appearance", icon: Theme.iPalette, word: "look", caption: "look · wallpaper, home screen, colours, bar", files: ["SetAppearance.qml"], |
| 22 | keywords: "appearance theme style" }, |
| 23 | { id: "shell", icon: Theme.iDashboard, word: "shell", caption: "shell · home screen, dock, osd, notifications", files: ["SetShell.qml"], |
| 24 | keywords: "aurora" }, |
| 25 | { id: "windows", icon: Theme.iWindows, word: "windows", caption: "windows · hyprland gaps, borders, effects, layout", files: ["SetWindows.qml"], |
| 26 | keywords: "hyprland tiling" }, |
| 27 | { id: "input", icon: Theme.iMouse, word: "input", caption: "input · keyboard, mouse, touchpad", files: ["SetInput.qml"], keywords: "" }, |
| 28 | { id: "keybinds", icon: Theme.iKeyboard, word: "keys", caption: "keys · hyprland binds", files: [], |
| 29 | keywords: "keybinds shortcuts hotkeys bindings" }, |
| 30 | { id: "display", icon: Theme.iMonitor, word: "display", caption: "display · monitors, layout, night light", files: ["SetDisplay.qml"], |
| 31 | keywords: "screen monitor resolution" }, |
| 32 | { id: "power", icon: Theme.iBolt, word: "power", caption: "power · battery, profiles, idle", files: ["BatteryInfo.qml", "SetPower.qml", "GpuSwitch.qml"], |
| 33 | keywords: "energy sleep" }, |
| 34 | { id: "about", icon: Theme.iArch, word: "about", caption: "about · this system", files: ["SetAbout.qml"], keywords: "info version" } |
| 35 | ] |
| 36 | |
| 37 | // rows the parser can't see. group = the SectionLabel to land on ("" = the top of the section) |
| 38 | readonly property var extras: [ |
| 39 | { section: "network", group: "", label: "wi-fi on / off", hint: "rescan for networks", |
| 40 | keywords: "wifi wireless wlan radio internet scan" }, |
| 41 | { section: "network", group: "networks", label: "wi-fi networks", hint: "connect, password, forget", |
| 42 | keywords: "wifi ssid connect password forget hotspot" }, |
| 43 | { section: "bluetooth", group: "", label: "bluetooth on / off", hint: "search for devices", |
| 44 | keywords: "bt adapter radio discover scan" } |
| 45 | ] |
| 46 | |
| 47 | readonly property string dir: Quickshell.env("HOME") + "/.config/quickshell/aurora/" |
| 48 | property var parsed: ({}) // file -> [{kind, group, label, hint, keywords, context}] |
| 49 | |
| 50 | readonly property var files: { |
| 51 | var out = [] |
| 52 | for (var i = 0; i < sections.length; i++) |
| 53 | for (var j = 0; j < sections[i].files.length; j++) out.push(sections[i].files[j]) |
| 54 | return out |
| 55 | } |
| 56 | Variants { |
| 57 | model: idx.files |
| 58 | FileView { |
| 59 | required property string modelData |
| 60 | path: idx.dir + modelData |
| 61 | onLoaded: idx.parse(modelData, text()) |
| 62 | } |
| 63 | } |
| 64 | |
| 65 | // every entry: {kind: section|group|row, section, word, group, label, hint, order, l, k, c, h} |
| 66 | // l/k/c/h = lowercased label / keywords (own synonyms, options) / context (section, group + its keywords) / hint, |
| 67 | // for search() |
| 68 | readonly property var entries: { |
| 69 | var out = [] |
| 70 | function add(s, e) { |
| 71 | out.push({ kind: e.kind, section: s.id, word: s.word, group: e.group, label: e.label, hint: e.hint, |
| 72 | order: out.length, l: norm(e.label), k: norm(e.keywords), |
| 73 | c: norm(s.word + " " + s.id + " " + (e.context || "")), h: norm(e.hint) }) |
| 74 | } |
| 75 | for (var i = 0; i < sections.length; i++) { |
| 76 | var s = sections[i] |
| 77 | add(s, { kind: "section", group: "", label: s.word, hint: s.caption.replace(/^[^·]*·\s*/, ""), |
| 78 | keywords: s.id + " " + s.keywords }) |
| 79 | for (var j = 0; j < s.files.length; j++) { |
| 80 | var p = parsed[s.files[j]] || [] |
| 81 | for (var n = 0; n < p.length; n++) add(s, p[n]) |
| 82 | } |
| 83 | for (var x = 0; x < extras.length; x++) |
| 84 | if (extras[x].section === s.id) |
| 85 | add(s, { kind: "group", group: extras[x].group, |
| 86 | label: extras[x].label, hint: extras[x].hint, keywords: extras[x].keywords }) |
| 87 | } |
| 88 | return out |
| 89 | } |
| 90 | |
| 91 | // the text of `key: "literal"` at the block's own level, or null (expressions don't count) |
| 92 | function lit(top, key) { |
| 93 | var m = new RegExp("(?:^|[\\s;])" + key + ":\\s*\"((?:[^\"\\\\]|\\\\.)*)\"\\s*(?:;|\\n|$)").exec(top) |
| 94 | return m ? m[1].replace(/\\(.)/g, "$1") : null |
| 95 | } |
| 96 | // from just after a `{`: the block's own text (nested {} [] left out) and the nested text, skipping strings/comments |
| 97 | function block(t, i) { |
| 98 | var depth = 1, top = "", inner = "", q = "" |
| 99 | for (; i < t.length && depth > 0; i++) { |
| 100 | var ch = t[i] |
| 101 | if (q !== "") { |
| 102 | if (depth === 1) top += ch; else inner += ch |
| 103 | if (ch === "\\") { i++; if (depth === 1) top += t[i]; else inner += t[i] } |
| 104 | else if (ch === q) q = "" |
| 105 | continue |
| 106 | } |
| 107 | if (ch === "/" && t[i + 1] === "/") { var nl = t.indexOf("\n", i); i = (nl < 0 ? t.length : nl) - 1; continue } |
| 108 | if (ch === '"' || ch === "'" || ch === "`") q = ch |
| 109 | if (ch === "{" || ch === "[") { depth++; continue } |
| 110 | if (ch === "}" || ch === "]") { depth--; continue } |
| 111 | if (depth === 1) top += ch; else inner += ch |
| 112 | } |
| 113 | return { top: top, inner: inner } |
| 114 | } |
| 115 | function parse(file, t) { |
| 116 | var out = [], group = "", groupKw = "", re = /\b(Group|SectionLabel|Switch|Knob|Choice|Field|Timeout|IconBtn)\s*\{/g, m |
| 117 | while ((m = re.exec(t)) !== null) { |
| 118 | var b = block(t, re.lastIndex), kw = lit(b.top, "keywords") |
| 119 | if (m[1] === "Group" || m[1] === "SectionLabel") { |
| 120 | group = lit(b.top, m[1] === "Group" ? "title" : "text") || "" |
| 121 | groupKw = group !== "" ? kw || "" : "" |
| 122 | if (group !== "") out.push({ kind: "group", group: group, label: group, hint: "", keywords: groupKw, context: "" }) |
| 123 | continue |
| 124 | } |
| 125 | var label = lit(b.top, "label") |
| 126 | if (!label || (m[1] === "IconBtn" && kw === null)) continue |
| 127 | var opts = [] |
| 128 | if (m[1] === "Choice") { |
| 129 | var om, ore = /\blabel:\s*"([^"]*)"/g |
| 130 | while ((om = ore.exec(b.inner)) !== null) opts.push(om[1]) |
| 131 | } |
| 132 | out.push({ kind: "row", group: group, label: label, hint: lit(b.top, "hint") || "", |
| 133 | keywords: (kw || "") + " " + opts.join(" "), context: group + " " + groupKw }) |
| 134 | } |
| 135 | var p = Object.assign({}, parsed) |
| 136 | p[file] = out |
| 137 | parsed = p |
| 138 | } |
| 139 | |
| 140 | // every word has to match the entry (its own text or its section/group); at least one its own text. |
| 141 | // own: label prefix > a label word > keywords > a hint word > inside a label word. Best first, then page order. |
| 142 | // A hyphenated word is indexed split and joined ("24-hour" -> "24 hour 24hour"), the query joins them ("wi-fi" = "wifi"). |
| 143 | function norm(s) { |
| 144 | s = s.toLowerCase() |
| 145 | return " " + s.replace(/-/g, " ") + (s.indexOf("-") >= 0 ? " " + s.replace(/-/g, "") : "") |
| 146 | } |
| 147 | function search(q) { |
| 148 | var words = q.toLowerCase().replace(/-/g, "").trim().split(/\s+/).filter(function (w) { return w !== "" }) |
| 149 | if (!words.length) return [] |
| 150 | var hits = [] |
| 151 | for (var i = 0; i < entries.length; i++) { |
| 152 | var e = entries[i], total = 0, own = false |
| 153 | for (var j = 0; j < words.length; j++) { |
| 154 | var w = words[j], at = e.l.indexOf(" " + w) |
| 155 | var sc = Math.max(at === 0 ? 1000 : at > 0 ? 500 : e.l.indexOf(w) > 0 ? 60 : 0, |
| 156 | e.k.indexOf(" " + w) >= 0 ? 250 : e.k.indexOf(w) >= 0 ? 150 |
| 157 | : e.h.indexOf(" " + w) >= 0 ? 80 : 0) |
| 158 | if (sc > 0) own = true |
| 159 | else sc = e.c.indexOf(" " + w) >= 0 ? 200 : e.c.indexOf(w) >= 0 ? 100 : 0 |
| 160 | if (sc <= 0) { total = 0; break } |
| 161 | total += sc |
| 162 | } |
| 163 | if (total > 0 && own) hits.push({ e: e, sc: total }) |
| 164 | } |
| 165 | hits.sort(function (a, b) { return b.sc - a.sc || a.e.order - b.e.order }) |
| 166 | var res = [] |
| 167 | for (var n = 0; n < hits.length && n < 60; n++) res.push(hits[n].e) |
| 168 | return res |
| 169 | } |
| 170 | } |