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

+93 -1
View File
@@ -164,7 +164,89 @@ fn build_shaders() {
}
}
/// Emit the two compile-time env vars the About modal's "Build" row reads
/// (`src/about_qt.rs`): the build DATE (`QBZ_BUILD_DATE`, `YYYY-MM-DD`) and the
/// short git COMMIT (`QBZ_BUILD_COMMIT`). Ported from `crates/qbz/build.rs:16-41`.
///
/// Both degrade gracefully and neither can fail the build:
/// - the date prefers `SOURCE_DATE_EPOCH` (reproducible builds; Flathub sets
/// it) and falls back to the wall clock;
/// - the commit shells out to `git rev-parse --short HEAD` and is simply EMPTY
/// in an offline source tarball with no `.git` (Flathub/Snap).
fn emit_build_stamp() {
// Re-run when HEAD moves so the embedded commit stays fresh.
//
// Ask git where HEAD actually lives instead of assuming `../../.git/HEAD`:
// in a git WORKTREE `.git` is a 70-byte file, not a directory, so that path
// does not exist — and cargo treats a missing `rerun-if-changed` target as
// always-dirty, which re-ran this whole script (per-shader qsb, the
// recursive asset sweep, cxx-qt/moc codegen) on every single cargo
// invocation in the tree. Emit nothing when git cannot answer.
if let Some(head) = std::process::Command::new("git")
.args(["rev-parse", "--git-path", "HEAD"])
.output()
.ok()
.filter(|o| o.status.success())
.and_then(|o| String::from_utf8(o.stdout).ok())
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty() && std::path::Path::new(s).exists())
{
println!("cargo:rerun-if-changed={head}");
}
println!("cargo:rerun-if-env-changed=SOURCE_DATE_EPOCH");
let epoch: i64 = std::env::var("SOURCE_DATE_EPOCH")
.ok()
.and_then(|s| s.trim().parse::<i64>().ok())
.or_else(|| {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.ok()
.map(|d| d.as_secs() as i64)
})
.unwrap_or(0);
println!("cargo:rustc-env=QBZ_BUILD_DATE={}", format_ymd(epoch));
let commit = std::process::Command::new("git")
.args(["rev-parse", "--short", "HEAD"])
.output()
.ok()
.filter(|o| o.status.success())
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
.unwrap_or_default();
println!("cargo:rustc-env=QBZ_BUILD_COMMIT={commit}");
}
/// Unix timestamp (seconds, UTC) -> `YYYY-MM-DD`, via Howard Hinnant's
/// days→civil algorithm. Copied as-is from `crates/qbz/build.rs:58-75`: it is
/// chrono-free BY DESIGN, and `qbz-qt` has no chrono dependency to lean on.
/// Empty string for a zero/invalid epoch.
fn format_ymd(epoch_secs: i64) -> String {
if epoch_secs <= 0 {
return String::new();
}
let days = epoch_secs.div_euclid(86_400);
// days_from_civil inverse (Hinnant, "chrono-Compatible Low-Level Date Algorithms").
let z = days + 719_468;
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
let doe = z - era * 146_097; // [0, 146096]
let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365; // [0, 399]
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365]
let mp = (5 * doy + 2) / 153; // [0, 11]
let d = doy - (153 * mp + 2) / 5 + 1; // [1, 31]
let m = if mp < 10 { mp + 3 } else { mp - 9 }; // [1, 12]
let year = if m <= 2 { y + 1 } else { y };
format!("{year:04}-{m:02}-{d:02}")
}
fn main() {
// The About modal's build stamp. Emitted before anything else so a failure
// anywhere below cannot leave the env vars unset (env! is a compile error
// when they are).
emit_build_stamp();
// Bake the shaders BEFORE the qrc sweep below collects qml/assets, so a
// freshly compiled .qsb is the one that gets embedded.
build_shaders();
@@ -203,7 +285,7 @@ fn main() {
// (myqbz_qt.rs, blacklist_qt.rs, toast_qt.rs, …) are plain
// modules and must NOT be listed — only files that declare a
// #[cxx_qt::bridge] mod belong in this array.
rust_files: &["src/bridge.rs", "src/session_bridge.rs", "src/shell_bridge.rs", "src/player_bridge.rs", "src/queue_bridge.rs", "src/home_bridge.rs", "src/viz_bridge.rs", "src/immersive_bridge.rs", "src/suggestions_bridge.rs", "src/hotkeys_bridge.rs", "src/search_bridge.rs", "src/local_bridge.rs", "src/library_bridge.rs", "src/album_bridge.rs", "src/artist_bridge.rs", "src/scene_bridge.rs", "src/musician_bridge.rs", "src/lyrics_qt.rs", "src/icon_tint_qt.rs", "src/cast_bridge.rs", "src/myqbz_bridge.rs", "src/myqbz_add_bridge.rs", "src/disco_bridge.rs", "src/blacklist_bridge.rs", "src/playlist_picker_bridge.rs", "src/playlist_manager_bridge.rs", "src/playlist_import_bridge.rs", "src/dac_wizard_bridge.rs", "src/folder_edit_bridge.rs", "src/playlist_edit_bridge.rs", "src/qconnect_bridge.rs", "src/kiosk_nav_bridge.rs", "src/mini_bridge.rs", "src/tray_bridge.rs"],
rust_files: &["src/bridge.rs", "src/session_bridge.rs", "src/shell_bridge.rs", "src/player_bridge.rs", "src/queue_bridge.rs", "src/home_bridge.rs", "src/viz_bridge.rs", "src/immersive_bridge.rs", "src/suggestions_bridge.rs", "src/hotkeys_bridge.rs", "src/search_bridge.rs", "src/local_bridge.rs", "src/library_bridge.rs", "src/album_bridge.rs", "src/artist_bridge.rs", "src/scene_bridge.rs", "src/musician_bridge.rs", "src/lyrics_qt.rs", "src/icon_tint_qt.rs", "src/cast_bridge.rs", "src/myqbz_bridge.rs", "src/myqbz_add_bridge.rs", "src/disco_bridge.rs", "src/blacklist_bridge.rs", "src/playlist_picker_bridge.rs", "src/playlist_manager_bridge.rs", "src/playlist_import_bridge.rs", "src/dac_wizard_bridge.rs", "src/folder_edit_bridge.rs", "src/playlist_edit_bridge.rs", "src/qconnect_bridge.rs", "src/kiosk_nav_bridge.rs", "src/mini_bridge.rs", "src/tray_bridge.rs", "src/about_bridge.rs"],
qml_files: &[
"qml/LoginScreen.qml",
"qml/Main.qml",
@@ -231,6 +313,7 @@ fn main() {
"qml/controls/PlaylistPickerModal.qml",
"qml/controls/PmFolderIcon.qml",
"qml/controls/QbzCircleAction.qml",
"qml/controls/QbzColorPicker.qml",
"qml/controls/QbzConfirmModal.qml",
"qml/controls/QbzContextMenu.qml",
"qml/controls/QbzEmptyState.qml",
@@ -269,6 +352,7 @@ fn main() {
"qml/rows/TrackListHeader.qml",
"qml/rows/TrackRow.qml",
"qml/settings/AppearanceSettings.qml",
"qml/settings/CustomThemeEditor.qml",
"qml/settings/IntegrationsSettings.qml",
"qml/settings/SettingsView.qml",
"qml/shell/AmbientField.qml",
@@ -316,7 +400,13 @@ fn main() {
// AppShell mounts last.
"qml/shell/QconnectFlyout.qml",
"qml/shell/LogViewerModal.qml",
"qml/shell/ReportIssueModal.qml",
"qml/shell/QconnectDevModal.qml",
// About QBZ + What's New (the header menu's last two rows),
// both mounted at the AppShell overlay tail and self-gated on
// QbzAbout's two documents.
"qml/shell/AboutModal.qml",
"qml/shell/WhatsNewModal.qml",
// Immersive mode (2026-08-02 immersive-port contract §2) —
// its own module directory like views/local/ and
// views/playlistmanager/. B2 shipped the root overlay + the
@@ -362,6 +452,7 @@ fn main() {
"qml/views/HomeView.qml",
"qml/views/LibraryView.qml",
"qml/views/LocalLibraryView.qml",
"qml/views/PlaylistRail.qml",
"qml/views/PlaylistView.qml",
"qml/views/SearchView.qml",
"qml/views/SectionRail.qml",
@@ -404,6 +495,7 @@ fn main() {
"qml/settings/BlacklistSettings.qml",
"qml/settings/DacWizardModal.qml",
"qml/settings/DeveloperSettings.qml",
"qml/settings/DiagnosticsPanel.qml",
"qml/settings/LibFolderEditModal.qml",
"qml/settings/LibraryFolderTable.qml",
"qml/settings/LocalLibrarySettings.qml",