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:
parent
6f32d56c5b
commit
c212b06d15
1 changed files with 296 additions and 9 deletions
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue