aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-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");