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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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.
|
||||
|
||||
@@ -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"); }
|
||||
|
||||
Reference in New Issue
Block a user