pragma Singleton import QtQuick import Quickshell import Quickshell.Io // The launcher's brain: the application index, the query language, ranking, // and the actual spawning. The UI in launcher/ owns no logic beyond keys and // animation — everything it shows comes out of `results`. // // The one idea the launcher is built around is that a query is a *verb plus an // object*. Fuzzel only ever had the verb: you typed a name and it ran the app // with no arguments. Here the object is first class, so `gitlab.enqs.de` is a // complete thought — the object is the URL, and the list becomes the set of // verbs that can act on it, with the system default already selected. Hitting // Enter the moment you stop typing is the fast path; arrowing down to a // different verb is the slow one. Singleton { id: root // ------------------------------------------------------------- the index // Every launchable thing, flattened. A desktop entry with actions // contributes one item per action as well as itself, so "Brave (Private)" // is a peer of "Brave" in the list rather than something you have to drill // into. That is the whole reason fuzzel's `show-actions` was off: nested // disclosure in a launcher is a step too many. readonly property var index: { const out = []; const apps = DesktopEntries.applications.values; for (let i = 0; i < apps.length; i++) { const e = apps[i]; if (e.noDisplay) continue; out.push({ key: root._entryKey(e.id), id: e.id, actionId: "", name: e.name, sub: e.genericName || e.comment || "", icon: e.icon, exec: e.execString, categories: root._bag(e.categories), keywords: root._bag(e.keywords), terminal: e.runInTerminal, kind: "app" }); const actions = e.actions; for (let j = 0; j < actions.length; j++) { const a = actions[j]; out.push({ key: root._entryKey(e.id) + ":" + a.id, id: e.id, actionId: a.id, name: e.name + " · " + a.name, sub: a.name, icon: a.icon || e.icon, exec: a.execString, categories: root._bag(e.categories), keywords: root._bag(e.keywords), terminal: e.runInTerminal, kind: "action" }); } } return out; } function _entryKey(id: string): string { return id.endsWith(".desktop") ? id : id + ".desktop"; } // Categories and Keywords come across as string *lists*, not strings, and // an absent one is undefined rather than empty. Both get flattened to one // lowercase haystack so the scoring below can just call `includes`. function _bag(v: var): string { if (!v) return ""; return (Array.isArray(v) ? v.join(" ") : String(v)).toLowerCase(); } // --------------------------------------------------------- query parsing // A query resolves to a mode and, in app mode, an optional argument. There // are two ways to end up with an argument: // // pinned you pressed Tab on a result, which froze that app as the verb // and handed the rest of the input to it. Unambiguous, works // for arguments that look like nothing in particular. // implicit what you typed is *obviously* an object — a URL, a path, an // email. Nothing is pinned, so every app is a candidate verb // and the ranking decides who goes first. // // Implicit detection is deliberately conservative. A false positive turns // an app search into an open-with list, which is jarring; a false negative // just means you press Tab. readonly property var urlRe: /^[a-z][a-z0-9+.-]*:\/\//i readonly property var hostRe: /^([a-z0-9-]+\.)+[a-z]{2,}(\/\S*)?$/i readonly property var mailRe: /^[^\s@]+@[^\s@]+\.[a-z]{2,}$/i readonly property var pathRe: /^(~|\/|\.\/)\S*$/ function classify(text: string): string { const t = text.trim(); if (t === "") return ""; if (root.urlRe.test(t) || root.hostRe.test(t)) return "url"; if (root.mailRe.test(t)) return "mail"; if (root.pathRe.test(t)) return "path"; return ""; } // Turn a bare host into something a browser will accept. `gitlab.enqs.de` // on its own is a relative path as far as xdg-open is concerned. function normalizeArg(text: string, kind: string): string { const t = text.trim(); if (kind === "url") return root.urlRe.test(t) ? t : "https://" + t; if (kind === "mail") return "mailto:" + t; if (kind === "path" && t.startsWith("~")) return Quickshell.env("HOME") + t.slice(1); return t; } // ------------------------------------------------------------- defaults // Which app the system would have picked. This is what puts Brave at row // zero without the launcher having an opinion about browsers. property string defaultBrowser: "" property string defaultMail: "" Process { running: true command: ["xdg-mime", "query", "default", "x-scheme-handler/https"] stdout: StdioCollector { onStreamFinished: root.defaultBrowser = this.text.trim() } } Process { running: true command: ["xdg-mime", "query", "default", "x-scheme-handler/mailto"] stdout: StdioCollector { onStreamFinished: root.defaultMail = this.text.trim() } } // --------------------------------------------------------------- ranking // Subsequence match with a bias toward matches that start a word. Returns // -1 for no match. The numbers are only meaningful relative to each other. function fuzzy(hay: string, needle: string): real { if (needle === "") return 0; const h = hay.toLowerCase(); const n = needle.toLowerCase(); if (h === n) return 1000; if (h.startsWith(n)) return 700 - h.length; // Start of any word inside the name — "code" hitting "Visual Studio // Code" should beat "code" scattered through "Chromium Dev Tools". const word = h.indexOf(" " + n); if (word !== -1) return 500 - word; if (h.includes(n)) return 300 - h.indexOf(n); // Plain subsequence, penalised by how far apart the letters are. let score = 120; let at = -1; for (let i = 0; i < n.length; i++) { const next = h.indexOf(n[i], at + 1); if (next === -1) return -1; score -= (next - at - 1) * 0.5; at = next; } return Math.max(score, 1); } function _appScore(item: var, term: string): real { if (term === "") return 0; let best = root.fuzzy(item.name, term); // Secondary fields are worth less than the name on purpose: matching a // Keywords= line should break a tie, not win outright. const sub = root.fuzzy(item.sub, term); if (sub > 0) best = Math.max(best, sub * 0.45); const id = root.fuzzy(item.id, term); if (id > 0) best = Math.max(best, id * 0.6); if (item.keywords.includes(term.toLowerCase())) best = Math.max(best, 140); // An action only surfaces on a free-text search once you have typed // enough to plausibly mean it, otherwise every browser triples the // length of the list for a one-letter query. if (item.kind === "action" && term.length < 2) return -1; return best; } // How well an app suits an object it did not ask for. Everything stays in // the list — the point is that you can open a URL with anything — but the // system default and then the plausible handlers float up. function _affinity(item: var, kind: string): real { let a = 0; if (kind === "url" || kind === "mail") { const wanted = kind === "url" ? root.defaultBrowser : root.defaultMail; if (wanted !== "" && item.key.startsWith(wanted)) a += 4000; if (item.categories.includes("webbrowser")) a += 2000; if (kind === "mail" && item.categories.includes("email")) a += 2500; if (item.categories.includes("network")) a += 400; } else if (kind === "path") { if (item.categories.includes("filemanager")) a += 2500; if (item.categories.includes("texteditor")) a += 1800; if (item.categories.includes("development")) a += 600; if (item.terminal) a += 300; } // The main entry outranks its own actions for an unasked-for object: // "open this link in Brave" is the common case, "…in a private window" // is the deliberate one. if (item.kind === "action") a -= 250; return a; } // The single entry point the UI binds to. // // term what to match apps against ("" means "everything") // arg the object, if any // argKind "" | url | mail | path function search(term: string, arg: string, argKind: string): var { const scored = []; const idx = root.index; const open = arg !== ""; for (let i = 0; i < idx.length; i++) { const item = idx[i]; let s = root._appScore(item, term); if (term !== "" && s < 0) continue; // With nothing typed, actions would double the length of a list // that is already just "your apps, by how often you use them". if (term === "" && !open && item.kind === "action") continue; if (open) s += root._affinity(item, argKind); s += root.frecency(item.key); scored.push({ item: item, score: s }); } scored.sort((a, b) => b.score - a.score || a.item.name.localeCompare(b.item.name)); return scored.slice(0, 60).map(e => e.item); } // -------------------------------------------------------------- frecency // Usage decays: something you opened forty times last year should not // outrank what you opened twice this morning. Half-life is a week, which // is long enough to be stable and short enough to follow a change of // project. property var usage: ({}) readonly property real halfLife: 7 * 24 * 3600 function frecency(key: string): real { const u = root.usage[key]; if (!u) return 0; const age = Date.now() / 1000 - u.t; return u.n * 240 * Math.pow(0.5, age / root.halfLife); } function _remember(key: string): void { const u = root.usage[key]; const now = Date.now() / 1000; // Decay the stored count on write rather than letting it grow without // bound, so `n` stays a decayed weight instead of a lifetime total. const decayed = u ? u.n * Math.pow(0.5, (now - u.t) / root.halfLife) : 0; root.usage[key] = { n: decayed + 1, t: now }; root.usageChanged(); store.setText(JSON.stringify(root.usage)); } FileView { id: store path: Quickshell.env("HOME") + "/.local/state/takemi/launcher.json" preload: true printErrors: false atomicWrites: true onLoaded: { try { root.usage = JSON.parse(store.text()) || ({}); } catch (e) { root.usage = ({}); } } } // ----------------------------------------------------------------- spawn // Handing uwsm a desktop ID is the good path: it expands the Exec field // codes around our argument, picks the terminal for Terminal=true entries, // and drops the process in its own scope. // // It refuses one thing, and it is exactly the thing this launcher is for. // `Exec=brave --incognito` declares no field code, so uwsm rejects // `brave-browser.desktop:new-private-window ` with "does not support // arguments" — which would make "open this link in a private window", the // motivating example for the whole feature, the one case that does not // work. Most Desktop Action entries are written that way. // // So when the target declares no placeholder we build the command line // ourselves and hand uwsm an argv instead of an ID. It is still scoped and // still supervised; we just did the expansion. // // The same fallback covers entries whose file name uwsm will not accept as // an ID at all. Its check is the regex below, so `Proton Authenticator // .desktop` and most Steam/Lutris shortcuts ("Hollow Knight.desktop") fail // with "Invalid Desktop Entry ID" before anything runs. readonly property var fieldRe: /%[fFuU]/ readonly property var anyFieldRe: /^%[a-zA-Z]$/ readonly property var uwsmIdRe: /^[a-zA-Z0-9_][a-zA-Z0-9_.-]*\.desktop(:.*)?$/ function launch(item: var, arg: string): void { root._remember(item.key); const byId = root.uwsmIdRe.test(item.key); if (byId && arg === "") { Quickshell.execDetached(["uwsm", "app", "--", item.key]); return; } if (byId && root.fieldRe.test(item.exec || "")) { Quickshell.execDetached(["uwsm", "app", "--", item.key, arg]); return; } const argv = root._tokenize(item.exec || "") .filter(t => !root.anyFieldRe.test(t)); if (argv.length === 0) { if (byId) Quickshell.execDetached(["uwsm", "app", "--", item.key]); return; } if (arg !== "") argv.push(arg); // Without this the unit is named after the bare executable and every // app launched this way collides in the unit namespace. const name = item.key.replace(".desktop", "").replace(/[^a-zA-Z0-9_-]/g, "-"); const head = ["uwsm", "app", "-a", name]; if (item.terminal) head.push("-T"); Quickshell.execDetached(head.concat(["--"]).concat(argv)); } // Exec lines are shell-ish but not shell: the spec allows double quotes and // backslash escapes, and nothing else. Splitting on whitespace would break // every entry with a quoted path, and running it through `sh -c` would make // a desktop file a code-execution surface. function _tokenize(line: string): var { const out = []; let cur = ""; let quoted = false; let has = false; for (let i = 0; i < line.length; i++) { const c = line[i]; if (c === "\\" && i + 1 < line.length) { cur += line[++i]; has = true; } else if (c === '"') { quoted = !quoted; has = true; } else if (!quoted && (c === " " || c === "\t")) { if (has) { out.push(cur); cur = ""; has = false; } } else { cur += c; has = true; } } if (has) out.push(cur); return out; } function runShell(line: string, inTerminal: bool): void { if (line.trim() === "") return; if (inTerminal) Quickshell.execDetached(["uwsm", "app", "-T", "--", "sh", "-c", line]); else Quickshell.execDetached(["uwsm", "app", "--", "sh", "-c", line]); } function copy(text: string): void { Quickshell.execDetached(["wl-copy", "--", text]); } // Everything above spawns through `Quickshell.execDetached` rather than a // `Process`, and it has to. `uwsm app` does not exit once the scope is up — // it stays on as the scope's main process for as long as the app lives. A // shared `Process` therefore never goes idle, so the next `exec()` kills the // previous uwsm, systemd tears down the scope with it, and launching the // second app closes the first. Shell reload did the same thing to every app // at once, since `manageLifetime` kills the child when the object goes away. // // A detached spawn is owned by nobody here, which is exactly right: once // uwsm has the scope, the shell has no further business in that lifetime. // ----------------------------------------------------------------- dmenu // The launcher can stand in for `fuzzel --dmenu`, which is what lets fuzzel // leave the system entirely: fluxo's bluetooth menu and anything else that // pipes a list through a picker go through here instead. // // The transport is two files, because IPC calls return immediately and a // dmenu has to block until someone chooses. `takemi-dmenu` writes the // choices, calls in, then waits for the answer file to appear. If the // launcher is dismissed the answer file is written empty, so the caller // sees an empty selection rather than hanging. property var dmenuItems: [] property string dmenuPrompt: "" property string dmenuOut: "" readonly property bool dmenuActive: root.dmenuOut !== "" function beginDmenu(inPath: string, outPath: string, prompt: string): void { root.dmenuPrompt = prompt; root.dmenuOut = outPath; dmenuIn.path = ""; dmenuIn.path = inPath; } // Writing the answer with a FileView does not work: assigning `path` kicks // off a read of a file that does not exist yet, and the write never lands — // which leaves the caller blocked until its own timeout. A one-shot // redirect is both simpler and actually synchronous from the caller's point // of view, since the file appears complete or not at all. // // The choice and the path go in as positional arguments rather than being // interpolated into the script, so a clipboard entry containing a quote or // a `$(…)` is data and stays data. function finishDmenu(choice: string): void { if (root.dmenuOut === "") return; // Write beside the target and rename, so the waiting script can treat // "the file exists" as "the answer is complete". dmenuWriter.exec(["sh", "-c", 'printf "%s" "$1" > "$2.part" && mv "$2.part" "$2"', "sh", choice, root.dmenuOut]); root.dmenuOut = ""; root.dmenuItems = []; root.dmenuPrompt = ""; } Process { id: dmenuWriter } FileView { id: dmenuIn onLoaded: { const out = []; const lines = dmenuIn.text().split("\n"); for (let i = 0; i < lines.length; i++) { if (lines[i] === "") continue; out.push({ kind: "dmenu", name: lines[i], sub: "", icon: "" }); } root.dmenuItems = out; } } function searchDmenu(term: string): var { if (term === "") return root.dmenuItems.slice(0, 200); const scored = []; for (let i = 0; i < root.dmenuItems.length; i++) { const c = root.dmenuItems[i]; const s = root.fuzzy(c.name, term); if (s < 0) continue; scored.push({ item: c, score: s }); } scored.sort((a, b) => b.score - a.score); return scored.slice(0, 200).map(e => e.item); } // ------------------------------------------------------------- clipboard // cliphist keeps the history; this just reads and replays it. `cliphist // list` emits "\t" per line, where the preview is already // single-line and truncated — binary entries come through as a // "[[ binary data … ]]" placeholder, which is exactly what should be shown // for them anyway. property var clips: [] function refreshClips(): void { clipList.running = false; clipList.running = true; } Process { id: clipList command: ["cliphist", "list"] stdout: StdioCollector { onStreamFinished: { const out = []; const lines = this.text.split("\n"); for (let i = 0; i < lines.length; i++) { const tab = lines[i].indexOf("\t"); if (tab <= 0) continue; const id = lines[i].slice(0, tab); if (!/^[0-9]+$/.test(id)) continue; out.push({ kind: "clip", clipId: id, name: lines[i].slice(tab + 1), sub: "", icon: "edit-paste" }); } root.clips = out; } } } // The id is checked against /^[0-9]+$/ on the way in, above, which is what // makes it safe to put on a shell command line. Decoding needs the pipe: // entries can be binary, so the bytes must go straight from cliphist into // wl-copy without being round-tripped through a QML string. function pasteClip(item: var): void { if (!/^[0-9]+$/.test(item.clipId)) return; Quickshell.execDetached(["sh", "-c", "cliphist decode " + item.clipId + " | wl-copy"]); } function searchClips(term: string): var { if (term === "") return root.clips.slice(0, 60); const scored = []; for (let i = 0; i < root.clips.length; i++) { const c = root.clips[i]; const s = root.fuzzy(c.name, term); if (s < 0) continue; scored.push({ item: c, score: s }); } scored.sort((a, b) => b.score - a.score); return scored.slice(0, 60).map(e => e.item); } // ------------------------------------------------------------ calculator // Arithmetic only. The expression is checked against a whitelist before it // goes anywhere near the JS engine — a launcher that evals whatever you // typed is a launcher that runs whatever a stray paste contained. readonly property var mathRe: /^[0-9+\-*/%(). ,e]+$/i function calc(expr: string): string { const e = expr.trim(); if (e === "" || !root.mathRe.test(e)) return ""; try { const v = Function('"use strict"; return (' + e + ')')(); if (typeof v !== "number" || !isFinite(v)) return ""; return String(Math.round(v * 1e10) / 1e10); } catch (err) { return ""; } } }