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.
228 lines
7.9 KiB
QML
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()
|
|
}
|
|
}
|