diff options
| author | Danilo M. <danix@danix.xyz> | 2026-09-25 19:05:54 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-09-25 19:05:54 +0200 |
| commit | 2fb72210ad5d8151718930b87fb7194467e600a4 (patch) | |
| tree | 98d0a2c9e1301ec340ed734e5e0a4fb59eea4442 /docs | |
| parent | 3ba00d86ab05bc1b01294d2db684c2eee37e8164 (diff) | |
| download | quickshell-2fb72210ad5d8151718930b87fb7194467e600a4.tar.gz quickshell-2fb72210ad5d8151718930b87fb7194467e600a4.zip | |
docs(keybinds): implementation plan
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'docs')
| -rw-r--r-- | docs/superpowers/plans/2026-09-25-keybinds.md | 587 |
1 files changed, 587 insertions, 0 deletions
diff --git a/docs/superpowers/plans/2026-09-25-keybinds.md b/docs/superpowers/plans/2026-09-25-keybinds.md new file mode 100644 index 0000000..9e7391f --- /dev/null +++ b/docs/superpowers/plans/2026-09-25-keybinds.md @@ -0,0 +1,587 @@ +# Keybind Reminder Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** A `SUPER + k` overlay listing every live Hyprland bind with its description, grouped. + +**Architecture:** New quickshell component `keybinds/`, shaped like `window-switcher/`. Each opening runs `hyprctl binds -j`; a pure `Binds.js` decodes modmasks and groups binds by the `Group: Label` description. Descriptions are added to every `hl.bind` in `hypr-theme` (and the one in `conky-theme-udt`). + +**Tech Stack:** Quickshell 0.3.1 QML, plain JS (`.pragma library`), Hyprland 0.56.2 Lua config, `node` for the one check. + +Spec: `docs/superpowers/specs/2026-09-25-keybinds-design.md`. + +Three repos are touched, each committed on its own: +`quickshell` (this one), `../hypr-theme`, `../conky-theme-udt`. + +--- + +### Task 1: `Binds.js` and its check + +**Files:** +- Create: `keybinds/test_binds.js` +- Create: `keybinds/Binds.js` + +- [ ] **Step 1: Write the failing check** + +`keybinds/test_binds.js`: + +```js +// 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. + +// Run: node keybinds/test_binds.js +// Binds.js is a QML library, so its .pragma line is stripped before node +// evaluates it. + +const fs = require("fs"); +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 };")(); + +assert.deepStrictEqual(B.mods(0), []); +assert.deepStrictEqual(B.mods(65), ["SUPER", "SHIFT"]); +assert.deepStrictEqual(B.mods(13), ["CTRL", "ALT", "SHIFT"]); +assert.strictEqual(B.keys({ modmask: 69, key: "F1" }), "SUPER + CTRL + SHIFT + F1"); +assert.strictEqual(B.keys({ modmask: 0, key: "XF86AudioMute" }), "XF86AudioMute"); + +assert.deepStrictEqual(B.group([ + { modmask: 64, key: "s", description: "" }, + { modmask: 64, key: "w", description: "Apps: Browser" }, + { modmask: 0, key: "XF86AudioMute", description: "Media: Mute" }, + { modmask: 64, key: "e", description: "Apps: Files" }, + { modmask: 8, key: "x", description: "No colon here" }, +]), [ + { name: "Apps", rows: [ + { keys: "SUPER + w", label: "Browser" }, + { keys: "SUPER + e", label: "Files" }, + ] }, + { name: "Media", rows: [{ keys: "XF86AudioMute", label: "Mute" }] }, + { name: "Other", rows: [{ keys: "ALT + x", label: "No colon here" }] }, + { name: "Undescribed", rows: [{ keys: "SUPER + s", label: "no description" }] }, +]); + +console.log("ok"); +``` + +- [ ] **Step 2: Run it, expect failure** + +Run: `node keybinds/test_binds.js` +Expected: `ENOENT: no such file or directory ... Binds.js` + +- [ ] **Step 3: Write `keybinds/Binds.js`** + +```js +.pragma library +// 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. + +// hyprctl binds -j into display groups. Pure, so node can check it: +// see test_binds.js. + +// Modmask bits, in the order they are written in keybindings.lua. +const MODS = [[64, "SUPER"], [4, "CTRL"], [8, "ALT"], [1, "SHIFT"]]; + +function mods(mask) { + return MODS.filter(m => mask & m[0]).map(m => m[1]); +} + +function keys(bind) { + return mods(bind.modmask).concat([bind.key]).join(" + "); +} + +// A description reads "Group: Label". A bind without one still shows, last, +// so a gap in the config is visible rather than silently missing. +function group(binds) { + const groups = []; + const byName = {}; + const undescribed = []; + for (const b of binds) { + const d = b.description || ""; + if (!d) { + undescribed.push({ keys: keys(b), label: "no description" }); + continue; + } + const i = d.indexOf(": "); + const name = i < 0 ? "Other" : d.slice(0, i); + const label = i < 0 ? d : d.slice(i + 2); + if (!byName[name]) { + byName[name] = { name: name, rows: [] }; + groups.push(byName[name]); + } + byName[name].rows.push({ keys: keys(b), label: label }); + } + if (undescribed.length) groups.push({ name: "Undescribed", rows: undescribed }); + return groups; +} +``` + +Note: `.pragma library` must be the first line of a QML JS file, so it sits +above the licence header. + +- [ ] **Step 4: Run it, expect pass** + +Run: `node keybinds/test_binds.js` +Expected: `ok` + +- [ ] **Step 5: Commit** (in `quickshell`) + +```bash +git add keybinds/Binds.js keybinds/test_binds.js +git commit -m "feat(keybinds): group hyprctl binds by description" +``` + +--- + +### Task 2: the overlay component + +**Files:** +- Create: `keybinds/shell.qml` +- Create: `keybinds/Keybinds.qml` +- Create: `keybinds/Theme.qml` (symlink) +- Create: `keybinds/README.md` +- Modify: `README.md` (Implementations list), `AGENTS.md` (component list) + +- [ ] **Step 1: Theme symlink** + +```bash +ln -s ../shared/Theme.qml keybinds/Theme.qml +``` + +- [ ] **Step 2: `keybinds/shell.qml`** + +```qml +// 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 Quickshell +import Quickshell.Io + +ShellRoot { + Keybinds { id: keybinds } + + // Bound to SUPER + k in Hyprland: + // `qs -p <this dir> ipc call keybinds toggle` + IpcHandler { + target: "keybinds" + function toggle() { keybinds.toggle(); } + function show() { keybinds.open = true; } + function close() { keybinds.close(); } + } +} +``` + +- [ ] **Step 3: `keybinds/Keybinds.qml`** + +```qml +// 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 Quickshell +import Quickshell.Io +import Quickshell.Wayland +import QtQuick +import "Binds.js" as Binds + +Scope { + id: root + + property bool open: false + property string monitor: "DP-1" + property var groups: [] + // 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 screenObj: + Quickshell.screens.find(s => s.name === root.monitor) ?? Quickshell.screens[0] + + function toggle() { root.open = !root.open; } + function close() { root.open = false; } + + // Asked afresh on every opening, so the list is what Hyprland has loaded + // right now: an edit to keybindings.lua shows after `hyprctl reload`, and + // not before, which is when it becomes true. + onOpenChanged: if (open) { + root.status = "Loading..."; + // A Process already running ignores running = true. + proc.running = false; + proc.running = true; + } + + Process { + id: proc + command: ["hyprctl", "binds", "-j"] + stdout: StdioCollector { + onStreamFinished: { + try { + root.groups = Binds.group(JSON.parse(text)); + root.status = ""; + } catch (e) { + root.status = "hyprctl binds -j gave nothing readable"; + } + } + } + } + + // A config with no visible window exits, reporting nothing. This 1x1 + // click-through window holds the shell open while the overlay is hidden. + PanelWindow { + anchors { top: true; left: true } + implicitWidth: 1 + implicitHeight: 1 + color: "transparent" + exclusionMode: ExclusionMode.Ignore + mask: Region {} + WlrLayershell.keyboardFocus: WlrKeyboardFocus.None + } + + LazyLoader { + active: root.open + + PanelWindow { + screen: root.screenObj + anchors { top: true; left: true; right: true; bottom: true } + color: "transparent" + exclusionMode: ExclusionMode.Ignore + WlrLayershell.layer: WlrLayer.Overlay + WlrLayershell.namespace: "quickshell-keybinds" + WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive + + // Keys reach a focused item, not a window. + Item { + anchors.fill: parent + focus: true + Keys.onEscapePressed: root.close() + + Rectangle { + anchors.fill: parent + color: Qt.rgba(Theme.base.r, Theme.base.g, Theme.base.b, 0.82) + + MouseArea { + anchors.fill: parent + onClicked: root.close() + } + + Text { + anchors.centerIn: parent + visible: root.status !== "" + text: root.status + color: Theme.text + font.family: Theme.fontFamily + font.pixelSize: 22 + } + + Flow { + anchors.centerIn: parent + width: parent.width * 0.9 + spacing: 40 + visible: root.status === "" + + Repeater { + model: root.groups + + Column { + required property var modelData + width: 440 + spacing: 4 + + Text { + text: modelData.name + color: Theme.accent + font.family: Theme.fontFamily + font.pixelSize: 18 + font.bold: true + bottomPadding: 6 + } + + Repeater { + model: modelData.rows + + // Fixed widths summing to the column's: + // a Row sizes to its children. + Row { + required property var modelData + spacing: 12 + + Text { + width: 240 + elide: Text.ElideLeft + text: modelData.keys + color: Theme.subtext + font.family: Theme.fontFamily + font.pixelSize: 14 + } + Text { + width: 188 + elide: Text.ElideRight + text: modelData.label + color: Theme.text + font.family: Theme.fontFamily + font.pixelSize: 14 + } + } + } + } + } + } + } + } + } + } +} +``` + +- [ ] **Step 4: `keybinds/README.md`** + +````markdown +# keybinds + +Every Hyprland keybind with what it does, grouped, over a dimmed screen. +SUPER+k opens it, Escape or a click closes it. + +The list is `hyprctl binds -j`, asked on every opening, so it is what Hyprland +has loaded: binds generated in Lua loops included, and an edit to +`keybindings.lua` shows once `hyprctl reload` makes it real, not before. +There is no cache to go stale. + +`hyprctl` reports every Lua action as `__lua` with an opaque number, so the +text comes from each bind's `description` option, written `Group: Label`: + + hl.bind("SUPER + w", hl.dsp.exec_cmd(P.browser), { description = "Apps: Browser" }) + +The part before `: ` is the heading. A bind with no description is listed +last under "Undescribed", so a missing one shows instead of vanishing. + +## Running + + qs -p ./keybinds + qs -p ./keybinds ipc call keybinds toggle + +## Blur + +Optional, in the Hyprland config: + + hl.layer_rule({ + name = "blur-keybinds", + match = { namespace = "^(quickshell-keybinds)$" }, + blur = true, + xray = false, + ignore_alpha = 0.1, + }) + +## Check + + node keybinds/test_binds.js +```` + +- [ ] **Step 5: list the component** + +In `README.md` and `AGENTS.md`, add under the component list, after `window-switcher/`: + +``` + keybinds/ live keybind reminder, on SUPER+k +``` + +In `README.md` also change "They are three separate shells" to "They are separate shells". + +- [ ] **Step 6: smoke check** + +Run in the background, harness-owned (not `&`/`setsid`, see AGENTS.md): +`timeout 20 qs -p keybinds` with `run_in_background`, then +`qs -p keybinds ipc call keybinds show`, then read its output. +Expected: `Configuration Loaded`, no `ReferenceError`/`TypeError`. +Then `qs -p keybinds ipc call keybinds close`. + +- [ ] **Step 7: Commit** (in `quickshell`) + +```bash +git add keybinds README.md AGENTS.md +git commit -m "feat(keybinds): live keybind reminder overlay" +``` + +--- + +### Task 3: descriptions and SUPER+k in `hypr-theme` + +**Files:** +- Modify: `../hypr-theme/sections/keybindings.lua` (every `hl.bind`) +- Modify: `../hypr-theme/sections/autostart.lua:30` +- Modify: `../hypr-theme/sections/decorations.lua:67` + +- [ ] **Step 1: add a description to every bind** + +Append `, { description = "..." }` as the third argument, or add a +`description = "..."` field to the existing options table. Exact text: + +| Bind | Description | +| --- | --- | +| SUPER + w | Apps: Browser | +| SUPER + SHIFT + w | Apps: Private browser | +| SUPER + r | Apps: Code editor | +| SUPER + SHIFT + r | Apps: Typora | +| SUPER + g | Apps: GIMP | +| ALT + CTRL + SHIFT + l | Clipboard: Show as QR code | +| CTRL + SHIFT + l | Clipboard: History | +| ALT + Return | Apps: Terminal | +| ALT + SHIFT + Return | Apps: SSH menu | +| ALT + h | Layout: Cycle orientation | +| CTRL + SUPER + r | Media: Mute microphone input | +| ALT + w | Layout: Toggle group | +| SUPER + XF86AudioPrev | Layout: Previous layout | +| SUPER + XF86AudioNext | Layout: Next layout | +| CTRL + SUPER + period | Layout: Move to next column | +| CTRL + SUPER + comma | Layout: Move to previous column | +| XF86AudioPlay | Media: Play / pause | +| XF86AudioPrev | Media: Previous track | +| XF86AudioNext | Media: Next track | +| SUPER + XF86AudioRaiseVolume | Keyboard: Next layout | +| SUPER + XF86AudioLowerVolume | Keyboard: Previous layout | +| ALT + TAB | Desktop: Window switcher | +| SUPER + h | Apps: GitHub repos | +| SUPER + y | Apps: Passwords | +| SUPER + l | Session: Lock screen | +| SUPER + m | Apps: Signal | +| SUPER + e | Apps: File manager | +| SUPER + v | Desktop: VM manager | +| SUPER + period | Apps: Emoji picker | +| SUPER + Home | Apps: LLM chat | +| XF86Calculator | Apps: Calculator | +| XF86Mail | Apps: Mail | +| XF86Search | Apps: Discord | +| SUPER + Return | Desktop: Drawer | +| SUPER + F5 | Notes: Open note | +| SUPER + F6 | Notes: Add note | +| SUPER + F7 | Notes: Edit note | +| SUPER + F8 | Notes: Delete note | +| ALT + SHIFT + r | Session: Reload Hyprland | +| ALT + Escape | Session: Kill a window by click | +| SUPER + x | Session: Power menu | +| SUPER + SHIFT + s | Session: Screenshot menu | +| SUPER + c | Windows: Close | +| SUPER + SHIFT + Space | Windows: Toggle floating | +| ALT + F2 | Apps: Launcher | +| SUPER + left/right/up/down | Windows: Focus left / right / up / down | +| loop, focus | Workspaces: Go to N (built as `"Workspaces: Go to " .. g.wss[i]`) | +| loop, move | Workspaces: Move window to N (`"Workspaces: Move window to " .. g.wss[i]`) | +| SUPER + d | Workspaces: Toggle scratchpad | +| SUPER + SHIFT + d | Workspaces: Move window to scratchpad | +| SUPER + mouse_down | Workspaces: Next | +| SUPER + mouse_up | Workspaces: Previous | +| SUPER + mouse:272 | Windows: Drag to move | +| SUPER + mouse:273 | Windows: Drag to resize | +| XF86AudioRaiseVolume | Media: Volume up | +| XF86AudioLowerVolume | Media: Volume down | +| XF86AudioMute | Media: Mute | +| XF86AudioMicMute | Media: Mute microphone | +| XF86MonBrightnessUp | Media: Brightness up | +| XF86MonBrightnessDown | Media: Brightness down | +| CTRL + SUPER + mouse_up | Zoom: In | +| CTRL + SUPER + mouse_down | Zoom: Out | +| CTRL + SUPER + z | Zoom: Toggle | + +- [ ] **Step 2: bind SUPER + k**, under APP LAUNCHERS after the drawer bind: (`<repo>` is the absolute quickshell path, as in the existing binds; only the live config, not this repo, carries it): + +```lua +-- Keybind reminder (quickshell): lists `hyprctl binds -j`, so every bind +-- above needs a description to show what it does. +hl.bind(mainMod .. " + k", hl.dsp.exec_cmd("qs -p <repo>/keybinds ipc call keybinds toggle"), { description = "Desktop: Keybind reminder" }) +``` + +- [ ] **Step 3: autostart**, after the window-switcher line in `autostart.lua`: + +```lua + hl.exec_cmd("qs -p <repo>/keybinds") +``` + +- [ ] **Step 4: blur rule**, in `decorations.lua` after `blur-window-switcher`: + +```lua +-- Frosted glass for the quickshell keybind reminder. +hl.layer_rule({ + name = "blur-keybinds", + match = { namespace = "^(quickshell-keybinds)$" }, + blur = true, + xray = false, + ignore_alpha = 0.1, +}) +``` + +- [ ] **Step 5: reload and verify** + +```bash +hyprctl reload +hyprctl binds -j | python3 -c "import json,sys; b=json.load(sys.stdin); print(len(b), [(x['modmask'],x['key']) for x in b if not x['description']])" +``` + +Expected: `82 [(64, 's')]`, only the dashboard bind undescribed (Task 4). + +- [ ] **Step 6: Commit** (in `../hypr-theme`) + +```bash +git add sections/keybindings.lua sections/autostart.lua sections/decorations.lua +git commit -m "feat(keybindings): describe every bind for the keybind reminder" +``` + +--- + +### Task 4: dashboard bind in `conky-theme-udt` + +**Files:** +- Modify: `../conky-theme-udt/hypr/dashboard.lua` (the `SUPER + s` bind) + +- [ ] **Step 1:** + +```lua +hl.bind("SUPER + s", hl.dsp.workspace.toggle_special("dash"), { description = "Workspaces: Toggle dashboard" }) +``` + +- [ ] **Step 2: verify**: rerun Task 3 Step 5. Expected: `82 []`. + +- [ ] **Step 3: Commit** (in `../conky-theme-udt`) + +```bash +git add hypr/dashboard.lua +git commit -m "feat(dashboard): describe the dashboard bind" +``` + +--- + +### Task 5: hand over + +- [ ] The running session has no `keybinds` shell until next login. The user + starts it from their own terminal (`qs -p ~/Programming/GIT/quickshell/keybinds` + detached, or `./reload.sh keybinds`), presses SUPER+k, and judges the layout. |
