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 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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue