quickshell: launcher searches files and the web
Results become one flat list — apps, then files, then a web fallback —
so a single set of arrow keys walks all of it and activate() dispatches
on the item kind.
Files come from one fd run per settled keystroke, scoped to $HOME and
capped with --max-results so fd exits as soon as it has enough; no index
and no daemon, so nothing is ever stale. Spaces in the query become
gaps ("report 2024" finds report-2024.pdf) and regex metacharacters are
escaped so a query can't make fd bail.
Web row is last and always there, with bangs (!yt !gh !no !np !w) to
retarget it. Suppressed for arithmetic so Enter keeps copying the sum.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
a15e4d5a32
commit
03fda2a6bb
1 changed files with 208 additions and 23 deletions
|
|
@ -732,6 +732,8 @@ in
|
||||||
readonly property string cava: "${pkgs.cava}/bin/cava"
|
readonly property string cava: "${pkgs.cava}/bin/cava"
|
||||||
readonly property string cavaConfig: "${cavaConfig}"
|
readonly property string cavaConfig: "${cavaConfig}"
|
||||||
readonly property string wlCopy: "${pkgs.wl-clipboard}/bin/wl-copy"
|
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) {
|
function activate(item) {
|
||||||
if (!item) return;
|
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;
|
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
|
// Enter takes the calculator only when there's nothing to
|
||||||
// launch, so "3+3" ⏎ copies the answer.
|
// launch, so "3+3" ⏎ copies the answer.
|
||||||
function submit() {
|
function submit() {
|
||||||
|
|
@ -1537,6 +1559,127 @@ in
|
||||||
open = false;
|
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
|
// ── Pinned apps: a 3-up tile grid above the results, in the
|
||||||
// order the ids are stored. Stored as one comma-joined
|
// order the ids are stored. Stored as one comma-joined
|
||||||
// string — .desktop ids never contain commas, and a plain
|
// string — .desktop ids never contain commas, and a plain
|
||||||
|
|
@ -1604,20 +1747,25 @@ in
|
||||||
activate(DesktopEntries.applications.values.find(a => a.id === id));
|
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() {
|
function refilter() {
|
||||||
let q = searchInput.text.toLowerCase().trim();
|
let q = searchInput.text.toLowerCase().trim();
|
||||||
calcResult = calc(searchInput.text);
|
calcResult = calc(searchInput.text);
|
||||||
menuAppId = "";
|
menuAppId = "";
|
||||||
syncPins();
|
syncPins();
|
||||||
let apps = DesktopEntries.applications.values.filter(a => !a.noDisplay);
|
let apps = DesktopEntries.applications.values.filter(a => !a.noDisplay);
|
||||||
let list;
|
|
||||||
if (q === "") {
|
if (q === "") {
|
||||||
if (activeCat === "") {
|
let list = activeCat === "" ? [] : apps.filter(a => inCat(a, activeCat));
|
||||||
list = [];
|
|
||||||
} else {
|
|
||||||
list = apps.filter(a => inCat(a, activeCat));
|
|
||||||
list.sort((a, b) => a.name.localeCompare(b.name));
|
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 {
|
} else {
|
||||||
let scored = [];
|
let scored = [];
|
||||||
for (let i = 0; i < apps.length; i++) {
|
for (let i = 0; i < apps.length; i++) {
|
||||||
|
|
@ -1628,10 +1776,22 @@ in
|
||||||
if (s > 0) scored.push({ app: apps[i], s: s });
|
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));
|
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;
|
entries = list;
|
||||||
syncModel(list);
|
syncModel(list);
|
||||||
|
if (reset || launcherList.currentIndex >= list.length)
|
||||||
launcherList.currentIndex = list.length > 0 ? 0 : -1;
|
launcherList.currentIndex = list.length > 0 ? 0 : -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1653,20 +1813,28 @@ in
|
||||||
if (j === -1) {
|
if (j === -1) {
|
||||||
m.insert(i, {
|
m.insert(i, {
|
||||||
appId: a.id,
|
appId: a.id,
|
||||||
|
kind: a.kind,
|
||||||
name: a.name,
|
name: a.name,
|
||||||
icon: Quickshell.iconPath(a.icon, true) || "",
|
icon: a.icon,
|
||||||
desc: a.genericName !== "" ? a.genericName : a.comment,
|
desc: a.desc,
|
||||||
pinned: isPinned(a.id)
|
glyph: a.glyph,
|
||||||
|
pinned: a.kind === "app" && isPinned(a.id)
|
||||||
});
|
});
|
||||||
} else if (j !== i) {
|
} else if (j !== i) {
|
||||||
m.move(j, i, 1);
|
m.move(j, i, 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
while (m.count > list.length) m.remove(m.count - 1);
|
while (m.count > list.length) m.remove(m.count - 1);
|
||||||
// Surviving rows are moved, not rebuilt, so their pin
|
// Surviving rows are moved, not rebuilt, so their mutable
|
||||||
// flag has to be refreshed in place.
|
// fields are refreshed in place. The web row keeps one
|
||||||
for (let i = 0; i < m.count; i++)
|
// stable id on purpose — its text changes every keystroke
|
||||||
m.setProperty(i, "pinned", isPinned(m.get(i).appId));
|
// 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 }
|
ListModel { id: launcherModel }
|
||||||
|
|
@ -2031,9 +2199,11 @@ in
|
||||||
|
|
||||||
delegate: Item {
|
delegate: Item {
|
||||||
required property string appId
|
required property string appId
|
||||||
|
required property string kind
|
||||||
required property string name
|
required property string name
|
||||||
required property string icon
|
required property string icon
|
||||||
required property string desc
|
required property string desc
|
||||||
|
required property string glyph
|
||||||
required property bool pinned
|
required property bool pinned
|
||||||
required property int index
|
required property int index
|
||||||
width: launcherList.width
|
width: launcherList.width
|
||||||
|
|
@ -2045,15 +2215,29 @@ in
|
||||||
anchors.leftMargin: 12
|
anchors.leftMargin: 12
|
||||||
spacing: 12
|
spacing: 12
|
||||||
|
|
||||||
Image {
|
// Apps carry a themed icon; file and
|
||||||
visible: source != ""
|
// web rows fall back to a glyph in
|
||||||
|
// the same 32px slot so names line up.
|
||||||
|
Item {
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
width: 32
|
width: 32
|
||||||
height: 32
|
height: 32
|
||||||
|
|
||||||
|
Image {
|
||||||
|
anchors.fill: parent
|
||||||
|
visible: source != ""
|
||||||
sourceSize.width: 32
|
sourceSize.width: 32
|
||||||
sourceSize.height: 32
|
sourceSize.height: 32
|
||||||
source: icon
|
source: icon
|
||||||
}
|
}
|
||||||
|
SIcon {
|
||||||
|
anchors.centerIn: parent
|
||||||
|
visible: icon === ""
|
||||||
|
text: glyph
|
||||||
|
font.pixelSize: 20
|
||||||
|
color: Theme.base0D
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Column {
|
Column {
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
|
@ -2096,6 +2280,7 @@ in
|
||||||
onEntered: launcherList.currentIndex = index
|
onEntered: launcherList.currentIndex = index
|
||||||
onClicked: (mouse) => {
|
onClicked: (mouse) => {
|
||||||
if (mouse.button === Qt.RightButton) {
|
if (mouse.button === Qt.RightButton) {
|
||||||
|
if (kind !== "app") return; // nothing to pin
|
||||||
const p = mapToItem(launcherPanel, mouse.x, mouse.y);
|
const p = mapToItem(launcherPanel, mouse.x, mouse.y);
|
||||||
launcherPanel.menuX = p.x;
|
launcherPanel.menuX = p.x;
|
||||||
launcherPanel.menuY = p.y;
|
launcherPanel.menuY = p.y;
|
||||||
|
|
@ -2214,7 +2399,7 @@ in
|
||||||
anchors.leftMargin: 10
|
anchors.leftMargin: 10
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
visible: searchInput.text === ""
|
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
|
color: Theme.base03
|
||||||
font.pixelSize: 14
|
font.pixelSize: 14
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue