Files
Panama/config/dot/quickshell/modules/cheatsheet/Cheatsheet.qml
T
Gabriel Brown 9fbbdd902b 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.
2026-08-21 23:53:50 -04:00

228 lines
7.9 KiB
QML

// 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()
}
}