diff --git a/settings/quickshell.nix b/settings/quickshell.nix index a826ac9..5a3077e 100644 --- a/settings/quickshell.nix +++ b/settings/quickshell.nix @@ -732,6 +732,8 @@ in readonly property string cava: "${pkgs.cava}/bin/cava" readonly property string cavaConfig: "${cavaConfig}" readonly property string wlCopy: "${pkgs.wl-clipboard}/bin/wl-copy" + readonly property string fd: "${pkgs.fd}/bin/fd" + readonly property string xdgOpen: "${pkgs.xdg-utils}/bin/xdg-open" } ''; }; @@ -1518,12 +1520,32 @@ in } } + // Results are a single flat list of heterogeneous items — + // apps, then files, then a web-search fallback — so one set + // of arrow keys walks all of it and there's no focus to + // shuffle between sections. Every item carries a `kind` and + // the same display fields; `activate` dispatches on kind. function activate(item) { if (!item) return; - item.execute(); + if (item.kind === "app") item.app.execute(); + else if (item.kind === "file") Quickshell.execDetached([Commands.xdgOpen, item.path]); + else if (item.kind === "web") Quickshell.execDetached([Commands.xdgOpen, item.url]); + else return; open = false; } + function appItem(a) { + return { + kind: "app", + id: a.id, + name: a.name, + desc: a.genericName !== "" ? a.genericName : a.comment, + icon: Quickshell.iconPath(a.icon, true) || "", + glyph: "", + app: a + }; + } + // Enter takes the calculator only when there's nothing to // launch, so "3+3" ⏎ copies the answer. function submit() { @@ -1537,6 +1559,127 @@ in open = false; } + // ── File search: one fd run per settled keystroke, scoped to + // $HOME and nothing above it. No index and no daemon — + // --max-results lets fd exit the moment it has enough, which + // is what keeps this feeling instant, and results are never + // stale the way a nightly updatedb is. + property var fileHits: [] + property string fdQuery: "" + // A run cut short still flushes whatever stdout it had, and + // those lines are a half-finished search. Flag that one + // flush to be thrown away. + property bool fdDropNext: false + readonly property int fdMin: 3 // shorter queries match everything + readonly property int fdMax: 6 // rows to keep + + Timer { + id: fdTimer + interval: 200 + onTriggered: launcherPanel.runFd() + } + + Process { + id: fdProc + stdout: StdioCollector { + onStreamFinished: launcherPanel.takeFiles(text) + } + } + + function killFd() { + if (!fdProc.running) return; + fdDropNext = true; + fdProc.running = false; + } + + function runFd() { + const q = searchInput.text.trim(); + if (q.length < fdMin) { + killFd(); + fileHits = []; + rebuild(false); + return; + } + // Spaces become gaps, so "report 2024" finds + // report-2024.pdf. Everything else is escaped, so a + // query with brackets in it can't make fd bail. + const pat = q.replace(/[.^$*+?()[\]{}|\\]/g, "\\$&").replace(/\s+/g, ".*"); + fdQuery = q; + // Only one fd at a time. Assigning command/running while + // the old one is still dying is deliberate — quickshell + // re-runs startProcessIfReady() from onFinished, so the + // new command starts as soon as the old process reaps. + killFd(); + fdProc.command = [ + Commands.fd, "--absolute-path", "--ignore-case", "--hidden", + "--type", "f", "--type", "d", + "--max-results", String(fdMax), + "--exclude", ".git", "--exclude", ".cache", "--exclude", "node_modules", + "--exclude", ".local/share/Steam", "--exclude", ".steam", + pat, Quickshell.env("HOME") + ]; + fdProc.running = true; + } + + function takeFiles(text) { + if (fdDropNext) { fdDropNext = false; return; } + // A run that finished after the query moved on is junk. + if (fdQuery !== searchInput.text.trim()) return; + const home = Quickshell.env("HOME"); + const lines = text.split("\n").filter(l => l !== ""); + let out = []; + for (let i = 0; i < lines.length && i < fdMax; i++) { + const raw = lines[i]; + const isDir = raw.endsWith("/"); // fd marks dirs + const p = isDir ? raw.slice(0, -1) : raw; + const cut = p.lastIndexOf("/"); + const dir = p.slice(0, cut); + out.push({ + kind: "file", + id: "file:" + p, + name: p.slice(cut + 1), + desc: dir.startsWith(home) ? "~" + dir.slice(home.length) : dir, + icon: "", + glyph: isDir ? "folder" : "file", + path: p + }); + } + fileHits = out; + rebuild(false); + } + + // ── Web search: last row, always. A leading bang retargets + // it; anything else goes to the default engine. + readonly property string webEngine: "https://duckduckgo.com/?q=" + readonly property var bangs: [ + { b: "!yt", label: "YouTube", glyph: "youtube", url: "https://www.youtube.com/results?search_query=" }, + { b: "!gh", label: "GitHub", glyph: "github", url: "https://github.com/search?q=" }, + { b: "!no", label: "NixOS options", glyph: "package", url: "https://search.nixos.org/options?query=" }, + { b: "!np", label: "nixpkgs", glyph: "package", url: "https://search.nixos.org/packages?query=" }, + { b: "!w", label: "Wikipedia", glyph: "book-open", url: "https://en.wikipedia.org/w/index.php?search=" } + ] + + function webItem(raw) { + const q = raw.trim(); + // Arithmetic is an answer, not a search term — leave the + // row off so ⏎ keeps copying the result. + if (q === "" || calcResult !== "") return null; + const sp = q.indexOf(" "); + const head = (sp === -1 ? q : q.slice(0, sp)).toLowerCase(); + const bang = bangs.find(x => x.b === head); + const term = bang ? q.slice(sp === -1 ? q.length : sp + 1).trim() : q; + if (term === "") return null; // a bare "!yt" has nothing to search yet + return { + kind: "web", + id: "web", + name: term, + desc: "Search " + (bang ? bang.label : "the web"), + icon: "", + glyph: bang ? bang.glyph : "globe", + url: (bang ? bang.url : webEngine) + encodeURIComponent(term) + }; + } + // ── Pinned apps: a 3-up tile grid above the results, in the // order the ids are stored. Stored as one comma-joined // string — .desktop ids never contain commas, and a plain @@ -1604,20 +1747,25 @@ in activate(DesktopEntries.applications.values.find(a => a.id === id)); } + // App matches for the current query, kept apart from + // fileHits so a late fd result can be merged in without + // rescoring every desktop entry again. + property var appHits: [] + function refilter() { let q = searchInput.text.toLowerCase().trim(); calcResult = calc(searchInput.text); menuAppId = ""; syncPins(); let apps = DesktopEntries.applications.values.filter(a => !a.noDisplay); - let list; if (q === "") { - if (activeCat === "") { - list = []; - } else { - list = apps.filter(a => inCat(a, activeCat)); - list.sort((a, b) => a.name.localeCompare(b.name)); - } + let list = activeCat === "" ? [] : apps.filter(a => inCat(a, activeCat)); + list.sort((a, b) => a.name.localeCompare(b.name)); + appHits = list.map(a => appItem(a)); + // Nothing typed: no files to look for. + fdTimer.stop(); + killFd(); + fileHits = []; } else { let scored = []; for (let i = 0; i < apps.length; i++) { @@ -1628,11 +1776,23 @@ in if (s > 0) scored.push({ app: apps[i], s: s }); } scored.sort((a, b) => b.s - a.s || a.app.name.localeCompare(b.app.name)); - list = scored.slice(0, 12).map(x => x.app); + appHits = scored.slice(0, 8).map(x => appItem(x.app)); + fdTimer.restart(); } + rebuild(true); + } + + // Merge the three sources into the one list. `reset` moves + // the highlight home; a late fd result passes false so it + // can't yank the selection out from under the arrow keys. + function rebuild(reset) { + let list = appHits.concat(fileHits); + const w = webItem(searchInput.text); + if (w !== null) list.push(w); entries = list; syncModel(list); - launcherList.currentIndex = list.length > 0 ? 0 : -1; + if (reset || launcherList.currentIndex >= list.length) + launcherList.currentIndex = list.length > 0 ? 0 : -1; } // Diff the new result list into the ListModel instead of @@ -1653,20 +1813,28 @@ in if (j === -1) { m.insert(i, { appId: a.id, + kind: a.kind, name: a.name, - icon: Quickshell.iconPath(a.icon, true) || "", - desc: a.genericName !== "" ? a.genericName : a.comment, - pinned: isPinned(a.id) + icon: a.icon, + desc: a.desc, + glyph: a.glyph, + pinned: a.kind === "app" && isPinned(a.id) }); } else if (j !== i) { m.move(j, i, 1); } } while (m.count > list.length) m.remove(m.count - 1); - // Surviving rows are moved, not rebuilt, so their pin - // flag has to be refreshed in place. - for (let i = 0; i < m.count; i++) - m.setProperty(i, "pinned", isPinned(m.get(i).appId)); + // Surviving rows are moved, not rebuilt, so their mutable + // fields are refreshed in place. The web row keeps one + // stable id on purpose — its text changes every keystroke + // and a new id would make it flicker. + for (let i = 0; i < m.count; i++) { + m.setProperty(i, "name", list[i].name); + m.setProperty(i, "desc", list[i].desc); + m.setProperty(i, "glyph", list[i].glyph); + m.setProperty(i, "pinned", list[i].kind === "app" && isPinned(list[i].id)); + } } ListModel { id: launcherModel } @@ -2031,9 +2199,11 @@ in delegate: Item { required property string appId + required property string kind required property string name required property string icon required property string desc + required property string glyph required property bool pinned required property int index width: launcherList.width @@ -2045,14 +2215,28 @@ in anchors.leftMargin: 12 spacing: 12 - Image { - visible: source != "" + // Apps carry a themed icon; file and + // web rows fall back to a glyph in + // the same 32px slot so names line up. + Item { anchors.verticalCenter: parent.verticalCenter width: 32 height: 32 - sourceSize.width: 32 - sourceSize.height: 32 - source: icon + + Image { + anchors.fill: parent + visible: source != "" + sourceSize.width: 32 + sourceSize.height: 32 + source: icon + } + SIcon { + anchors.centerIn: parent + visible: icon === "" + text: glyph + font.pixelSize: 20 + color: Theme.base0D + } } Column { @@ -2096,6 +2280,7 @@ in onEntered: launcherList.currentIndex = index onClicked: (mouse) => { if (mouse.button === Qt.RightButton) { + if (kind !== "app") return; // nothing to pin const p = mapToItem(launcherPanel, mouse.x, mouse.y); launcherPanel.menuX = p.x; launcherPanel.menuY = p.y; @@ -2214,7 +2399,7 @@ in anchors.leftMargin: 10 anchors.verticalCenter: parent.verticalCenter visible: searchInput.text === "" - text: launcherPanel.activeCat === "" ? "Search apps" : "Search all apps" + text: launcherPanel.activeCat === "" ? "Search apps, files, the web" : "Search all apps" color: Theme.base03 font.pixelSize: 14 }