aboutsummaryrefslogtreecommitdiffstats
path: root/keybinds
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-09-25 20:12:48 +0200
committerDanilo M. <danix@danix.xyz>2026-09-25 20:12:48 +0200
commit851dbc76b3ba533b128e4d42d25560a95d6d3722 (patch)
treeced834871dfa997e8be3142ce36b322b28326ccd /keybinds
parent85206cf2939109b2b28044b35b37be510ad2b3fa (diff)
downloadquickshell-851dbc76b3ba533b128e4d42d25560a95d6d3722.tar.gz
quickshell-851dbc76b3ba533b128e4d42d25560a95d6d3722.zip
feat(keybinds): draw the macropad beside the list
The pad glyph on each row was too small to read. The left fifth of the overlay now draws the pad at size, each key labelled with its plain action; hovering a key or an encoder lists everything it does, held keys and turn direction spelled out. padActions() in Binds.js inverts the grouped rows into that per-key list, and PAD's turn values became words for it. The panel is pinned at a fixed height: centred, it moved whenever the hovered key's list changed length. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'keybinds')
-rw-r--r--keybinds/Binds.js42
-rw-r--r--keybinds/Keybinds.qml83
-rw-r--r--keybinds/PadPanel.qml162
-rw-r--r--keybinds/README.md10
-rw-r--r--keybinds/test_binds.js20
5 files changed, 233 insertions, 84 deletions
diff --git a/keybinds/Binds.js b/keybinds/Binds.js
index d02c714..1f809cf 100644
--- a/keybinds/Binds.js
+++ b/keybinds/Binds.js
@@ -49,7 +49,7 @@ function keys(bind) {
// Where a bind sits on the macropad, keyed by the combo as keys() writes it,
// so a renamed key above must be renamed here too. key is 1-12, left to
// right and top to bottom; knob is the top or bottom encoder with how it is
-// turned; mod is what is held with it. The pad's firmware sends these combos.
+// turned or pressed; mod is what is held with it. The pad's firmware sends these combos.
const PAD = Object.assign(Object.create(null), {
"Win + w": { key: 1 },
"Win + SHIFT + w": { key: 1, mod: "Shift" },
@@ -63,16 +63,16 @@ const PAD = Object.assign(Object.create(null), {
"ALT + h": { key: 6 },
"Win + CTRL + r": { key: 7 },
"ALT + w": { key: 8 },
- "Volume -": { knob: "top", turn: "↺" },
- "Volume +": { knob: "top", turn: "↻" },
- "Mute": { knob: "top", turn: "●" },
- "Win + Volume -": { knob: "top", turn: "↺", mod: "Win" },
- "Win + Volume +": { knob: "top", turn: "↻", mod: "Win" },
- "Previous song": { knob: "bottom", turn: "↺" },
- "Next song": { knob: "bottom", turn: "↻" },
- "Play/Pause": { knob: "bottom", turn: "●" },
- "Win + Previous song": { knob: "bottom", turn: "↺", mod: "Win" },
- "Win + Next song": { knob: "bottom", turn: "↻", mod: "Win" },
+ "Volume -": { knob: "top", turn: "Turn left" },
+ "Volume +": { knob: "top", turn: "Turn right" },
+ "Mute": { knob: "top", turn: "Press" },
+ "Win + Volume -": { knob: "top", turn: "Turn left", mod: "Win" },
+ "Win + Volume +": { knob: "top", turn: "Turn right", mod: "Win" },
+ "Previous song": { knob: "bottom", turn: "Turn left" },
+ "Next song": { knob: "bottom", turn: "Turn right" },
+ "Play/Pause": { knob: "bottom", turn: "Press" },
+ "Win + Previous song": { knob: "bottom", turn: "Turn left", mod: "Win" },
+ "Win + Next song": { knob: "bottom", turn: "Turn right", mod: "Win" },
});
// The bottom row is workspaces 1-4, 5-8 with Shift, and moves the window
// there with Win held on the keyboard.
@@ -80,7 +80,7 @@ for (let i = 1; i <= 4; i++) {
PAD["CTRL + F" + i] = { key: 8 + i };
PAD["CTRL + SHIFT + F" + i] = { key: 8 + i, mod: "Shift" };
PAD["Win + CTRL + F" + i] = { key: 8 + i, mod: "Win" };
- PAD["Win + CTRL + SHIFT + F" + i] = { key: 8 + i, mod: "Win Shift" };
+ PAD["Win + CTRL + SHIFT + F" + i] = { key: 8 + i, mod: "Win + Shift" };
}
// A description reads "Group: Label". A bind without one still shows, last,
@@ -109,3 +109,21 @@ function group(binds) {
if (undescribed.length) groups.push({ name: "Undescribed", rows: undescribed });
return groups;
}
+
+// Everything each pad key or encoder does, from group()'s output: "k1" to
+// "k12", "top" and "bottom", each a list of { how, label } in list order.
+function padActions(groups) {
+ const out = Object.create(null);
+ for (const g of groups) {
+ for (const r of g.rows) {
+ if (!r.pad) continue;
+ const id = r.pad.key ? "k" + r.pad.key : r.pad.knob;
+ if (!out[id]) out[id] = [];
+ out[id].push({
+ how: [r.pad.mod, r.pad.turn || "Press"].filter(Boolean).join(" + "),
+ label: r.label,
+ });
+ }
+ }
+ return out;
+}
diff --git a/keybinds/Keybinds.qml b/keybinds/Keybinds.qml
index 532ae86..c94a871 100644
--- a/keybinds/Keybinds.qml
+++ b/keybinds/Keybinds.qml
@@ -24,6 +24,7 @@ Scope {
// Non-empty while loading or after a failure; the grid shows only when
// this is empty, so a stale list from the last opening never shows.
property string status: "Loading..."
+ readonly property var padActions: Binds.padActions(root.groups)
readonly property var screenObj:
Quickshell.screens.find(s => s.name === root.monitor) ?? Quickshell.screens[0]
@@ -104,6 +105,22 @@ Scope {
font.pixelSize: 22
}
+ // The left fifth is the macropad; the list centres in the
+ // rest.
+ Item {
+ id: listArea
+ x: parent.width * 0.2
+ width: parent.width * 0.8
+ height: parent.height
+ }
+
+ PadPanel {
+ width: parent.width * 0.2
+ height: parent.height
+ visible: root.status === ""
+ actions: root.padActions
+ }
+
// Groups fill a column down to the screen's height, then
// start the next one, so a long group never leaves a row
// of short ones sitting on empty space. The width is left
@@ -111,7 +128,7 @@ Scope {
// ponytail: a single group taller than the screen clips;
// split it in keybindings.lua if one ever gets there.
Flow {
- anchors.centerIn: parent
+ anchors.centerIn: listArea
height: parent.height * 0.9
flow: Flow.TopToBottom
spacing: 28
@@ -122,7 +139,7 @@ Scope {
Column {
required property var modelData
- width: 552
+ width: 440
spacing: 4
Text {
@@ -159,68 +176,6 @@ Scope {
font.family: Theme.fontFamily
font.pixelSize: 14
}
-
- // The macropad, drawn small: its 4x3
- // keys and two encoders, the one to
- // use lit. Empty but still 100 wide
- // on a row the pad has no part in, so
- // the column keeps its width.
- Item {
- id: padGlyph
- readonly property var pad: modelData.pad ?? null
- width: 100
- height: 17
- anchors.verticalCenter: parent.verticalCenter
-
- Grid {
- visible: padGlyph.pad !== null
- columns: 4
- spacing: 1
-
- Repeater {
- model: 12
-
- Rectangle {
- required property int index
- width: 5
- height: 5
- radius: 1
- color: padGlyph.pad?.key === index + 1
- ? Theme.accent : Theme.surfaceAlt
- }
- }
- }
-
- Column {
- x: 26
- visible: padGlyph.pad !== null
- spacing: 3
-
- Repeater {
- model: ["top", "bottom"]
-
- Rectangle {
- required property string modelData
- width: 7
- height: 7
- radius: 3.5
- color: padGlyph.pad?.knob === modelData
- ? Theme.accent : Theme.surfaceAlt
- }
- }
- }
-
- Text {
- x: 38
- anchors.verticalCenter: parent.verticalCenter
- visible: padGlyph.pad !== null
- text: [padGlyph.pad?.turn, padGlyph.pad?.mod]
- .filter(Boolean).join(" ")
- color: Theme.subtext
- font.family: Theme.fontFamily
- font.pixelSize: 12
- }
- }
}
}
}
diff --git a/keybinds/PadPanel.qml b/keybinds/PadPanel.qml
new file mode 100644
index 0000000..04c95a5
--- /dev/null
+++ b/keybinds/PadPanel.qml
@@ -0,0 +1,162 @@
+// Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+//
+// This program is free software; you can redistribute it and/or modify
+// it under the terms of the GNU General Public License version 2 as
+// published by the Free Software Foundation.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+
+import QtQuick
+
+// The macropad as it sits on the desk: twelve keys in three rows of four,
+// the two encoders to their right. Each key carries its plain action;
+// hovering a key or an encoder lists everything it does underneath. The
+// last one hovered stays listed, so moving the pointer off does not blank it.
+Item {
+ id: panel
+
+ // Binds.padActions(): "k1".."k12", "top", "bottom" to [{ how, label }].
+ property var actions: ({})
+ property string hovered: ""
+
+ // Keys are unit wide; the pad is four of them, three gaps, a gap and an
+ // encoder: 5.56 units, fitted to most of the panel's width.
+ readonly property int unit: Math.floor(width * 0.85 / 5.56)
+ readonly property int padWidth: Math.round(unit * 5.56)
+
+ function plain(id) {
+ const a = (actions[id] ?? []).find(x => x.how === "Press");
+ return a ? a.label : "";
+ }
+
+ function title(id) {
+ if (id === "top") return "Top encoder";
+ if (id === "bottom") return "Bottom encoder";
+ return "Key " + id.slice(1);
+ }
+
+ // Pinned at a fixed height rather than centred: centred, the pad moved
+ // every time the list under it changed length.
+ Column {
+ anchors.horizontalCenter: parent.horizontalCenter
+ y: parent.height * 0.3
+ spacing: panel.unit * 0.5
+
+ Row {
+ spacing: panel.unit * 0.3
+
+ Grid {
+ id: keys
+ columns: 4
+ spacing: panel.unit * 0.12
+
+ Repeater {
+ model: 12
+
+ Rectangle {
+ required property int index
+ readonly property string padId: "k" + (index + 1)
+
+ width: panel.unit
+ height: panel.unit
+ radius: panel.unit * 0.12
+ color: panel.hovered === padId ? Theme.surfaceAlt : Theme.surface
+ border.color: panel.hovered === padId ? Theme.accent : "transparent"
+ border.width: 2
+
+ Text {
+ anchors.fill: parent
+ anchors.margins: 4
+ text: panel.plain(parent.padId)
+ wrapMode: Text.Wrap
+ elide: Text.ElideRight
+ horizontalAlignment: Text.AlignHCenter
+ verticalAlignment: Text.AlignVCenter
+ color: Theme.text
+ font.family: Theme.fontFamily
+ font.pixelSize: Math.max(10, panel.unit * 0.16)
+ }
+
+ MouseArea {
+ anchors.fill: parent
+ hoverEnabled: true
+ onEntered: panel.hovered = parent.padId
+ }
+ }
+ }
+ }
+
+ // One encoder level with the top row, one with the bottom.
+ Item {
+ width: panel.unit * 0.9
+ height: keys.height
+
+ Repeater {
+ model: ["top", "bottom"]
+
+ Rectangle {
+ required property string modelData
+
+ y: modelData === "top" ? 0 : parent.height - height
+ width: parent.width
+ height: width
+ radius: width / 2
+ color: panel.hovered === modelData ? Theme.surfaceAlt : Theme.surface
+ border.color: panel.hovered === modelData ? Theme.accent : Theme.overlay
+ border.width: 2
+
+ MouseArea {
+ anchors.fill: parent
+ hoverEnabled: true
+ onEntered: panel.hovered = parent.modelData
+ }
+ }
+ }
+ }
+ }
+
+ Column {
+ width: panel.padWidth
+ spacing: 6
+
+ Text {
+ text: panel.hovered ? panel.title(panel.hovered) : "Hover a key or an encoder"
+ color: Theme.accent
+ font.family: Theme.fontFamily
+ font.pixelSize: 18
+ font.bold: true
+ bottomPadding: 4
+ }
+
+ Repeater {
+ model: panel.actions[panel.hovered] ?? []
+
+ // Fixed widths summing to the column's: a Row sizes to its
+ // children.
+ Row {
+ required property var modelData
+ spacing: 12
+
+ Text {
+ width: panel.padWidth * 0.4
+ text: modelData.how
+ color: Theme.subtext
+ font.family: Theme.fontFamily
+ font.pixelSize: 15
+ }
+ Text {
+ width: panel.padWidth * 0.6 - 12
+ text: modelData.label
+ wrapMode: Text.Wrap
+ color: Theme.text
+ font.family: Theme.fontFamily
+ font.pixelSize: 15
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/keybinds/README.md b/keybinds/README.md
index c4fa64f..e933600 100644
--- a/keybinds/README.md
+++ b/keybinds/README.md
@@ -20,10 +20,12 @@ Key names are Hyprland's unless `NAMES` in `Binds.js` gives a friendlier one
(`mouse:272` shows as `LMB`, `XF86Calculator` as `Fn+F9`), and SUPER shows as
Win. Add a line there to rename another.
-A bind the macropad sends shows the pad beside it: its 4x3 keys and two
-encoders drawn small, the one to press lit, with the turn direction and any
-key held. `PAD` in `Binds.js` maps each combo to its place, keyed by the combo
-as displayed, so renaming a key in `NAMES` means renaming it there too.
+The left fifth of the screen is the macropad, drawn as it sits on the desk:
+twelve keys, each carrying its plain action, and the two encoders. Hovering
+a key or an encoder lists everything it does underneath, with what is held
+and how an encoder is turned. `PAD` in `Binds.js` maps each combo the pad's
+firmware sends to its place, keyed by the combo as displayed, so renaming a
+key in `NAMES` means renaming it there too.
## Running
diff --git a/keybinds/test_binds.js b/keybinds/test_binds.js
index 08be66a..df1e4b5 100644
--- a/keybinds/test_binds.js
+++ b/keybinds/test_binds.js
@@ -18,7 +18,7 @@ const assert = require("assert");
const src = fs.readFileSync(__dirname + "/Binds.js", "utf8")
.replace(/^\.pragma library$/m, "");
-const B = new Function(src + "\nreturn { mods, keys, group, PAD };")();
+const B = new Function(src + "\nreturn { mods, keys, group, PAD, padActions };")();
assert.deepStrictEqual(B.mods(0), []);
assert.deepStrictEqual(B.mods(65), ["Win", "SHIFT"]);
@@ -41,7 +41,7 @@ assert.deepStrictEqual(B.group([
{ keys: "Win + w", label: "Browser", pad: { key: 1 } },
{ keys: "Win + e", label: "Files" },
] },
- { name: "Media", rows: [{ keys: "Mute", label: "Mute", pad: { knob: "top", turn: "●" } }] },
+ { name: "Media", rows: [{ keys: "Mute", label: "Mute", pad: { knob: "top", turn: "Press" } }] },
{ name: "Other", rows: [{ keys: "ALT + x", label: "No colon here" }] },
{ name: "Undescribed", rows: [{ keys: "Win + s", label: "no description" }] },
]);
@@ -57,8 +57,20 @@ assert.deepStrictEqual(B.group([
assert.deepStrictEqual(B.PAD["Win + SHIFT + r"], { key: 2, mod: "Shift" });
assert.deepStrictEqual(B.PAD["CTRL + ALT + SHIFT + l"], { key: 4, mod: "Alt" });
assert.deepStrictEqual(B.PAD["CTRL + F1"], { key: 9 });
-assert.deepStrictEqual(B.PAD["Win + CTRL + SHIFT + F4"], { key: 12, mod: "Win Shift" });
-assert.deepStrictEqual(B.PAD["Win + Previous song"], { knob: "bottom", turn: "↺", mod: "Win" });
+assert.deepStrictEqual(B.PAD["Win + CTRL + SHIFT + F4"], { key: 12, mod: "Win + Shift" });
+assert.deepStrictEqual(B.PAD["Win + Previous song"], { knob: "bottom", turn: "Turn left", mod: "Win" });
assert.strictEqual(B.PAD["toString"], undefined);
+// Everything one pad key or encoder does, plain press first as listed.
+assert.deepStrictEqual(B.padActions(B.group([
+ { modmask: 64, key: "w", description: "Apps: Browser" },
+ { modmask: 65, key: "w", description: "Apps: Private browser" },
+ { modmask: 0, key: "XF86AudioRaiseVolume", description: "Media: Volume up" },
+ { modmask: 64, key: "XF86AudioRaiseVolume", description: "Keyboard: Next layout" },
+ { modmask: 64, key: "e", description: "Apps: Files" },
+])), Object.assign(Object.create(null), {
+ k1: [{ how: "Press", label: "Browser" }, { how: "Shift + Press", label: "Private browser" }],
+ top: [{ how: "Turn right", label: "Volume up" }, { how: "Win + Turn right", label: "Next layout" }],
+}));
+
console.log("ok");