feat(qt): keyboard shortcuts cheatsheet and customize editor (B3)
km
This commit is contained in:
4 files changed
+886
No files matched your search
@@ -133,6 +133,12 @@ fn main() {
|
||||
"qml/shell/SidebarNowPlayingDock.qml",
|
||||
"qml/shell/AudioSettingsMenu.qml",
|
||||
"qml/shell/ViewModeMenu.qml",
|
||||
// Hotkeys (2026-08-03 hotkeys-port contract §4.4/§4.5, block
|
||||
// B3): the read-only cheatsheet + the editable customize
|
||||
// editor, both self-gated on QbzHotkeys and mounted in
|
||||
// AppShell with the global overlays.
|
||||
"qml/shell/KeyboardShortcutsModal.qml",
|
||||
"qml/shell/CustomizeShortcutsModal.qml",
|
||||
// Qobuz Connect (2026-08-01 contract §2): the ONE shared
|
||||
// device flyout both bars mount + the diagnostics modal
|
||||
// AppShell mounts last.
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user