| 1 | import QtQuick |
| 2 | import QtQuick.Layouts |
| 3 | import Quickshell |
| 4 | import Quickshell.Io |
| 5 | import Quickshell.Wayland |
| 6 | import Quickshell.Hyprland |
| 7 | |
| 8 | // App / command runner (replaced fuzzel on SUPER+R). One fuzzy list of desktop apps (+ their actions), |
| 9 | // aurora commands and every executable on $PATH, ranked by match and launch count. |
| 10 | // `=expr` calculator (Enter copies), `>cmd` runs a shell command. Ctrl+Enter runs in the terminal. |
| 11 | // dmenu mode (`aurora-run -d`, `dmenu` IPC): plain list from the script's stdin, the choice goes back over a fifo. |
| 12 | PanelWindow { |
| 13 | id: win |
| 14 | property bool opened: false |
| 15 | property var shell // shell.qml root: toggleOverlay / openTab / barShown |
| 16 | signal closeRequested() |
| 17 | signal openRequested() // a dmenu request is loaded and wants the window |
| 18 | |
| 19 | readonly property int rowH: 36 |
| 20 | readonly property int rows: 10 |
| 21 | readonly property int maxShown: 60 |
| 22 | readonly property string home: Quickshell.env("HOME") |
| 23 | |
| 24 | // ---- dmenu mode ---- |
| 25 | property string mode: "run" // run | dmenu |
| 26 | property var dmItems: [] // [{kind:"dm", name, low, i}] |
| 27 | property string dmPrompt: "" |
| 28 | property string dmPlaceholder: "" |
| 29 | property int dmLines: -1 // -1 default, 0 = input only (no list) |
| 30 | property bool dmPassword: false |
| 31 | property bool dmIndex: false |
| 32 | property string dmFifo: "" // pending request's reply fifo, "" when none |
| 33 | readonly property bool dmenu: mode === "dmenu" |
| 34 | readonly property int rowsShown: !dmenu ? rows |
| 35 | : dmLines === 0 ? 0 : Math.min(dmLines > 0 ? dmLines : rows, 15, Math.max(1, dmItems.length)) |
| 36 | |
| 37 | // dir holds `opts` (key=value lines) and `items` (stdin of the script), see ~/.local/bin/aurora-run |
| 38 | function startDmenu(dir) { |
| 39 | if (dmFifo !== "") reply(false, "") // a newer request replaces a pending one |
| 40 | loadProc.dir = dir |
| 41 | loadProc.command = ["sh", "-c", 'cat "$1/opts"; printf "\\036\\n"; [ -f "$1/items" ] && cat "$1/items"', "sh", dir] |
| 42 | loadProc.running = true |
| 43 | } |
| 44 | Process { |
| 45 | id: loadProc |
| 46 | property string dir |
| 47 | stdout: StdioCollector { |
| 48 | onStreamFinished: { |
| 49 | var sep = text.indexOf("\x1e\n") |
| 50 | if (sep < 0) return |
| 51 | var o = {}, ol = text.substring(0, sep).split("\n") |
| 52 | for (var i = 0; i < ol.length; i++) { |
| 53 | var eq = ol[i].indexOf("=") |
| 54 | if (eq > 0) o[ol[i].substring(0, eq)] = ol[i].substring(eq + 1) |
| 55 | } |
| 56 | var items = [], l = text.substring(sep + 2).split("\n") |
| 57 | for (var j = 0; j < l.length; j++) |
| 58 | if (l[j] !== "") items.push({ kind: "dm", key: "", name: l[j], low: l[j].toLowerCase(), i: j, |
| 59 | sub: "", icon: "", glyph: "", tag: "" }) |
| 60 | win.dmPrompt = o.prompt || "" |
| 61 | win.dmPlaceholder = o.placeholder || "" |
| 62 | win.dmLines = o.lines !== undefined && o.lines !== "" ? parseInt(o.lines) : -1 |
| 63 | win.dmPassword = o.password === "1" |
| 64 | win.dmIndex = o.index === "1" |
| 65 | win.dmItems = items |
| 66 | win.dmFifo = loadProc.dir + "/reply" |
| 67 | win.mode = "dmenu" |
| 68 | filter.text = "" |
| 69 | win.current = 0 |
| 70 | win.openRequested() |
| 71 | filter.forceActiveFocus() |
| 72 | } |
| 73 | } |
| 74 | } |
| 75 | // "<0|1>\n<value>\0" to the fifo through stdin, so a password never shows up in a process's argv |
| 76 | function reply(ok, value) { |
| 77 | replyProc.payload = (ok ? "0" : "1") + "\n" + value + "\0" |
| 78 | replyProc.command = ["sh", "-c", 'cat > "$1"', "sh", dmFifo] |
| 79 | replyProc.stdinEnabled = true |
| 80 | replyProc.running = true |
| 81 | dmFifo = "" |
| 82 | } |
| 83 | Process { |
| 84 | id: replyProc |
| 85 | property string payload |
| 86 | onStarted: { write(payload); payload = ""; stdinEnabled = false } |
| 87 | } |
| 88 | function endDmenu() { |
| 89 | mode = "run" |
| 90 | dmItems = [] |
| 91 | dmPassword = false |
| 92 | filter.text = "" |
| 93 | } |
| 94 | |
| 95 | visible: opened |
| 96 | implicitWidth: dmenu ? 640 : 560 |
| 97 | implicitHeight: 24 + 36 + rowsShown * rowH + 22 |
| 98 | exclusiveZone: 0 |
| 99 | color: "transparent" |
| 100 | WlrLayershell.layer: WlrLayer.Overlay |
| 101 | WlrLayershell.namespace: "aurora-runner" |
| 102 | WlrLayershell.keyboardFocus: opened ? WlrKeyboardFocus.Exclusive : WlrKeyboardFocus.None |
| 103 | |
| 104 | HyprlandFocusGrab { |
| 105 | windows: [win] |
| 106 | active: win.opened |
| 107 | onCleared: win.closeRequested() |
| 108 | } |
| 109 | |
| 110 | property int current: 0 |
| 111 | property string armed: "" |
| 112 | Timer { id: disarm; interval: 3000; onTriggered: win.armed = "" } |
| 113 | |
| 114 | onOpenedChanged: { |
| 115 | if (!opened) { |
| 116 | // closed without a choice (esc, outside click, another overlay): the script gets "cancelled" |
| 117 | if (dmFifo !== "") reply(false, "") |
| 118 | if (dmenu) endDmenu() |
| 119 | return |
| 120 | } |
| 121 | armed = "" |
| 122 | filter.forceActiveFocus() |
| 123 | if (dmenu) return // startDmenu already reset the input |
| 124 | filter.text = "" |
| 125 | current = 0 |
| 126 | if (Date.now() - binsAt > 60000) binProc.running = true |
| 127 | } |
| 128 | |
| 129 | // ---- sources ---- |
| 130 | function basename(p) { p = String(p); return p.substring(p.lastIndexOf("/") + 1) } |
| 131 | |
| 132 | // desktop entries + their actions ("Librewolf: New Private Window") |
| 133 | property var apps: [] |
| 134 | function loadApps() { |
| 135 | var out = [], l = DesktopEntries.applications.values |
| 136 | for (var i = 0; i < l.length; i++) { |
| 137 | var e = l[i] |
| 138 | if (e.noDisplay) continue |
| 139 | var ic = e.icon ? Quickshell.iconPath(e.icon, true) : "" |
| 140 | var bin = e.command && e.command.length ? basename(e.command[0]) : "" |
| 141 | out.push({ kind: "app", key: "app:" + e.id, name: e.name, sub: e.genericName || e.comment || "", |
| 142 | icon: ic, glyph: Theme.iApp, tag: "app", entry: e, action: null, bin: bin, |
| 143 | hay: [e.genericName || "", (e.keywords || []).join(" "), bin] }) |
| 144 | var acts = e.actions || [] |
| 145 | for (var j = 0; j < acts.length; j++) |
| 146 | out.push({ kind: "act", key: "act:" + e.id + ":" + acts[j].id, name: e.name + ": " + acts[j].name, sub: "", |
| 147 | icon: ic, glyph: Theme.iApp, tag: "app", entry: e, action: acts[j], bin: "", hay: [] }) |
| 148 | } |
| 149 | apps = out |
| 150 | } |
| 151 | Component.onCompleted: loadApps() |
| 152 | Connections { |
| 153 | target: DesktopEntries |
| 154 | function onApplicationsChanged() { win.loadApps() } |
| 155 | } |
| 156 | |
| 157 | // executables on $PATH (first one wins, like the shell). Apps whose binary is already listed are skipped. |
| 158 | property var bins: [] |
| 159 | property double binsAt: 0 |
| 160 | Process { |
| 161 | id: binProc |
| 162 | command: ["sh", "-c", 'set -f; IFS=:; for d in $PATH; do [ -d "$d" ] && find -L "$d" -maxdepth 1 -type f -executable -printf "%f\\t%p\\n" 2>/dev/null; done'] |
| 163 | stdout: StdioCollector { |
| 164 | onStreamFinished: { |
| 165 | var seen = {}, out = [], l = text.split("\n") |
| 166 | for (var i = 0; i < l.length; i++) { |
| 167 | var t = l[i].indexOf("\t") |
| 168 | if (t <= 0) continue |
| 169 | var n = l[i].substring(0, t) |
| 170 | if (seen[n]) continue |
| 171 | seen[n] = true |
| 172 | out.push({ kind: "bin", key: "bin:" + n, name: n, sub: l[i].substring(t + 1), |
| 173 | icon: "", glyph: Theme.iConsole, tag: "bin", hay: [] }) |
| 174 | } |
| 175 | win.bins = out |
| 176 | win.binsAt = Date.now() |
| 177 | } |
| 178 | } |
| 179 | } |
| 180 | |
| 181 | // aurora's own actions. Session ones come from Session (same confirm rules as the power menu). |
| 182 | readonly property var commands: { |
| 183 | var c = [ |
| 184 | { id: "panel", glyph: Theme.iHome, name: "control panel" }, |
| 185 | { id: "audio", glyph: Theme.iVolHigh, name: "audio settings" }, |
| 186 | { id: "net", glyph: Theme.iWifi, name: "network settings" }, |
| 187 | { id: "bt", glyph: Theme.iBt, name: "bluetooth settings" }, |
| 188 | { id: "sys", glyph: Theme.iChip, name: "system info" }, |
| 189 | { id: "notif", glyph: Theme.iBell, name: "notifications" }, |
| 190 | { id: "settings", glyph: Theme.iCog, name: "aurora settings" }, |
| 191 | { id: "wall", glyph: Theme.iImage, name: "wallpaper picker" }, |
| 192 | { id: "wallrnd", glyph: Theme.iShuffle, name: "random wallpaper" }, |
| 193 | { id: "clip", glyph: Theme.iClipboard, name: "clipboard history" }, |
| 194 | { id: "overview", glyph: Theme.iWindow, name: "window overview" }, |
| 195 | { id: "cal", glyph: Theme.iCal, name: "calendar" }, |
| 196 | { id: "power", glyph: Theme.iPower, name: "power menu" }, |
| 197 | { id: "caffeine", glyph: Theme.iCoffee, name: "toggle caffeine" }, |
| 198 | { id: "dnd", glyph: Theme.iBellOff, name: "toggle do not disturb" }, |
| 199 | { id: "bar", glyph: Theme.iMonitor, name: "toggle bar" }, |
| 200 | { id: "picker", glyph: Theme.iPicker, name: "colour picker" }, |
| 201 | { id: "shot", glyph: Theme.iCrop, name: "screenshot region" }, |
| 202 | { id: "ocr", glyph: Theme.iCrop, name: "screenshot ocr" } |
| 203 | ] |
| 204 | if (Rec.available) c.push({ id: "rec", glyph: Theme.iRec, name: Rec.busy ? "stop recording" : "record screen" }) |
| 205 | var out = [] |
| 206 | for (var i = 0; i < c.length; i++) |
| 207 | out.push({ kind: "aurora", key: "aur:" + c[i].id, id: c[i].id, name: c[i].name, sub: "", icon: "", |
| 208 | glyph: c[i].glyph, tag: "aurora", confirm: false, hay: [] }) |
| 209 | var s = Session.actions |
| 210 | for (var j = 0; j < s.length; j++) |
| 211 | out.push({ kind: "session", key: "ses:" + s[j].id, id: s[j].id, name: s[j].label, sub: "session", icon: "", |
| 212 | glyph: s[j].icon, tag: "aurora", confirm: s[j].confirm, hay: [] }) |
| 213 | return out |
| 214 | } |
| 215 | |
| 216 | // ---- frecency: launch counts in ~/.cache/aurora/runner.json ---- |
| 217 | property var counts: ({}) |
| 218 | FileView { |
| 219 | id: store |
| 220 | path: win.home + "/.cache/aurora/runner.json" |
| 221 | printErrors: false |
| 222 | onLoaded: { try { win.counts = JSON.parse(text()) } catch (e) { win.counts = {} } } |
| 223 | } |
| 224 | function bump(key) { |
| 225 | var c = {} |
| 226 | for (var k in counts) c[k] = counts[k] |
| 227 | c[key] = (c[key] || 0) + 1 |
| 228 | counts = c |
| 229 | store.setText(JSON.stringify(c)) |
| 230 | } |
| 231 | |
| 232 | // ---- calculator ---- |
| 233 | readonly property var mathFns: ["sqrt", "cbrt", "abs", "sin", "cos", "tan", "asin", "acos", "atan", |
| 234 | "log", "log2", "log10", "exp", "floor", "ceil", "round", "min", "max", "pow"] |
| 235 | function calc(expr) { |
| 236 | if (!/^[0-9a-z\s.+\-*\/%^(),]+$/i.test(expr) || !/\d|pi/i.test(expr)) return "" |
| 237 | var bad = false |
| 238 | var js = expr.replace(/\^/g, "**").replace(/[a-z][a-z0-9]*/gi, function (w) { |
| 239 | var l = w.toLowerCase() |
| 240 | if (l === "pi") return "Math.PI" |
| 241 | if (l === "e") return "Math.E" |
| 242 | if (l === "ln") return "Math.log" |
| 243 | if (win.mathFns.indexOf(l) >= 0) return "Math." + l |
| 244 | bad = true |
| 245 | return w |
| 246 | }) |
| 247 | if (bad) return "" |
| 248 | try { |
| 249 | var r = (new Function("return (" + js + ")"))() |
| 250 | if (typeof r !== "number" || !isFinite(r)) return "" |
| 251 | return String(Number(r.toPrecision(12))) |
| 252 | } catch (e) { return "" } |
| 253 | } |
| 254 | |
| 255 | // ---- ranking ---- |
| 256 | function rank(it, q) { |
| 257 | var s = Fuzzy.score(it.name, q) |
| 258 | for (var i = 0; i < it.hay.length; i++) |
| 259 | if (it.hay[i] !== "") s = Math.max(s, Fuzzy.score(it.hay[i], q) * 0.6) |
| 260 | if (s <= 0) return 0 |
| 261 | var w = it.kind === "app" ? 1 : it.kind === "bin" ? 0.6 : 0.85 |
| 262 | return s * w + 60 * Math.log(1 + (counts[it.key] || 0)) |
| 263 | } |
| 264 | readonly property var shown: { |
| 265 | var raw = filter.text, q = raw.toLowerCase().trim(), res = [] |
| 266 | if (dmenu) { |
| 267 | if (dmLines === 0) return res |
| 268 | var cap = 1000, dl = dmItems, di |
| 269 | if (q === "") return dl.length > cap ? dl.slice(0, cap) : dl |
| 270 | var hits = [] |
| 271 | for (di = 0; di < dl.length; di++) { |
| 272 | var dsc = Fuzzy.scoreLow(dl[di].low, q) |
| 273 | if (dsc > 0) hits.push({ it: dl[di], sc: dsc }) |
| 274 | } |
| 275 | hits.sort(function (x, y) { return y.sc - x.sc || x.it.i - y.it.i }) |
| 276 | for (di = 0; di < hits.length && di < cap; di++) res.push(hits[di].it) |
| 277 | return res |
| 278 | } |
| 279 | if (q.charAt(0) === ">") { |
| 280 | var cmd = raw.trim().substring(1).trim() |
| 281 | if (cmd !== "") res.push({ kind: "sh", key: "", name: cmd, sub: "run in sh", icon: "", glyph: Theme.iShell, tag: "sh" }) |
| 282 | return res |
| 283 | } |
| 284 | if (q.charAt(0) === "=") { |
| 285 | var r = calc(q.substring(1)) |
| 286 | res.push({ kind: "calc", key: "", name: r !== "" ? r : "...", sub: q.substring(1).trim(), icon: "", |
| 287 | glyph: Theme.iCalc, tag: "=", value: r }) |
| 288 | return res |
| 289 | } |
| 290 | if (/[\d)]\s*[+\-*\/%^]\s*[\d(.]/.test(q)) { |
| 291 | var v = calc(q) |
| 292 | if (v !== "") res.push({ kind: "calc", key: "", name: v, sub: q, icon: "", glyph: Theme.iCalc, tag: "=", value: v }) |
| 293 | } |
| 294 | |
| 295 | var all = [], i |
| 296 | var appBins = {} |
| 297 | for (i = 0; i < apps.length; i++) { all.push(apps[i]); if (apps[i].bin) appBins[apps[i].bin] = true } |
| 298 | for (i = 0; i < commands.length; i++) all.push(commands[i]) |
| 299 | var scored = [] |
| 300 | if (q === "") { |
| 301 | // most used first, then apps A-Z; no actions / bins / commands unless used before |
| 302 | for (i = 0; i < bins.length; i++) if (counts[bins[i].key]) all.push(bins[i]) |
| 303 | for (i = 0; i < all.length; i++) { |
| 304 | var n = counts[all[i].key] || 0 |
| 305 | if (n > 0 || all[i].kind === "app") scored.push({ it: all[i], sc: n }) |
| 306 | } |
| 307 | scored.sort(function (x, y) { return y.sc - x.sc || x.it.name.localeCompare(y.it.name) }) |
| 308 | } else { |
| 309 | for (i = 0; i < bins.length; i++) if (!appBins[bins[i].name]) all.push(bins[i]) |
| 310 | for (i = 0; i < all.length; i++) { |
| 311 | var sc = rank(all[i], q) |
| 312 | if (sc > 0) scored.push({ it: all[i], sc: sc }) |
| 313 | } |
| 314 | scored.sort(function (x, y) { return y.sc - x.sc || x.it.name.length - y.it.name.length }) |
| 315 | } |
| 316 | for (i = 0; i < scored.length && res.length < maxShown; i++) res.push(scored[i].it) |
| 317 | // nothing matched: offer to run the text as a command, like fuzzel's execute-input |
| 318 | if (res.length === 0 && q !== "") |
| 319 | res.push({ kind: "sh", key: "", name: raw.trim(), sub: "run in sh", icon: "", glyph: Theme.iShell, tag: "sh" }) |
| 320 | return res |
| 321 | } |
| 322 | |
| 323 | // ---- launching ---- |
| 324 | function exec(cmd, term) { |
| 325 | Quickshell.execDetached({ command: term ? ["foot", "-e"].concat(cmd) : cmd, workingDirectory: home }) |
| 326 | } |
| 327 | function trigger(i, term) { |
| 328 | if (dmenu) { |
| 329 | // like fuzzel: the selected line, or the typed text when nothing matches / Ctrl+Enter |
| 330 | var sel = term ? null : shown[i] |
| 331 | if (dmIndex) reply(true, sel ? String(sel.i) : "-1") |
| 332 | else reply(true, sel ? sel.name : filter.text) |
| 333 | closeRequested() |
| 334 | return |
| 335 | } |
| 336 | var it = shown[i] |
| 337 | if (!it) return |
| 338 | current = i |
| 339 | if (it.confirm && armed !== it.key) { armed = it.key; disarm.restart(); return } |
| 340 | armed = "" |
| 341 | if (it.key !== "") bump(it.key) |
| 342 | closeRequested() |
| 343 | switch (it.kind) { |
| 344 | case "app": |
| 345 | if (term || it.entry.runInTerminal) exec(it.entry.command, true) |
| 346 | else it.entry.execute() |
| 347 | break |
| 348 | case "act": it.action.execute(); break |
| 349 | case "bin": exec([it.name], term); break |
| 350 | case "sh": exec(["sh", "-c", it.name], term); break |
| 351 | case "calc": if (it.value !== "") Quickshell.execDetached(["wl-copy", "--", it.value]); break |
| 352 | case "session": Session.run(it.id); break |
| 353 | case "aurora": command(it.id); break |
| 354 | } |
| 355 | } |
| 356 | function command(id) { |
| 357 | switch (id) { |
| 358 | case "panel": shell.openTab("home"); break |
| 359 | case "audio": case "net": case "bt": case "sys": case "notif": case "settings": |
| 360 | shell.openTab(id); break |
| 361 | case "wall": case "clip": case "overview": case "cal": case "power": |
| 362 | shell.toggleOverlay(id); break |
| 363 | case "wallrnd": Wall.random(); break |
| 364 | case "caffeine": Toggles.caffeine = !Toggles.caffeine; break |
| 365 | case "dnd": Notifs.dnd = !Notifs.dnd; break |
| 366 | case "bar": shell.barShown = !shell.barShown; break |
| 367 | case "picker": Toggles.pickColour(); break |
| 368 | case "shot": Shot.take("region"); break |
| 369 | case "ocr": Shot.take("ocr"); break |
| 370 | case "rec": Rec.toggle("screen"); break |
| 371 | } |
| 372 | } |
| 373 | |
| 374 | // ---- UI ---- |
| 375 | Rectangle { |
| 376 | anchors.fill: parent |
| 377 | color: Theme.bgA |
| 378 | border.width: 2 |
| 379 | border.color: Theme.accent |
| 380 | |
| 381 | ColumnLayout { |
| 382 | anchors.fill: parent |
| 383 | anchors.margins: 12 |
| 384 | spacing: 2 |
| 385 | |
| 386 | Rectangle { |
| 387 | Layout.fillWidth: true |
| 388 | Layout.bottomMargin: 6 |
| 389 | implicitHeight: 30 |
| 390 | color: Theme.surface |
| 391 | border.width: 1 |
| 392 | border.color: filter.activeFocus ? Theme.accent : Theme.line |
| 393 | Text { |
| 394 | id: promptText |
| 395 | anchors.left: parent.left |
| 396 | anchors.leftMargin: 10 |
| 397 | anchors.verticalCenter: parent.verticalCenter |
| 398 | text: win.dmenu && win.dmPrompt !== "" ? win.dmPrompt : (win.dmPassword ? Theme.iLock : Theme.iSearch) |
| 399 | color: win.dmenu && win.dmPrompt !== "" ? Theme.accent : Theme.dim |
| 400 | font.family: Theme.font |
| 401 | font.pixelSize: Theme.fontSize |
| 402 | } |
| 403 | Text { |
| 404 | anchors.left: filter.left |
| 405 | anchors.verticalCenter: parent.verticalCenter |
| 406 | visible: filter.text === "" && win.dmenu && win.dmPlaceholder !== "" |
| 407 | text: win.dmPlaceholder |
| 408 | color: Theme.dim |
| 409 | font.family: Theme.font |
| 410 | font.pixelSize: Theme.fontSize |
| 411 | } |
| 412 | TextInput { |
| 413 | id: filter |
| 414 | anchors.fill: parent |
| 415 | anchors.leftMargin: Math.max(32, promptText.implicitWidth + 20) |
| 416 | anchors.rightMargin: 10 |
| 417 | verticalAlignment: TextInput.AlignVCenter |
| 418 | echoMode: win.dmPassword ? TextInput.Password : TextInput.Normal |
| 419 | passwordCharacter: "*" |
| 420 | color: Theme.fg |
| 421 | selectionColor: Theme.accent |
| 422 | selectedTextColor: Theme.bg |
| 423 | font.family: Theme.font |
| 424 | font.pixelSize: Theme.fontSize |
| 425 | clip: true |
| 426 | onTextChanged: { win.current = 0; win.armed = "" } |
| 427 | Keys.onEscapePressed: win.closeRequested() |
| 428 | Keys.onReturnPressed: event => win.trigger(win.current, event.modifiers & Qt.ControlModifier) |
| 429 | Keys.onEnterPressed: event => win.trigger(win.current, event.modifiers & Qt.ControlModifier) |
| 430 | Keys.onPressed: event => { |
| 431 | var n = win.shown.length |
| 432 | if (!n) return |
| 433 | var step = 0 |
| 434 | if (event.key === Qt.Key_Down || event.key === Qt.Key_Tab) step = 1 |
| 435 | else if (event.key === Qt.Key_Up || event.key === Qt.Key_Backtab) step = -1 |
| 436 | else if (event.key === Qt.Key_PageDown) step = Math.max(2, win.rowsShown) |
| 437 | else if (event.key === Qt.Key_PageUp) step = -Math.max(2, win.rowsShown) |
| 438 | else if ((event.modifiers & Qt.ControlModifier) && (event.key === Qt.Key_N || event.key === Qt.Key_J)) step = 1 |
| 439 | else if ((event.modifiers & Qt.ControlModifier) && (event.key === Qt.Key_P || event.key === Qt.Key_K)) step = -1 |
| 440 | if (step === 0) return |
| 441 | if (Math.abs(step) === 1) win.current = (win.current + step + n) % n |
| 442 | else win.current = Math.max(0, Math.min(n - 1, win.current + step)) |
| 443 | win.armed = "" |
| 444 | event.accepted = true |
| 445 | } |
| 446 | } |
| 447 | } |
| 448 | |
| 449 | ListView { |
| 450 | id: list |
| 451 | Layout.fillWidth: true |
| 452 | Layout.fillHeight: true |
| 453 | visible: win.rowsShown > 0 |
| 454 | clip: true |
| 455 | spacing: 0 |
| 456 | boundsBehavior: Flickable.StopAtBounds |
| 457 | model: win.shown |
| 458 | currentIndex: win.current |
| 459 | highlightFollowsCurrentItem: false |
| 460 | onCurrentIndexChanged: positionViewAtIndex(currentIndex, ListView.Contain) |
| 461 | delegate: Rectangle { |
| 462 | id: row |
| 463 | required property var modelData |
| 464 | required property int index |
| 465 | readonly property bool selected: win.current === index |
| 466 | readonly property bool isArmed: win.armed !== "" && win.armed === modelData.key |
| 467 | width: list.width |
| 468 | height: win.rowH |
| 469 | color: isArmed ? Theme.err : (selected ? Theme.surfaceHi : (rm.containsMouse ? Theme.surface : "transparent")) |
| 470 | border.width: selected ? 1 : 0 |
| 471 | border.color: isArmed ? Theme.err : Theme.accent |
| 472 | |
| 473 | Rectangle { // selection marker, like ClipWindow |
| 474 | visible: row.selected |
| 475 | width: 2 |
| 476 | height: parent.height |
| 477 | color: row.isArmed ? Theme.bg : Theme.accent |
| 478 | } |
| 479 | Item { |
| 480 | id: icoBox |
| 481 | anchors.left: parent.left |
| 482 | anchors.leftMargin: row.modelData.kind === "dm" ? 2 : 10 |
| 483 | anchors.verticalCenter: parent.verticalCenter |
| 484 | visible: row.modelData.kind !== "dm" |
| 485 | width: visible ? 24 : 0 |
| 486 | height: 24 |
| 487 | Image { |
| 488 | id: img |
| 489 | anchors.fill: parent |
| 490 | visible: row.modelData.icon !== "" && status === Image.Ready |
| 491 | source: row.modelData.icon |
| 492 | sourceSize.width: 48 |
| 493 | sourceSize.height: 48 |
| 494 | asynchronous: true |
| 495 | smooth: true |
| 496 | } |
| 497 | Text { |
| 498 | anchors.centerIn: parent |
| 499 | visible: !img.visible |
| 500 | text: row.modelData.glyph |
| 501 | color: row.isArmed ? Theme.bg : Theme.accent |
| 502 | font.family: Theme.font |
| 503 | font.pixelSize: Theme.fontSize + 4 |
| 504 | } |
| 505 | } |
| 506 | Text { |
| 507 | id: tag |
| 508 | anchors.right: parent.right |
| 509 | anchors.rightMargin: 10 |
| 510 | anchors.verticalCenter: parent.verticalCenter |
| 511 | text: row.isArmed ? "sure?" : row.modelData.tag |
| 512 | color: row.isArmed ? Theme.bg : Theme.dim |
| 513 | font.family: Theme.font |
| 514 | font.pixelSize: Theme.fontSize - 2 |
| 515 | } |
| 516 | Text { |
| 517 | id: nameText |
| 518 | anchors.left: icoBox.right |
| 519 | anchors.leftMargin: 10 |
| 520 | anchors.verticalCenter: parent.verticalCenter |
| 521 | width: Math.min(implicitWidth, tag.x - x - 12) |
| 522 | elide: Text.ElideRight |
| 523 | text: row.modelData.name |
| 524 | color: row.isArmed ? Theme.bg : Theme.fg |
| 525 | font.family: Theme.font |
| 526 | font.pixelSize: Theme.fontSize |
| 527 | font.bold: row.modelData.kind === "calc" |
| 528 | } |
| 529 | Text { |
| 530 | anchors.left: nameText.right |
| 531 | anchors.leftMargin: 10 |
| 532 | anchors.right: tag.left |
| 533 | anchors.rightMargin: 12 |
| 534 | anchors.verticalCenter: parent.verticalCenter |
| 535 | elide: Text.ElideRight |
| 536 | text: row.modelData.sub |
| 537 | color: row.isArmed ? Theme.bg : Theme.dim |
| 538 | font.family: Theme.font |
| 539 | font.pixelSize: Theme.fontSize - 2 |
| 540 | } |
| 541 | MouseArea { |
| 542 | id: rm |
| 543 | anchors.fill: parent |
| 544 | hoverEnabled: true |
| 545 | cursorShape: Qt.PointingHandCursor |
| 546 | onEntered: if (win.current !== row.index) { win.current = row.index; win.armed = "" } |
| 547 | onClicked: win.trigger(row.index, false) |
| 548 | } |
| 549 | } |
| 550 | } |
| 551 | |
| 552 | Text { |
| 553 | Layout.fillWidth: true |
| 554 | Layout.topMargin: 6 |
| 555 | text: !win.dmenu ? (win.shown.length ? "enter run ctrl+enter terminal =calc >cmd esc close" : "no match") |
| 556 | : win.rowsShown === 0 ? "enter ok esc cancel" |
| 557 | : win.shown.length ? "enter select ctrl+enter typed text esc cancel" : "no match enter typed text esc cancel" |
| 558 | color: win.shown.length || win.dmenu ? Theme.dim : Theme.err |
| 559 | font.family: Theme.font |
| 560 | font.pixelSize: Theme.fontSize - 2 |
| 561 | } |
| 562 | } |
| 563 | } |
| 564 | } |