diff options
| -rw-r--r-- | keybinds/Binds.js | 42 | ||||
| -rw-r--r-- | keybinds/Keybinds.qml | 83 | ||||
| -rw-r--r-- | keybinds/PadPanel.qml | 162 | ||||
| -rw-r--r-- | keybinds/README.md | 10 | ||||
| -rw-r--r-- | keybinds/test_binds.js | 20 |
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"); |
