Files
qbz/crates/qbz-qt/qml/shell/CustomizeShortcutsModal.qml
T
vicrodh ed7fa588ea fix(qt): elevated controls lift on hover instead of darkening
Buttons that rest on surfaceElevated used surfaceHover for their hover
fill: a 6% white wash meant for rows that rest transparent. Over an
elevated button it lands DARKER than the rest fill — on a modal's dark
ground the Cancel/Save/footer buttons went near-black under the pointer.

QbzTheme gains surfaceElevatedHover (a lift of surfaceElevated, derived
so every palette has it), its ambient half-alpha twin, and the
elevatedFill / elevatedHoverFill pair an ambient-aware control paints.
Every site with the old pairing (65 across modals, toolbars, selects,
circle actions, chips and cards) now hovers to the lift.

cc
2026-09-14 00:49:54 -06:00

540 lines
24 KiB
QML

// 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.surfaceElevatedHover
: 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.surfaceElevatedHover
: 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
}
}
}
}
}
}
}
}