From 9fbbdd902b91613672dc42faeff0b7f0ccb030f7 Mon Sep 17 00:00:00 2001 From: Gabriel Brown Date: Fri, 21 Aug 2026 23:53:50 -0400 Subject: [PATCH] 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. --- README.md | 2 +- config/dot/hypr/README.md | 1 + config/dot/hypr/keybinds.lua | 4 + .../modules/cheatsheet/Cheatsheet.qml | 227 ++++++++++++++++++ .../modules/cheatsheet/CheatsheetGroup.qml | 66 +++++ .../dot/quickshell/modules/cheatsheet/qmldir | 3 + config/dot/quickshell/services/Keybinds.qml | 12 +- config/dot/quickshell/services/ShellState.qml | 3 +- config/dot/quickshell/shell.qml | 20 ++ .../share/vicinae/scripts/keyboard-shortcuts | 9 + tests/quickshell/cheatsheet-contract | 125 ++++++++++ tests/quickshell/panama-commands-contract | 2 +- 12 files changed, 469 insertions(+), 5 deletions(-) create mode 100644 config/dot/quickshell/modules/cheatsheet/Cheatsheet.qml create mode 100644 config/dot/quickshell/modules/cheatsheet/CheatsheetGroup.qml create mode 100644 config/dot/quickshell/modules/cheatsheet/qmldir create mode 100755 config/local/share/vicinae/scripts/keyboard-shortcuts create mode 100755 tests/quickshell/cheatsheet-contract diff --git a/README.md b/README.md index f5c7a7f..66675a4 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/config/dot/hypr/README.md b/config/dot/hypr/README.md index 2d5a349..d3b0b8c 100644 --- a/config/dot/hypr/README.md +++ b/config/dot/hypr/README.md @@ -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 diff --git a/config/dot/hypr/keybinds.lua b/config/dot/hypr/keybinds.lua index 624b8c8..b35b87b 100644 --- a/config/dot/hypr/keybinds.lua +++ b/config/dot/hypr/keybinds.lua @@ -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"), diff --git a/config/dot/quickshell/modules/cheatsheet/Cheatsheet.qml b/config/dot/quickshell/modules/cheatsheet/Cheatsheet.qml new file mode 100644 index 0000000..a499f2c --- /dev/null +++ b/config/dot/quickshell/modules/cheatsheet/Cheatsheet.qml @@ -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() + } +} diff --git a/config/dot/quickshell/modules/cheatsheet/CheatsheetGroup.qml b/config/dot/quickshell/modules/cheatsheet/CheatsheetGroup.qml new file mode 100644 index 0000000..c4839fc --- /dev/null +++ b/config/dot/quickshell/modules/cheatsheet/CheatsheetGroup.qml @@ -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 + } + } + } +} diff --git a/config/dot/quickshell/modules/cheatsheet/qmldir b/config/dot/quickshell/modules/cheatsheet/qmldir new file mode 100644 index 0000000..ff5f96d --- /dev/null +++ b/config/dot/quickshell/modules/cheatsheet/qmldir @@ -0,0 +1,3 @@ +module qs.modules.cheatsheet +Cheatsheet 1.0 Cheatsheet.qml +CheatsheetGroup 1.0 CheatsheetGroup.qml diff --git a/config/dot/quickshell/services/Keybinds.qml b/config/dot/quickshell/services/Keybinds.qml index 38210c1..6eb1428 100644 --- a/config/dot/quickshell/services/Keybinds.qml +++ b/config/dot/quickshell/services/Keybinds.qml @@ -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,7 +278,8 @@ Singleton { parts.push(modifier.name); } const key = String(bind.key ?? ""); - parts.push(root.keyNames[key] ?? (key.length === 1 ? key.toUpperCase() : key)); + if (key !== "") + parts.push(root.keyNames[key] ?? (key.length === 1 ? key.toUpperCase() : key)); return parts.join(" + "); } diff --git a/config/dot/quickshell/services/ShellState.qml b/config/dot/quickshell/services/ShellState.qml index e55fbc0..7216eef 100644 --- a/config/dot/quickshell/services/ShellState.qml +++ b/config/dot/quickshell/services/ShellState.qml @@ -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. diff --git a/config/dot/quickshell/shell.qml b/config/dot/quickshell/shell.qml index 84e23ad..61f933d 100644 --- a/config/dot/quickshell/shell.qml +++ b/config/dot/quickshell/shell.qml @@ -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"); } diff --git a/config/local/share/vicinae/scripts/keyboard-shortcuts b/config/local/share/vicinae/scripts/keyboard-shortcuts new file mode 100755 index 0000000..3b06a9f --- /dev/null +++ b/config/local/share/vicinae/scripts/keyboard-shortcuts @@ -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 diff --git a/tests/quickshell/cheatsheet-contract b/tests/quickshell/cheatsheet-contract new file mode 100755 index 0000000..99a7a6a --- /dev/null +++ b/tests/quickshell/cheatsheet-contract @@ -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' diff --git a/tests/quickshell/panama-commands-contract b/tests/quickshell/panama-commands-contract index 844ea82..3a52749 100755 --- a/tests/quickshell/panama-commands-contract +++ b/tests/quickshell/panama-commands-contract @@ -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