Files
qbz/crates/qbz-qt/qml/controls/QbzCircleAction.qml
T
vicrodh e1876c15f0 feat(qt): the artist page's radio disc goes live, with a start-up indicator
The disc was btnEnabled: false with a comment saying neither engine had a seam
on a Qt bridge. Both do now. ArtistPageView.slint:426-461 opens a two-row
dropdown — QBZ Radio (the local qbz-radio pool builder) and Qobuz Radio
(/radio/artist) — and that is what this restores. The smart arm already
shipped behind Discover's spotlight RADIO card and simply was not wired here;
the Qobuz arm is new (get_radio_artist + the same enrich_radio_tracks the album
radio uses, because its track objects are just as bare).

Both labels already exist in all eight catalogues, so no new msgid.

AND THE WAIT IS VISIBLE NOW. A radio is a fetch, an enrich and a queue write;
until the first track resolves nothing on screen moves, so the click read as
dead. QbzCircleAction gains the `loading` arm the reference already has
(CircleAction.slint:18/:77) and QbzHome.radioPending carries the KEY of the
radio being built — "album:<id>" or "artist:<id>" — so the Discover rail spins
only the station that was clicked instead of all six.

The spinner rides QbzShell.pulseMs, NOT a RotationAnimation. Qt Quick has no
partial redraws, so a continuous animator presents the whole window at display
rate (~1.2% GPU per present on the owner's hybrid laptop); a 60 Hz spin for the
seconds a radio takes is exactly the bill the pulse law refuses. 12 degrees per
~30 Hz tick, and the Connections is disabled unless the button is spinning and
on screen, so at rest it writes nothing. theme/QbzSpinner.qml still uses a
RotationAnimation and is deliberately not reused.

On the cards, `loading` also pins the hover disc up: it only exists on hover,
so it vanished the moment the pointer left after a click, which is what made
the click look swallowed.

A RadioGuard disarms the key in Drop, so a bad id, a network error and an empty
result all clear it. A disc spinning forever is worse than no indicator.

cc
2026-08-19 23:55:07 -06:00

204 lines
10 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)
// :66 glyph — primary 19, secondary 15
// :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
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 onSurfaceTint: "textPrimary"
width: primary ? 44 : 32
height: primary ? 44 : 32
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.surfaceHover
: (theme.ambientOn ? theme.surfaceElevatedA50 : theme.surfaceElevated)))
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.onSurfaceTint)
}
QbzIcon {
visible: !root.loading
name: root.name
width: root.primary ? 19 : 15
height: root.primary ? 19 : 15
// 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.onSurfaceTint))
}
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) }
}
}