feat(qt): custom-theme editor, artist portrait, playlist rail, About/What's New and diagnostics

Five surfaces the Qt frontend was missing, landed together because they share
five wiring files (build.rs, main.rs, shell_bridge.rs, AppShell.qml,
HeaderBar.qml) and splitting them would produce commits that do not build.

ARTIST PAGE
- The Playlists section used a hand-rolled Rectangle delegate: a MouseArea with
  no onClicked, and a RoundedImage with no fit:, so the default crop blew an
  800x380 playlist banner up inside a 200x200 box and showed the middle 47% at
  2.1x. Replaced by the real cards/PlaylistCard.qml — already live on five other
  surfaces — mounted in a new views/PlaylistRail.qml modelled on SectionRail.
  ArtistPlaylist publishes the five roles the card reads.
- The portrait gains what the album cover has: the right-click menu the Slint
  reference already has (Add/Change/Remove image, Open in browser, Save as…) and
  the lightbox this port invented for albums and playlists. Custom images are
  keyed by artist NAME into the same custom_artwork.json the Slint build writes,
  so they round-trip between the two.
- CoverLightbox is now aspect-aware. It forced a square box on the premise that
  covers are square; artist portraits are not (1080x720 measured in the cache).
- Removing an override invalidates the pipeline's resolved path for that url:
  artwork_qt::cached_path consults the override FIRST, so coverMap held the
  custom file and the header kept showing the picture the user had just deleted.
- artwork_qt::local_path is the inverse of file_url. trim_start_matches("file://")
  is not: it leaves the percent-escapes in place, so any path holding % # or ?
  came back as a name that does not exist on disk.
- One new msgid, "View image", in the seven non-en locales — matching its sibling
  "View cover", since en carries none of that family and falls back to the msgid.

CUSTOM THEME
- The editor, the colour picker and the swatch grid, ported 1:1 from
  AppearanceSettings.slint and ColorPicker.slint. The 11 editable base tokens,
  their expansion into the full 55-value palette and the on-disk format all stay
  in the shared qbz-theme crate, so a theme authored in the shipping Slint build
  already rendered here and now round-trips both ways.
- Hue is normalised 0..1 at every Qt.hsva boundary; the reference stores 0..360,
  and porting the number instead of the domain would render every swatch red.
- The first-selection seed is captured BEFORE set_theme persists the slug,
  because current_slug() re-reads it from disk — a seed taken afterwards would
  resolve "custom", find no file and snapshot the default it was meant to replace.
- The in-memory base is dropped when the theme row enters "custom": it is
  memoized for the life of the process and filled at bridge construction, so a
  process that booted without the file would keep serving the OLED default even
  after the shipping Slint build wrote the user's theme.
- The disk write is debounced to drag-end and atomic; the reference persists on
  every drag frame with a truncating write, on a file the other build has open.

ABOUT + WHAT'S NEW
- Both modals, with their producers. render_markdown is transcribed line for line
  from the reference: h1 and h2 collapse to one level, an indent-0 bullet becomes
  a section plus a TOC entry, bold and backticks are stripped rather than styled.
  Text.MarkdownText is a different subset and would have rendered a different
  document from the same source. Pinned by tests, including the real 2.0.2 body.
- The codename is a single const, so the release checklist has one Qt-side place
  to bump.

DIAGNOSTICS
- The panel in Settings > Developer, the Report an Issue modal and its menu row.
- The log bundle carried eight hand-built header fields where the reference
  carries about seventy, so every issue filed from a Qt build arrived with no
  kernel, no distro, no install method, no GPU, no exclusive-mode / DAC /
  sample-rate state and no playback context. The report is injected INTO
  qbz_log::bundle rather than replacing it, keeping the <details> wrapper that
  makes a pasted bundle readable.
- The GTK / WebKit / GSK / GDK rows are CUT. In a Qt process those libraries are
  not mapped, so their runtime column can only read "—", and their saved column
  would come from a graphics.json this binary never reads or writes — foreign
  state presented as this app's configuration.

