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