feat(qt): keyboard shortcuts cheatsheet and customize editor (B3)

km
This commit is contained in:
vicrodh committed 2026-08-03 14:29:49 -06:00
1 parent 5130e872e7
commit 07b0ba818e
4 files changed
+886

No files matched your search

+14
View File
@@ -694,6 +694,20 @@ Rectangle {
PlaylistEditModal {
anchors.fill: parent
}
// The hotkeys pair (2026-08-03 hotkeys-port contract §4.4/§4.5, block
// B3): the read-only cheatsheet (`?` / the HeaderBar menu row) and the
// editable customize editor. Same declaration-order convention as their
// neighbours — Slint mounts them with the global modals too
// (AppShell.slint:908-917, after the other modals, BEFORE the immersive
// overlay and the toast — so both keep painting above, byte-parity).
// Both self-gate on QbzHotkeys.cheatsheetOpen / .customizeOpen, so while
// closed each is an invisible, non-interactive Item.
KeyboardShortcutsModal {
anchors.fill: parent
}
CustomizeShortcutsModal {
anchors.fill: parent
}
QbzToast {
anchors.fill: parent
}
@@ -0,0 +1,478 @@
// CustomizeShortcutsModal — the EDITABLE keybindings editor, port of
// crates/qbz-ui/ui/shell/CustomizeShortcutsModal.slint (itself a 1:1 port of
// the Tauri KeybindingsSettings.svelte + ShortcutInput.svelte).
//
// Block B3 of the 2026-08-03 hotkeys-port contract (§4.5):
// shell same as the cheatsheet (scrim #bf000000, Radius.md surface-card,
// 1px border, faked 32px shadow), card width min(root - 80, 1080),
// height min(max(420, content), root - 80)
// header tr("Customize Shortcuts") + "N modified" ACCENT BADGE
// (modifiedCount > 0; text = count + " " + tr("modified")) + X.
// NO keyboard icon — the editor header has none
// (CustomizeShortcutsModal.slint:208-261, trap 6)
// body THREE round-robin columns from the SAME QbzHotkeys.groupsJson
// doc the cheatsheet renders
// keycap the capture button: max(120, label + 24) x 32, Radius.sm;
// border 2px while recording/modified else 1px; colour priority
// danger(conflict) -> accent(recording/modified) -> subtle;
// label = pendingDisplay live / tr("Press keys…") while recording /
// the formatted shortcut; monospace; click -> startRecord(id)
// reset per-row 30x30 ELEVATED 1px-bordered chip (NOT a ghost, trap 6),
// rotate-ccw icon, ONLY when the row is modified -> resetOne(id)
// footer tr("Reset All to Defaults") danger GHOST, opacity 0.3 + inert
// when modifiedCount == 0 -> resetAll()
//
// The actual key capture happens in RUST (contract §3.3): this modal only
// flips state via startRecord / cancelRecord and DISPLAYS the live pending
// combo + any conflict. The modal's FocusScope SWALLOWS ALL KEYS while
// recording and routes them to QbzHotkeys.keyPressed(key, modifiers, text,
// false) — capture arm A, the "belt and suspenders" of contract §1.1(A) (the
// Rust arm is the semantic owner; the QML swallow keeps the key from also
// reaching the AppShell root dispatcher via the focus chain). Escape =
// cancelRecord if recording (via the routed capture arm) else close. NOT
// recording -> non-Escape keys are REJECTED so they propagate to the root
// dispatcher (Slint parity: the global hook still fires bindings under the
// open editor).
//
// Scrim-click and X both cancel an in-flight recording before closing
// (Slint :151-158, :247-251) so we never leave a dangling capture.
//
// FOCUS: same lifecycle as the cheatsheet — 30ms deferred grab on the open
// edge, shell-root restore on the close edge (§1.4.3; see
// KeyboardShortcutsModal.qml's header for the full note).
import QtQuick
import com.blitzfc.qbz
import "../theme"
Item {
id: root
QbzTheme { id: theme }
function t(s) { return QbzSession.tr(s, QbzSession.trRev) }
// Same guarded parse as the cheatsheet (see its header — trap 15 covers
// the pre-publish frame, the try/catch covers a corrupt publish).
readonly property var doc: {
try {
return JSON.parse(QbzHotkeys.groupsJson)
} catch (e) {
return ({ "col1": [], "col2": [], "col3": [] })
}
}
readonly property bool recording: QbzHotkeys.recordingId !== ""
visible: QbzHotkeys.customizeOpen
enabled: root.visible
function _close() {
// Cancel an in-flight recording first — never leave a dangling
// capture (Slint :154-155).
if (root.recording)
QbzHotkeys.cancelRecord()
QbzHotkeys.customizeOpen = false
}
// §1.4.3 focus lifecycle (identical shape to the cheatsheet's — grab
// deferred 30ms on open, shell-root restore on the close edge).
Connections {
target: QbzHotkeys
function onCustomizeOpenChanged() {
if (QbzHotkeys.customizeOpen) {
focusGrab.restart()
} else {
var p = root
while (p.parent) {
if (p.parent.isQbzShellRoot === true) {
p.parent.forceActiveFocus()
return
}
p = p.parent
}
}
}
}
Timer {
id: focusGrab
interval: 30
repeat: false
onTriggered: keyScope.forceActiveFocus()
}
FocusScope {
id: keyScope
anchors.fill: parent
// Capture arm A (contract §4.5 / §1.1(A)): while a row is recording,
// swallow EVERY key here and route it to the Rust brain with
// textInputFocused = false. Escape cancels the recording inside the
// Rust capture arm; conflict keeps recording. NOT recording: Escape
// closes, everything else propagates to the AppShell dispatcher.
Keys.onPressed: function (event) {
if (root.recording) {
QbzHotkeys.keyPressed(event.key, event.modifiers, event.text, false)
event.accepted = true
return
}
if (event.key === Qt.Key_Escape) {
root._close()
event.accepted = true
}
}
// Scrim — a click outside the panel closes the editor AND cancels
// any in-progress recording (Slint :151-158).
Rectangle {
anchors.fill: parent
color: "#bf000000"
MouseArea {
anchors.fill: parent
onClicked: root._close()
}
}
// Faked 32px drop shadow (#00000080) — the QbzConfirmModal precedent.
Rectangle {
x: card.x
y: card.y + 8
width: card.width
height: card.height
radius: theme.radiusMd
color: "#00000080"
opacity: 0.5
}
Rectangle {
id: card
width: Math.min(root.width - 80, 1080)
// Slint :163-164: min(max(420, content), root - 80).
height: Math.min(Math.max(420, headerRow.height + footerRow.height + 2
+ bodyRow.implicitHeight + 32),
root.height - 80)
x: Math.round((root.width - width) / 2)
y: Math.round((root.height - height) / 2)
radius: theme.radiusMd
color: theme.surfaceCard
border.width: 1
border.color: theme.borderSubtle
// FIRST child: swallows clicks so they never reach the scrim.
MouseArea { anchors.fill: parent }
// --- Header: title + "N modified" badge + X (Slint :208-261) --
// NO keyboard icon here — the editor header has none (trap 6).
Item {
id: headerRow
anchors.top: parent.top
width: parent.width
height: 60 // Spacing.md (16) top/bottom around the 28px X
Text {
id: titleLabel
x: 20 // Spacing.lg
anchors.verticalCenter: parent.verticalCenter
text: root.t("Customize Shortcuts")
color: theme.textPrimary
font.pixelSize: theme.fontSection
font.weight: theme.weightSemibold
}
Rectangle {
id: modBadge
anchors.left: titleLabel.right
anchors.leftMargin: 10
anchors.verticalCenter: parent.verticalCenter
visible: QbzHotkeys.modifiedCount > 0
width: badgeLabel.implicitWidth + 16
height: 20
radius: theme.radiusSm
color: theme.accent
Text {
id: badgeLabel
anchors.centerIn: parent
text: QbzHotkeys.modifiedCount + " " + root.t("modified")
color: theme.accentText
font.pixelSize: theme.fontLegal
font.weight: theme.weightMedium
}
}
Item {
id: closeX
anchors.right: parent.right
anchors.rightMargin: 20
anchors.verticalCenter: parent.verticalCenter
width: 28
height: 28
QbzIcon {
anchors.centerIn: parent
width: 17
height: 17
name: "x"
tintName: closeArea.containsMouse ? "textPrimary" : "muted"
}
MouseArea {
id: closeArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: root._close()
}
}
}
Rectangle {
id: headerDivider
anchors.top: headerRow.bottom
width: parent.width
height: 1
color: theme.borderSubtle
}
// --- Body: scrollable three-column editor (Slint :270-301) ---
Flickable {
anchors.top: headerDivider.bottom
anchors.bottom: footerDivider.top
width: parent.width
clip: true
contentWidth: width
contentHeight: bodyContent.height
Item {
id: bodyContent
width: parent.width
// Spacing.md (16) top/bottom padding.
height: bodyRow.implicitHeight + 32
Row {
id: bodyRow
x: 20 // Spacing.lg
y: 16
width: parent.width - 40
spacing: 20 // Spacing.lg
KbEditColumn { width: (bodyRow.width - 40) / 3; groups: root.doc.col1 }
KbEditColumn { width: (bodyRow.width - 40) / 3; groups: root.doc.col2 }
KbEditColumn { width: (bodyRow.width - 40) / 3; groups: root.doc.col3 }
}
}
}
Rectangle {
id: footerDivider
anchors.bottom: footerRow.top
width: parent.width
height: 1
color: theme.borderSubtle
}
// --- Footer: "Reset All to Defaults" (Slint :310-360) --------
Item {
id: footerRow
anchors.bottom: parent.bottom
width: parent.width
height: 70 // Spacing.md (16) top/bottom around the 38px button
Rectangle {
id: resetAll
anchors.centerIn: parent
readonly property bool active: QbzHotkeys.modifiedCount > 0
width: resetAllRow.implicitWidth + 32 // padding 16 each side
height: 38
radius: theme.radiusSm
opacity: resetAll.active ? 1.0 : 0.3
// Danger GHOST: danger-bg fill on hover only, 1px danger
// border, danger label (theme.dangerBg IS Slint's
// Theme.danger-bg; QbzTheme.qml:71).
color: (resetAll.active && resetAllArea.containsMouse)
? theme.dangerBg : "transparent"
border.width: 1
border.color: theme.danger
Row {
id: resetAllRow
anchors.centerIn: parent
spacing: 8
QbzIcon {
anchors.verticalCenter: parent.verticalCenter
width: 14
height: 14
name: "rotate-ccw"
// "danger" is served by the runtime tint bake
// (icon_tint_qt.rs token_for; both glyphs are
// masters). The floor gap is the documented
// toast-class one: no qrc danger/ dir, so a dead
// bake renders nothing — same reachability as
// QbzToast's per-kind glyph.
tintName: "danger"
}
Text {
anchors.verticalCenter: parent.verticalCenter
text: root.t("Reset All to Defaults")
color: theme.danger
font.pixelSize: theme.fontBody
}
}
MouseArea {
id: resetAllArea
anchors.fill: parent
hoverEnabled: true
cursorShape: resetAll.active ? Qt.PointingHandCursor
: Qt.ArrowCursor
onClicked: if (resetAll.active) QbzHotkeys.resetAll()
}
}
}
}
}
// One editable category block (Slint KbEditGroup :31-141): the verbatim
// group header (NO divider — that is cheatsheet-only) + the capture rows.
component KbEditColumn: Column {
id: kbCol
property var groups: []
spacing: 20 // Spacing.lg between groups
Repeater {
model: kbCol.groups
delegate: Column {
id: kbGroup
width: kbCol.width
spacing: 8 // Spacing.sm (KbEditGroup's own spacing)
readonly property var group: modelData
Text {
// VERBATIM label — NOT uppercased (trap 6).
text: kbGroup.group.label
color: theme.textMuted
font.pixelSize: theme.fontLegal
font.weight: theme.weightSemibold
}
Repeater {
model: kbGroup.group.rows
delegate: Item {
id: entryRow
width: kbGroup.width
readonly property var entry: modelData
readonly property bool recording:
QbzHotkeys.recordingId === entryRow.entry.id
readonly property bool conflicting:
entryRow.recording && QbzHotkeys.conflictLabel !== ""
// Slint :45: min-height 36; the conflict line grows it.
height: Math.max(36, rightCol.implicitHeight)
// LEFT: action label.
Text {
anchors.left: parent.left
anchors.right: rightCol.left
anchors.rightMargin: 10
anchors.verticalCenter: parent.verticalCenter
text: entryRow.entry.label
color: theme.textPrimary
font.pixelSize: theme.fontBody
elide: Text.ElideRight
}
// RIGHT: keycap capture button + reset, the conflict
// message BELOW (Slint :56-139).
Column {
id: rightCol
anchors.right: parent.right
width: implicitWidth
spacing: 4
Row {
anchors.right: parent.right
width: implicitWidth
height: 32
spacing: 8
// Keycap capture button (Radius.sm rect, NOT
// a pill — ADR-008 / trap 6).
Rectangle {
id: capture
width: Math.max(120, capLabel.implicitWidth + 24)
height: 32
radius: theme.radiusSm
color: entryRow.recording
? theme.bgHover
: (capArea.containsMouse ? theme.surfaceHover
: theme.surfaceElevated)
border.width: (entryRow.recording || entryRow.entry.modified) ? 2 : 1
// Colour priority (trap 6): danger on
// conflict -> accent recording/modified
// -> subtle.
border.color: entryRow.conflicting
? theme.danger
: (entryRow.recording
? theme.accent
: (entryRow.entry.modified ? theme.accent
: theme.borderSubtle))
Text {
id: capLabel
anchors.centerIn: parent
// Live pending combo / "Press keys…"
// while recording / the formatted
// shortcut (Slint :84-88).
text: entryRow.recording
? (QbzHotkeys.pendingDisplay !== ""
? QbzHotkeys.pendingDisplay
: root.t("Press keys…"))
: entryRow.entry.shortcut
color: theme.textPrimary
font.pixelSize: theme.fontBody
font.weight: theme.weightMedium
font.family: "monospace"
}
MouseArea {
id: capArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: QbzHotkeys.startRecord(entryRow.entry.id)
}
}
// Per-row reset — ONLY when modified; a 30x30
// ELEVATED 1px-bordered chip, NOT a ghost
// (trap 6). Invisible children leave no gap
// in a Row.
Rectangle {
visible: entryRow.entry.modified
width: 30
height: 30
anchors.verticalCenter: parent.verticalCenter
radius: theme.radiusSm
color: resetArea.containsMouse ? theme.surfaceHover
: theme.surfaceElevated
border.width: 1
border.color: theme.borderSubtle
QbzIcon {
anchors.centerIn: parent
width: 14
height: 14
name: "rotate-ccw"
tintName: resetArea.containsMouse ? "textPrimary" : "muted"
}
MouseArea {
id: resetArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: QbzHotkeys.resetOne(entryRow.entry.id)
}
}
}
// Conflict message BELOW the keycap, danger,
// right-aligned (Slint :132-138).
Text {
visible: entryRow.conflicting
width: rightCol.width
horizontalAlignment: Text.AlignRight
text: root.t("Already used by") + " " + QbzHotkeys.conflictLabel
color: theme.danger
font.pixelSize: theme.fontLegal
}
}
}
}
}
}
}
}
@@ -0,0 +1,388 @@
// KeyboardShortcutsModal — the READ-ONLY hotkeys cheatsheet, port of
// crates/qbz-ui/ui/shell/KeyboardShortcutsModal.slint (itself a 1:1 port of
// the Tauri KeyboardShortcutsModal.svelte). Every keybinding grouped by
// category (Playback / Navigation / Interface / ...), each row showing the
// localized action label (left) and its formatted shortcut keycap (right).
// The footer "Customize Shortcuts" button hands off to the editable editor
// (CustomizeShortcutsModal.qml).
//
// Block B3 of the 2026-08-03 hotkeys-port contract (§4.4 — layout is
// CONTRACTUAL):
// scrim full-window #bf000000 (Slint #000000bf CONVERTED — Slint is
// #RRGGBBAA, Qt is #AARRGGBB), click closes
// card width min(root - 80, 980), height min(content, root - 80),
// Radius.md, surface-card, 1px border-subtle, 32px shadow (faked —
// the QbzConfirmModal/LoginScreen precedent: a translucent rounded
// rect behind the panel, because a real blurred drop shadow is a
// visual change owing its own parity pass)
// header keyboard icon 20px + tr("Keyboard Shortcuts") + X close
// body THREE round-robin columns from QbzHotkeys.groupsJson (the Rust
// brain splits the 5 groups cols[i%3], §3.4); group header = muted
// legal-size semibold label VERBATIM (NOT uppercased, trap 6) +
// 1px divider (the divider is cheatsheet-only — the editor has
// none); rows = elevated rects, label left + keycap chip right
// keycap Radius.sm rect, NOT a pill (ADR-008, trap 6), surface-card bg,
// 1px border-muted, monospace
// footer divider + centered 40px tr("Customize Shortcuts") button ->
// close + QbzHotkeys.openCustomize()
//
// Self-gated mount (the ADR-010 shape every AppShell global overlay uses):
// an invisible, non-interactive Item while QbzHotkeys.cheatsheetOpen is
// false. Opened by the `?` hotkey (ui.showShortcuts) and by the HeaderBar
// app-menu row (B2). Escape / scrim-click / X all close it.
//
// The data doc arrives as ONE JSON string ({col1,col2,col3} of
// {label, rows[{id,label,shortcut,modified,contextual}]}) because cxx-qt
// qproperties cannot express nested models — the same document shape
// QbzPlaylistEdit.editJson / QbzFolderEdit.editJson use. Labels arrive
// LOCALIZED (Rust-side qbz_i18n::t, B1); the parse is guarded because a raw
// JSON.parse in a binding throws on the pre-publish frame
// (PlaylistView.qml:55-61) — the bridge's construction-seeded default is the
// full shape (trap 15), but the guard also covers a corrupt publish.
//
// FOCUS (contract §4.4 + §1.4.3): the modal owns a FocusScope whose Escape
// closes (Item/FocusScope modal Escapes fire BEFORE the central §1.2 stack —
// trap 4, they accept the event). The grab is DEFERRED 30ms past the open
// edge (the Slint focus-timer :119-126, and this port's ImmersiveView.qml
// :105-112 idiom): focusing synchronously races the visibility transition.
// On the close edge Qt strands activeFocus on the now-invisible scope, which
// kills the AppShell key dispatcher until the next click — so every close
// path hands focus back to the shell root (the dispatcher's fallback focus
// item, §1.4.3; the duck-walk is QbzConfirmModal.qml's _restoreShellFocus).
import QtQuick
import com.blitzfc.qbz
import "../theme"
Item {
id: root
QbzTheme { id: theme }
function t(s) { return QbzSession.tr(s, QbzSession.trRev) }
// Guarded parse (see the header). Full-shape fallback so the three
// columns render empty instead of throwing on a bad frame.
readonly property var doc: {
try {
return JSON.parse(QbzHotkeys.groupsJson)
} catch (e) {
return ({ "col1": [], "col2": [], "col3": [] })
}
}
visible: QbzHotkeys.cheatsheetOpen
enabled: root.visible
function _close() {
QbzHotkeys.cheatsheetOpen = false
}
// §1.4.3: the FocusScope GRABS focus while open and Qt strands it on the
// now-invisible scope at close. Restore the shell root on the close edge
// (the edge, not the close functions, so the ui.escape stack arm 3 and
// closeAll() restore too, whichever path closed us).
Connections {
target: QbzHotkeys
function onCheatsheetOpenChanged() {
if (QbzHotkeys.cheatsheetOpen) {
focusGrab.restart()
} else {
var p = root
while (p.parent) {
if (p.parent.isQbzShellRoot === true) {
p.parent.forceActiveFocus()
return
}
p = p.parent
}
}
}
}
// 30ms deferred focus grab (the Slint focus-timer, :119-126;
// ImmersiveView.qml:105-112 in this port).
Timer {
id: focusGrab
interval: 30
repeat: false
onTriggered: keyScope.forceActiveFocus()
}
FocusScope {
id: keyScope
anchors.fill: parent
// The modal's OWN Escape (contract §4.4) — accepts, so the central
// §1.2 stack arm 3 never double-fires on the same press.
Keys.onEscapePressed: function (event) {
root._close()
event.accepted = true
}
// Scrim — click outside the card closes (Slint :87-92).
Rectangle {
anchors.fill: parent
color: "#bf000000"
MouseArea {
anchors.fill: parent
onClicked: root._close()
}
}
// Faked 32px drop shadow (#00000080) — see the header note.
Rectangle {
x: card.x
y: card.y + 8
width: card.width
height: card.height
radius: theme.radiusMd
color: "#00000080"
opacity: 0.5
}
Rectangle {
id: card
width: Math.min(root.width - 80, 980)
// Slint :100-105: chrome (header + footer + the two 1px dividers)
// + the body's real content height, capped at root - 80. The
// Flickable stretches to whatever room is left and scrolls.
height: Math.min(headerRow.height + footerRow.height + 2
+ bodyRow.implicitHeight + 40,
root.height - 80)
x: Math.round((root.width - width) / 2)
y: Math.round((root.height - height) / 2)
radius: theme.radiusMd
color: theme.surfaceCard
border.width: 1
border.color: theme.borderSubtle
// FIRST child: swallows clicks so they never reach the scrim
// (the Slint's empty panel TouchArea, :137).
MouseArea { anchors.fill: parent }
// --- Header: icon + title + X (Slint :143-184) ---------------
Item {
id: headerRow
anchors.top: parent.top
width: parent.width
height: 68 // padding 20 top/bottom around the 28px close-x
QbzIcon {
id: titleIcon
x: 20
anchors.verticalCenter: parent.verticalCenter
width: 20
height: 20
name: "keyboard"
tintName: "textPrimary"
}
Text {
anchors.left: titleIcon.right
anchors.leftMargin: 10
anchors.right: closeX.left
anchors.rightMargin: 10
anchors.verticalCenter: parent.verticalCenter
text: root.t("Keyboard Shortcuts")
color: theme.textPrimary
font.pixelSize: theme.fontHeading
font.weight: theme.weightSemibold
elide: Text.ElideRight
}
Item {
id: closeX
anchors.right: parent.right
anchors.rightMargin: 20
anchors.verticalCenter: parent.verticalCenter
width: 28
height: 28
QbzIcon {
anchors.centerIn: parent
width: 17
height: 17
name: "x"
tintName: closeArea.containsMouse ? "textPrimary" : "muted"
}
MouseArea {
id: closeArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: root._close()
}
}
}
Rectangle {
id: headerDivider
anchors.top: headerRow.bottom
width: parent.width
height: 1
color: theme.borderSubtle
}
// --- Body: scrollable grouped list (Slint :193-220) ----------
Flickable {
anchors.top: headerDivider.bottom
anchors.bottom: footerDivider.top
width: parent.width
clip: true
contentWidth: width
contentHeight: bodyContent.height
Item {
id: bodyContent
width: parent.width
height: bodyRow.implicitHeight + 40
// Groups laid out across THREE columns (round-robin by
// index — the Rust brain already split them, §3.4) so the
// cheatsheet fits without a tall scroll.
Row {
id: bodyRow
x: 20
y: 20
width: parent.width - 40
spacing: 24
KbCheatsheetColumn { width: (bodyRow.width - 48) / 3; groups: root.doc.col1 }
KbCheatsheetColumn { width: (bodyRow.width - 48) / 3; groups: root.doc.col2 }
KbCheatsheetColumn { width: (bodyRow.width - 48) / 3; groups: root.doc.col3 }
}
}
}
Rectangle {
id: footerDivider
anchors.bottom: footerRow.top
width: parent.width
height: 1
color: theme.borderSubtle
}
// --- Footer: Customize Shortcuts (Slint :229-255) ------------
Item {
id: footerRow
anchors.bottom: parent.bottom
width: parent.width
height: 72 // padding 16 top/bottom around the 40px button
Rectangle {
anchors.centerIn: parent
width: customizeLabel.implicitWidth + 40
height: 40
radius: theme.radiusSm
color: customizeArea.containsMouse ? theme.surfaceHover
: theme.surfaceElevated
border.width: 1
border.color: customizeArea.containsMouse ? theme.accent
: theme.borderSubtle
Text {
id: customizeLabel
anchors.centerIn: parent
text: root.t("Customize Shortcuts")
color: customizeArea.containsMouse ? theme.textPrimary
: theme.textSecondary
font.pixelSize: theme.fontBody
}
MouseArea {
id: customizeArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
// Slint :249-252: close the cheatsheet, open the
// editor (openCustomize recomputes the groups, §3.4).
onClicked: {
QbzHotkeys.cheatsheetOpen = false
QbzHotkeys.openCustomize()
}
}
}
}
}
}
// One category block (Slint KbGroupBlock :19-78): the verbatim group
// header + 1px divider (cheatsheet-only) + its binding rows. Inline
// component — used three times above, nowhere else.
component KbCheatsheetColumn: Column {
id: kbCol
// The column model: an array of {label, rows} from groupsJson.
property var groups: []
spacing: 24
Repeater {
model: kbCol.groups
delegate: Column {
id: kbGroup
width: kbCol.width
spacing: 12
// `group` captured once: inside the nested rows Repeater,
// `modelData` rebinds to the ROW.
readonly property var group: modelData
Column {
width: parent.width
spacing: 8
Text {
// VERBATIM label — NOT uppercased (trap 6).
text: kbGroup.group.label
color: theme.textMuted
font.pixelSize: theme.fontLegal
font.weight: theme.weightSemibold
}
Rectangle {
width: parent.width
height: 1
color: theme.borderSubtle
}
}
Repeater {
model: kbGroup.group.rows
delegate: Rectangle {
width: kbGroup.width
// Slint :38: entry-row.preferred-height + 16 — the
// row content (the keycap is the tallest child) plus
// the layout's 8+8 padding, plus a further 16.
height: keycapLabel.implicitHeight + 40
radius: theme.radiusSm
color: theme.surfaceElevated
Text {
anchors.left: parent.left
anchors.leftMargin: 12
anchors.right: keycap.left
anchors.rightMargin: 12
anchors.verticalCenter: parent.verticalCenter
text: modelData.label
color: theme.textPrimary
font.pixelSize: theme.fontBody
elide: Text.ElideRight
}
// Keycap — a subtle key-like chip (Radius.sm, NOT a
// pill, ADR-008 / trap 6).
Rectangle {
id: keycap
anchors.right: parent.right
anchors.rightMargin: 12
anchors.verticalCenter: parent.verticalCenter
width: keycapLabel.implicitWidth + 16
height: keycapLabel.implicitHeight + 8
radius: theme.radiusSm
color: theme.surfaceCard
border.width: 1
border.color: theme.borderMuted
Text {
id: keycapLabel
anchors.centerIn: parent
text: modelData.shortcut
color: theme.textSecondary
// fontBody so the arrow-key glyphs (← → ↑ ↓)
// read clearly (Slint :68-70); monospace per
// the contract's keycap rule.
font.pixelSize: theme.fontBody
font.family: "monospace"
}
}
}
}
}
}
}
}