Answer "what can I press" in one keypress

The Shortcuts settings page answers "how do I change this", which is
worth opening a window for. This answers the other question, the one
you have with your hands already on the keyboard, so it is an overlay
on SUPER + / and the same key closes it.

It reads Keybinds.grouped() rather than a written-down list, so a
shortcut rebound in Settings shows its new chord here with nothing kept
in sync. A cheatsheet that lies is worse than none: it gets consulted
exactly when somebody does not already know.

Three columns, balanced by how many shortcuts each category holds. The
first attempt used a Flow, which wraps into as many columns as it likes
and made 120 binds across six uneven categories unreadable; it also
sized the card from a child that filled it, which is a circular binding
and produced a card taller than the display with its contents running
off the bottom. Both were found by looking at it rather than by a test,
which is the argument for looking at it.

Fixes a real bug on the way past: luaChord and formatChord appended the
key unconditionally, so the window switcher's modifier-only release
bind became "SUPER + " with a dangling separator. That matched neither
the chord keybinds.lua binds nor the one an override is keyed by, so
that bind could never be rebound and had no category -- it was sitting
in a seventh group of its own, which is how it was noticed.
This commit is contained in:
Gabriel Brown
2026-08-21 23:53:50 -04:00
parent 6ae8265730
commit 9fbbdd902b
12 changed files with 469 additions and 5 deletions
+1 -1
View File
@@ -113,7 +113,7 @@ docs/ Settings reference, and the design specs behind the work
## Tests
140 of them, under `tests/`. Run the lot, or a subset by pattern:
141 of them, under `tests/`. Run the lot, or a subset by pattern:
```sh
panama test # everything
+1
View File
@@ -284,6 +284,7 @@ The mental model is unchanged from Forge:
| `SUPER + SHIFT + S` | Screen Intelligence — read text and codes from a selection |
| `SUPER + SHIFT + P` | Color picker |
| `CTRL + ALT + L` · `SUPER + Backspace` | Lock (SUPER+L is "focus right") |
| `SUPER + /` | Every shortcut, on screen. Reads the live keymap, so a rebind shows here |
| `CTRL + ALT + Delete` | Power menu |
### Apps
+4
View File
@@ -161,6 +161,10 @@ bind(mod .. " + Space", hl.dsp.exec_cmd(launcher), { description = "Launcher" })
-- application without dropping to a TTY. Depends on nothing but wofi itself.
bind(mod .. " + SHIFT + R", hl.dsp.exec_cmd("wofi"), { description = "Fallback launcher" })
-- Every shortcut, on one key. Slash because "what are the keys" is a question,
-- and because it is the one punctuation key no other bind wants.
bind(mod .. " + slash", hl.dsp.exec_cmd(qs("cheatsheet", "toggle")), { description = "Keyboard shortcuts" })
-- Clipboard history and emoji, straight into the relevant launcher view.
-- Deeplink form is the one from vicinae's own Hyprland quickstart.
bind(mod .. " + V", hl.dsp.exec_cmd("vicinae vicinae://launch/clipboard/history"),
@@ -0,0 +1,227 @@
// Every shortcut, on one keypress.
//
// This is a different job from the Shortcuts settings page, which answers "how
// do I change this" and is worth opening a window for. This answers "what can
// I press", which is a question you have while your hands are already on the
// keyboard and which needs answering in under a second. So it is an overlay,
// it is one key, and it closes on the same key.
//
// It reads the live keymap rather than a written-down copy: Keybinds.grouped()
// comes from `hyprctl binds`, so a shortcut somebody rebound in Settings shows
// its new chord here without anything being kept in sync. Categories come from
// hypr/keybinds.lua's manifest -- see Keybinds.categoryManifest.
//
// Three columns, balanced by how many shortcuts each category holds. Not a
// Flow: with about 120 binds across six wildly uneven categories -- Windows has
// forty, Session has three -- a Flow wraps into as many columns as it likes and
// the result is unreadable. The columns are computed instead.
import Quickshell
import Quickshell.Wayland
import QtQuick
import qs.config
import qs.services
import qs.widgets
PanelWindow {
id: root
readonly property bool open: ShellState.cheatsheetOpen
readonly property int columnCount: 3
// Categories dealt into columns, longest first, each going to whichever
// column is currently shortest. Sorting first is what stops the fortieth
// window bind landing in a column that already has thirty workspace ones.
readonly property var columns: {
const buckets = [];
for (let index = 0; index < root.columnCount; index++)
buckets.push({ groups: [], weight: 0 });
const groups = Array.from(Keybinds.grouped())
.sort((a, b) => b.binds.length - a.binds.length);
for (const group of groups) {
let target = buckets[0];
for (const bucket of buckets) {
if (bucket.weight < target.weight)
target = bucket;
}
target.groups.push(group);
// Two lines of overhead per heading, so a column of many small
// categories is not treated as shorter than it looks.
target.weight += group.binds.length + 2;
}
return buckets.map(bucket => bucket.groups);
}
anchors { top: true; bottom: true; left: true; right: true }
color: "transparent"
exclusiveZone: 0
exclusionMode: ExclusionMode.Ignore
// The `^qs-popover` prefix rule in hypr/rules.lua blurs what is behind
// this. The dimming is painted here rather than added to the overlay rule,
// the way PolkitPrompt does it, because this is a card on a scrim rather
// than a full-screen takeover.
WlrLayershell.namespace: "qs-popover-cheatsheet"
WlrLayershell.layer: WlrLayer.Overlay
WlrLayershell.keyboardFocus: root.open
? WlrKeyboardFocus.Exclusive
: WlrKeyboardFocus.None
// Stays mapped for the length of the close animation, or it vanishes
// instantly and only the opening is ever seen.
property bool mapped: false
visible: root.mapped
onOpenChanged: {
if (root.open) {
unmapTimer.stop();
root.mapped = true;
// The keymap can change while the session runs: a rebind in
// Settings, or a compositor reload.
Keybinds.refresh();
} else {
unmapTimer.restart();
}
}
Timer {
id: unmapTimer
interval: Theme.durNormal
onTriggered: root.mapped = false
}
Rectangle {
anchors.fill: parent
color: Theme.alpha(Theme.bgDark, Theme.overlayAlpha)
opacity: root.open ? 1 : 0
Behavior on opacity { NumberAnimation { duration: Theme.durNormal; easing.type: Easing.OutCubic } }
// Anywhere outside the card closes it, which is what every other
// transient surface on this desktop does.
MouseArea {
anchors.fill: parent
onClicked: ShellState.close()
}
}
Rectangle {
id: card
anchors.centerIn: parent
width: Math.min(root.width - 120, 1240)
// Sized from its content, capped at the screen. Deliberately NOT
// computed from a child that fills it: that is a circular binding, and
// it produced a card taller than the display with its contents running
// off the bottom edge.
height: Math.min(root.height - 120, header.height + body.contentHeight + 72)
radius: Theme.popoverRadius
color: Theme.alpha(Theme.bgPopover, Theme.popoverAlpha)
border.width: 1
border.color: Theme.alpha(Theme.fg, 0.1)
opacity: root.open ? 1 : 0
scale: root.open ? 1 : 0.98
Behavior on opacity { NumberAnimation { duration: Theme.durNormal; easing.type: Easing.OutCubic } }
Behavior on scale { NumberAnimation { duration: Theme.durNormal; easing.type: Easing.OutCubic } }
PrismEdge {
anchors.top: parent.top
anchors.left: parent.left
anchors.right: parent.right
inset: Theme.popoverRadius
}
// Clicks on the card itself must not fall through to the scrim.
MouseArea { anchors.fill: parent }
Item {
id: header
anchors.top: parent.top
anchors.left: parent.left
anchors.right: parent.right
anchors.margins: 28
height: title.implicitHeight
Text {
id: title
text: "Keyboard shortcuts"
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeLarge
font.weight: Font.DemiBold
}
Text {
anchors.right: parent.right
anchors.verticalCenter: title.verticalCenter
text: Keybinds.loaded
? Keybinds.binds.length + " shortcuts · Esc to close"
: "Reading the keymap…"
color: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
}
}
// Scrolls only when it has to. On a display tall enough for the whole
// keymap this never moves, which is the common case and the one worth
// optimising for -- a cheatsheet you have to scroll is a document.
Flickable {
id: body
anchors.top: header.bottom
anchors.topMargin: 18
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
anchors.leftMargin: 28
anchors.rightMargin: 28
anchors.bottomMargin: 28
contentWidth: width
contentHeight: columnRow.implicitHeight
clip: true
boundsBehavior: Flickable.StopAtBounds
Row {
id: columnRow
width: parent.width
spacing: 24
Repeater {
model: root.columns
Column {
required property var modelData
width: (columnRow.width - columnRow.spacing * (root.columnCount - 1)) / root.columnCount
spacing: 20
Repeater {
model: parent.modelData
CheatsheetGroup {
required property var modelData
width: parent.width
name: modelData.name
binds: modelData.binds
}
}
}
}
}
}
}
// Escape closes, like every other dialog here. The same chord that opened
// it also closes it, which ShellState.toggle handles.
Item {
anchors.fill: parent
focus: true
Keys.onEscapePressed: ShellState.close()
}
}
@@ -0,0 +1,66 @@
// One category of shortcuts, as a column.
//
// Deliberately plain: a chord on the left, what it does on the right, and no
// separators between rows. The cheatsheet is read at a glance while a key is
// held, so anything that draws the eye away from the two columns is in the way.
import QtQuick
import qs.config
Column {
id: root
required property string name
required property var binds
spacing: 3
Text {
text: root.name
color: Theme.accent
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.DemiBold
font.capitalization: Font.AllUppercase
font.letterSpacing: 0.6
bottomPadding: 5
}
Repeater {
model: root.binds
Item {
required property var modelData
width: root.width
height: chord.implicitHeight + 5
// The chord is monospaced and tabular so a column of them lines up
// rather than jittering with the width of each key name. This is
// the one place in the shell where a monospaced face is correct:
// it is showing keys, not prose.
Text {
id: chord
width: 168
text: modelData.chord
color: Theme.fg
font.family: Theme.fontMono
font.pixelSize: Theme.fontSizeSmall
font.features: Theme.tabularFigures
elide: Text.ElideRight
}
Text {
anchors.left: chord.right
anchors.leftMargin: 10
anchors.right: parent.right
anchors.verticalCenter: chord.verticalCenter
text: modelData.description
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
elide: Text.ElideRight
}
}
}
}
@@ -0,0 +1,3 @@
module qs.modules.cheatsheet
Cheatsheet 1.0 Cheatsheet.qml
CheatsheetGroup 1.0 CheatsheetGroup.qml
+9 -1
View File
@@ -260,7 +260,14 @@ Singleton {
if ((bind.modmask & modifier.bit) !== 0)
parts.push(modifier.name.toUpperCase());
}
parts.push(String(bind.key ?? ""));
// A modifier-only bind has no key at all -- the window switcher commits
// on Super RELEASE. Appending an empty string left a dangling "SUPER + "
// that matched neither the chord hypr/keybinds.lua binds nor the one an
// override would be keyed by, so that bind could never be rebound and
// never found its category.
const key = String(bind.key ?? "");
if (key !== "")
parts.push(key);
return parts.join(" + ");
}
@@ -271,6 +278,7 @@ Singleton {
parts.push(modifier.name);
}
const key = String(bind.key ?? "");
if (key !== "")
parts.push(root.keyNames[key] ?? (key.length === 1 ? key.toUpperCase() : key));
return parts.join(" + ");
}
@@ -17,7 +17,7 @@ Singleton {
id: root
// Exactly one of these may be non-empty at a time.
// "" | "overview" | "quicksettings" | "notifications" | "clipboard" | "capture" | "activity" | "powermenu"
// "" | "overview" | "quicksettings" | "notifications" | "clipboard" | "capture" | "activity" | "powermenu" | "cheatsheet"
property string activeOverlay: ""
readonly property bool overviewOpen: activeOverlay === "overview"
@@ -27,6 +27,7 @@ Singleton {
readonly property bool captureOpen: activeOverlay === "capture"
readonly property bool activityOpen: activeOverlay === "activity"
readonly property bool powerMenuOpen: activeOverlay === "powermenu"
readonly property bool cheatsheetOpen: activeOverlay === "cheatsheet"
// Settings is a normal application window rather than a transient overlay.
// It can stay open while Quick Settings or the notification center appears.
+20
View File
@@ -34,6 +34,7 @@ import qs.modules.quicksettings
import qs.modules.notifications
import qs.modules.capture
import qs.modules.powermenu
import qs.modules.cheatsheet
import qs.modules.clipboard
import qs.modules.datemenu
import qs.modules.focus
@@ -98,6 +99,7 @@ ShellRoot {
QuickSettings { id: quickSettings }
DateMenu { id: dateMenu }
ClipboardPanel {}
Cheatsheet {}
CaptureOverlay {}
IntelligenceResult {}
ActivityPanel {}
@@ -126,6 +128,24 @@ ShellRoot {
function cancel(): void { WindowSwitcherState.cancel(); }
}
// Every shortcut on one keypress. Toggle so the same chord closes it.
IpcHandler {
target: "cheatsheet"
function toggle(): void { ShellState.toggle("cheatsheet"); }
function open(): void { ShellState.open("cheatsheet"); }
function close(): void { ShellState.close(); }
function status(): string {
return JSON.stringify({
open: ShellState.cheatsheetOpen,
groups: Keybinds.grouped().map(group => group.name),
binds: Keybinds.binds.length,
uncategorised: Keybinds.binds
.filter(bind => !Keybinds.categoryManifest[bind.luaChord])
.map(bind => bind.chord)
});
}
}
IpcHandler {
target: "overview"
function toggle(): void { ShellState.toggle("overview"); }
+9
View File
@@ -0,0 +1,9 @@
#!/usr/bin/env bash
# @vicinae.schemaVersion 1
# @vicinae.title Keyboard Shortcuts
# @vicinae.mode silent
# @vicinae.icon ../../icons/hicolor/scalable/apps/panama-settings.svg
# @vicinae.description Every shortcut this desktop has, on one screen.
# @vicinae.keywords ["shortcuts", "keys", "keybinds", "cheatsheet", "hotkeys", "help"]
exec qs ipc call cheatsheet toggle
+125
View File
@@ -0,0 +1,125 @@
#!/usr/bin/env bash
# The cheatsheet: every shortcut, on one keypress.
#
# It answers a different question from the Shortcuts settings page. That page
# is "how do I change this" and is worth opening a window for; this is "what
# can I press", asked with your hands already on the keyboard. So the
# properties that matter are that it is one key away, that it closes on the
# same key, and above all that it cannot drift from the real keymap.
#
# That last one is why it reads Keybinds.grouped() rather than a written-down
# list: a shortcut rebound in Settings has to show its new chord here without
# anything being kept in sync. A cheatsheet that lies is worse than none,
# because it is consulted precisely when somebody does not already know.
#
# The live half opens the real surface and checks it maps. The layout is not
# asserted -- how many columns look right is a judgement, not a contract.
set -uo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
module="$repo_dir/config/dot/quickshell/modules/cheatsheet"
shell_qml="$repo_dir/config/dot/quickshell/shell.qml"
state="$repo_dir/config/dot/quickshell/services/ShellState.qml"
keybinds="$repo_dir/config/dot/hypr/keybinds.lua"
rules="$repo_dir/config/dot/hypr/rules.lua"
command_file="$repo_dir/config/local/share/vicinae/scripts/keyboard-shortcuts"
findings=()
note() { findings+=("$1"); }
# ── The module ───────────────────────────────────────────────────────────────
for file in Cheatsheet.qml CheatsheetGroup.qml qmldir; do
[[ -r "$module/$file" ]] || note "the cheatsheet module has no $file"
done
grep -q '^Cheatsheet 1.0 Cheatsheet.qml$' "$module/qmldir" \
|| note 'Cheatsheet is not registered in its qmldir, so the shell would fail to load entirely'
grep -q '^CheatsheetGroup 1.0 CheatsheetGroup.qml$' "$module/qmldir" \
|| note 'CheatsheetGroup is not registered in its qmldir'
# ── It reads the live keymap ─────────────────────────────────────────────────
grep -q 'Keybinds.grouped()' "$module/Cheatsheet.qml" \
|| note 'the cheatsheet does not read the live keymap, so it can drift from what the keys actually do'
grep -q 'Keybinds.refresh()' "$module/Cheatsheet.qml" \
|| note 'the cheatsheet does not re-read the keymap when opened, so a rebind would not show until the shell restarted'
# ── Wiring ───────────────────────────────────────────────────────────────────
grep -q 'import qs.modules.cheatsheet' "$shell_qml" \
|| note 'shell.qml does not import the cheatsheet module'
grep -qE '^\s*Cheatsheet \{\}' "$shell_qml" \
|| note 'shell.qml never instantiates the cheatsheet'
grep -q 'target: "cheatsheet"' "$shell_qml" \
|| note 'there is no cheatsheet IPC target'
grep -q 'cheatsheetOpen: activeOverlay === "cheatsheet"' "$state" \
|| note 'ShellState does not track the cheatsheet, so it would not close when another overlay opens'
grep -q '"cheatsheet"' <(grep 'Exactly one of these' -A2 "$state") \
|| note 'the ShellState overlay comment does not list the cheatsheet'
# The namespace has to match a layer rule or the surface gets no blur. The
# popover rule matches by prefix, which is why this name was chosen.
grep -q 'qs-popover-cheatsheet' "$module/Cheatsheet.qml" \
|| note 'the cheatsheet does not use a namespace beginning qs-popover, so it would be drawn without blur'
grep -q "namespace = \"^qs-popover\"" "$rules" \
|| note 'the qs-popover layer rule no longer matches by prefix, so the cheatsheet lost its blur'
# ── One key, and the same key closes it ──────────────────────────────────────
grep -q 'qs("cheatsheet", "toggle")' "$keybinds" \
|| note 'no keybind opens the cheatsheet, or it does not toggle so the same key cannot close it'
grep -qE 'bind\(mod \.\. " \+ slash".*description' "$keybinds" \
|| note 'the cheatsheet bind carries no description, which drops it from the keymap it is meant to show'
[[ -x "$command_file" ]] || note 'there is no launcher command for the cheatsheet'
# ── Live ─────────────────────────────────────────────────────────────────────
if ! command -v qs >/dev/null 2>&1 || ! qs ipc call cheatsheet status >/dev/null 2>&1; then
if (( ${#findings[@]} > 0 )); then
printf 'cheatsheet contract: %d finding(s)\n' "${#findings[@]}" >&2
printf ' - %s\n' "${findings[@]}" >&2
exit 1
fi
printf 'cheatsheet contract: PASS (static; no running shell)\n'
exit 0
fi
was_open="$(qs ipc call cheatsheet status | jq -r .open)"
qs ipc call cheatsheet open >/dev/null
sleep 1
[[ "$(qs ipc call cheatsheet status | jq -r .open)" == "true" ]] \
|| note 'the cheatsheet did not open'
hyprctl layers -j | grep -q 'qs-popover-cheatsheet' \
|| note 'the cheatsheet reports open but its layer never mapped, so nothing is on screen'
# Toggling closes it, which is what makes the opening chord also the closing one.
qs ipc call cheatsheet toggle >/dev/null
sleep 1
[[ "$(qs ipc call cheatsheet status | jq -r .open)" == "false" ]] \
|| note 'toggling an open cheatsheet did not close it'
status="$(qs ipc call cheatsheet status)"
groups="$(jq -r '.groups | length' <<<"$status")"
(( groups >= 4 )) || note "the cheatsheet shows $groups categories; the keymap has six"
(( groups <= 8 )) || note "the cheatsheet shows $groups categories, which suggests grouping fell back to guesswork"
# Every bind is categorised by hypr/keybinds.lua. One that is not falls back to
# substring derivation and lands in a group of its own, which is how the
# modifier-only switcher bind was found sitting in a seventh category by itself.
uncategorised="$(jq -r '.uncategorised | join(", ")' <<<"$status")"
[[ -z "$uncategorised" ]] \
|| note "these binds carry no authored category: $uncategorised"
[[ "$was_open" == "true" ]] && qs ipc call cheatsheet open >/dev/null
if (( ${#findings[@]} > 0 )); then
printf 'cheatsheet contract: %d finding(s)\n' "${#findings[@]}" >&2
printf ' - %s\n' "${findings[@]}" >&2
exit 1
fi
printf 'cheatsheet contract: PASS\n'
+1 -1
View File
@@ -77,7 +77,7 @@ declare -a standalone=(
lock-screen suspend-system log-out reboot-system power-off
remind-me list-reminders pick-color
switch-window force-quit-window kill-process ssh-hosts recent-files
copy-password
copy-password keyboard-shortcuts
)
# Generated commands must match their source. A stale command dispatches to a