From be2d0d200b09f3326076bccfefc1f656fcf55f78 Mon Sep 17 00:00:00 2001 From: Bernardo Magri Date: Wed, 15 Jul 2026 10:04:33 +0100 Subject: [PATCH] feat(menu): group keybinds cheatsheet Window/Workspace/Menu/Media (#108) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every bind in keybinds.nix carries a group; SUPER+? renders section headers. checks.keybinds-cheatsheet asserts all groups are valid and every description appears in the sheet (same source as Hyprland binds). Verified: V0 flake check; built cheatsheet shows ── Window ── / etc. --- agent/BACKLOG.md | 7 --- agent/JOURNAL.md | 10 +++++ docs/ROADMAP.md | 4 ++ flake.nix | 42 +++++++++++++++++ modules/home/keybinds.nix | 95 ++++++++++++++++++++------------------- modules/home/rofi.nix | 34 +++++++++++--- 6 files changed, 131 insertions(+), 61 deletions(-) diff --git a/agent/BACKLOG.md b/agent/BACKLOG.md index 5faa340..91c3bb9 100644 --- a/agent/BACKLOG.md +++ b/agent/BACKLOG.md @@ -261,13 +261,6 @@ scoped implementation/verification tasks before work starts. Pass = every current route has one deliberate home, navigation remains shallow, and no root-level entry is added or lost. -### 108. Keybindings menu presentation and completeness audit - -Group the menu as Window, Workspace, Menu, and Media, then prove every live -Hyprland binding appears from the canonical source. Do not repeat the obsolete -claim that float/move bindings are absent. Pass = presentation is scannable and -an automated comparison detects omissions or stale displayed bindings. - ### 110. `[big]` Retire Control Center safely Split this into two phases before implementation: first build a migration/drop diff --git a/agent/JOURNAL.md b/agent/JOURNAL.md index cb2142a..9247a12 100644 --- a/agent/JOURNAL.md +++ b/agent/JOURNAL.md @@ -19,6 +19,16 @@ Template: --- +## 2026-07-15 — #108 keybinds cheatsheet groups + completeness check +- **Task:** BACKLOG #108 — group SUPER+? as Window/Workspace/Menu/Media; + automated completeness. +- **Did:** every bind/extra in keybinds.nix gets `group`; cheatsheet renders + section headers; `checks.keybinds-cheatsheet` asserts groups + every desc + present (proved via assert). +- **Verified:** V0 flake check; cheatsheet file shows ── Window ── etc. +- **Pending:** V3 eyeball SUPER+? on a real session (optional). +- **Next suggestion:** #94 residual or #107 state.json rename. + ## 2026-07-15 — Fix batch #112 disk picker, #106 menu Back, #113 offline theme - **Task:** Second fix-only batch from NEXT/NOW residuals. - **Did:** (112) `list_installable_disks` excludes fd/loop/sr/zram/tiny diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 3956652..fc1b355 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -404,6 +404,10 @@ Design/decision records and a running log of shipped work (items marked decision rather than a drive-by. Proved to fail: dropping snapshot makes it name the missing entry. **V3 pending** — that the apps *launch* needs real hardware (HARDWARE-QUEUE, Acer M5-481T). +- ✓ **Keybinds cheatsheet groups (#108):** SUPER+? sections Window / + Workspace / Menu / Media from keybinds.nix `group`; + `checks.keybinds-cheatsheet` fails if a bind lacks a group or a desc is + missing from the sheet. - ✓ **Fix batch #112 / #106 / #113:** installer disk picker drops fd0/loop/ optical/<8 GiB and lists largest-first (OVMF no longer defaults to floppy); `checks.menu-back` guards every internal rofi list emits ↩ Back; offline diff --git a/flake.nix b/flake.nix index d640e4c..3efa1e7 100644 --- a/flake.nix +++ b/flake.nix @@ -477,6 +477,48 @@ touch $out ''; + # Keybinds cheatsheet completeness + grouping (#108): every bind + # from keybinds.nix has a group, and every desc appears in the + # rendered cheatsheet (same source hyprland.nix binds from). + keybinds-cheatsheet = + let + inherit (nixpkgs.lib) assertMsg concatLists concatMapStringsSep + concatStringsSep elem filter hasInfix; + kb = import ./modules/home/keybinds.nix; + groups = [ "Window" "Workspace" "Menu" "Media" ]; + allBinds = kb.binds ++ kb.multiLayoutBinds; + badGroup = filter (b: !(elem (b.group or null) groups)) allBinds; + extraBad = filter (e: !(elem (e.group or null) groups)) kb.extra; + descs = map (b: b.desc) allBinds ++ map (e: e.desc) kb.extra; + linesFor = g: + let rows = filter (b: b.group == g) allBinds + ++ filter (e: e.group == g) kb.extra; + in if rows == [ ] then [ ] + else [ ("── " + g + " ──") ] ++ map (r: r.desc) rows; + sheet = concatStringsSep "\n" (concatLists (map linesFor groups)); + missing = filter (d: !(hasInfix d sheet)) descs; + ok = badGroup == [ ] && extraBad == [ ] && missing == [ ] + && hasInfix "── Window ──" sheet + && hasInfix "── Workspace ──" sheet + && hasInfix "── Menu ──" sheet + && hasInfix "── Media ──" sheet; + msg = + (if badGroup != [ ] then + "binds missing/invalid group: " + + concatMapStringsSep ", " (b: b.desc) badGroup + "\n" + else "") + + (if extraBad != [ ] then + "extra missing/invalid group: " + + concatMapStringsSep ", " (e: e.desc) extraBad + "\n" + else "") + + (if missing != [ ] then + "descs missing from grouped sheet: " + + concatStringsSep ", " missing + "\n" + else ""); + in + assert assertMsg ok ("keybinds-cheatsheet: " + msg); + pkgs.runCommand "nomarchy-keybinds-cheatsheet" { } "touch $out"; + # Installer keyboard no-variant regression (item 91): gum's `(none)` # row is display-only. Exercise it through the real template # patcher, then build the exact us + empty-variant console keymap diff --git a/modules/home/keybinds.nix b/modules/home/keybinds.nix index 0e0827b..45dd291 100644 --- a/modules/home/keybinds.nix +++ b/modules/home/keybinds.nix @@ -7,85 +7,86 @@ # key — the key name # action — the Hyprland dispatcher (+args) that the bind runs # desc — human label, shown in the cheatsheet +# group — cheatsheet section: Window | Workspace | Menu | Media (#108) # # `extra` holds cheatsheet-only rows for binds generated elsewhere # (per-workspace numbers, mouse drags) so they still show up under SUPER+?. { binds = [ - { mods = "$mod"; key = "Return"; action = "exec, $terminal"; desc = "Open terminal"; } - { mods = "$mod"; key = "Space"; action = "exec, rofi -show drun -theme launcher"; desc = "App launcher"; } - { mods = "$mod"; key = "M"; action = "exec, nomarchy-menu"; desc = "Main menu"; } - { mods = "$mod"; key = "E"; action = "exec, $terminal -e yazi"; desc = "File manager (yazi)"; } - { mods = "$mod"; key = "Q"; action = "killactive"; desc = "Close window"; } - { mods = "$mod"; key = "F"; action = "fullscreen"; desc = "Fullscreen"; } - { mods = "$mod"; key = "V"; action = "togglefloating"; desc = "Toggle floating"; } - { mods = "$mod SHIFT"; key = "E"; action = "exit"; desc = "Exit Hyprland"; } + { mods = "$mod"; key = "Return"; action = "exec, $terminal"; desc = "Open terminal"; group = "Window"; } + { mods = "$mod"; key = "Space"; action = "exec, rofi -show drun -theme launcher"; desc = "App launcher"; group = "Menu"; } + { mods = "$mod"; key = "M"; action = "exec, nomarchy-menu"; desc = "Main menu"; group = "Menu"; } + { mods = "$mod"; key = "E"; action = "exec, $terminal -e yazi"; desc = "File manager (yazi)"; group = "Window"; } + { mods = "$mod"; key = "Q"; action = "killactive"; desc = "Close window"; group = "Window"; } + { mods = "$mod"; key = "F"; action = "fullscreen"; desc = "Fullscreen"; group = "Window"; } + { mods = "$mod"; key = "V"; action = "togglefloating"; desc = "Toggle floating"; group = "Window"; } + { mods = "$mod SHIFT"; key = "E"; action = "exit"; desc = "Exit Hyprland"; group = "Window"; } # Theme picker (menu dispatcher): apply writes the state and runs # home-manager switch (progress via notify-send). - { mods = "$mod"; key = "T"; action = "exec, nomarchy-menu theme"; desc = "Theme picker"; } + { mods = "$mod"; key = "T"; action = "exec, nomarchy-menu theme"; desc = "Theme picker"; group = "Menu"; } # Cycle the current theme's wallpapers (instant, no rebuild). - { mods = "$mod SHIFT"; key = "T"; action = "exec, nomarchy-theme-sync bg next"; desc = "Next wallpaper"; } + { mods = "$mod SHIFT"; key = "T"; action = "exec, nomarchy-theme-sync bg next"; desc = "Next wallpaper"; group = "Menu"; } # Power menu via the dispatcher. Not Escape: Super+Escape gets # swallowed before reaching the dispatcher on some setups. - { mods = "$mod"; key = "X"; action = "exec, nomarchy-menu power"; desc = "Power menu"; } + { mods = "$mod"; key = "X"; action = "exec, nomarchy-menu power"; desc = "Power menu"; group = "Menu"; } - { mods = "$mod"; key = "N"; action = "exec, swaync-client -t"; desc = "Notification centre"; } + { mods = "$mod"; key = "N"; action = "exec, swaync-client -t"; desc = "Notification centre"; group = "Media"; } # SUPER+? — ? is Shift+/. SHIFT stays in the modmask (you hold it), but # the keysym must be the BASE key `slash`, not `question`: Hyprland # resolves the sym with Shift consumed, so `question` never matches while # Shift is down — same as the `$mod SHIFT, 1` workspace binds. (item 26 # fixed the modmask but kept the shifted keysym → still dead; item 32.) # The cheatsheet still renders this row as SUPER + ? (rofi.nix). - { mods = "$mod SHIFT"; key = "slash"; action = "exec, nomarchy-menu keybinds"; desc = "Keybindings cheatsheet"; } + { mods = "$mod SHIFT"; key = "slash"; action = "exec, nomarchy-menu keybinds"; desc = "Keybindings cheatsheet"; group = "Menu"; } # Menu functions — SUPER+CTRL+ jumps straight to a # nomarchy-menu module (all also reachable from the SUPER+M picker). - { mods = "$mod CTRL"; key = "V"; action = "exec, nomarchy-menu clipboard"; desc = "Clipboard history"; } - { mods = "$mod CTRL"; key = "C"; action = "exec, nomarchy-menu calc"; desc = "Calculator"; } - { mods = "$mod CTRL"; key = "W"; action = "exec, nomarchy-menu web"; desc = "Web search"; } - { mods = "$mod CTRL"; key = "F"; action = "exec, nomarchy-menu files"; desc = "File search"; } - { mods = "$mod CTRL"; key = "E"; action = "exec, nomarchy-menu emoji"; desc = "Emoji picker"; } - { mods = "$mod CTRL"; key = "N"; action = "exec, nomarchy-menu network"; desc = "Network (networkmanager_dmenu)"; } - { mods = "$mod CTRL"; key = "B"; action = "exec, nomarchy-menu bluetooth"; desc = "Bluetooth"; } - { mods = "$mod CTRL"; key = "K"; action = "exec, nomarchy-menu keyboard"; desc = "Keyboard layout"; } - { mods = "$mod CTRL"; key = "S"; action = "exec, nomarchy-menu capture"; desc = "Screenshot / capture"; } - { mods = "$mod CTRL"; key = "P"; action = "exec, nomarchy-menu colorpicker"; desc = "Color picker (→ clipboard)"; } - { mods = "$mod CTRL"; key = "A"; action = "exec, nomarchy-menu ask"; desc = "Ask Claude"; } - { mods = "$mod CTRL"; key = "D"; action = "exec, nomarchy-menu dnd"; desc = "Do Not Disturb toggle"; } + { mods = "$mod CTRL"; key = "V"; action = "exec, nomarchy-menu clipboard"; desc = "Clipboard history"; group = "Menu"; } + { mods = "$mod CTRL"; key = "C"; action = "exec, nomarchy-menu calc"; desc = "Calculator"; group = "Menu"; } + { mods = "$mod CTRL"; key = "W"; action = "exec, nomarchy-menu web"; desc = "Web search"; group = "Menu"; } + { mods = "$mod CTRL"; key = "F"; action = "exec, nomarchy-menu files"; desc = "File search"; group = "Menu"; } + { mods = "$mod CTRL"; key = "E"; action = "exec, nomarchy-menu emoji"; desc = "Emoji picker"; group = "Menu"; } + { mods = "$mod CTRL"; key = "N"; action = "exec, nomarchy-menu network"; desc = "Network (networkmanager_dmenu)"; group = "Menu"; } + { mods = "$mod CTRL"; key = "B"; action = "exec, nomarchy-menu bluetooth"; desc = "Bluetooth"; group = "Menu"; } + { mods = "$mod CTRL"; key = "K"; action = "exec, nomarchy-menu keyboard"; desc = "Keyboard layout"; group = "Menu"; } + { mods = "$mod CTRL"; key = "S"; action = "exec, nomarchy-menu capture"; desc = "Screenshot / capture"; group = "Media"; } + { mods = "$mod CTRL"; key = "P"; action = "exec, nomarchy-menu colorpicker"; desc = "Color picker (→ clipboard)"; group = "Menu"; } + { mods = "$mod CTRL"; key = "A"; action = "exec, nomarchy-menu ask"; desc = "Ask Claude"; group = "Menu"; } + { mods = "$mod CTRL"; key = "D"; action = "exec, nomarchy-menu dnd"; desc = "Do Not Disturb toggle"; group = "Media"; } # I as in "settings" muscle memory (Super+I elsewhere); T for Tools. - { mods = "$mod CTRL"; key = "I"; action = "exec, nomarchy-menu system"; desc = "System menu"; } - { mods = "$mod CTRL"; key = "T"; action = "exec, nomarchy-menu tools"; desc = "Tools menu"; } + { mods = "$mod CTRL"; key = "I"; action = "exec, nomarchy-menu system"; desc = "System menu"; group = "Menu"; } + { mods = "$mod CTRL"; key = "T"; action = "exec, nomarchy-menu tools"; desc = "Tools menu"; group = "Menu"; } # The one SUPER+CTRL row that is not a menu module: lock earns the # family slot because bare SUPER+L is a common app bind and Escape is # unusable (see the power-menu note above). Same dispatcher the power # menu's Lock row uses (rofi.nix) — logind, not `hyprlock` directly, so # the session is marked locked and hypridle's lock_cmd stays the one # place that decides how a lock actually looks. - { mods = "$mod CTRL"; key = "L"; action = "exec, loginctl lock-session"; desc = "Lock screen"; } - { mods = "$mod SHIFT"; key = "C"; action = "exec, hyprpicker -a"; desc = "Color picker (→ clipboard)"; } + { mods = "$mod CTRL"; key = "L"; action = "exec, loginctl lock-session"; desc = "Lock screen"; group = "Menu"; } + { mods = "$mod SHIFT"; key = "C"; action = "exec, hyprpicker -a"; desc = "Color picker (→ clipboard)"; group = "Menu"; } # Focus — SUPER + arrow keys. - { mods = "$mod"; key = "left"; action = "movefocus, l"; desc = "Focus left"; } - { mods = "$mod"; key = "right"; action = "movefocus, r"; desc = "Focus right"; } - { mods = "$mod"; key = "up"; action = "movefocus, u"; desc = "Focus up"; } - { mods = "$mod"; key = "down"; action = "movefocus, d"; desc = "Focus down"; } + { mods = "$mod"; key = "left"; action = "movefocus, l"; desc = "Focus left"; group = "Window"; } + { mods = "$mod"; key = "right"; action = "movefocus, r"; desc = "Focus right"; group = "Window"; } + { mods = "$mod"; key = "up"; action = "movefocus, u"; desc = "Focus up"; group = "Window"; } + { mods = "$mod"; key = "down"; action = "movefocus, d"; desc = "Focus down"; group = "Window"; } # Multi-monitor workspace movement — SUPER + ALT + arrow keys. - { mods = "$mod ALT"; key = "left"; action = "movecurrentworkspacetomonitor, l"; desc = "Move workspace to left monitor"; } - { mods = "$mod ALT"; key = "right"; action = "movecurrentworkspacetomonitor, r"; desc = "Move workspace to right monitor"; } - { mods = "$mod ALT"; key = "up"; action = "movecurrentworkspacetomonitor, u"; desc = "Move workspace to upper monitor"; } - { mods = "$mod ALT"; key = "down"; action = "movecurrentworkspacetomonitor, d"; desc = "Move workspace to lower monitor"; } + { mods = "$mod ALT"; key = "left"; action = "movecurrentworkspacetomonitor, l"; desc = "Move workspace to left monitor"; group = "Workspace"; } + { mods = "$mod ALT"; key = "right"; action = "movecurrentworkspacetomonitor, r"; desc = "Move workspace to right monitor"; group = "Workspace"; } + { mods = "$mod ALT"; key = "up"; action = "movecurrentworkspacetomonitor, u"; desc = "Move workspace to upper monitor"; group = "Workspace"; } + { mods = "$mod ALT"; key = "down"; action = "movecurrentworkspacetomonitor, d"; desc = "Move workspace to lower monitor"; group = "Workspace"; } # Screenshots (the menu's Capture module has the rest: OCR, recording). # Bare Print → region to clipboard; the two → file binds save a # timestamped PNG under ~/Pictures/Screenshots and toast the path, the # same plumbing the Capture menu's "→ file" rows use. - { mods = ""; key = "Print"; action = "exec, grim -g \"$(slurp)\" - | wl-copy && notify-send Screenshot \"Region copied to clipboard.\""; desc = "Screenshot region → clipboard"; } - { mods = "SHIFT"; key = "Print"; action = "exec, f=$HOME/Pictures/Screenshots/$(date +%Y%m%d-%H%M%S).png; mkdir -p $HOME/Pictures/Screenshots && grim -g \"$(slurp)\" \"$f\" && notify-send \"Screenshot saved\" \"$f\""; desc = "Screenshot region → file"; } - { mods = "CTRL"; key = "Print"; action = "exec, f=$HOME/Pictures/Screenshots/$(date +%Y%m%d-%H%M%S).png; mkdir -p $HOME/Pictures/Screenshots && grim \"$f\" && notify-send \"Screenshot saved\" \"$f\""; desc = "Screenshot screen → file"; } - { mods = "$mod SHIFT"; key = "Print"; action = "exec, f=$HOME/Pictures/Screenshots/$(date +%Y%m%d-%H%M%S).png; mkdir -p $HOME/Pictures/Screenshots && grim -g \"$(slurp)\" - | satty --filename - --fullscreen --output-filename \"$f\""; desc = "Annotate region"; } + { mods = ""; key = "Print"; action = "exec, grim -g \"$(slurp)\" - | wl-copy && notify-send Screenshot \"Region copied to clipboard.\""; desc = "Screenshot region → clipboard"; group = "Media"; } + { mods = "SHIFT"; key = "Print"; action = "exec, f=$HOME/Pictures/Screenshots/$(date +%Y%m%d-%H%M%S).png; mkdir -p $HOME/Pictures/Screenshots && grim -g \"$(slurp)\" \"$f\" && notify-send \"Screenshot saved\" \"$f\""; desc = "Screenshot region → file"; group = "Media"; } + { mods = "CTRL"; key = "Print"; action = "exec, f=$HOME/Pictures/Screenshots/$(date +%Y%m%d-%H%M%S).png; mkdir -p $HOME/Pictures/Screenshots && grim \"$f\" && notify-send \"Screenshot saved\" \"$f\""; desc = "Screenshot screen → file"; group = "Media"; } + { mods = "$mod SHIFT"; key = "Print"; action = "exec, f=$HOME/Pictures/Screenshots/$(date +%Y%m%d-%H%M%S).png; mkdir -p $HOME/Pictures/Screenshots && grim -g \"$(slurp)\" - | satty --filename - --fullscreen --output-filename \"$f\""; desc = "Annotate region"; group = "Media"; } ]; # Rendered only when the session has >1 layout (a comma in @@ -94,14 +95,14 @@ # step. `current` targets the focused keyboard, so a board with its # own per-device layout (a single one) is a no-op, never a leak. multiLayoutBinds = [ - { mods = "$mod SHIFT"; key = "K"; action = "exec, hyprctl switchxkblayout current next"; desc = "Cycle keyboard layout"; } + { mods = "$mod SHIFT"; key = "K"; action = "exec, hyprctl switchxkblayout current next"; desc = "Cycle keyboard layout"; group = "Menu"; } ]; extra = [ - { keys = "SUPER + 1-9, 0"; desc = "Switch to workspace 1-10 (0 = 10)"; } - { keys = "SUPER + SHIFT + 1-9, 0"; desc = "Move window to workspace 1-10 (0 = 10)"; } - { keys = "SUPER + drag"; desc = "Move (LMB) / resize (RMB) window"; } - { keys = "Volume / Brightness"; desc = "Hardware keys, shown via the OSD"; } - { keys = "Bar: 󰾪 click"; desc = "Caffeine — hold the screen awake (idle inhibitor)"; } + { keys = "SUPER + 1-9, 0"; desc = "Switch to workspace 1-10 (0 = 10)"; group = "Workspace"; } + { keys = "SUPER + SHIFT + 1-9, 0"; desc = "Move window to workspace 1-10 (0 = 10)"; group = "Workspace"; } + { keys = "SUPER + drag"; desc = "Move (LMB) / resize (RMB) window"; group = "Window"; } + { keys = "Volume / Brightness"; desc = "Hardware keys, shown via the OSD"; group = "Media"; } + { keys = "Bar: 󰾪 click"; desc = "Caffeine — hold the screen awake (idle inhibitor)"; group = "Media"; } ]; } diff --git a/modules/home/rofi.nix b/modules/home/rofi.nix index 247893d..6f1edad 100644 --- a/modules/home/rofi.nix +++ b/modules/home/rofi.nix @@ -133,17 +133,37 @@ let # renders "SHIFT + /". `?` IS Shift+/, so collapse that back to the # single documented glyph — the cheatsheet reads "SUPER + ?". in lib.replaceStrings [ "SHIFT + /" ] [ "?" ] (prefix + key); - cheatRows = - map (b: padRight 22 (prettyKeys b) + b.desc) (keybinds.binds + # Grouped cheatsheet (#108): Window → Workspace → Menu → Media. + # Section headers are non-selectable chrome (rofi still shows them as rows; + # Enter is a no-op either way). Order matches the group enum. + cheatGroupOrder = [ "Window" "Workspace" "Menu" "Media" ]; + cheatBindRows = + map (b: { + inherit (b) group desc; + keys = prettyKeys b; + }) (keybinds.binds ++ lib.optionals (lib.hasInfix "," cfg.keyboard.layout) keybinds.multiLayoutBinds) # Launch-or-focus binds (nomarchy.launchOrFocus, generated in # hyprland.nix) — same renderer, so they land in the cheatsheet too. - ++ map (e: padRight 22 (prettyKeys e) - + (if e.desc != "" then e.desc - else "Focus or launch ${if e.command == "" then lib.toLower e.class else e.command}")) - cfg.launchOrFocus - ++ map (e: padRight 22 e.keys + e.desc) keybinds.extra; + ++ map (e: { + group = "Menu"; + keys = prettyKeys e; + desc = if e.desc != "" then e.desc + else "Focus or launch ${if e.command == "" then lib.toLower e.class else e.command}"; + }) cfg.launchOrFocus + ++ map (e: { + group = e.group or "Menu"; + keys = e.keys; + inherit (e) desc; + }) keybinds.extra; + cheatRowsFor = g: + let rows = lib.filter (r: r.group == g) cheatBindRows; + in lib.optionals (rows != [ ]) ( + [ ("── " + g + " ──") ] + ++ map (r: padRight 22 r.keys + r.desc) rows + ); + cheatRows = lib.concatLists (map cheatRowsFor cheatGroupOrder); cheatsheetFile = pkgs.writeText "nomarchy-keybinds.txt" (lib.concatStringsSep "\n" cheatRows + "\n");