SHELL
- The four always-on-top overlays (toast, tooltip, art preview, immersive) relied
  on declaration order and carried no z, so any z-bearing modal buried them: a
  toast raised by a background event behind an opaque panel is never seen. They
  now sit in a band above the modals, keeping their relative order.
- build.rs asked cargo to watch ../../.git/HEAD, which does not exist in a git
  worktree (.git is a file). Cargo treats a missing rerun-if-changed target as
  always-dirty, so the whole build script — per-shader qsb, the recursive asset
  sweep, cxx-qt/moc codegen — re-ran on every cargo invocation.

Verified: cargo test -p qbz-qt 343 passed, cargo test -p qbz-library 83 passed,
qml_singleton_xref and qml_resolution_audit clean over 35 bridges and 265 QML
files, and qmlcachegen compiles every one of them. Smoke-tested by hand.

Contracts, recon and adversarial reviews:
qbz-nix-docs/qt-frontend/2026-08-14-six-item-round/

cc
This commit is contained in:
vicrodh committed 2026-08-15 00:52:25 -06:00
1 parent 4bfd8b30e2
commit 8bd02e35d3
46 files changed
+6365 -148

No files matched your search

+180 -64
View File
@@ -1,6 +1,8 @@
// Artist detail page — QML port of artist/ArtistPageView.slint.
//
// Header (200px circular portrait, name, bio + Read more, CircleAction
// Header (200px circular portrait — right-click for the image menu
// (Add/Change/Remove custom image, View image, Open in browser, Save as…),
// left-click for the lightbox — name, bio + Read more, CircleAction
// row: Follow / Radio / Network / ⋯, From-catalog/In-library toggle),
// JUMP TO bar (jump-scroll), Popular Tracks (artwork + album column rows,
// Load more 5→all, play/shuffle-all), Latest release, release sections
@@ -803,6 +805,7 @@ Rectangle {
}
onArtistChanged: {
syncArtistState()
invalidatePortraitOverride()
dispatchCovers()
}
// Cover dispatch keys off the raw document (artist.artUrl etc.), so
@@ -925,6 +928,29 @@ Rectangle {
if (fresh.length > 0) QbzShell.sidebarArtworkWindow(JSON.stringify(fresh))
}
// A custom portrait is consulted by `artwork_qt::cached_path` (it checks
// `cover_artwork_qt::override_for_url` FIRST), so once one exists
// `coverMap[artist.artUrl]` holds the OVERRIDE file, not the Qobuz one.
// Removing the override has to invalidate that entry as well, or the
// header and the lightbox keep showing the picture the user just deleted:
// `dispatchedCovers` dedupes by url and is cleared only on an artist
// change, so the republish alone never re-resolves it. Add/Change need the
// same invalidation in reverse.
property string _lastCustomPortrait: ""
function invalidatePortraitOverride() {
var cur = (artist && artist.customImagePath) ? artist.customImagePath : ""
if (cur === root._lastCustomPortrait) return
root._lastCustomPortrait = cur
var url = (artist && artist.artUrl) ? artist.artUrl : ""
if (url === "") return
var m = Object.assign({}, root.coverMap)
delete m[url]
root.coverMap = m
// Drop the dedupe key too, so the dispatchCovers() that follows in the
// same handler actually re-requests it.
delete root.dispatchedCovers[url]
}
// THE RULE FOR THIS FUNCTION: every section of the page that binds
// `root.coverMap` has to contribute its urls here, or its covers are
// never requested and the section renders empty tiles forever — nothing
@@ -1999,19 +2025,49 @@ Rectangle {
width: parent.width - 64
spacing: 32
// Circular portrait (rounded Rectangle + clip round-clips
// on this Qt build — verified against the phase-3 circles).
// Circular portrait. The circle comes from RoundedImage's
// MASK, not from a clip: QML's `clip` is rectangular and does
// NOT follow a Rectangle's radius — theme/RoundedImage.qml:3-6
// says so and proved it with an isolated scene on this Qt
// build, and :508 measures this exact radius-100-on-200px
// case. The `clip: true` that used to sit here (with a comment
// claiming the opposite) therefore rounded nothing, cost an
// unconditional batch root, and would have swallowed anything
// mounted inside the frame — which is why the menu and the
// lightbox are view-root siblings, exactly as
// AlbumView.qml:586-588 keeps them.
Rectangle {
width: 200
height: 200
radius: 100
color: theme.surfaceElevated
clip: true
RoundedImage {
anchors.fill: parent
source: root.coverMap[artist.artUrl] || ""
// A custom portrait (the SHARED custom_artwork store,
// keyed by artist NAME) beats the url-keyed pipeline
// image — the album header's rule on the artist axis.
source: (artist.customImageUrl || "") !== ""
? artist.customImageUrl
: (root.coverMap[artist.artUrl] || "")
radius: 100
}
// Left-click: the lightbox (NEW in this port — the
// reference lets left clicks pass through,
// ArtistPageView.slint:290-293). Right-click: the portrait
// menu the reference DOES have (ArtistPageView.slint:292-353),
// which this port simply never paid.
MouseArea {
anchors.fill: parent
acceptedButtons: Qt.LeftButton | Qt.RightButton
cursorShape: Qt.PointingHandCursor
onClicked: function (mouse) {
if (mouse.button === Qt.RightButton)
portraitMenu.openAtCursor(portraitMenuAnchor, mouse.x, mouse.y)
else if (root.bestArtistImage() !== "")
portraitLightbox.openWith(root.bestArtistImage())
}
}
Item { id: portraitMenuAnchor; anchors.fill: parent }
}
Column {
@@ -2509,67 +2565,27 @@ Rectangle {
}
// --- Playlists --------------------------------------------
Column {
// The reference mounts the SHARED playlist carousel here
// (ArtistPageView.slint:985-995 -> discover/PlaylistCarousel
// -> discover/PlaylistCard), so this page gets the same card
// Home, Search, Browse, Label and the Library feed already
// mount: body click opens, overlay play/favourite/⋯, pin
// badge, context menu — and the banner rendered CONTAIN
// instead of cropped 2.11:1 into a square.
//
// What stood here was a hand-rolled Rectangle delegate whose
// MouseArea had no onClicked at all (only a cursor, which is
// why it LOOKED clickable) and a RoundedImage with no `fit:`,
// i.e. the default crop. Its POC-NOTE claimed there was no
// playlist view yet; views/PlaylistView.qml has existed and
// been routed for a long time.
Item { visible: playlists.length > 0; width: 1; height: 32 }
PlaylistRail {
visible: playlists.length > 0
width: parent.width
spacing: 12
Item { width: 1; height: 32 }
Text {
text: QbzSession.tr("Playlists", QbzSession.trRev)
color: theme.textPrimary
font.pixelSize: theme.fontHeading
font.weight: theme.weightSemibold
}
ListView {
width: parent.width
height: 246
orientation: ListView.Horizontal
spacing: 32
clip: true
boundsBehavior: Flickable.StopAtBounds
model: playlists
delegate: Rectangle {
width: 200
height: 246
color: "transparent"
Column {
spacing: 0
Rectangle {
width: 200
height: 200
radius: theme.radiusSm
color: theme.surfaceElevated
clip: true
RoundedImage {
anchors.fill: parent
source: root.coverMap[modelData.artUrl] || ""
radius: theme.radiusSm
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
// POC-NOTE: no playlist view yet.
}
}
Item { width: 1; height: 6 }
Text {
width: 200
text: modelData.title
color: theme.textPrimary
font.pixelSize: theme.fontBody - 2
font.weight: theme.weightMedium
elide: Text.ElideRight
}
Text {
width: 200
text: modelData.subtitle
color: theme.textMuted
font.pixelSize: theme.fontLink - 1
elide: Text.ElideRight
}
}
}
}
title: QbzSession.tr("Playlists", QbzSession.trRev)
items: playlists
coverMap: root.coverMap
}
// --- Other (collapsed) ------------------------------------
@@ -3425,4 +3441,104 @@ Rectangle {
}
}
}
// --- Artist portrait: menu + lightbox -----------------------------------
// View-root siblings, never descendants of the portrait frame (the frame
// is a 200px circle; anything inside it would be masked away).
/// Best available portrait source for the lightbox: the custom override
/// first, then the file the artwork pipeline ALREADY downloaded for this
/// page, then the remote URL as a last resort.
///
/// Deliberate divergence from AlbumView.bestCoverSource(), which goes
/// straight to the remote URL and re-downloads a file it already has on
/// disk. Preferring the cache also matters more here: the artist `large`
/// segment is the ORIGINAL upload (measured on this machine's cache:
/// n=149, median 1439px, max 5679px), so the fetch it avoids is a big one.
function bestArtistImage() {
var custom = artist.customImageUrl || ""
if (custom !== "") return custom
var cached = root.coverMap[artist.artUrl] || ""
if (cached !== "") return cached
return artist.artUrl || ""
}
/// The portrait menu's rows, rebuilt per open so Add/Change/Remove track
/// the live flag (ArtistPageView.slint:307-351, plus "View image" — the
/// lightbox entry, this port's addition and the twin of the album menu's
/// "View cover").
///
/// The two url-only rows are hidden when there is nothing for them to act
/// on. The reference passes `ArtistState.artwork-url` blindly and shows
/// them even for an artist Qobuz has no portrait for, where they do
/// nothing — a small, deliberate divergence rather than a copied hole.
/// "Save as…" survives an empty url when a custom image exists, because
/// that path saves the local file.
function buildPortraitMenuModel() {
var rows = []
if (artist.hasCustomImage === true) {
rows.push({ "label": QbzSession.tr("Change image", QbzSession.trRev), "icon": "image-plus", "action": "add" })
rows.push({ "label": QbzSession.tr("Remove image", QbzSession.trRev), "icon": "trash-2", "action": "remove" })
} else {
rows.push({ "label": QbzSession.tr("Add image", QbzSession.trRev), "icon": "image-plus", "action": "add" })
}
if (root.bestArtistImage() !== "")
rows.push({ "label": QbzSession.tr("View image", QbzSession.trRev), "icon": "eye", "action": "view" })
if ((artist.artUrl || "") !== "")
rows.push({ "label": QbzSession.tr("Open in browser", QbzSession.trRev), "icon": "external-link", "action": "browser" })
if ((artist.artUrl || "") !== "" || artist.hasCustomImage === true)
rows.push({ "label": QbzSession.tr("Save as…", QbzSession.trRev), "icon": "cloud-download", "action": "save" })
return rows
}
QbzContextMenu {
id: portraitMenu
menuWidth: 196
onAboutToShow: portraitMenuRepeater.model = root.buildPortraitMenuModel()
Repeater {
id: portraitMenuRepeater
model: []
delegate: Rectangle {
required property var modelData
width: parent ? parent.width : 0
height: 33
radius: 5
color: pmiArea.containsMouse ? theme.surfaceHover : "transparent"
Row {
anchors.fill: parent
anchors.leftMargin: 8
spacing: 8
QbzIcon { name: modelData.icon; width: 15; height: 15; anchors.verticalCenter: parent.verticalCenter; tintName: "secondary" }
Text {
height: parent.height
width: parent.width - 23
text: modelData.label
color: theme.textSecondary
font.pixelSize: 13
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
}
}
MouseArea {
id: pmiArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
portraitMenu.close()
var a = modelData.action
// The store is keyed by NAME (ArtistPageView.slint:312),
// so the name — not the id — is what travels.
if (a === "add") QbzArtist.imageAddCustom(artist.name || "", artist.artUrl || "")
else if (a === "remove") QbzArtist.imageRemoveCustom(artist.name || "", artist.artUrl || "")
else if (a === "view") portraitLightbox.openWith(root.bestArtistImage())
else if (a === "browser") QbzShell.openExternalUrl(artist.artUrl)
else if (a === "save") QbzArtist.imageSaveAs(artist.name || "", artist.artUrl || "")
}
}
}
}
}
CoverLightbox { id: portraitLightbox }
}