aboutsummaryrefslogtreecommitdiffstats
path: root/docs
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-09-25 19:05:54 +0200
committerDanilo M. <danix@danix.xyz>2026-09-25 19:05:54 +0200
commit2fb72210ad5d8151718930b87fb7194467e600a4 (patch)
tree98d0a2c9e1301ec340ed734e5e0a4fb59eea4442 /docs
parent3ba00d86ab05bc1b01294d2db684c2eee37e8164 (diff)
downloadquickshell-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.md587
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.