Files
qbz/crates/qbz-qt/qml/settings/CustomThemeEditor.qml
T
vicrodh 8bd02e35d3 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
2026-08-15 00:52:25 -06:00

196 lines
7.8 KiB
QML

// CustomThemeEditor — the base-token swatch grid of the custom theme, plus
// the ONE shared inline colour picker that expands under whichever swatch is
// open. The QML port of the grid block at
// crates/qbz-ui/ui/settings/AppearanceSettings.slint:333-425 (the
// TokenGroupLabel and CustomTokenCell sub-components live at :33-38 and
// :44-76).
//
// It is mounted DIRECTLY in the Appearance Column, never inside a SettingRow:
// SettingRow hardcodes its height (52 / 64, controls/SettingRow.qml:18) and
// vertically centres one control, so a ~250px picker inside one would be
// clipped. The reference places the grid outside its own SettingRow for the
// same reason (a bare VerticalLayout at AppearanceSettings.slint:333).
//
// STATE. The eleven swatch colours + the polarity ride
// QbzShell.customThemeJson (src/custom_theme_qt.rs) as
// {"isDark":bool,"tokens":{"<kebab-key>":"#aarrggbb"}} — never local truth.
// Which swatch is OPEN is the exception: that is pure view state (the Slint
// keeps it in AppearanceState.custom-open-token only because Slint has no
// other place to put it) and stays here.
//
// The token keys are the SAME kebab strings the Rust controller matches on
// (custom_theme_qt::TOKEN_KEYS) and the same ones the Slint rows carry, so the
// vocabulary is single-sourced across both frontends.
import QtQuick
import com.blitzfc.qbz
import "../controls"
import "../theme"
Column {
id: root
QbzTheme { id: theme }
/// The token key whose picker is expanded; "" = none open.
property string openToken: ""
/// Collapse the picker — the seed action re-seeds every swatch, so leaving
/// a picker open on a token that just changed underneath is the same
/// stale-editor state the reference clears (custom_theme.rs:187).
function closePicker() { root.openToken = "" }
/// Leaving the Custom theme collapses the picker too. The reference clears
/// `custom-open-token` inside `push_base_to_state`, which runs on EVERY
/// entry into Custom (custom_theme.rs:187, main.rs:11415) — without this,
/// Custom -> OLED -> Custom comes back with a picker still expanded under
/// whatever swatch was last open.
onVisibleChanged: if (!visible) root.openToken = ""
readonly property var doc: {
try {
return JSON.parse(QbzShell.customThemeJson)
} catch (e) {
return ({})
}
}
readonly property var tokens: doc.tokens || ({})
readonly property bool isDark: doc.isDark === true
/// One swatch colour by key. Guarded: an empty document during the very
/// first frames must not bind `color` to undefined.
function tokenColor(key) {
const v = tokens[key]
return v === undefined ? "transparent" : v
}
// Width comes DOWN from the settings column, never up from the children:
// the picker binds its own width to this, so letting a Column derive its
// implicitWidth from that child would be a binding loop.
width: parent ? parent.width : 0
spacing: 14
topPadding: 8
bottomPadding: 8
// A small muted sub-group label (AppearanceSettings.slint:33-38).
component TokenGroupLabel: Text {
color: theme.textMuted
font.pixelSize: 10
font.letterSpacing: 1
font.weight: theme.weightSemibold
}
// One editable base-token CELL: a compact swatch with its label
// underneath, laid inline with its group siblings. Clicking TOGGLES the
// shared picker (clicking the open one again closes it).
component TokenCell: Column {
id: cell
property string tokenKey: ""
property string label: ""
readonly property bool open: root.openToken === cell.tokenKey
spacing: 6
Rectangle {
width: 56
height: 32
radius: theme.radiusSm
border.width: cell.open ? 2 : 1
border.color: cell.open ? theme.accent : theme.borderSubtle
color: root.tokenColor(cell.tokenKey)
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: root.openToken = cell.open ? "" : cell.tokenKey
}
}
Text {
width: 56
text: cell.label
color: cell.open ? theme.textPrimary : theme.textMuted
font.pixelSize: theme.fontLegal
horizontalAlignment: Text.AlignHCenter
elide: Text.ElideRight
}
}
// --- Row 1: SURFACES / TEXT / ACCENT ---------------------------------
Row {
spacing: 28
Column {
spacing: 8
TokenGroupLabel { text: QbzSession.tr("SURFACES", QbzSession.trRev) }
Row {
spacing: 10
TokenCell { tokenKey: "surface-main"; label: QbzSession.tr("Background", QbzSession.trRev) }
TokenCell { tokenKey: "surface-card"; label: QbzSession.tr("Surface", QbzSession.trRev) }
TokenCell { tokenKey: "surface-elevated"; label: QbzSession.tr("Elevated", QbzSession.trRev) }
}
}
Column {
spacing: 8
TokenGroupLabel { text: QbzSession.tr("TEXT", QbzSession.trRev) }
Row {
spacing: 10
TokenCell { tokenKey: "text-primary"; label: QbzSession.tr("Text", QbzSession.trRev) }
TokenCell { tokenKey: "text-secondary"; label: QbzSession.tr("Secondary text", QbzSession.trRev) }
}
}
Column {
spacing: 8
TokenGroupLabel { text: QbzSession.tr("ACCENT", QbzSession.trRev) }
Row {
spacing: 10
TokenCell { tokenKey: "accent"; label: QbzSession.tr("Accent", QbzSession.trRev) }
}
}
}
// --- Row 2: STATUS / OTHER -------------------------------------------
Row {
spacing: 28
Column {
spacing: 8
TokenGroupLabel { text: QbzSession.tr("STATUS", QbzSession.trRev) }
Row {
spacing: 10
TokenCell { tokenKey: "danger"; label: QbzSession.tr("Danger", QbzSession.trRev) }
TokenCell { tokenKey: "warning"; label: QbzSession.tr("Warning", QbzSession.trRev) }
TokenCell { tokenKey: "success"; label: QbzSession.tr("Success", QbzSession.trRev) }
}
}
Column {
spacing: 8
TokenGroupLabel { text: QbzSession.tr("OTHER", QbzSession.trRev) }
Row {
spacing: 10
TokenCell { tokenKey: "border"; label: QbzSession.tr("Border", QbzSession.trRev) }
TokenCell { tokenKey: "favorite"; label: QbzSession.tr("Favorite", QbzSession.trRev) }
}
}
}
// --- The ONE shared picker, for whichever token is open ---------------
//
// `visible` and not a Loader: the picker is ~250px of static QML with no
// model behind it, and keeping the instance alive across opens preserves
// nothing (it re-seeds from `value` on every external change anyway) while
// a Loader would cost a rebuild per swatch click. An invisible child of a
// Column is skipped by the positioner, so the collapsed state leaves no
// gap — the same idiom AppearanceSettings.qml uses for its platform rows.
QbzColorPicker {
width: root.width
visible: root.openToken !== ""
value: root.tokenColor(root.openToken)
// Live drag: re-derive + repaint now, write debounced in Rust.
onPicked: function (hex) { QbzShell.customSetToken(root.openToken, hex) }
// Drag end: the settled value lands on disk without waiting the debounce.
onReleased: QbzShell.customFlush()
// A committed HEX is a discrete edit — set it and persist it.
onHexCommitted: function (hex) {
QbzShell.customSetToken(root.openToken, hex)
QbzShell.customFlush()
}
}
}