// 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() // Wheel-lock (the DiscoverConfigModal rule). onWheel: function (wheel) { wheel.accepted = true } } } // 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: "#80000000" 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 // Wheel-lock (the DiscoverConfigModal rule). onWheel: function (wheel) { wheel.accepted = true } } // --- 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 } } // Keymap preset. Switching swaps the BASE shortcut table // only — user overrides are kept and keep winning over it // (hotkeys_qt::active_bindings_with), so Default <-> Vim is // lossless. `keymapIndex` is republished with the groups, so // the selection and the keycaps cannot disagree. Row { id: keymapPicker anchors.right: closeX.left anchors.rightMargin: 12 anchors.verticalCenter: parent.verticalCenter spacing: 4 Text { anchors.verticalCenter: parent.verticalCenter rightPadding: 6 text: root.t("Keymap") color: theme.textMuted font.pixelSize: theme.fontLegal } Repeater { model: [{ "label": root.t("Default"), "index": 0 }, { "label": root.t("Vim"), "index": 1 }] delegate: Rectangle { id: seg required property var modelData readonly property bool current: QbzHotkeys.keymapIndex === seg.modelData.index width: segLabel.implicitWidth + 20 height: 26 radius: theme.radiusSm color: seg.current ? theme.accent : (segArea.containsMouse ? theme.surfaceHover : "transparent") border.width: 1 border.color: seg.current ? theme.accent : theme.borderSubtle Text { id: segLabel anchors.centerIn: parent text: seg.modelData.label color: seg.current ? theme.accentText : theme.textPrimary font.pixelSize: theme.fontLegal font.weight: theme.weightMedium } MouseArea { id: segArea anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor onClicked: QbzHotkeys.setKeymap(seg.modelData.index) } } } } 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 } } } } } } } }