quickshell: launcher calculator + app pinning, knobless monitor meters

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 <noreply@anthropic.com>
This commit is contained in:
rope 2026-08-01 14:32:07 +01:00
parent 6f32d56c5b
commit c212b06d15

View file

@ -731,6 +731,7 @@ in
readonly property string faviconFetch: "${faviconScript}" readonly property string faviconFetch: "${faviconScript}"
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"
} }
''; '';
}; };
@ -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. // ToggleSwitch: small on/off pill.
component ToggleSwitch: Rectangle { component ToggleSwitch: Rectangle {
property bool on: false property bool on: false
@ -1442,6 +1465,14 @@ in
property string activeCat: "" property string activeCat: ""
property var entries: [] 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: [ readonly property var catDefs: [
{ key: "All", label: "All apps", icon: "layout-grid" }, { key: "All", label: "All apps", icon: "layout-grid" },
{ key: "Network", label: "Internet", icon: "globe" }, { key: "Network", label: "Internet", icon: "globe" },
@ -1493,13 +1524,66 @@ in
open = false; 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() { function refilter() {
let q = searchInput.text.toLowerCase().trim(); let q = searchInput.text.toLowerCase().trim();
calcResult = calc(searchInput.text);
menuAppId = "";
let apps = DesktopEntries.applications.values.filter(a => !a.noDisplay); let apps = DesktopEntries.applications.values.filter(a => !a.noDisplay);
let list; let list;
if (q === "") { if (q === "") {
if (activeCat === "") { if (activeCat === "") {
list = []; list = pinnedApps();
} else { } else {
list = apps.filter(a => inCat(a, activeCat)); 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));
@ -1508,6 +1592,9 @@ in
let scored = []; let scored = [];
for (let i = 0; i < apps.length; i++) { for (let i = 0; i < apps.length; i++) {
let s = score(apps[i].name, apps[i].genericName + " " + apps[i].comment, q); 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 }); 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));
@ -1538,13 +1625,18 @@ in
appId: a.id, appId: a.id,
name: a.name, name: a.name,
icon: Quickshell.iconPath(a.icon, true) || "", 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) { } 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
// 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 } ListModel { id: launcherModel }
@ -1569,6 +1661,82 @@ in
return 0; 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 // Content anchored to the bottom so the grow reveals upward
Item { Item {
anchors.fill: parent anchors.fill: parent
@ -1586,6 +1754,51 @@ in
width: launcherPanel.panelW - 24 width: launcherPanel.panelW - 24
spacing: 8 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. // Category-browse breadcrumb: back to the grid.
HoverRow { HoverRow {
width: parent.width width: parent.width
@ -1661,6 +1874,7 @@ in
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 bool pinned
required property int index required property int index
width: launcherList.width width: launcherList.width
height: 56 height: 56
@ -1683,7 +1897,8 @@ in
Column { Column {
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
width: launcherList.width - 68 // Leave room for the pin badge.
width: launcherList.width - (pinned ? 92 : 68)
spacing: 1 spacing: 1
SText { 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 { MouseArea {
anchors.fill: parent anchors.fill: parent
hoverEnabled: true hoverEnabled: true
acceptedButtons: Qt.LeftButton | Qt.RightButton
onEntered: launcherList.currentIndex = index 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 clip: true
onTextChanged: launcherPanel.refilter() 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.onUpPressed: launcherList.currentIndex = Math.max(0, launcherList.currentIndex - 1)
Keys.onDownPressed: launcherList.currentIndex = Math.min(launcherPanel.entries.length - 1, 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.onTabPressed: launcherList.currentIndex = (launcherList.currentIndex + 1) % Math.max(1, launcherPanel.entries.length)
Keys.onReturnPressed: launcherPanel.activate(launcherPanel.entries[launcherList.currentIndex]) Keys.onReturnPressed: launcherPanel.submit()
Keys.onEnterPressed: launcherPanel.activate(launcherPanel.entries[launcherList.currentIndex]) Keys.onEnterPressed: launcherPanel.submit()
} }
SText { 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 width: parent.width
spacing: 10 spacing: 10
SText { width: 42; text: "cpu"; color: Theme.base04; anchors.verticalCenter: parent.verticalCenter } SText { width: 42; text: "cpu"; color: Theme.base04; anchors.verticalCenter: parent.verticalCenter }
PillSlider { MeterBar {
width: parent.width - 42 - 76 - 20 width: parent.width - 42 - 76 - 20
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
value: srvWidget.cpu / 100 value: srvWidget.cpu / 100
@ -3216,7 +3503,7 @@ in
width: parent.width width: parent.width
spacing: 10 spacing: 10
SText { width: 42; text: "ram"; color: Theme.base04; anchors.verticalCenter: parent.verticalCenter } SText { width: 42; text: "ram"; color: Theme.base04; anchors.verticalCenter: parent.verticalCenter }
PillSlider { MeterBar {
width: parent.width - 42 - 76 - 20 width: parent.width - 42 - 76 - 20
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
value: srvWidget.memTotal > 0 ? srvWidget.memUsed / srvWidget.memTotal : 0 value: srvWidget.memTotal > 0 ? srvWidget.memUsed / srvWidget.memTotal : 0