Files
qbz/crates/qbz-qt/qml/controls/QbzConfirmModal.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

312 lines
13 KiB
QML

// QbzConfirmModal — THE port's confirm dialog. Before this file the tree had
// no confirm surface at all (grep -rl confirm qml/ found only LyricsPanel and
// ArtistView, neither a dialog), so every destructive action either mutated
// straight away or was not shipped. It is written generic on purpose: the
// Blacklist manager's Clear-All is the first caller, and MyQBZ delete,
// playlist delete and the offline-cache purge all need the same surface.
//
// Geometry is 1:1 with the Slint Clear-All modal
// (settings/BlacklistManagerView.slint:992-1113), which itself mirrors
// PlaylistDuplicateConfirmModal:
// scrim full-bleed #000000bf, click dismisses
// panel width min(host - 80, 400), height = content, centered, Radius.md,
// surface-main, 1px border-subtle, drop-shadow blur 32 #00000080
// padding 24, spacing 16; title heading/semibold, body 15 text-secondary
// buttons right-aligned, spacing 10, both 36px tall, Radius.sm,
// width = label + 32
// danger fill Theme.danger, hover Theme.danger.darker(0.12) — NOT
// `danger-hover`, which is a translucent tint for GHOST danger
// surfaces and would wash a filled button out (the Slint comment at
// :1080-1089 spells this out at length); label hard #ffffff
//
// MOUNT IT LAST inside the view root with `anchors.fill: parent`. ADR-009's
// ">= 3000" is satisfied both structurally (declaration order IS z-order) and
// by the explicit z: 3100 below — the same layer the port's only other in-pane
// modal scrim sits on (controls/DiscoverConfigModal.qml:56), so the two never
// fight.
//
// The shadow is FAKED (a translucent rounded rect behind the panel) — the
// LoginScreen.qml:30-49 precedent, including its 2026-07-29 note: the old
// "effects render nothing here" doctrine is RETIRED (both effect modules are
// installed and this port runs on the GPU; RoundedImage.qml detects the
// software path with GraphicsInfo.api instead of assuming it). A real blurred
// drop shadow is a visual change owing its own parity pass, so the fake stays
// for now, exactly as LoginScreen's does. The port's only other in-pane modal
// (controls/DiscoverConfigModal.qml) draws no shadow at all.
import QtQuick
import com.blitzfc.qbz
import "../theme"
Item {
property bool kioskHost: false
id: root
property string title: ""
property string body: ""
/// "" is not a valid label — the default is the shared msgid, as a BINDING
/// so a live language switch re-translates it (QbzSession.trRev).
property string cancelLabel: QbzSession.tr("Cancel", QbzSession.trRev)
property string confirmLabel: ""
/// Filled-red confirm button (the Clear-All / delete voice). false gives
/// the accent fill instead, for a non-destructive confirmation.
///
/// NAMED `danger`, not `destructive`: spec 03 §6.10/§10 specifies `danger`,
/// and `controls/SettingsButton.qml:15` already carries `property bool
/// danger` for exactly this concept (red border + red label + a `favorite`
/// glyph tint). Two names for one idea across two neighbouring controls is
/// how a call site ends up setting a property that does not exist — which in
/// QML is not an error, just a silently-ignored line.
property bool danger: true
property string checkboxLabel: ""
property bool checkboxChecked: false
readonly property bool opened: root._open
property bool _open: false
signal confirmed()
signal cancelled()
QbzTheme { id: theme }
function open() {
root._open = true
// AFTER _open, so `visible` is already true — an invisible item cannot
// take active focus and the Esc handler would be dead.
Qt.callLater(function () { cancelButton.forceActiveFocus() })
}
function close() {
root._open = false
root._restoreShellFocus()
}
// §1.4.3 (2026-08-03 hotkeys-port contract): a modal control GRABS focus
// on open and Qt strands activeFocus on that now-invisible item at close,
// which kills the AppShell key dispatcher until the next click. Restore
// the shell root — the dispatcher's fallback focus item — on every close
// path. Duck-walk, because this modal mounts under several parents (the
// Blacklist manager, FolderModals, MyQBZ delete).
function _restoreShellFocus() {
var p = root
while (p.parent) {
if (p.parent.isQbzShellRoot === true) {
p.parent.forceActiveFocus()
return
}
p = p.parent
}
}
function _cancel() {
root.close()
root.cancelled()
}
function _confirm() {
root.close()
root.confirmed()
}
visible: root._open
enabled: root._open
z: 3100
Keys.onEscapePressed: function (event) {
root._cancel()
event.accepted = true
}
// Scrim. `radius` is load-bearing, not decoration: this Item fills the
// view root, which fills AppShell's rounded content frame, and Qt Quick's
// `clip` is a rectangular scissor that does not follow `radius` — so an
// opaque full-bleed child paints into the frame's four bezel corners
// (AppShell.qml:252-259, which names DiscoverConfigModal's scrim as the
// one that had to be fixed exactly this way).
Rectangle {
anchors.fill: parent
radius: theme.radiusMd
color: "#bf000000"
MouseArea {
anchors.fill: parent
onClicked: root._cancel()
// Wheel-lock (the DiscoverConfigModal rule).
onWheel: function (wheel) { wheel.accepted = true }
}
}
// Faked drop shadow (blur 32 / #00000080) — see the header note.
Rectangle {
x: panel.x
y: panel.y + 8
width: panel.width
height: panel.height
radius: theme.radiusMd
color: "#80000000"
opacity: 0.5
}
Rectangle {
id: panel
width: Math.min(root.width - (root.kioskHost ? 24 : 80), root.kioskHost ? 640 : 400)
// The Slint panel is `height: panel.preferred-height` over a
// VerticalLayout with padding 24 -> content + 48.
height: panelCol.implicitHeight + 48
x: Math.round((root.width - width) / 2)
y: Math.round((root.height - height) / 2)
radius: theme.radiusMd
color: theme.surfaceMain
border.width: 1
border.color: theme.borderSubtle
// Swallow clicks so they never reach the scrim (the Slint's empty
// panel TouchArea, :1030).
MouseArea {
anchors.fill: parent
// Wheel-lock (the DiscoverConfigModal rule).
onWheel: function (wheel) { wheel.accepted = true }
}
Column {
id: panelCol
x: 24
y: 24
width: parent.width - 48
spacing: 16
Text {
width: parent.width
text: root.title
color: theme.textPrimary
font.pixelSize: root.kioskHost ? (theme.fontHeading) * 1.2 : (theme.fontHeading)
font.weight: theme.weightSemibold
wrapMode: Text.WordWrap
}
Text {
visible: root.body !== ""
width: parent.width
text: root.body
color: theme.textSecondary
font.pixelSize: root.kioskHost ? (theme.fontBody) * 1.2 : (theme.fontBody)
wrapMode: Text.WordWrap
}
Row {
visible: root.checkboxLabel !== ""
width: parent.width
spacing: 10
QbzCheckbox {
kioskHost: root.kioskHost
checked: root.checkboxChecked
Accessible.name: root.checkboxLabel
onToggled: root.checkboxChecked = !root.checkboxChecked
}
Text {
width: parent.width - 54
text: root.checkboxLabel
color: theme.textSecondary
font.pixelSize: theme.fontBody
wrapMode: Text.WordWrap
MouseArea {
anchors.fill: parent
onClicked: root.checkboxChecked = !root.checkboxChecked
}
}
}
Item {
width: parent.width
height: root.kioskHost ? 44 : 36
Row {
anchors.right: parent.right
spacing: 10
Rectangle {
id: cancelButton
width: cancelLbl.implicitWidth + 32
height: root.kioskHost ? 44 : 36
radius: theme.radiusSm
color: cancelArea.containsMouse ? theme.surfaceElevatedHover
: theme.surfaceElevated
activeFocusOnTab: root.opened
border.width: activeFocus ? 2 : 0
border.color: theme.accent
Accessible.role: Accessible.Button
Accessible.name: root.cancelLabel
Accessible.onPressAction: root._cancel()
KeyNavigation.tab: confirmButton
KeyNavigation.backtab: confirmButton
Keys.onPressed: function (event) {
if (!event.isAutoRepeat
&& (event.key === Qt.Key_Space
|| event.key === Qt.Key_Return
|| event.key === Qt.Key_Enter)) {
root._cancel()
event.accepted = true
}
}
Text {
id: cancelLbl
anchors.centerIn: parent
text: root.cancelLabel
color: theme.textPrimary
font.pixelSize: root.kioskHost ? (theme.fontBody) * 1.2 : (theme.fontBody)
}
MouseArea {
id: cancelArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onPressed: cancelButton.forceActiveFocus()
onClicked: root._cancel()
}
}
Rectangle {
id: confirmButton
width: confirmLbl.implicitWidth + 32
height: root.kioskHost ? 44 : 36
radius: theme.radiusSm
color: root.danger
? (confirmArea.containsMouse ? Qt.darker(theme.danger, 1.12)
: theme.danger)
: (confirmArea.containsMouse ? theme.accentHover : theme.accent)
activeFocusOnTab: root.opened
border.width: activeFocus ? 2 : 0
border.color: root.danger ? "#ffffff" : theme.accentGlyphColor
Accessible.role: Accessible.Button
Accessible.name: root.confirmLabel
Accessible.onPressAction: root._confirm()
KeyNavigation.tab: cancelButton
KeyNavigation.backtab: cancelButton
Keys.onPressed: function (event) {
if (!event.isAutoRepeat
&& (event.key === Qt.Key_Space
|| event.key === Qt.Key_Return
|| event.key === Qt.Key_Enter)) {
root._confirm()
event.accepted = true
}
}
Text {
id: confirmLbl
anchors.centerIn: parent
text: root.confirmLabel
// The Slint hard-codes #ffffff on the filled danger
// button and that is correct here — `danger` is a
// fixed red under every palette. The accent arm goes
// through the theme's on-accent selector instead
// (QbzTheme.qml:314-317), which is the port's
// owner-approved WCAG deviation.
color: root.danger ? "#ffffff" : theme.accentGlyphColor
font.pixelSize: root.kioskHost ? (theme.fontBody) * 1.2 : (theme.fontBody)
font.weight: theme.weightMedium
}
MouseArea {
id: confirmArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onPressed: confirmButton.forceActiveFocus()
onClicked: root._confirm()
}
}
}
}
}
}
}