From c212b06d15180a4131b2b7c41c7f14758228516b Mon Sep 17 00:00:00 2001 From: rope Date: Sat, 1 Aug 2026 14:32:07 +0100 Subject: [PATCH] quickshell: launcher calculator + app pinning, knobless monitor meters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Type arithmetic in the launcher ("3+3") and a result card appears above the list; click or Enter copies it. Right-click any result to pin it to the top — pins persist in ~/.local/state and outrank ties in search. CPU/RAM rows in the server card use a new knobless MeterBar instead of PillSlider, which read as draggable controls. Co-Authored-By: Claude Opus 5 --- settings/quickshell.nix | 305 ++++++++++++++++++++++++++++++++++++++-- 1 file changed, 296 insertions(+), 9 deletions(-) diff --git a/settings/quickshell.nix b/settings/quickshell.nix index fac5331..4df0d4d 100644 --- a/settings/quickshell.nix +++ b/settings/quickshell.nix @@ -731,6 +731,7 @@ in readonly property string faviconFetch: "${faviconScript}" readonly property string cava: "${pkgs.cava}/bin/cava" readonly property string cavaConfig: "${cavaConfig}" + readonly property string wlCopy: "${pkgs.wl-clipboard}/bin/wl-copy" } ''; }; @@ -1177,6 +1178,28 @@ in } } + // ── MeterBar: read-only level readout. PillSlider minus the + // knob and the drag area — a monitor row shouldn't look like + // something you can grab. + component MeterBar: Item { + property real value: 0 + property color fillColor: Theme.base0D + property real trackH: 5 + height: trackH + Rectangle { + anchors.fill: parent + radius: parent.trackH / 2 + color: Theme.base02 + } + Rectangle { + height: parent.height + width: Math.max(0, Math.min(1, parent.value)) * parent.width + radius: parent.trackH / 2 + color: parent.fillColor + Behavior on width { NumberAnimation { duration: 200; easing.type: Easing.OutCubic } } + } + } + // ── ToggleSwitch: small on/off pill. component ToggleSwitch: Rectangle { property bool on: false @@ -1442,6 +1465,14 @@ in property string activeCat: "" property var entries: [] + // Right-click menu target — "" means closed. + property string menuAppId: "" + property real menuX: 0 + property real menuY: 0 + + // Non-empty when the query parses as arithmetic. + property string calcResult: "" + readonly property var catDefs: [ { key: "All", label: "All apps", icon: "layout-grid" }, { key: "Network", label: "Internet", icon: "globe" }, @@ -1493,13 +1524,66 @@ in open = false; } + // Enter takes the calculator only when there's nothing to + // launch, so "3+3" ⏎ copies the answer. + function submit() { + if (calcResult !== "" && entries.length === 0) copyCalc(); + else activate(entries[launcherList.currentIndex]); + } + + function copyCalc() { + if (calcResult === "") return; + Quickshell.execDetached([Commands.wlCopy, calcResult]); + open = false; + } + + // ── Pinned apps: ids kept at the top of the list. Stored as + // one comma-joined string — .desktop ids never contain + // commas, and a plain string is what JsonAdapter is + // guaranteed to round-trip. + FileView { + id: pinFile + path: Quickshell.env("HOME") + "/.local/state/quickshell-launcher-pins.json" + printErrors: false + adapter: JsonAdapter { + id: pinState + property string ids: "" + } + onLoadFailed: writeAdapter() + } + + function pinIds() { + return pinState.ids.split(",").filter(x => x !== ""); + } + + function isPinned(id) { + return id !== "" && pinIds().indexOf(id) !== -1; + } + + function togglePin(id) { + const cur = pinIds(); + const next = cur.filter(x => x !== id); + if (next.length === cur.length) next.unshift(id); // wasn't pinned + pinState.ids = next.join(","); + pinFile.writeAdapter(); + refilter(); + } + + // Drops ids whose .desktop file is gone (uninstalled app). + function pinnedApps() { + const all = DesktopEntries.applications.values; + return pinIds().map(id => all.find(a => a.id === id)).filter(a => a !== undefined); + } + function refilter() { let q = searchInput.text.toLowerCase().trim(); + calcResult = calc(searchInput.text); + menuAppId = ""; let apps = DesktopEntries.applications.values.filter(a => !a.noDisplay); let list; if (q === "") { if (activeCat === "") { - list = []; + list = pinnedApps(); } else { list = apps.filter(a => inCat(a, activeCat)); list.sort((a, b) => a.name.localeCompare(b.name)); @@ -1508,6 +1592,9 @@ in let scored = []; for (let i = 0; i < apps.length; i++) { let s = score(apps[i].name, apps[i].genericName + " " + apps[i].comment, q); + // Pins get a boost, not a veto: an exact prefix + // match on an unpinned app still wins. + if (s > 0 && isPinned(apps[i].id)) s += 2; 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)); @@ -1538,13 +1625,18 @@ in appId: a.id, name: a.name, icon: Quickshell.iconPath(a.icon, true) || "", - desc: a.genericName !== "" ? a.genericName : a.comment + desc: a.genericName !== "" ? a.genericName : a.comment, + pinned: 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)); } ListModel { id: launcherModel } @@ -1569,6 +1661,82 @@ in return 0; } + // ── Calculator: type "3+3", get 6. A hand-rolled + // recursive-descent parser rather than eval(), so the query + // never reaches the JS engine as code. Precedence: + // ^ (right-assoc) > unary sign > * / % > + -. + // Returns "" for anything that isn't arithmetic, which is + // also how the result card decides to stay hidden. + function calc(raw) { + const q = raw.trim(); + if (q === "") return ""; + if (!/^[-+*\/%^().\d\s]+$/.test(q)) return ""; + if (!/[-+*\/%^]/.test(q)) return ""; // a bare number is not a sum + try { + const v = calcParse(q); + if (typeof v !== "number" || !isFinite(v)) return ""; + return String(Math.round(v * 1e8) / 1e8); + } catch (e) { + return ""; // half-typed expression: show nothing + } + } + + function calcParse(s) { + let i = 0; + function ws() { while (s[i] === " ") i++; } + function atom() { + ws(); + if (s[i] === "(") { + i++; + const v = add(); + ws(); + if (s[i] !== ")") throw 1; + i++; + return v; + } + if (s[i] === "-") { i++; return -pow(); } + if (s[i] === "+") { i++; return pow(); } + const st = i; + while (i < s.length && ((s[i] >= "0" && s[i] <= "9") || s[i] === ".")) i++; + if (i === st) throw 1; + const n = parseFloat(s.slice(st, i)); + if (isNaN(n)) throw 1; + return n; + } + function pow() { + const b = atom(); + ws(); + if (s[i] === "^") { i++; return Math.pow(b, pow()); } + return b; + } + function mul() { + let v = pow(); + for (;;) { + ws(); + const c = s[i]; + if (c !== "*" && c !== "/" && c !== "%") return v; + i++; + const r = pow(); + v = c === "*" ? v * r : c === "/" ? v / r : v % r; + } + } + function add() { + let v = mul(); + for (;;) { + ws(); + const c = s[i]; + if (c !== "+" && c !== "-") return v; + i++; + const r = mul(); + v = c === "+" ? v + r : v - r; + } + } + const out = add(); + ws(); + if (i !== s.length) throw 1; // trailing junk + return out; + } + // Content anchored to the bottom so the grow reveals upward Item { anchors.fill: parent @@ -1586,6 +1754,51 @@ in width: launcherPanel.panelW - 24 spacing: 8 + // Calculator result: appears the moment the query + // parses as arithmetic. Click (or ⏎) copies it. + Rectangle { + width: parent.width + height: 54 + radius: Theme.radiusCard + color: Theme.cardBg + visible: launcherPanel.calcResult !== "" + + Row { + anchors.fill: parent + anchors.leftMargin: 14 + anchors.rightMargin: 14 + spacing: 10 + + SIcon { + anchors.verticalCenter: parent.verticalCenter + text: "equal" + font.pixelSize: 18 + color: Theme.base0D + } + SText { + anchors.verticalCenter: parent.verticalCenter + width: parent.width - 28 - 20 - copyHint.width + text: launcherPanel.calcResult + font.pixelSize: 20 + font.weight: Font.Medium + elide: Text.ElideRight + } + SText { + id: copyHint + anchors.verticalCenter: parent.verticalCenter + text: "copy" + font.pixelSize: 11 + color: Theme.base04 + } + } + + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: launcherPanel.copyCalc() + } + } + // Category-browse breadcrumb: back to the grid. HoverRow { width: parent.width @@ -1661,6 +1874,7 @@ in required property string name required property string icon required property string desc + required property bool pinned required property int index width: launcherList.width height: 56 @@ -1683,7 +1897,8 @@ in Column { anchors.verticalCenter: parent.verticalCenter - width: launcherList.width - 68 + // Leave room for the pin badge. + width: launcherList.width - (pinned ? 92 : 68) spacing: 1 SText { @@ -1704,11 +1919,31 @@ in } } + SIcon { + anchors.verticalCenter: parent.verticalCenter + anchors.right: parent.right + anchors.rightMargin: 12 + visible: pinned + text: "pin" + font.pixelSize: 13 + color: Theme.base04 + } + MouseArea { anchors.fill: parent hoverEnabled: true + acceptedButtons: Qt.LeftButton | Qt.RightButton onEntered: launcherList.currentIndex = index - onClicked: launcherPanel.activate(launcherPanel.entries[index]) + onClicked: (mouse) => { + if (mouse.button === Qt.RightButton) { + const p = mapToItem(launcherPanel, mouse.x, mouse.y); + launcherPanel.menuX = p.x; + launcherPanel.menuY = p.y; + launcherPanel.menuAppId = appId; + } else { + launcherPanel.activate(launcherPanel.entries[index]); + } + } } } } @@ -1801,12 +2036,17 @@ in clip: true onTextChanged: launcherPanel.refilter() - Keys.onEscapePressed: launcherPanel.open = false + // Escape backs out of the context menu first, + // then closes the launcher. + Keys.onEscapePressed: { + if (launcherPanel.menuAppId !== "") launcherPanel.menuAppId = ""; + else launcherPanel.open = false; + } Keys.onUpPressed: launcherList.currentIndex = Math.max(0, launcherList.currentIndex - 1) Keys.onDownPressed: launcherList.currentIndex = Math.min(launcherPanel.entries.length - 1, launcherList.currentIndex + 1) Keys.onTabPressed: launcherList.currentIndex = (launcherList.currentIndex + 1) % Math.max(1, launcherPanel.entries.length) - Keys.onReturnPressed: launcherPanel.activate(launcherPanel.entries[launcherList.currentIndex]) - Keys.onEnterPressed: launcherPanel.activate(launcherPanel.entries[launcherList.currentIndex]) + Keys.onReturnPressed: launcherPanel.submit() + Keys.onEnterPressed: launcherPanel.submit() } SText { @@ -1820,6 +2060,53 @@ in } } } + + // Right-click menu. The backdrop swallows the next click + // anywhere in the panel so the menu dismisses without + // also launching whatever is underneath it. + MouseArea { + anchors.fill: parent + visible: launcherPanel.menuAppId !== "" + acceptedButtons: Qt.LeftButton | Qt.RightButton + z: 9 + onClicked: launcherPanel.menuAppId = "" + } + + Rectangle { + id: ctxMenu + visible: launcherPanel.menuAppId !== "" + z: 10 + width: 156 + height: 34 + radius: Theme.radiusSmall + color: Theme.base02 + x: Math.max(0, Math.min(parent.width - width, launcherPanel.menuX)) + y: Math.max(0, Math.min(parent.height - height, launcherPanel.menuY)) + + readonly property bool pinned: launcherPanel.isPinned(launcherPanel.menuAppId) + + HoverRow { + anchors.fill: parent + onClicked: launcherPanel.togglePin(launcherPanel.menuAppId) + + Row { + anchors.verticalCenter: parent.verticalCenter + anchors.left: parent.left + anchors.leftMargin: 10 + spacing: 8 + SIcon { + anchors.verticalCenter: parent.verticalCenter + text: ctxMenu.pinned ? "pin-off" : "pin" + font.pixelSize: 14 + color: Theme.base0D + } + SText { + anchors.verticalCenter: parent.verticalCenter + text: ctxMenu.pinned ? "Unpin" : "Pin to top" + } + } + } + } } } @@ -3197,7 +3484,7 @@ in width: parent.width spacing: 10 SText { width: 42; text: "cpu"; color: Theme.base04; anchors.verticalCenter: parent.verticalCenter } - PillSlider { + MeterBar { width: parent.width - 42 - 76 - 20 anchors.verticalCenter: parent.verticalCenter value: srvWidget.cpu / 100 @@ -3216,7 +3503,7 @@ in width: parent.width spacing: 10 SText { width: 42; text: "ram"; color: Theme.base04; anchors.verticalCenter: parent.verticalCenter } - PillSlider { + MeterBar { width: parent.width - 42 - 76 - 20 anchors.verticalCenter: parent.verticalCenter value: srvWidget.memTotal > 0 ? srvWidget.memUsed / srvWidget.memTotal : 0