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
220 lines
11 KiB
QML
220 lines
11 KiB
QML
// QbzCircleAction — the circular header action, BOTH arms of Slint
|
|
// primitives/CircleAction.slint. Consolidated in phase 22 from THREE copies
|
|
// (AlbumView.CircleBtn ≡ ArtistView.CircleBtn, PlaylistView.CircleBtn =
|
|
// superset with the `primary` + `btnEnabled` arms).
|
|
//
|
|
// Numbers, read off primitives/CircleAction.slint:
|
|
// :24 diameter — primary 44, secondary 32 (the hierarchy lives HERE, not
|
|
// in the callers; the port had 40 and was uniformly small). Compact
|
|
// headers may deliberately collapse the primary to the secondary
|
|
// footprint without giving up its accent palette.
|
|
// :66 glyph — primary 19, secondary 15; the compact primary follows the
|
|
// secondary footprint here too.
|
|
// :48 ON-SURFACE arm (`overlay: false`, the default): primary = accent
|
|
// disc, secondary = surface-elevated disc, hover/active surface-hover
|
|
// :60 ring — border-strong (the port used border-muted)
|
|
// :70 glyph tint — active accent; else primary WHITE, secondary
|
|
// text-primary. The port painted the on-surface primary glyph BLACK,
|
|
// which is the OTHER arm's colour.
|
|
// :56 OVERLAY arm (`overlay: true`) — for buttons sitting on the dark
|
|
// artwork atmosphere (the album / artist header once the header
|
|
// gradient is on): primary = solid white disc with a BLACK glyph,
|
|
// secondary = #ffffff24 fill (#ffffff3d hover/active), 1.5px
|
|
// #ffffffcc ring, white glyph.
|
|
// NOTE: the card hover-overlay circles are still the separate
|
|
// CardOverlayButton.qml — same palette, different sizing contract.
|
|
//
|
|
// --- LIGHT-THEME LEGIBILITY (Slint 2.0.2, "CircleAction buttons legible on
|
|
// --- light themes" — docs/release-2.0.2/CHANGELOG.md:213) ----------------
|
|
// The `on-surface` arm IS that fix: CircleAction.slint:30-37 exists exactly
|
|
// because "the default light-on-dark palette (white fills/rings/icons)
|
|
// vanishes on light themes' plain surface". Its secondary glyph is
|
|
// `Theme.text-primary` (CircleAction.slint:73) — the theme's max-contrast
|
|
// colour, DARK on a light theme.
|
|
//
|
|
// The port could not express that at first: icon tints were PRE-BAKED SVG
|
|
// variants only, and the "primary" bake is a hardcoded `fill="#ffffff"` — it
|
|
// is NOT text-primary, it is white. So the on-surface secondary (a
|
|
// `surface-elevated` disc, i.e. near-white on a light theme) painted a WHITE
|
|
// glyph on it: the exact vanishing act the .slint arm was written to stop.
|
|
// The stopgap was `onSurfaceTint = theme.isDark ? "primary" : "black"`, a
|
|
// 2-value approximation of text-primary.
|
|
//
|
|
// It is no longer needed. QbzIcon resolves theme-following tints through
|
|
// src/icon_tint_qt.rs, which bakes the live theme's ACTUAL text-primary hex,
|
|
// so this arm now says "textPrimary" and is exact under all 36 themes.
|
|
//
|
|
// The OVERLAY arm keeps its literals: it sits on hosts that are
|
|
// theme-independent — the artwork band, the solid white disc — and Slint
|
|
// hardcodes them for the same reason (CircleAction.slint:70-74, `#ffffff` /
|
|
// `#000000` literals next to `Theme.text-primary`). It spells them
|
|
// "white"/"black" now, not "primary", so no one re-reads the legacy alias as
|
|
// the theme token.
|
|
//
|
|
// The on-surface PRIMARY glyph USED to be in that group and is not any more.
|
|
// Its host is NOT theme-independent — it is `theme.accent` — and the fixed
|
|
// #ffffff CircleAction.slint:72 puts on it is unreadable on 16 of the 35
|
|
// shipped palettes (worst: high-contrast 1.70:1, then ikari 1.74, wcag-dark
|
|
// 1.82). It now takes `theme.accentGlyphTint`, the measured
|
|
// on-accent selector; see the note at the tint binding and the long rationale
|
|
// in theme/QbzTheme.qml.
|
|
|
|
import QtQuick
|
|
import com.blitzfc.qbz
|
|
import "../theme"
|
|
|
|
Rectangle {
|
|
id: root
|
|
property string name: ""
|
|
property bool active: false
|
|
property bool primary: false
|
|
/// Preserve the primary action palette while using the secondary action's
|
|
/// footprint. Album headers use this when their global compact mode is on.
|
|
property bool compactPrimary: false
|
|
/// Optional footprint for exceptionally dense hosts. Zero keeps the
|
|
/// canonical 44/32px sizes; compact album headers use 28px for every
|
|
/// action so Play no longer dominates the row.
|
|
property int diameterOverride: 0
|
|
property bool btnEnabled: true
|
|
/// While the action's work is in flight the glyph swaps for a spinning
|
|
/// arc (CircleAction.slint:18 / :77 — the reference has this arm and the
|
|
/// port did not, so every slow header action read as a dead click).
|
|
/// Clicks are gated for the duration: a second click cannot start the
|
|
/// same work twice, and it cannot re-open a dropdown over a request the
|
|
/// user already made.
|
|
property bool loading: false
|
|
/// Light-on-dark palette for a button painted over artwork/atmosphere
|
|
/// (CircleAction.slint's default arm; `on-surface: false` there).
|
|
property bool overlay: false
|
|
signal clicked(var mouse)
|
|
|
|
QbzTheme { id: theme }
|
|
|
|
/// Slint's `Theme.text-primary` glyph tint (CircleAction.slint:73),
|
|
/// runtime-tinted so it is the real token under every theme.
|
|
readonly property string surfaceTint: "textPrimary"
|
|
|
|
readonly property bool largePrimary: primary && !compactPrimary
|
|
readonly property int resolvedDiameter: diameterOverride > 0
|
|
? diameterOverride : (largePrimary ? 44 : 32)
|
|
|
|
width: resolvedDiameter
|
|
height: resolvedDiameter
|
|
radius: width / 2
|
|
color: overlay
|
|
? (primary
|
|
? (cbArea.containsMouse && btnEnabled ? "#d6ffffff" : "#ffffff")
|
|
: ((cbArea.containsMouse || active) ? "#3dffffff" : "#24ffffff"))
|
|
: (primary
|
|
? (cbArea.containsMouse && btnEnabled ? theme.accentHover : theme.accent)
|
|
// Resting fill translucent under the dynamic background
|
|
// (CircleAction.slint:53-55). The overlay variant above sits on
|
|
// artwork and keeps its white scale.
|
|
: ((cbArea.containsMouse || active)
|
|
? theme.elevatedHoverFill
|
|
: theme.elevatedFill))
|
|
border.width: primary ? 0 : 1.5
|
|
border.color: overlay ? "#ccffffff" : theme.borderStrong
|
|
// A loading button keeps FULL opacity — it is working, not unavailable,
|
|
// and dimming it would say the opposite of what the spinner says. Only
|
|
// `btnEnabled` dims.
|
|
opacity: btnEnabled ? 1.0 : 0.4
|
|
|
|
// --- The spinner arm --------------------------------------------------
|
|
// ON THE SHARED SHELL PULSE, never a RotationAnimation. Qt Quick has no
|
|
// partial redraws, so any continuous animator presents the WHOLE window at
|
|
// display rate — ~1.2% GPU per present on the owner's hybrid laptop
|
|
// (qt-frontend/2026-08-11-scenegraph-batches §9, THE PULSE LAW). A 60 Hz
|
|
// RotationAnimation for the two-to-five seconds a radio takes to build is
|
|
// exactly the bill that doctrine exists to refuse; theme/QbzSpinner.qml
|
|
// still uses one and is NOT reused here for that reason.
|
|
//
|
|
// 12 degrees per ~30 Hz pulse = one turn per second, the same rate and the
|
|
// same arithmetic as the offline manager's downloading glyph
|
|
// (OfflineManagerView.qml:154) and the transport's loading ring.
|
|
//
|
|
// The Connections is DISABLED unless the button is actually spinning and
|
|
// on screen, so at rest this component writes nothing at all — the other
|
|
// half of the law.
|
|
property real spinPhase: 0
|
|
Connections {
|
|
target: QbzShell
|
|
enabled: root.loading && root.visible
|
|
function onPulseMsChanged() { root.spinPhase = (root.spinPhase + 12) % 360 }
|
|
}
|
|
QbzIcon {
|
|
visible: root.loading
|
|
name: "loader-circle"
|
|
// CircleAction.slint:78 `size: diameter * 0.45` — 19.8 on the 44px
|
|
// primary, 14.4 on the 32px secondary, i.e. deliberately a hair
|
|
// smaller than the glyph it replaces so the disc does not look fuller
|
|
// while it waits.
|
|
width: Math.round(root.width * 0.45)
|
|
height: Math.round(root.height * 0.45)
|
|
x: Math.round((parent.width - width) / 2)
|
|
y: Math.round((parent.height - height) / 2)
|
|
rotation: root.spinPhase
|
|
// CircleAction.slint:80-83, the same four-way selector the glyph tint
|
|
// uses below: dark on the solid white overlay primary, white on the
|
|
// overlay ghost, themed on-surface.
|
|
tintName: root.overlay
|
|
? (root.primary ? "black" : "white")
|
|
: (root.primary ? theme.accentGlyphTint : root.surfaceTint)
|
|
}
|
|
|
|
QbzIcon {
|
|
visible: !root.loading
|
|
name: root.name
|
|
width: root.diameterOverride > 0
|
|
? Math.round(root.resolvedDiameter * 0.46)
|
|
: (root.largePrimary ? 19 : 15)
|
|
height: width
|
|
// Whole-pixel centring, both axes — CircleAction.slint:67-68 rounds
|
|
// them explicitly for the same reason: 44-19 and 32-15 are both ODD,
|
|
// so `anchors.centerIn` lands the glyph on 12.5px / 8.5px and the
|
|
// software renderer resamples it soft, right next to the now
|
|
// pixel-crisp 28px QbzNavButton chevrons.
|
|
x: Math.round((parent.width - width) / 2)
|
|
y: Math.round((parent.height - height) / 2)
|
|
// CircleAction.slint:70-74:
|
|
// active -> Theme.accent
|
|
// on-surface + primary -> #ffffff <- DELIBERATE DIVERGENCE, see
|
|
// below; this port uses the measured
|
|
// on-accent selector instead
|
|
// on-surface + secondary -> Theme.text-primary <- the light-theme
|
|
// case; a fixed white would vanish here
|
|
// overlay + primary -> #000000 (glyph on the white disc)
|
|
// overlay + secondary -> #ffffff
|
|
//
|
|
// The ONE divergence is the on-surface PRIMARY glyph. Its host is the
|
|
// 44px `theme.accent` disc (`color:` above, accentHover on hover —
|
|
// which is why the selector folds both fills in) and CircleAction.slint:72
|
|
// hardcodes #ffffff on it — which measures 1.70:1 on high-contrast,
|
|
// 1.74 on ikari, 1.82 on wcag-dark, 1.85 on kurosaki and so on for
|
|
// 16 of the 35 shipped palettes (the two accessibility themes lead
|
|
// that list, which is the strongest part of the argument).
|
|
// `theme.accentGlyphTint` is that decision made against the
|
|
// live accent (QbzTheme.qml, "ON AN ACCENT FILL"); it still resolves
|
|
// to a white glyph on Dark / Light / OLED, so the default themes'
|
|
// play button is unchanged.
|
|
//
|
|
// The OVERLAY arm keeps its literals: those hosts (the solid white
|
|
// disc, the artwork band) are theme-independent, so there is nothing
|
|
// to measure against and nothing to diverge from.
|
|
tintName: root.active
|
|
? "accent"
|
|
: (root.overlay
|
|
? (root.primary ? "black" : "white")
|
|
: (root.primary ? theme.accentGlyphTint : root.surfaceTint))
|
|
}
|
|
MouseArea {
|
|
id: cbArea
|
|
anchors.fill: parent
|
|
enabled: parent.btnEnabled && !parent.loading
|
|
hoverEnabled: true
|
|
cursorShape: (parent.btnEnabled && !parent.loading)
|
|
? Qt.PointingHandCursor : Qt.ArrowCursor
|
|
onClicked: function (mouse) { parent.clicked(mouse) }
|
|
}
|
|
}
|