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 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