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
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
+10 -2
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,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(" + ");
}
@@ -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