diff --git a/crates/qbz-log/src/bundle.rs b/crates/qbz-log/src/bundle.rs index 3ac985252..0d0ca9e04 100644 --- a/crates/qbz-log/src/bundle.rs +++ b/crates/qbz-log/src/bundle.rs @@ -25,6 +25,25 @@ pub fn format_diagnostics_bundle( f: &DiagFields, last_lines: &[LogLine], max_lines: usize, +) -> String { + format_diagnostics_bundle_with_report(f, "", last_lines, max_lines) +} + +/// [`format_diagnostics_bundle`] with a full diagnostics REPORT injected between +/// the eight-field header and the log block. +/// +/// This exists because the eight fields above are all a frontend can produce on +/// its own, and a bug report wants the ~70 the Diagnostics panel gathers (kernel, +/// distro, install method, GPU, exclusive-mode / DAC / sample-rate state, +/// playback context). The caller renders that markdown — this only wraps it, so +/// the `
` collapsible that makes the paste readable on GitHub +/// survives. Pass `""` for no report; [`format_diagnostics_bundle`] is exactly +/// that call. +pub fn format_diagnostics_bundle_with_report( + f: &DiagFields, + report: &str, + last_lines: &[LogLine], + max_lines: usize, ) -> String { let n = max_lines.min(last_lines.len()); let start = last_lines.len() - n; @@ -42,6 +61,11 @@ pub fn format_diagnostics_bundle( "**Locale:** {} **Log level:** {}\n", f.locale, f.log_level )); + if !report.trim().is_empty() { + out.push('\n'); + out.push_str(report.trim_end()); + out.push('\n'); + } out.push_str("\n```log\n"); for line in tail { out.push_str(&format!( @@ -111,4 +135,39 @@ mod tests { assert!(out.contains("line 7")); assert!(!out.contains("line 6")); } + + #[test] + fn report_lands_between_header_and_log_block() { + let lines = vec![LogLine { + ts: 1, + level: Level::Info, + target: "t".into(), + message: "hello".into(), + }]; + let report = "# qbz diagnostics\n\n- **Kernel:** 7.1.6\n"; + let out = format_diagnostics_bundle_with_report(&fields(), report, &lines, 200); + + let header = out.find("**Audio backend:**").expect("header"); + let body = out.find("- **Kernel:**").expect("report body"); + let block = out.find("```log").expect("log block"); + assert!(header < body && body < block, "wrong order: {out}"); + // The collapsible survives — that is the whole reason this wraps + // rather than replaces. + assert!(out.contains("
qbz diagnostics")); + assert!(out.trim_end().ends_with("
")); + } + + #[test] + fn empty_report_is_byte_identical_to_the_plain_bundle() { + let lines = vec![LogLine { + ts: 1, + level: Level::Info, + target: "t".into(), + message: "hello".into(), + }]; + assert_eq!( + format_diagnostics_bundle(&fields(), &lines, 200), + format_diagnostics_bundle_with_report(&fields(), " \n ", &lines, 200) + ); + } } diff --git a/crates/qbz-qt/build.rs b/crates/qbz-qt/build.rs index 6b8fcae54..2a54e7174 100644 --- a/crates/qbz-qt/build.rs +++ b/crates/qbz-qt/build.rs @@ -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::().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", diff --git a/crates/qbz-qt/qml/Main.qml b/crates/qbz-qt/qml/Main.qml index a67b6105f..704644bc0 100644 --- a/crates/qbz-qt/qml/Main.qml +++ b/crates/qbz-qt/qml/Main.qml @@ -354,6 +354,12 @@ ApplicationWindow { // (contract §15 trap 27). The Connections that answer those four // signals are below, beside the window verbs they call. QbzTray.boot() + // About / What's New. Its four invokables self-register the hop, so + // today's menu-driven paths work without this line — but the FIRST + // publish that is not preceded by an invokable (an avatar prefetch at + // startup, an auto-show, a "new version" badge) would be a silent + // no-op. Booting it here keeps it in the same contract as the other 34. + QbzAbout.boot() // Seed the maximized latch ONCE, imperatively — see its declaration for // why it must not be a binding on the bridge property. diff --git a/crates/qbz-qt/qml/assets/icons/accent/wand-sparkles.svg b/crates/qbz-qt/qml/assets/icons/accent/wand-sparkles.svg new file mode 100644 index 000000000..41056ab8d --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/accent/wand-sparkles.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/black/wand-sparkles.svg b/crates/qbz-qt/qml/assets/icons/black/wand-sparkles.svg new file mode 100644 index 000000000..be4e2f83b --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/black/wand-sparkles.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/muted/wand-sparkles.svg b/crates/qbz-qt/qml/assets/icons/muted/wand-sparkles.svg new file mode 100644 index 000000000..88f1e7202 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/muted/wand-sparkles.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/primary/wand-sparkles.svg b/crates/qbz-qt/qml/assets/icons/primary/wand-sparkles.svg new file mode 100644 index 000000000..e4a89a708 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/primary/wand-sparkles.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/secondary/wand-sparkles.svg b/crates/qbz-qt/qml/assets/icons/secondary/wand-sparkles.svg new file mode 100644 index 000000000..3984aeef8 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/secondary/wand-sparkles.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/warning/wand-sparkles.svg b/crates/qbz-qt/qml/assets/icons/warning/wand-sparkles.svg new file mode 100644 index 000000000..b64a5b6d0 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/warning/wand-sparkles.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/crates/qbz-qt/qml/assets/qbz-logo-128.png b/crates/qbz-qt/qml/assets/qbz-logo-128.png new file mode 100644 index 000000000..cdaefe49d Binary files /dev/null and b/crates/qbz-qt/qml/assets/qbz-logo-128.png differ diff --git a/crates/qbz-qt/qml/controls/QbzColorPicker.qml b/crates/qbz-qt/qml/controls/QbzColorPicker.qml new file mode 100644 index 000000000..bd40c9588 --- /dev/null +++ b/crates/qbz-qt/qml/controls/QbzColorPicker.qml @@ -0,0 +1,271 @@ +// QbzColorPicker — the QML port of primitives/ColorPicker.slint: an inline, +// expandable HSV colour picker (SV square + hue strip + preview + editable +// HEX field). Used by the custom-theme editor; one instance is mounted at a +// time, behind the open swatch. +// +// Design decisions carried over from the reference, and one that is NOT: +// +// - INLINE, never a Popup. The parent mounts exactly one picker at a time, +// so there is no z-order or positioning fight (ColorPicker.slint:4-6). +// - The picker OWNS its hue/sat/val state, seeded from `value` on mount and +// on EXTERNAL change only. A fully derived model would lose the hue at +// s == 0 or v == 0 (ColorPicker.slint:7-9). Two guards keep it: `dragging` +// skips mid-drag feedback, and a hex compare skips the parent's own +// round-tripped colour. +// - NO Timer / NumberAnimation / Behavior. The reference has none, and the +// shell's repaint-pulse rule forbids adding any: the picker repaints on +// user input and costs nothing at rest. (The reference's stated reason is +// a Slint compiler panic on Timer-in-`if`; ours is the pulse contract. +// Same conclusion, different rationale — do not "restore" an animation.) +// +// HUE DOMAIN. The Slint stores hue in 0..360 (`Colors.hsv`). `Qt.hsva()` takes +// ALL FOUR arguments in 0..1, so `hue` here is NORMALISED 0..1 and every +// boundary — the SV base colour, the strip marker position, the pointer maths +// — works in that domain. Feeding 217 into Qt.hsva clamps to 1.0 and paints +// every swatch red. +// +// ENCODING. `picked(hex)` and the HEX field speak 6-digit `#rrggbb` — the +// EDIT/PERSIST encoding of the custom-theme base tokens (qbz_theme +// Rgba::to_hex). `value` comes in as a `color`, so it accepts the +// `#aarrggbb` render encoding the bridge publishes; the alpha is ignored, +// base tokens being opaque by contract. + +import QtQuick +import com.blitzfc.qbz +import "../theme" + +Rectangle { + id: root + + /// The current value, driven by the parent. Read on mount + external change. + property color value: "#ff4285f4" + /// Emitted live (drag included) with a 6-digit `#rrggbb` string. Named + /// `picked`, not the reference's `changed`: in QML `changed` collides + /// visually with the auto-generated `Changed` handlers and reads + /// as one at every call site. Same semantics. + signal picked(string hex) + /// Emitted when a colour drag ENDS — the owner persists here, so a settled + /// drag does not wait out the write debounce. + signal released() + /// Emitted when the HEX field is committed (the parent parses + feeds back). + signal hexCommitted(string hex) + + QbzTheme { id: theme } + + // --- owned HSV state ------------------------------------------------- + property real hue: 0.6028 // 0..1 (Qt.hsva domain — see the header) + property real sat: 0.74 // 0..1 + property real val: 0.96 // 0..1 + // True only while an SV/hue drag is in flight, so the round-tripped colour + // the parent feeds back does not fight the crosshair mid-drag. + property bool dragging: false + + readonly property color current: Qt.hsva(root.hue, root.sat, root.val, 1) + readonly property string currentHex: _hex(root.current) + + /// A QML `color` to 6-digit `#rrggbb`. `.r/.g/.b` are 0..1 floats that came + /// from 8-bit channels, so this round-trips exactly. + function _hex(c) { + function b(x) { + var v = Math.max(0, Math.min(255, Math.round(x * 255))) + return (v < 16 ? "0" : "") + v.toString(16) + } + return "#" + b(c.r) + b(c.g) + b(c.b) + } + + /// Seed the owned HSV from `value` (mount + external change). Mirrors + /// ColorPicker.slint:51-69, with hue normalised to 0..1. + function decompose() { + var r = root.value.r, g = root.value.g, b = root.value.b + var mx = Math.max(r, Math.max(g, b)) + var mn = Math.min(r, Math.min(g, b)) + var d = mx - mn + root.val = mx + root.sat = mx <= 0 ? 0 : d / mx + if (d <= 0) + root.hue = 0 // gray: hue undefined -> 0 + else if (mx === r) + root.hue = ((((g - b) / d) % 6) + 6) % 6 / 6 // JS % keeps the sign + else if (mx === g) + root.hue = (((b - r) / d) + 2) / 6 + else + root.hue = (((r - g) / d) + 4) / 6 + } + + Component.onCompleted: root.decompose() + + // Reseed on EXTERNAL changes only. The comparison is on the 8-bit HEX and + // not on the colours themselves: `value` is our own emitted hex parsed + // back, so it is byte-quantised, while `current` is float HSV output — + // comparing those directly would report a difference for every edit and + // re-decompose (resetting the hue whenever s or v hits 0), which is the + // exact failure ColorPicker.slint:74-79 documents. + onValueChanged: { + if (!root.dragging && root._hex(root.value) !== root.currentHex) + root.decompose() + } + + // --- layout ---------------------------------------------------------- + color: theme.surfaceCard + radius: theme.radiusSm + border.width: 1 + border.color: theme.borderSubtle + implicitHeight: col.implicitHeight + 24 + height: implicitHeight + + Column { + id: col + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.margins: 12 + spacing: 10 + + // Saturation / Value square. + Rectangle { + id: sv + width: parent.width + height: 140 + radius: theme.radiusSm + clip: true + // Base: the pure hue. + color: Qt.hsva(root.hue, 1, 1, 1) + // Both overlays carry the parent's radius: Qt's `clip` scissors to + // the bounding RECT, unlike Slint's, which honours border-radius — + // so a square-cornered child paints over the rounded corners the + // reference renders (ColorPicker.slint:118-124). + // + // White -> transparent (left to right) = saturation. + Rectangle { + anchors.fill: parent + radius: sv.radius + gradient: Gradient { + orientation: Gradient.Horizontal + GradientStop { position: 0.0; color: "#ffffffff" } + GradientStop { position: 1.0; color: "#00ffffff" } + } + } + // Transparent -> black (top to bottom) = value. + Rectangle { + anchors.fill: parent + radius: sv.radius + gradient: Gradient { + GradientStop { position: 0.0; color: "#00000000" } + GradientStop { position: 1.0; color: "#ff000000" } + } + } + // Crosshair: x = saturation, y = (1 - value). + Rectangle { + width: 14 + height: 14 + radius: 7 + x: Math.max(0, Math.min(sv.width - width, root.sat * sv.width)) + y: Math.max(0, Math.min(sv.height - height, (1 - root.val) * sv.height)) + color: "transparent" + border.width: 2 + border.color: "#ffffffff" + Rectangle { + x: 2 + y: 2 + width: parent.width - 4 + height: parent.height - 4 + radius: width / 2 + color: "transparent" + border.width: 1 + border.color: "#80000000" + } + } + MouseArea { + anchors.fill: parent + cursorShape: Qt.CrossCursor + onPressed: function (m) { root.dragging = true; root._pickSv(m.x, m.y) } + onPositionChanged: function (m) { if (root.dragging) root._pickSv(m.x, m.y) } + onReleased: { root.dragging = false; root.released() } + onCanceled: { root.dragging = false; root.released() } + } + } + + // Hue strip. + Rectangle { + id: hueStrip + width: parent.width + height: 14 + radius: 7 + clip: true + gradient: Gradient { + orientation: Gradient.Horizontal + GradientStop { position: 0.0000; color: "#ffff0000" } + GradientStop { position: 0.1666; color: "#ffffff00" } + GradientStop { position: 0.3333; color: "#ff00ff00" } + GradientStop { position: 0.5000; color: "#ff00ffff" } + GradientStop { position: 0.6666; color: "#ff0000ff" } + GradientStop { position: 0.8333; color: "#ffff00ff" } + GradientStop { position: 1.0000; color: "#ffff0000" } + } + // Marker. + Rectangle { + width: 4 + height: parent.height + 4 + y: -2 + x: Math.max(0, Math.min(hueStrip.width - width, root.hue * hueStrip.width)) + radius: 2 + color: "#ffffffff" + border.width: 1 + border.color: "#80000000" + } + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onPressed: function (m) { root.dragging = true; root._pickHue(m.x) } + onPositionChanged: function (m) { if (root.dragging) root._pickHue(m.x) } + onReleased: { root.dragging = false; root.released() } + onCanceled: { root.dragging = false; root.released() } + } + } + + // Preview swatch + editable HEX field. + Row { + width: parent.width + spacing: 10 + Rectangle { + width: 40 + height: 34 + radius: theme.radiusSm + border.width: 1 + border.color: theme.borderSubtle + color: root.current + } + QbzLineEdit { + // The plain arm is a fixed 240 wide; the picker spans the + // settings column, so the field takes what the swatch leaves. + width: Math.max(120, parent.width - 50) + anchors.verticalCenter: parent.verticalCenter + text: root.currentHex + placeholder: "#rrggbb" + // Enter commits. No hotkey-guard wiring is needed: the shell + // derives "a text input has focus" structurally from the + // active focus item (AppShell.qml, the `afi instanceof + // TextInput` gate inside Keys.onPressed), so the Slint + // UiFocusState dance (ColorPicker.slint:229-234, a workaround + // for #619) has no counterpart here. + onAccepted: function (v) { root.hexCommitted(v) } + // Commit only well-formed #rrggbb (7 chars); a shorter string + // is an in-progress edit and is ignored until it is valid. + onEdited: function (v) { if (v.length === 7) root.hexCommitted(v) } + } + } + } + + // Both pointer handlers build the hex from the values they just assigned + // rather than reading `currentHex`, so the emission cannot depend on + // binding evaluation order. + function _pickSv(mx, my) { + root.sat = Math.max(0, Math.min(1, mx / sv.width)) + root.val = Math.max(0, Math.min(1, 1 - my / sv.height)) + root.picked(root._hex(Qt.hsva(root.hue, root.sat, root.val, 1))) + } + function _pickHue(mx) { + root.hue = Math.max(0, Math.min(1, mx / hueStrip.width)) + root.picked(root._hex(Qt.hsva(root.hue, root.sat, root.val, 1))) + } +} diff --git a/crates/qbz-qt/qml/settings/AppearanceSettings.qml b/crates/qbz-qt/qml/settings/AppearanceSettings.qml index a4e67f454..466c44e78 100644 --- a/crates/qbz-qt/qml/settings/AppearanceSettings.qml +++ b/crates/qbz-qt/qml/settings/AppearanceSettings.qml @@ -13,9 +13,6 @@ // "Detected: " line): there is no file-chooser seam here, so the // button would do nothing. The Source dropdown still persists all three // values (the shared ui_prefs key the Slint reads). -// - The Custom-theme editor (seed-from-current, the dark-polarity toggle and -// the base-token swatch grid): the port applies custom_theme.json read-only -// and has no ColorPicker primitive. // - The commented-out Slint blocks (immersive background / panels / FPS, // window-title template) stay out, 1:1 the owner's cut. @@ -136,6 +133,44 @@ Column { } } + // Custom-theme editor rows (the appended "Custom" theme only), 1:1 with + // AppearanceSettings.slint:313-425. "Start from current theme" re-seeds + // the base from the applied palette; the "Dark theme" toggle flips + // polarity; the grid below edits each base token and the rest of the + // palette is derived live in Rust on every change. + // + // There is deliberately NO save / save-as, NO delete, NO named-theme list + // and NO import/export: the model is ONE implicit custom theme that + // autosaves, and the reference has none of those affordances either. + SettingRow { + visible: QbzShell.themeSlug === "custom" + label: QbzSession.tr("Start from current theme", QbzSession.trRev) + description: QbzSession.tr("Copy the colors of the currently applied theme into the editor as a starting point.", QbzSession.trRev) + SettingsButton { + text: QbzSession.tr("Use current colors", QbzSession.trRev) + onClicked: { + QbzShell.customSeedFromCurrent() + customThemeEditor.closePicker() + } + } + } + SettingRow { + visible: QbzShell.themeSlug === "custom" + label: QbzSession.tr("Dark theme", QbzSession.trRev) + description: QbzSession.tr("Set the overall light or dark polarity. Affects derived shades, borders and overlays.", QbzSession.trRev) + QbzToggle { + checked: customThemeEditor.isDark + onToggled: function (v) { QbzShell.customToggleDark(v) } + } + } + // NOT inside a SettingRow: that control hardcodes 52/64px and centres one + // child, which would clip the ~250px picker. The reference keeps the grid + // outside its own rows for the same reason. + CustomThemeEditor { + id: customThemeEditor + visible: QbzShell.themeSlug === "custom" + } + SettingsSpacer { } SettingsDivider { } SettingsSpacer { } diff --git a/crates/qbz-qt/qml/settings/CustomThemeEditor.qml b/crates/qbz-qt/qml/settings/CustomThemeEditor.qml new file mode 100644 index 000000000..03484bddd --- /dev/null +++ b/crates/qbz-qt/qml/settings/CustomThemeEditor.qml @@ -0,0 +1,195 @@ +// 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":{"":"#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() + } + } +} diff --git a/crates/qbz-qt/qml/settings/DeveloperSettings.qml b/crates/qbz-qt/qml/settings/DeveloperSettings.qml index 98c090d4b..2e8dd3182 100644 --- a/crates/qbz-qt/qml/settings/DeveloperSettings.qml +++ b/crates/qbz-qt/qml/settings/DeveloperSettings.qml @@ -9,8 +9,10 @@ // - "Export settings…" writes the bundle immediately with auth EXCLUDED; the // SettingsExportModal's include-auth gate (default OFF) is not ported, so // the safe default is the only behaviour. -// - The inline DiagnosticsPanel (seven saved-vs-runtime tables) is not -// ported — it is a view of its own, not a settings row. +// - The inline DiagnosticsPanel is ported and mounted last, as in the +// reference — with the GTK/WebKit/GSK/GDK rows CUT, because in a Qt process +// they can only read "—" and their saved column would come from a file this +// binary never writes. The panel's own header explains the cut. import QtQuick import com.blitzfc.qbz @@ -105,4 +107,10 @@ Column { font.pixelSize: theme.fontLegal wrapMode: Text.WordWrap } + + SettingsSpacer { } + + // ========================== DIAGNOSTICS ============================== + // Last child, 1:1 with DeveloperSettings.slint:92. + DiagnosticsPanel { } } diff --git a/crates/qbz-qt/qml/settings/DiagnosticsPanel.qml b/crates/qbz-qt/qml/settings/DiagnosticsPanel.qml new file mode 100644 index 000000000..a03086077 --- /dev/null +++ b/crates/qbz-qt/qml/settings/DiagnosticsPanel.qml @@ -0,0 +1,327 @@ +// Settings > Developer > Diagnostics — the QML port of +// crates/qbz-ui/ui/settings/DiagnosticsPanel.slint, mounted as the LAST child +// of DeveloperSettings.qml exactly as the reference mounts it +// (DeveloperSettings.slint:92). Not a modal, not a view: a settings panel. +// +// The whole document arrives on ONE bridge property (QbzShell.diagnosticsJson, +// seeded with the full shape at construction) and is parsed behind a guard, the +// LogViewerModal pattern. `diagnostics_qt` publishes only while the master +// collapsible is expanded, which is why toggling it calls diagSetOpen(). +// +// --- ROWS DELIBERATELY CUT FROM THE REFERENCE, and why ------------------- +// The reference's Graphics and Environment sections carry WebKit2GTK, GTK, +// GSK Renderer, GDK Scale, GDK DPI Scale, Compositing Mode, Force DMA-BUF, +// Force X11 and the four WEBKIT_*/GDK_*/GSK_* env vars. Those are Tauri-era +// truths that Slint inherited verbatim. In a Qt process libwebkit2gtk and +// libgtk are not mapped at all, so their RUNTIME column can only ever read +// "—", and worse, their SAVED column would come out of a graphics.json this +// binary never reads or writes — foreign, stale state presented as this app's +// configuration. This port's standing rule is no dead rows, so they are gone. +// Do not "restore parity" by adding them back. +// +// `UI Loop Latency` is cut for a different reason: its producer +// (crates/qbz/src/ui_watchdog.rs) measures Slint's event-loop dispatch and has +// no Qt analogue. A Qt watchdog is its own piece of work. +// +// Everything true of a Qt process stays: OS/arch/kernel/distro/install method, +// the loaded glibc + ALSA + PipeWire + PulseAudio versions, the whole Audio +// section (saved vs the LIVE sink), GPU + renderer + desktop + Wayland + VM, +// the playback block, the QConnect block and the cast scan. +// +// Colours: the two status glyphs are 6-digit literals and safe to copy from +// the reference; everything else is a theme token. No 8-digit hex here — Slint +// writes #RRGGBBAA and Qt reads #AARRGGBB. +// +// No Timer, no Behavior, no animation: the "copied" flash and the 10s cast +// scan are both tokio sleeps in diagnostics_qt, which is what the shared-pulse +// rule wants. + +import QtQuick +import com.blitzfc.qbz +import "../controls" +import "../theme" + +Column { + id: root + + QbzTheme { id: theme } + + width: parent ? parent.width : 0 + spacing: 4 + + readonly property var doc: { + try { + return JSON.parse(QbzShell.diagnosticsJson) + } catch (e) { + return ({}) + } + } + property bool expanded: false + + function t(s) { return QbzSession.tr(s, QbzSession.trRev) } + + // ---------------------------------------------------------------- rows + component DiagRowView: Item { + id: rowView + property var model: ({}) + property bool showSaved: false + width: parent ? parent.width : 0 + height: Math.max(20, label.implicitHeight) + + Text { + id: label + width: rowView.width * 0.38 + text: rowView.model.label || "" + color: theme.textMuted + font.pixelSize: theme.fontLegal + elide: Text.ElideRight + } + Text { + id: savedCol + visible: rowView.showSaved + x: rowView.width * 0.38 + width: rowView.showSaved ? rowView.width * 0.26 : 0 + text: rowView.model.saved || "" + color: theme.textSecondary + font.pixelSize: theme.fontLegal + elide: Text.ElideRight + } + Text { + x: rowView.width * 0.38 + (rowView.showSaved ? rowView.width * 0.26 : 0) + width: rowView.width - x - 20 + text: rowView.model.runtime || "" + color: theme.textPrimary + font.pixelSize: theme.fontLegal + elide: Text.ElideRight + } + // 1 = agrees, 2 = disagrees, anything else = not applicable. + Text { + x: rowView.width - 16 + width: 16 + horizontalAlignment: Text.AlignRight + text: rowView.model.status === 1 ? "✓" + : (rowView.model.status === 2 ? "✗" : "·") + color: rowView.model.status === 1 ? "#4caf50" + : (rowView.model.status === 2 ? "#f44336" : theme.textMuted) + font.pixelSize: theme.fontLegal + } + } + + // ------------------------------------------------------------ sections + component DiagSection: Column { + id: section + property string title: "" + property var rows: [] + property bool showSaved: false + property bool startOpen: true + property bool open: startOpen + width: parent ? parent.width : 0 + spacing: 2 + visible: (section.rows || []).length > 0 + + Item { + width: parent.width + height: 28 + Text { + anchors.verticalCenter: parent.verticalCenter + text: section.title + color: theme.textPrimary + font.pixelSize: theme.fontLegal + font.weight: theme.weightSemibold + } + QbzIcon { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + width: 13 + height: 13 + name: section.open ? "chevron-up" : "chevron-down" + tintName: "muted" + } + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: section.open = !section.open + } + } + // Column headers, only while open and only where a saved column exists. + Item { + visible: section.open && section.showSaved + width: parent.width + height: visible ? 18 : 0 + Text { + width: parent.width * 0.38 + text: root.t("Setting") + color: theme.textDisabled + font.pixelSize: theme.fontLegal + } + Text { + x: parent.width * 0.38 + text: root.t("Saved") + color: theme.textDisabled + font.pixelSize: theme.fontLegal + } + Text { + x: parent.width * 0.64 + text: root.t("Runtime") + color: theme.textDisabled + font.pixelSize: theme.fontLegal + } + } + Repeater { + model: section.open ? (section.rows || []) : [] + delegate: DiagRowView { + required property var modelData + model: modelData + showSaved: section.showSaved + } + } + Item { width: 1; height: 6 } + } + + // ------------------------------------------------------------- header + Item { + width: parent.width + height: 44 + Column { + anchors.verticalCenter: parent.verticalCenter + spacing: 2 + Text { + text: root.t("Diagnostics") + color: theme.textPrimary + font.pixelSize: theme.fontBody + font.weight: theme.weightMedium + } + Text { + text: root.t("Runtime vs saved configuration snapshot.") + color: theme.textMuted + font.pixelSize: theme.fontLegal + } + } + QbzIcon { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + width: 15 + height: 15 + name: root.expanded ? "chevron-up" : "chevron-down" + tintName: "muted" + } + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: { + root.expanded = !root.expanded + // Gates the publisher: a collapsed panel is not reading, and a + // publish can carry a hundred rows. + QbzShell.diagSetOpen(root.expanded) + // First expand collects; afterwards Refresh is explicit, 1:1 + // with DiagnosticsPanel.slint:201-203. + if (root.expanded && root.doc.loaded !== true && root.doc.loading !== true) + QbzShell.diagRefresh() + } + } + } + + // The panel leaving the screen must stop the publisher too — Settings can + // be navigated away from with the section still expanded. + onVisibleChanged: if (!visible && root.expanded) QbzShell.diagSetOpen(false) + + // ---------------------------------------------------------------- body + Column { + visible: root.expanded + width: parent.width + spacing: 4 + + Row { + spacing: 8 + SettingsButton { + text: root.doc.loading === true ? root.t("Scanning…") : root.t("Refresh") + enabled: root.doc.loading !== true + onClicked: QbzShell.diagRefresh() + } + SettingsButton { + text: root.doc.copied === true ? root.t("Exported") : root.t("Export to clipboard") + enabled: root.doc.loaded === true + onClicked: QbzShell.diagExportClipboard() + } + } + + Text { + visible: (root.doc.error || "") !== "" + width: parent.width + text: root.doc.error || "" + color: "#f44336" + font.pixelSize: theme.fontLegal + wrapMode: Text.WordWrap + } + + Text { + text: "QBZ v" + (root.doc.appVersion || "") + color: theme.textMuted + font.pixelSize: theme.fontLegal + } + + Item { width: 1; height: 4 } + + DiagSection { + title: root.t("System") + rows: root.doc.system || [] + } + DiagSection { + title: root.t("Playback") + rows: root.doc.playback || [] + } + DiagSection { + title: root.t("Qobuz Connect") + rows: root.doc.qconnect || [] + } + + // Cast is bespoke in the reference too: its rows only exist after a + // scan, so it carries its own button and stays visible while empty. + Column { + width: parent.width + spacing: 2 + Item { + width: parent.width + height: 28 + Text { + anchors.verticalCenter: parent.verticalCenter + text: root.t("Cast Discovery") + color: theme.textPrimary + font.pixelSize: theme.fontLegal + font.weight: theme.weightSemibold + } + } + SettingsButton { + text: root.doc.castScanning === true + ? root.t("Scanning…") + : root.t("Scan for devices") + enabled: root.doc.castScanning !== true + onClicked: QbzShell.diagCastScan() + } + Repeater { + model: root.doc.cast || [] + delegate: DiagRowView { + required property var modelData + model: modelData + } + } + Item { width: 1; height: 6 } + } + + DiagSection { + title: root.t("Audio") + rows: root.doc.audio || [] + showSaved: true + } + DiagSection { + title: root.t("Graphics") + rows: root.doc.graphics || [] + showSaved: true + } + DiagSection { + title: root.t("Environment") + rows: root.doc.env || [] + startOpen: false + } + } +} diff --git a/crates/qbz-qt/qml/shell/AboutModal.qml b/crates/qbz-qt/qml/shell/AboutModal.qml new file mode 100644 index 000000000..293189fd0 --- /dev/null +++ b/crates/qbz-qt/qml/shell/AboutModal.qml @@ -0,0 +1,618 @@ +// About QBZ — QML port of crates/qbz-ui/ui/shell/AboutModal.slint (531 lines). +// +// Opened from the header hamburger menu ("About QBZ"). Everything that is not +// a translated literal comes from ONE document, QbzAbout.aboutJson +// (src/about_qt.rs): version, platform label, build stamp, release URL, +// codename, the author chip and the row-grouped contributor chips. +// +// Structure follows LogViewerModal.qml, which already solves the four things +// this file would otherwise get wrong: the guarded JSON.parse (a bare parse in +// a binding throws on the pre-publish frame), the self-gated mount (ADR-010 — +// an invisible, non-interactive Item while closed), the faked drop shadow (the +// port has no blurred shadow) and the panel sizing. z: 3000 explicitly, per +// ADR-009 as this port spells it. +// +// ── DELIBERATE DIVERGENCES FROM THE SLINT REFERENCE ──────────────────────── +// +// 1. CONTRIBUTORS USE `Flow`, NOT THE PRE-GROUPED ROWS. Slint pre-groups the +// chips into fixed rows of five ONLY because it has no flex-wrap +// (AboutModal.slint:432-436 says so). QML has `Flow`, which is what the +// original Tauri build used. The rows stay in the JSON so both frontends +// read one document shape; this file flattens them. +// 2. THE STRUCK-THROUGH "love" USES `font.strikeout`. Slint fakes it with a +// 1px Rectangle laid over the glyphs (AboutModal.slint:474-492) because +// Slint Text has no text-decoration. Qt Text has the real property. +// 3. THE ACKNOWLEDGMENTS LINE SAYS "Qt", NOT "Slint" — see its comment below. +// 4. Colour literals are `Qt.rgba(...)`, not the reference's `#RRGGBBAA`. +// Slint is #RRGGBBAA and Qt is #AARRGGBB, and copying a Slint literal +// verbatim is how this port shipped invisible scrims and invisible +// shadows across five modals, all of which read `#000000bf` / `#00000080` +// — ZERO alpha under Qt — where 20 other files spelled the same scrim +// `#bf000000`. Fixed 2026-08-14. Floats cannot be misread in either +// direction, which is why everything new here uses them. +// +// The 128px logo is deliberate and load-bearing: the 512px qbz-logo.png +// already in qml/assets aliases when scaled down to 56 (AboutModal.slint:184-186 +// records exactly that finding for femtovg; a 2.3x downscale is clean, a 9x one +// is not). +// +// No pills (ADR-008): the author + contributor chips are bordered radiusSm +// rectangles, and so is nothing else here. + +import QtQuick +import com.blitzfc.qbz +import "../theme" + +Item { + id: root + + // GUARDED — the property is seeded "{}" on the bridge, but a redeploy + // order or a future "" default must not poison every binding in the file. + readonly property var doc: { + try { + return JSON.parse(QbzAbout.aboutJson) + } catch (e) { + return ({}) + } + } + + // The contributor chips as ONE list. See divergence 1 in the header: the + // document keeps Slint's row grouping, `Flow` does the wrapping here. + readonly property var contributors: { + var rows = root.doc.contributorRows || [] + var out = [] + for (var i = 0; i < rows.length; i++) + for (var j = 0; j < (rows[i] || []).length; j++) + out.push(rows[i][j]) + return out + } + + anchors.fill: parent + z: 3000 + visible: root.doc.open === true + enabled: root.visible + + QbzTheme { id: theme } + + onVisibleChanged: { + if (visible) + keyScope.forceActiveFocus() + } + + // Escape closes. The LOCAL FocusScope idiom (LogViewerModal.qml:73-77) + // rather than growing hotkeys_qt.rs's central EscapeState — that walk is + // pinned by a unit test whose ordering is not this lane's to renegotiate. + FocusScope { + id: keyScope + anchors.fill: parent + Keys.onEscapePressed: QbzAbout.aboutClose() + } + + // ---- Sub-components (the Slint file's LinkButton / HandleChip) -------- + + // A bordered icon+label link button (the external-links row). + component LinkButton: Rectangle { + id: lb + property string label: "" + property string iconName: "external-link" + signal clicked() + + height: 34 + // Slint sizes this to `row.preferred-width`; the Row's implicitWidth + // is the same measure, plus the 14px side padding either side. + width: lbRow.implicitWidth + 28 + radius: theme.radiusSm + border.width: 1 + border.color: theme.borderSubtle + color: lbArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated + + Row { + id: lbRow + anchors.centerIn: parent + spacing: 8 + QbzIcon { + anchors.verticalCenter: parent.verticalCenter + name: lb.iconName + width: 15 + height: 15 + // Theme-following, both arms: the reference hands + // Theme.text-primary / Theme.text-secondary here. + tintName: lbArea.containsMouse ? "textPrimary" : "secondary" + } + Text { + anchors.verticalCenter: parent.verticalCenter + text: lb.label + color: lbArea.containsMouse ? theme.textPrimary : theme.textSecondary + font.pixelSize: 13 + font.weight: theme.weightMedium + } + QbzIcon { + anchors.verticalCenter: parent.verticalCenter + name: "external-link" + width: 11 + height: 11 + tintName: "muted" + } + } + + MouseArea { + id: lbArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: lb.clicked() + } + } + + // A bordered, clickable handle chip with a round GitHub avatar. NOT a pill + // (ADR-008): radiusSm + a subtle border. The avatar arrives asynchronously + // (about_qt.rs downloads it to the user cache and publishes a file:// path + // — this port loads no https:// into an Image.source anywhere), so the + // circle stays blank until then, exactly like the reference. + component HandleChip: Rectangle { + id: hc + property string name: "" + property string url: "" + property string avatar: "" + property int textSize: 13 + property int avatarSize: 20 + + height: 28 + width: hcRow.implicitWidth + 16 + radius: theme.radiusSm + border.width: 1 + border.color: theme.borderSubtle + color: hcArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated + + Row { + id: hcRow + anchors.left: parent.left + anchors.leftMargin: 5 + anchors.verticalCenter: parent.verticalCenter + spacing: 7 + + Rectangle { + anchors.verticalCenter: parent.verticalCenter + width: hc.avatarSize + height: hc.avatarSize + radius: width / 2 + color: theme.surfaceHover + // QML `clip` is rectangular, so the circle has to come from a + // mask — that is what RoundedImage exists for (see its header). + RoundedImage { + anchors.fill: parent + source: hc.avatar + radius: width / 2 + } + } + Text { + anchors.verticalCenter: parent.verticalCenter + text: hc.name + color: hcArea.containsMouse ? theme.accent : theme.textPrimary + font.pixelSize: hc.textSize + font.weight: theme.weightMedium + } + QbzIcon { + anchors.verticalCenter: parent.verticalCenter + name: "external-link" + width: 10 + height: 10 + tintName: "muted" + } + } + + MouseArea { + id: hcArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: QbzShell.openExternalUrl(hc.url) + } + } + + // A small uppercase-ish section heading (11px semibold, +0.5 tracking). + component SectionHeading: Text { + color: theme.textMuted + font.pixelSize: 11 + font.weight: theme.weightSemibold + font.letterSpacing: 0.5 + } + + // A label/value row of the build-info grid. + component InfoRow: Item { + id: ir + property string label: "" + property string value: "" + property bool italic: false + property color valueColor: theme.textPrimary + height: 18 + Text { + id: irLabel + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + width: 84 + text: ir.label + color: theme.textMuted + font.pixelSize: 13 + } + Text { + anchors.left: irLabel.right + anchors.leftMargin: 12 + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + text: ir.value + color: ir.valueColor + font.pixelSize: 13 + font.italic: ir.italic + elide: Text.ElideRight + } + } + + // ---- Scrim / shadow / panel ------------------------------------------ + + Rectangle { + anchors.fill: parent + // Slint `#000000bf` CONVERTED. Floats, not a hex — see divergence 4. + color: Qt.rgba(0, 0, 0, 0.75) + MouseArea { + anchors.fill: parent + onClicked: QbzAbout.aboutClose() + } + } + + // Faked 32px drop shadow (`drop-shadow-color: #00000080`) — the + // LogViewerModal/QbzConfirmModal precedent; a real blurred shadow owes its + // own parity pass. + Rectangle { + anchors.centerIn: panel + width: panel.width + 8 + height: panel.height + 8 + radius: theme.radiusMd + color: Qt.rgba(0, 0, 0, 0.5) + } + + Rectangle { + id: panel + anchors.centerIn: parent + // 840x770 capped, minus an 80px window margin — AboutModal.slint:163-164. + width: Math.min(root.width - 80, 840) + height: Math.min(root.height - 80, 770) + radius: theme.radiusMd + color: theme.surfaceCard + border.width: 1 + border.color: theme.borderSubtle + + // Swallow clicks so they never reach the closing backdrop. + MouseArea { anchors.fill: parent } + + Item { + id: stack + anchors.fill: parent + anchors.margins: 24 + + // ---- Header: branding + close X --------------------------- + Item { + id: headerRow + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + height: 56 + + Image { + id: logo + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + width: 56 + height: 56 + // The 128px source, NOT the 512px qbz-logo.png sitting + // beside it: a ~2.3x downscale renders crisp at 56px where + // the 9x downscale aliases (AboutModal.slint:184-186). + source: "../assets/qbz-logo-128.png" + fillMode: Image.PreserveAspectFit + smooth: true + mipmap: true + } + Column { + anchors.left: logo.right + anchors.leftMargin: 16 + anchors.right: aboutClose.left + anchors.rightMargin: 16 + anchors.verticalCenter: parent.verticalCenter + spacing: 2 + Text { + text: "QBZ" + color: theme.textPrimary + font.pixelSize: theme.fontTitle + font.weight: theme.weightBold + } + Text { + text: "v" + (root.doc.version || "") + color: theme.textMuted + font.pixelSize: theme.fontLegal + } + } + Item { + id: aboutClose + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + width: 28 + height: 28 + QbzIcon { + anchors.centerIn: parent + name: "x" + width: 17 + height: 17 + tintName: aboutCloseArea.containsMouse ? "textPrimary" : "muted" + } + MouseArea { + id: aboutCloseArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: QbzAbout.aboutClose() + } + } + } + + Rectangle { + id: headerRule + anchors.top: headerRow.bottom + anchors.topMargin: 16 + anchors.left: parent.left + anchors.right: parent.right + height: 1 + color: theme.borderSubtle + } + + // ---- Scrolling body --------------------------------------- + Flickable { + id: flick + anchors.top: headerRule.bottom + anchors.topMargin: 16 + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + clip: true + contentWidth: width + contentHeight: bodyCol.height + boundsBehavior: Flickable.StopAtBounds + + Column { + id: bodyCol + width: flick.width + + // Description. + Text { + width: parent.width + text: QbzSession.tr("A native music streaming client for Linux, designed for audiophiles who need bit-perfect Hi-Fi playback.", QbzSession.trRev) + color: theme.textSecondary + font.pixelSize: 14 + wrapMode: Text.WordWrap + } + + Item { width: 1; height: 16 } + + // Qobuz legal notice (branding + trademark lines). + Text { + width: parent.width + text: QbzSession.tr("This application uses the Qobuz API but is not certified by Qobuz.", QbzSession.trRev) + color: theme.textMuted + font.pixelSize: 12 + wrapMode: Text.WordWrap + } + Item { width: 1; height: 8 } + Text { + width: parent.width + text: QbzSession.tr("Qobuz™ is a trademark of Qobuz. QBZ is an open-source application licensed under the MIT License and is not affiliated with, endorsed by, or certified by Qobuz.", QbzSession.trRev) + color: theme.textMuted + // 11px so the trademark notice wraps to two lines + // instead of three. + font.pixelSize: 11 + wrapMode: Text.WordWrap + } + + Item { width: 1; height: 22 } + + // External links. "GitHub" is deliberately NOT translated + // in the reference; the other two are. + Row { + spacing: 12 + LinkButton { + label: "GitHub" + onClicked: QbzShell.openExternalUrl("https://github.com/vicrodh/qbz") + } + LinkButton { + label: QbzSession.tr("Release", QbzSession.trRev) + onClicked: QbzShell.openExternalUrl(root.doc.releaseUrl || "") + } + LinkButton { + label: QbzSession.tr("Website", QbzSession.trRev) + iconName: "globe" + onClicked: QbzShell.openExternalUrl("https://qbz.lol") + } + } + + Item { width: 1; height: 24 } + + // Build info — two label/value columns side by side. + SectionHeading { text: QbzSession.tr("Build Info", QbzSession.trRev) } + Item { width: 1; height: 12 } + Row { + width: parent.width + spacing: 16 + Column { + width: (parent.width - 16) / 2 + spacing: 6 + InfoRow { + width: parent.width + label: QbzSession.tr("Version", QbzSession.trRev) + value: root.doc.version || "" + } + InfoRow { + width: parent.width + label: QbzSession.tr("License", QbzSession.trRev) + // Literal in the reference too (AboutModal.slint:334). + value: "MIT" + } + InfoRow { + width: parent.width + label: QbzSession.tr("Build", QbzSession.trRev) + value: { + var d = root.doc.buildDate || "" + var c = root.doc.buildCommit || "" + return c === "" ? d : d + " (" + c + ")" + } + } + } + Column { + width: (parent.width - 16) / 2 + spacing: 6 + InfoRow { + width: parent.width + label: QbzSession.tr("Codename", QbzSession.trRev) + // Hand-set literal, on the Rust side + // (about_qt.rs CODENAME) so the release + // checklist has ONE Qt place to bump. + value: root.doc.codename || "" + italic: true + valueColor: theme.textSecondary + } + InfoRow { + width: parent.width + label: QbzSession.tr("Platform", QbzSession.trRev) + value: root.doc.platformLabel || "" + } + } + } + + Item { width: 1; height: 24 } + + // Acknowledgments — one prose paragraph. + SectionHeading { text: QbzSession.tr("Acknowledgments", QbzSession.trRev) } + Item { width: 1; height: 12 } + Text { + width: parent.width + // The msgid names Slint because it was written for the + // Slint build; this IS the Qt build. "Slint" is a + // proper noun and survives verbatim in all seven + // translated catalogs (checked: es de fr pt ru ja nl), + // so one substitution fixes every locale and adds ZERO + // new msgids to the shared catalog. If a future + // translation ever localises the word the replace is a + // no-op and the string simply reads as it does today. + text: QbzSession.tr("Built with Rust and Slint, and powered by Rodio and Symphonia. With thanks to Qobuz, MusicBrainz, Song.link/Odesli, LRCLIB, lyrics.ovh, and Lucide.", QbzSession.trRev) + .replace("Slint", "Qt") + color: theme.textSecondary + font.pixelSize: 12 + wrapMode: Text.WordWrap + } + + Item { width: 1; height: 24 } + + // Author. + SectionHeading { text: QbzSession.tr("Author", QbzSession.trRev) } + Item { width: 1; height: 12 } + HandleChip { + name: root.doc.authorName || "" + url: root.doc.authorUrl || "" + avatar: root.doc.authorAvatar || "" + textSize: 14 + avatarSize: 22 + } + + Item { width: 1; height: 24 } + + // Contributors — `Flow`, see divergence 1 in the header. + SectionHeading { text: QbzSession.tr("Contributors", QbzSession.trRev) } + Item { width: 1; height: 12 } + Flow { + width: parent.width + spacing: 8 + Repeater { + model: root.contributors + delegate: HandleChip { + id: contribChip + required property var modelData + name: contribChip.modelData.name || "" + url: contribChip.modelData.url || "" + avatar: contribChip.modelData.avatar || "" + } + } + } + + Item { width: 1; height: 24 } + Rectangle { width: parent.width; height: 1; color: theme.borderSubtle } + Item { width: 1; height: 18 } + + // ---- Signature ---------------------------------------- + Row { + anchors.horizontalCenter: parent.horizontalCenter + spacing: 6 + Text { + anchors.verticalCenter: parent.verticalCenter + text: QbzSession.tr("Made with", QbzSession.trRev) + color: theme.textMuted + font.pixelSize: 12 + } + Text { + anchors.verticalCenter: parent.verticalCenter + // NOT translated in the reference, deliberately. + text: "love" + color: theme.textDisabled + font.pixelSize: 12 + // Slint fakes this with a hairline Rectangle laid + // over the glyphs; Qt has the real property. + font.strikeout: true + } + Text { + anchors.verticalCenter: parent.verticalCenter + text: "hate" + color: theme.textSecondary + font.pixelSize: 12 + font.weight: theme.weightSemibold + } + Text { + anchors.verticalCenter: parent.verticalCenter + text: QbzSession.tr("in", QbzSession.trRev) + color: theme.textMuted + font.pixelSize: 12 + } + // An SVG, never a 🇲🇽 emoji — regional-indicator flags + // degrade to "MX" on many Linux font stacks + // (AboutModal.slint:506-508). The port already carries + // a full ISO-3166 flag set, so this reuses mx.svg + // rather than copying the reference's mexico-flag.svg. + Image { + anchors.verticalCenter: parent.verticalCenter + source: "qrc:/qt/qml/com/blitzfc/qbz/qml/assets/flags/mx.svg" + width: 18 + height: 12 + fillMode: Image.PreserveAspectFit + sourceSize: Qt.size(36, 24) + } + } + Item { width: 1; height: 8 } + Text { + width: parent.width + text: QbzSession.tr("Hatred towards all those companies that discriminate against the Linux community and don't provide us with a decent client for their product.", QbzSession.trRev) + color: theme.textMuted + font.pixelSize: 11 + horizontalAlignment: Text.AlignHCenter + wrapMode: Text.WordWrap + } + + Item { width: 1; height: 4 } + } + } + + // A SIBLING of the Flickable, not an attached ScrollBar — that is + // this port's scrollbar contract (QbzScrollBar.qml:4-6). + QbzScrollBar { + target: flick + anchors.right: parent.right + anchors.top: flick.top + anchors.bottom: flick.bottom + } + } + } +} diff --git a/crates/qbz-qt/qml/shell/AppShell.qml b/crates/qbz-qt/qml/shell/AppShell.qml index 51ccf0f8d..c454a4e55 100644 --- a/crates/qbz-qt/qml/shell/AppShell.qml +++ b/crates/qbz-qt/qml/shell/AppShell.qml @@ -198,6 +198,7 @@ Rectangle { anchors.top: parent.top height: theme.headerHeight hostWindow: root.hostWindow + onReportIssueRequested: reportIssueModal.open = true // Square corners (phase 12: the window is opaque; any rounding is // the compositor's business). } @@ -754,8 +755,17 @@ Rectangle { CustomizeShortcutsModal { anchors.fill: parent } + // The four overlays below are ALWAYS-ON-TOP: in the reference they are + // mounted after every modal (AppShell.slint:941-953) and so paint above + // them. This port relied on declaration order alone, which held only while + // no modal carried an explicit `z` — and the modal band is 3000-3200 + // (ADR-009 as this port spells it), so any z-carrying modal buried them: + // a toast raised by a background event behind an opaque panel is simply + // never seen. They now carry an explicit band of their own, ABOVE the + // modals, keeping the relative order they already had. QbzToast { anchors.fill: parent + z: 3500 } // Immersive mode root overlay (2026-08-02 immersive-port contract §5.1): @@ -768,17 +778,18 @@ Rectangle { // corners is correct for a window-level overlay (:555-563 above). ImmersiveView { anchors.fill: parent + z: 3510 } - // LAST child = above every surface, exactly like ArtPreviewOverlay.slint's - // mount in AppShell.slint. Non-interactive, so it never steals the hover - // that is keeping it open (see the file header). - ArtPreviewOverlay { } + // Above every surface (see the always-on-top band above), exactly like + // ArtPreviewOverlay.slint's mount in AppShell.slint. Non-interactive, so it + // never steals the hover that is keeping it open (see the file header). + ArtPreviewOverlay { z: 3520 } // The shell's ONE hover-tooltip overlay — the port of Slint's - // TooltipOverlay/SidebarTooltip mechanism, mounted LAST for the reason - // TooltipOverlay.slint's header gives: "mounted last in AppShell so no - // neighbour can cover it". Surfaces do not own a popup; they call + // TooltipOverlay/SidebarTooltip mechanism, topmost of the always-on-top + // band for the reason TooltipOverlay.slint's header gives: "mounted last in + // AppShell so no neighbour can cover it". Surfaces do not own a popup; they call // showRight()/showAbove()/hide() on this instance (see QbzTooltip.qml). // // WIRED SO FAR: the collapsed sidebar rail (Sidebar.qml). Everything else @@ -787,6 +798,7 @@ Rectangle { QbzTooltip { id: tooltipOverlay anchors.fill: parent + z: 3530 } // Qobuz Connect diagnostics modal (DeveloperSettings > QOBUZ CONNECT) — @@ -806,4 +818,23 @@ Rectangle { // on the document's `open`, so while closed it is an invisible, // non-interactive Item that parses one small JSON string. LogViewerModal { } + + // Report an issue (the header hamburger's row): explains the manual, + // redacted log-sharing flow and offers "Go to logs" + the GitHub bug + // template. Its `open` is LOCAL state — nothing in Rust needs to know it + // is up — so the menu row flips it directly through this id. + ReportIssueModal { id: reportIssueModal } + + // About QBZ + What's New (the header hamburger menu's last two rows), + // mirroring AppShell.slint:925-936 where both are mounted with the global + // modals. Each self-gates on its half of QbzAbout's two documents, so + // while closed it is an invisible, non-interactive Item that parses one + // small JSON string — and each carries an explicit `z: 3000` (ADR-009 as + // this port spells it) rather than relying on declaration order alone. + AboutModal { + anchors.fill: parent + } + WhatsNewModal { + anchors.fill: parent + } } diff --git a/crates/qbz-qt/qml/shell/HeaderBar.qml b/crates/qbz-qt/qml/shell/HeaderBar.qml index 0ad9180c7..fc634906a 100644 --- a/crates/qbz-qt/qml/shell/HeaderBar.qml +++ b/crates/qbz-qt/qml/shell/HeaderBar.qml @@ -22,10 +22,11 @@ // opens on the first keystroke instead of 220ms after the last one. The // dropdown itself is shell/Cortinilla.qml. // Right: the tri-state offline status badge with its flyout (recovery -// "Sign in" wired to QbzSession.recoveryLogin) and the app menu (user block -// + Keyboard Shortcuts + Documentation + Log Out + Close. Still missing vs -// Slint, each needing a surface the port does not have yet: Open Music Link, -// Report an Issue, What's New, About QBZ. +// "Sign in" wired to QbzSession.recoveryLogin) and the app menu (user block + +// Settings + Keyboard Shortcuts + Documentation + What's New + About QBZ + +// Report an Issue + Log Out + Close). ONE row is still missing against the +// Slint menu: Open Music Link, which needs the LinkResolver this port does not +// have yet. // // The custom window chrome IS built (the note claiming it was skipped was a // fossil): the header itself is the drag surface (`startSystemMove()` below), @@ -64,6 +65,9 @@ Rectangle { readonly property bool ambientOn: theme.ambientOn + /// Raised by the app menu's "Report an issue" row; AppShell owns the modal. + signal reportIssueRequested() + // The host ApplicationWindow (custom chrome); null in previews. property var hostWindow: null @@ -1071,11 +1075,11 @@ Rectangle { } // --- App menu (user block + Settings + Keyboard Shortcuts + - // Documentation + Log Out + Close) ------------------------------------- + // Documentation + What's New + About QBZ + Log Out + Close) ------------ // Still missing against the Slint menu, each needing a surface this port - // does not have yet: Open Music Link, Report an Issue, What's New, - // About QBZ. (The older note here also listed Keyboard Shortcuts and - // Documentation, which have since landed.) + // does not have yet: Open Music Link (the LinkResolver is not ported) and + // Report an Issue. (The older note here also listed Keyboard Shortcuts, + // Documentation, What's New and About QBZ, all of which have landed.) Popup { id: appMenu x: root.width - 234 - theme.spacingMd @@ -1204,6 +1208,42 @@ Rectangle { QbzShell.openExternalUrl("https://github.com/vicrodh/qbz/wiki") } } + // Slint order continues (HeaderBar.slint:1213-1240): Report an + // Issue -> What's New -> About QBZ -> divider -> Log Out. + AppMenuItem { + name: "bug" + label: QbzSession.tr("Report an issue", QbzSession.trRev) + onClicked: { + appMenu.close() + // The modal is mounted at the AppShell root (global modals + // do not live inside the header). QML ids are file-scoped, + // so this travels as a signal the host connects — the same + // shape the window verbs use rather than a bridge + // round-trip for state Rust has no use for. + root.reportIssueRequested() + } + } + AppMenuItem { + name: "wand-sparkles" + label: QbzSession.tr("What's New", QbzSession.trRev) + onClicked: { + appMenu.close() + QbzAbout.whatsNewOpen() + } + } + AppMenuItem { + name: "info" + label: QbzSession.tr("About QBZ", QbzSession.trRev) + onClicked: { + appMenu.close() + QbzAbout.aboutOpen() + } + } + // The divider Slint draws between About QBZ and Log Out + // (HeaderBar.slint:1237-1240), in this file's own idiom (:1120). + Item { width: 1; height: 4 } + Rectangle { width: parent.width; height: 1; color: theme.borderSubtle } + Item { width: 1; height: 4 } AppMenuItem { name: "log-out" label: QbzSession.tr("Log Out", QbzSession.trRev) diff --git a/crates/qbz-qt/qml/shell/ReportIssueModal.qml b/crates/qbz-qt/qml/shell/ReportIssueModal.qml new file mode 100644 index 000000000..dd119d046 --- /dev/null +++ b/crates/qbz-qt/qml/shell/ReportIssueModal.qml @@ -0,0 +1,183 @@ +// Report an issue — the QML port of crates/qbz-ui/ui/shell/ReportIssueModal.slint, +// opened from the header hamburger menu. +// +// Structure, scrim, faked shadow and the self-gated mount follow +// LogViewerModal.qml; `z: 3000` explicitly, ADR-009 as this port spells it. +// Colour literals are `Qt.rgba(...)` floats: Slint writes #RRGGBBAA and Qt +// reads #AARRGGBB, and five modals in this tree shipped an invisible scrim or +// shadow from copying one verbatim (fixed 2026-08-14). +// +// The open state is LOCAL, not a bridge document: nothing in Rust needs to know +// this modal is up, and the two buttons call verbs that already exist +// (QbzShell.logOpen, QbzShell.reportIssueOpen). The header menu sets `open`. +// +// No pills (ADR-008): both buttons are bordered radiusSm rectangles. + +import QtQuick +import com.blitzfc.qbz +import "../controls" +import "../theme" + +Item { + id: root + + QbzTheme { id: theme } + + property bool open: false + + anchors.fill: parent + z: 3000 + visible: root.open + enabled: root.visible + + function t(s) { return QbzSession.tr(s, QbzSession.trRev) } + + FocusScope { + anchors.fill: parent + focus: root.visible + Keys.onEscapePressed: root.open = false + } + + // Dimmed backdrop; a click closes. + Rectangle { + anchors.fill: parent + color: Qt.rgba(0, 0, 0, 0.75) + MouseArea { + anchors.fill: parent + onClicked: root.open = false + } + } + + // Faked 32px drop shadow — the LogViewerModal/QbzConfirmModal precedent; a + // real blurred shadow owes its own parity pass. + Rectangle { + anchors.centerIn: panel + width: panel.width + 8 + height: panel.height + 8 + radius: theme.radiusMd + color: Qt.rgba(0, 0, 0, 0.5) + } + + Rectangle { + id: panel + anchors.centerIn: parent + width: Math.min(root.width - 80, 520) + height: Math.min(root.height - 80, 300) + radius: theme.radiusMd + color: theme.surfaceCard + border.width: 1 + border.color: theme.borderSubtle + + // Swallow clicks so the scrim below does not close the modal. + MouseArea { anchors.fill: parent } + + Column { + anchors.fill: parent + anchors.margins: 20 + spacing: 14 + + // ---- Header: title + close X. + Item { + width: parent.width + height: 28 + Text { + anchors.verticalCenter: parent.verticalCenter + text: root.t("Report an issue") + color: theme.textPrimary + font.pixelSize: theme.fontHeading + font.weight: theme.weightSemibold + } + Rectangle { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + width: 28 + height: 28 + radius: theme.radiusSm + color: closeArea.containsMouse ? theme.surfaceHover : "transparent" + QbzIcon { + anchors.centerIn: parent + width: 17 + height: 17 + name: "x" + tintName: closeArea.containsMouse ? "primary" : "muted" + } + MouseArea { + id: closeArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: root.open = false + } + } + } + + // ---- Intro: the manual, redacted log-sharing explanation. + Text { + width: parent.width + height: parent.height - 28 - 36 - 28 + text: root.t("To make bug and issue resolution easier, it's recommended to share the application logs. We have been careful to mask the logs we send so no sensitive data is included. The process is manual, never automatic, so you can verify what is being shared.") + color: theme.textSecondary + font.pixelSize: theme.fontBody + wrapMode: Text.WordWrap + } + + // ---- Actions: Go to logs (secondary) / Create issue (primary). + Row { + anchors.right: parent.right + spacing: 10 + + Rectangle { + width: goLabel.implicitWidth + 28 + height: 36 + radius: theme.radiusSm + border.width: 1 + border.color: theme.borderSubtle + color: goArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated + Text { + id: goLabel + anchors.centerIn: parent + text: root.t("Go to logs") + color: theme.textSecondary + font.pixelSize: theme.fontLink + font.weight: theme.weightMedium + } + MouseArea { + id: goArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + root.open = false + QbzShell.logOpen() + } + } + } + + Rectangle { + width: createLabel.implicitWidth + 28 + height: 36 + radius: theme.radiusSm + color: createArea.containsMouse ? theme.accentHover : theme.accent + Text { + id: createLabel + anchors.centerIn: parent + text: root.t("Create issue report") + color: theme.accentText + font.pixelSize: theme.fontLink + font.weight: theme.weightMedium + } + MouseArea { + id: createArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + root.open = false + QbzShell.reportIssueOpen() + } + } + } + } + } + } +} diff --git a/crates/qbz-qt/qml/shell/WhatsNewModal.qml b/crates/qbz-qt/qml/shell/WhatsNewModal.qml new file mode 100644 index 000000000..a62fae90c --- /dev/null +++ b/crates/qbz-qt/qml/shell/WhatsNewModal.qml @@ -0,0 +1,368 @@ +// What's New — QML port of crates/qbz-ui/ui/shell/WhatsNewModal.slint. +// +// Opened from the header hamburger menu ("What's New"). src/whats_new_qt.rs +// fetches the GitHub release matching the running version on every open, +// renders its markdown body into a FLAT BLOCK MODEL, and publishes it as +// QbzAbout.whatsNewJson: `{ open, loading, version, date, hasBody, toc[], +// blocks[] }`. The block render is the centrepiece of this file. +// +// ── WHY THIS IS NOT `Text.MarkdownText` ──────────────────────────────────── +// +// The renderer's subset is NOT Markdown, and Qt's is a DIFFERENT subset. Off +// the same release body they disagree visibly: +// +// * `**bold**` and `` `code` `` are STRIPPED by the reference, styled by Qt; +// * an indent-0 `- item` is a level-0 SECTION + a TOC entry in the +// reference, a bullet in Qt; +// * `#` and `##` collapse to ONE level in the reference, two in Qt; +// * a whole-line `[text](url)` is a KIND_LINK BLOCK in the reference, an +// inline needing onLinkActivated in Qt. +// +// So the parse stays in Rust (whats_new_qt.rs, unit-tested against a real +// TAG_DETAILS.md body) and this file only lays out blocks. Kinds: 0 section, +// 1 bullet, 2 paragraph, 3 whole-line link. +// +// ── THE TOC IS DECORATIVE, AS IT IS IN THE REFERENCE ─────────────────────── +// +// WhatsNewModal.slint:122-126 records that Slint cannot scroll a Flickable to +// an arbitrary nested child, so the chips are an OVERVIEW, not jump links. +// Qt could do the real thing (the slug id is already in the block model), but +// that is a behaviour change, not a port — it is flagged in the delivery doc +// rather than smuggled in here. +// +// Structure, scrim, shadow and self-gate follow LogViewerModal.qml; z: 3000 +// explicitly, per ADR-009 as this port spells it. Colour literals are +// `Qt.rgba(...)` because Slint is #RRGGBBAA and Qt is #AARRGGBB (see +// AboutModal.qml's header for the five modals that shipped an invisible scrim +// or shadow from exactly that copy, fixed 2026-08-14). +// +// No pills (ADR-008): the TOC chips are bordered radiusSm rows. + +import QtQuick +import com.blitzfc.qbz +import "../theme" + +Item { + id: root + + readonly property var doc: { + try { + return JSON.parse(QbzAbout.whatsNewJson) + } catch (e) { + return ({}) + } + } + + anchors.fill: parent + z: 3000 + visible: root.doc.open === true + enabled: root.visible + + QbzTheme { id: theme } + + onVisibleChanged: { + if (visible) + keyScope.forceActiveFocus() + } + + FocusScope { + id: keyScope + anchors.fill: parent + Keys.onEscapePressed: QbzAbout.whatsNewClose() + } + + Rectangle { + anchors.fill: parent + // Slint `#000000bf` CONVERTED. + color: Qt.rgba(0, 0, 0, 0.75) + MouseArea { + anchors.fill: parent + onClicked: QbzAbout.whatsNewClose() + } + } + + // Faked 32px drop shadow (`drop-shadow-color: #00000080`). + Rectangle { + anchors.centerIn: panel + width: panel.width + 8 + height: panel.height + 8 + radius: theme.radiusMd + color: Qt.rgba(0, 0, 0, 0.5) + } + + Rectangle { + id: panel + anchors.centerIn: parent + // 820x700 capped, minus an 80px window margin — WhatsNewModal.slint:36-37. + width: Math.min(root.width - 80, 820) + height: Math.min(root.height - 80, 700) + radius: theme.radiusMd + color: theme.surfaceCard + border.width: 1 + border.color: theme.borderSubtle + + MouseArea { anchors.fill: parent } + + Item { + id: stack + anchors.fill: parent + anchors.margins: 24 + + // ---- Header: title + close X ------------------------------ + Item { + id: headerRow + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + height: 28 + + Text { + anchors.left: parent.left + anchors.right: wnClose.left + anchors.rightMargin: 10 + anchors.verticalCenter: parent.verticalCenter + text: { + var v = root.doc.version || "" + if (v === "") + return QbzSession.tr("What's new", QbzSession.trRev) + var d = root.doc.date || "" + return QbzSession.tr("What's new in v{}", QbzSession.trRev).replace("{}", v) + + (d === "" ? "" : " (" + d + ")") + } + color: theme.textPrimary + font.pixelSize: theme.fontHeading + font.weight: theme.weightSemibold + elide: Text.ElideRight + } + Item { + id: wnClose + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + width: 28 + height: 28 + QbzIcon { + anchors.centerIn: parent + name: "x" + width: 17 + height: 17 + tintName: wnCloseArea.containsMouse ? "textPrimary" : "muted" + } + MouseArea { + id: wnCloseArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: QbzAbout.whatsNewClose() + } + } + } + + // ---- Body region (stretches between header and footer) ---- + Item { + id: bodyBox + anchors.top: headerRow.bottom + anchors.topMargin: 16 + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: footerRow.top + anchors.bottomMargin: 16 + + // Loading state. + Text { + anchors.centerIn: parent + visible: root.doc.loading === true + text: QbzSession.tr("Loading…", QbzSession.trRev) + color: theme.textMuted + font.pixelSize: theme.fontBody + } + + // Empty state — no release body available. Reached on every + // DEV build: the fetch returns nothing for a draft or + // prerelease tag (whats_new_qt.rs), by design. + Text { + anchors.centerIn: parent + visible: root.doc.loading !== true && root.doc.hasBody !== true + width: Math.min(parent.width, 360) + text: QbzSession.tr("Release notes are not available.", QbzSession.trRev) + color: theme.textMuted + font.pixelSize: theme.fontBody + wrapMode: Text.WordWrap + horizontalAlignment: Text.AlignHCenter + } + + // Rendered release body. + Flickable { + id: flick + anchors.fill: parent + visible: root.doc.loading !== true && root.doc.hasBody === true + clip: true + contentWidth: width + contentHeight: bodyCol.height + boundsBehavior: Flickable.StopAtBounds + + Column { + id: bodyCol + width: flick.width + + // ---- TOC: the level-0 headings as a bordered index. + // Stacked, not wrapped, and NOT clickable — see the + // file header. + Column { + width: parent.width + spacing: 6 + visible: (root.doc.toc || []).length > 0 + Repeater { + model: root.doc.toc || [] + delegate: Rectangle { + id: tocChip + required property var modelData + height: 26 + width: tocLabel.implicitWidth + 24 + radius: theme.radiusSm + border.width: 1 + border.color: theme.borderSubtle + color: theme.surfaceElevated + Text { + id: tocLabel + anchors.left: parent.left + anchors.leftMargin: 12 + anchors.verticalCenter: parent.verticalCenter + text: tocChip.modelData.label || "" + color: theme.textSecondary + font.pixelSize: 11 + font.weight: theme.weightMedium + } + } + } + Item { width: 1; height: 8 } + Rectangle { width: parent.width; height: 1; color: theme.borderSubtle } + Item { width: 1; height: 8 } + } + + // ---- Body blocks. + Repeater { + model: root.doc.blocks || [] + delegate: Item { + id: blk + required property var modelData + readonly property int kind: modelData.kind || 0 + readonly property int level: modelData.level || 0 + // Vertical rhythm comes from the paddings, as + // in the reference: sections get extra top + // space, everything else a uniform 7px below. + readonly property int padTop: blk.kind === 0 ? 16 : 0 + readonly property int padBottom: blk.kind === 0 ? 6 : 7 + + width: bodyCol.width + height: line.implicitHeight + blk.padTop + blk.padBottom + + Row { + id: line + y: blk.padTop + // Bullet indentation: level 1 = first nesting. + x: blk.kind === 1 ? blk.level * 16 : 0 + width: blk.width - x + spacing: 8 + + Text { + id: glyph + visible: blk.kind === 1 + width: visible ? implicitWidth : 0 + text: blk.level >= 2 ? "◦" : "•" + color: blk.level >= 2 ? theme.textMuted : theme.textSecondary + font.pixelSize: blk.level >= 2 ? 11 : 12 + } + + Text { + id: blockText + width: line.width + - (glyph.visible ? glyph.width + line.spacing : 0) + text: blk.modelData.text || "" + wrapMode: Text.WordWrap + color: blk.kind === 3 + ? (linkArea.containsMouse ? theme.accentHover : theme.accent) + : blk.kind === 0 + ? theme.textPrimary + : blk.kind === 1 + ? (blk.level >= 2 ? theme.textMuted : theme.textSecondary) + : theme.textSecondary + font.pixelSize: blk.kind === 0 + ? 14 + : blk.kind === 1 + ? (blk.level >= 2 ? 11 : 12) + : 13 + font.weight: blk.kind === 0 + ? theme.weightBold + : blk.kind === 3 + ? theme.weightMedium + : theme.weightRegular + + // Whole-line link (kind 3) — the whole + // line is the hit target, matching the + // reference's TouchArea over the text. + MouseArea { + id: linkArea + anchors.fill: parent + enabled: blk.kind === 3 + visible: enabled + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: QbzShell.openExternalUrl(blk.modelData.url || "") + } + } + } + } + } + + Item { width: 1; height: 8 } + } + } + + QbzScrollBar { + target: flick + visible: flick.visible && flick.contentHeight > flick.height + anchors.right: parent.right + anchors.top: parent.top + anchors.bottom: parent.bottom + } + } + + // ---- Footer: the accent Close button ---------------------- + Item { + id: footerRow + anchors.bottom: parent.bottom + anchors.left: parent.left + anchors.right: parent.right + height: 36 + + Rectangle { + anchors.right: parent.right + height: 36 + width: closeLabel.implicitWidth + 36 + radius: theme.radiusSm + color: footerArea.containsMouse ? theme.accentHover : theme.accent + Text { + id: closeLabel + anchors.centerIn: parent + text: QbzSession.tr("Close", QbzSession.trRev) + // The measured on-accent selector, not a raw + // accent-text (theme/QbzTheme.qml, "ON AN ACCENT + // FILL") — the port-wide rule for a label on an accent + // fill, and the one place the reference's + // Theme.accent-text is knowingly diverged from. + color: theme.accentGlyphColor + font.pixelSize: 14 + font.weight: theme.weightMedium + } + MouseArea { + id: footerArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: QbzAbout.whatsNewClose() + } + } + } + } + } +} diff --git a/crates/qbz-qt/qml/views/ArtistView.qml b/crates/qbz-qt/qml/views/ArtistView.qml index d1520cc14..67a24d745 100644 --- a/crates/qbz-qt/qml/views/ArtistView.qml +++ b/crates/qbz-qt/qml/views/ArtistView.qml @@ -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 } } diff --git a/crates/qbz-qt/qml/views/CoverLightbox.qml b/crates/qbz-qt/qml/views/CoverLightbox.qml index 6d8aa1104..e4bd6a450 100644 --- a/crates/qbz-qt/qml/views/CoverLightbox.qml +++ b/crates/qbz-qt/qml/views/CoverLightbox.qml @@ -1,11 +1,20 @@ -// Cover lightbox — NEW in the Qt port (no Slint counterpart): the album art -// at its best available quality, fitted to the window. Opened by left-click -// on the AlbumView cover or by the cover menu's "View cover" entry. +// Artwork lightbox — NEW in the Qt port (no Slint counterpart): the artwork +// at its best available quality, fitted to the window. Opened by left-click on +// the AlbumView cover, the PlaylistView cover or the ArtistView portrait, and +// by those menus' "View cover" / "View image" entries. // // Shape follows TrackInfoModal: a full-window Popup parented to // Overlay.overlay, a dim scrim whose click closes, Escape closes. The image // asks for a decode capped at the window size (sourceSize), so a mega // variant does not cost a full-res decode to display at ~90% of the window. +// +// ASPECT-AWARE, and it has to be. This used to force a SQUARE box of +// `min(w,h) * 0.9` on the grounds that "covers are square". Album covers are; +// artist portraits frequently are NOT (1080x720 among the portraits measured +// in this machine's cache), and a landscape image squeezed into the min-axis +// square gave up roughly a third of the height available on a wide window. +// The box now takes the image's own ratio inside a 90% viewport, which lands +// on exactly the old square for a square source — nothing regresses. import QtQuick import QtQuick.Controls @@ -41,6 +50,7 @@ Popup { } contentItem: Item { + id: stage // Scrim click closes; clicks on the image itself are swallowed so // the lightbox stays up while the user inspects it. MouseArea { @@ -48,19 +58,32 @@ Popup { onClicked: root.close() } - // Fit the LONGER window axis at 90%, so the cover never touches the - // rim. Covers are square; PreserveAspectFit keeps any odd one honest. - readonly property real side: Math.min(root.width, root.height) * 0.9 + // The 90% viewport the artwork is fitted into, so it never touches + // the rim on either axis. + readonly property real maxW: root.width * 0.9 + readonly property real maxH: root.height * 0.9 + // The SOURCE ratio. `implicitWidth/Height` report the decoded size, + // which `sourceSize` has already scaled — but scaled preserving the + // aspect, so the ratio is the source's. 1.0 until the image reports, + // i.e. the square box this file used to force unconditionally. + readonly property real ratio: (art.implicitWidth > 0 && art.implicitHeight > 0) + ? (art.implicitWidth / art.implicitHeight) : 1.0 + readonly property real boxW: Math.min(stage.maxW, stage.maxH * stage.ratio) + readonly property real boxH: Math.min(stage.maxH, stage.maxW / stage.ratio) Image { id: art anchors.centerIn: parent - width: parent.side - height: parent.side + width: stage.boxW + height: stage.boxH source: root.artSource asynchronous: true fillMode: Image.PreserveAspectFit - sourceSize: Qt.size(parent.side, parent.side) + // Capped off the VIEWPORT, not off boxW/boxH: the box is derived + // from implicitWidth/Height, which sourceSize itself determines — + // binding the cap to the box would be a loop. The viewport is the + // real display bound anyway, so nothing is under-decoded. + sourceSize: Qt.size(Math.round(stage.maxW), Math.round(stage.maxH)) smooth: true visible: status === Image.Ready diff --git a/crates/qbz-qt/qml/views/PlaylistRail.qml b/crates/qbz-qt/qml/views/PlaylistRail.qml new file mode 100644 index 000000000..b570b1e4f --- /dev/null +++ b/crates/qbz-qt/qml/views/PlaylistRail.qml @@ -0,0 +1,111 @@ +// Horizontal PLAYLIST rail (discover/PlaylistCarousel.slint) — section +// header + page chevrons + clipped ListView of the shared PlaylistCard, +// with soft edge fades. +// +// This is SectionRail.qml with a different delegate, and that is deliberate: +// the reference keeps `Carousel.slint` and `PlaylistCarousel.slint` apart for +// the same reason. SectionRail's delegate is a fully-bound AlbumCard mounted +// three times by AlbumView; adding a `kind`/`delegateComponent` switch there +// would put a branch in the hot path of every album rail to serve one caller. +// +// Body click OPENS the playlist — the reference mounts this carousel with +// `body-opens: true` on the artist page (ArtistPageView.slint:985-995), which +// is also PlaylistCard.qml's built-in behaviour, so nothing is passed for it. +// +// The card's own hover Behaviors (opacity, 150ms) and the edge fades' are +// finite hover transitions, identical to the ones SectionRail already runs on +// the album pages — no steady-state presents/s is added, and there is no +// timer, no data-fed Behavior and no continuous animation anywhere here. + +import QtQuick +import com.blitzfc.qbz +import "../controls" +import "../cards" +import "../theme" + +Column { + id: root + property string title: "" + property var items: [] + // The host's url-keyed cover map ({url: file://path}). + property var coverMap: ({}) + + QbzTheme { id: theme } + width: parent ? parent.width : 0 + spacing: 12 + + // 200px card + 32px gap = the 232px pitch, the same maths SectionRail + // runs and the same geometry PlaylistCarousel.slint:75-78 declares. + readonly property int perPage: Math.max(1, Math.floor((rail.width + 32) / 232)) + readonly property int step: perPage * 232 + readonly property real maxScroll: Math.max(0, rail.contentWidth - rail.width) + + QbzSectionHeader { + title: root.title + // PlaylistCarousel's `show-view-all` is off on the artist page (the + // .slint passes no `view-all-*` there), so this rail has no link arm + // at all — the header simply draws none. + showViewAll: false + leftEnabled: rail.contentX > 1 + rightEnabled: rail.contentX < root.maxScroll - 1 + onPageLeft: rail.contentX = Math.max(0, rail.contentX - root.step) + onPageRight: rail.contentX = Math.min(root.maxScroll, rail.contentX + root.step) + } + + Item { + width: parent.width + height: 246 + ListView { + id: rail + anchors.fill: parent + orientation: ListView.Horizontal + spacing: 32 + clip: true + boundsBehavior: Flickable.StopAtBounds + model: root.items + delegate: PlaylistCard { + item: modelData + artSource: root.coverMap[modelData.artUrl] || "" + // The row carries the pin state (the producer stamps it from + // the per-user store); hardcoding false made a pinned + // playlist draw hollow, so the first click UN-pinned it. + // `artworkUrl` needs no hand-over: the card defaults it off + // `item.artUrl`, which is the REMOTE url the pin payload + // wants — coverMap holds file:// paths, worthless as a + // stored display snapshot. + isPinned: modelData.isPinned === true + } + } + // Edge fades — see SectionRail.qml for why they hide under the + // app-wide dynamic background (Carousel.slint / PlaylistCarousel + // .slint:163,170, `visible: !ShellState.app-background-active`). + Rectangle { + anchors.left: parent.left + anchors.top: parent.top + anchors.bottom: parent.bottom + width: 56 + visible: !theme.ambientOn + opacity: rail.contentX > 1 ? 1.0 : 0.0 + Behavior on opacity { NumberAnimation { duration: 150 } } + gradient: Gradient { + orientation: Gradient.Horizontal + GradientStop { position: 0.0; color: theme.surfaceMain } + GradientStop { position: 1.0; color: "transparent" } + } + } + Rectangle { + anchors.right: parent.right + anchors.top: parent.top + anchors.bottom: parent.bottom + width: 56 + visible: !theme.ambientOn + opacity: rail.contentX < root.maxScroll - 1 ? 1.0 : 0.0 + Behavior on opacity { NumberAnimation { duration: 150 } } + gradient: Gradient { + orientation: Gradient.Horizontal + GradientStop { position: 0.0; color: "transparent" } + GradientStop { position: 1.0; color: theme.surfaceMain } + } + } + } +} diff --git a/crates/qbz-qt/src/about_bridge.rs b/crates/qbz-qt/src/about_bridge.rs new file mode 100644 index 000000000..f50b256c6 --- /dev/null +++ b/crates/qbz-qt/src/about_bridge.rs @@ -0,0 +1,166 @@ +//! QbzAbout — the About QBZ + What's New bridge. +//! +//! Two documents and four invokables. The controllers are `about_qt.rs` and +//! `whats_new_qt.rs`; this file is glue and nothing else, exactly like +//! `musician_bridge.rs`. +//! +//! # Why a NEW bridge instead of more members on QbzShell +//! +//! `QbzShell` is already the port's 1200-line catch-all and several lanes edit +//! it at once. This port has 23 bridges and a small domain object is the normal +//! shape, so About/What's New gets its own rather than widening the shared +//! file. External links are the one thing that does NOT move here: +//! `QbzShell.openExternalUrl` (`shell_bridge.rs:440` / `:1202`) already exists +//! and both modals call it — a second opener would be a second thing to keep +//! in sync. +//! +//! # A new bridge is SEVEN artefacts, not one file +//! +//! This file · its `build.rs` `rust_files` entry · the `main.rs` `mod` line · +//! the QML singleton registration (which IS the build.rs entry) · the two +//! modal mounts in `AppShell.qml` · the two `HeaderBar.qml` rows. A bridge +//! missing from `build.rs` does not fail the build: its QML singleton simply +//! does not exist and every `QbzAbout.foo()` becomes a runtime ReferenceError +//! `cargo check` cannot see. `qml_singleton_xref.py` is the gate for that. +//! +//! # The Qt-thread hop registers ITSELF +//! +//! Every other domain singleton is booted from `Main.qml`'s `boot()` sweep. +//! [`QbzAbout::boot`] exists and does the same job, but the four invokables +//! ALSO register the hop on first use, because both modals are opened by a +//! user action long after startup and nothing here publishes before that. That +//! makes this bridge work whether or not the (integration-owned) `Main.qml` +//! sweep ever gains its line — a bridge whose publishes silently no-op because +//! one QML line is missing is exactly the class of failure `cargo check` +//! cannot see. + +use std::pin::Pin; +use std::sync::OnceLock; + +use cxx_qt::CxxQtThread; +use cxx_qt::Threading as _; +use cxx_qt_lib::QString; + +#[cxx_qt::bridge] +pub mod qbz_about_bridge { + extern "C++" { + include!("cxx-qt-lib/qstring.h"); + type QString = cxx_qt_lib::QString; + } + + #[auto_cxx_name] + extern "RustQt" { + #[qobject] + #[qml_element] + #[qml_singleton] + /// The About QBZ document — `open`, the version/platform/build stamp, + /// the release URL, the codename, the author chip and the row-grouped + /// contributor chips (avatars as `file://` paths). Produced by + /// `about_qt.rs`. + /// + /// Defaults to `"{}"`, NOT `""`: the QML host `JSON.parse`s this on + /// its very first frame and an empty string throws into the catch arm. + #[qproperty(QString, about_json)] + /// The What's New document — `open`, `loading`, `version`, `date`, + /// `hasBody`, the TOC and the flat block model. Produced by + /// `whats_new_qt.rs`. Same `"{}"` default, same reason. + #[qproperty(QString, whats_new_json)] + + type QbzAbout = super::QbzAboutRust; + + /// Registers this object's Qt-thread hop. Harmless to call twice, and + /// unnecessary in practice — every invokable below registers it too + /// (see the module header). + #[qinvokable] + fn boot(self: Pin<&mut QbzAbout>); + + /// The header menu's "About QBZ" row. Publishes the document open and + /// kicks the one-shot GitHub avatar fetch. + #[qinvokable] + fn about_open(self: Pin<&mut QbzAbout>); + + /// All About dismissal paths (Escape, the X, the backdrop). + #[qinvokable] + fn about_close(self: Pin<&mut QbzAbout>); + + /// The header menu's "What's New" row. Publishes the loading state + /// immediately, then fetches + renders the matching GitHub release off + /// the UI thread. No cache: every open re-fetches, like the reference. + #[qinvokable] + fn whats_new_open(self: Pin<&mut QbzAbout>); + + /// All What's New dismissal paths (Escape, the X, the backdrop, the + /// footer Close button). + #[qinvokable] + fn whats_new_close(self: Pin<&mut QbzAbout>); + } + + impl cxx_qt::Threading for QbzAbout {} +} + +use qbz_about_bridge::QbzAbout; + +/// Rust side of the About bridge (plain storage, phase-1 pattern). +pub struct QbzAboutRust { + about_json: QString, + whats_new_json: QString, +} + +impl Default for QbzAboutRust { + fn default() -> Self { + Self { + // "{}" and not "": both hosts JSON.parse these straight, and an + // empty string throws on the very first frame. + about_json: QString::from("{}"), + whats_new_json: QString::from("{}"), + } + } +} + +// --------------------------------------------------------------------------- +// The UI hop +// --------------------------------------------------------------------------- + +static QT_THREAD: OnceLock> = OnceLock::new(); + +/// Queue an about-bridge mutation onto the Qt event loop (no-op before the +/// hop is registered). +pub(crate) fn ui(f: impl FnOnce(Pin<&mut QbzAbout>) + Send + 'static) { + if let Some(thread) = QT_THREAD.get() { + let _ = thread.queue(f); + } +} + +impl qbz_about_bridge::QbzAbout { + /// Idempotent hop registration. Invokables run ON the Qt thread, so this + /// is safe from any of them. + fn register_thread(&self) { + if QT_THREAD.get().is_none() { + let _ = QT_THREAD.set(self.qt_thread()); + } + } + + pub fn boot(self: Pin<&mut Self>) { + self.register_thread(); + } + + pub fn about_open(self: Pin<&mut Self>) { + self.register_thread(); + crate::about_qt::open(); + } + + pub fn about_close(self: Pin<&mut Self>) { + self.register_thread(); + crate::about_qt::close(); + } + + pub fn whats_new_open(self: Pin<&mut Self>) { + self.register_thread(); + crate::whats_new_qt::open(); + } + + pub fn whats_new_close(self: Pin<&mut Self>) { + self.register_thread(); + crate::whats_new_qt::close(); + } +} diff --git a/crates/qbz-qt/src/about_qt.rs b/crates/qbz-qt/src/about_qt.rs new file mode 100644 index 000000000..71674c492 --- /dev/null +++ b/crates/qbz-qt/src/about_qt.rs @@ -0,0 +1,329 @@ +//! About QBZ modal controller — the Qt port of `crates/qbz/src/about.rs`. +//! +//! Publishes ONE JSON document (`QbzAbout.aboutJson`) holding everything the +//! modal renders that is not a translated literal: the version, the platform +//! label, the build stamp, the release URL, the codename, the author chip and +//! the row-grouped contributor chips. The only async part is the GitHub +//! avatars, which are downloaded to the user cache dir and handed to QML as +//! `file://` paths — this port has NO precedent for an `https://` URL in +//! `Image.source` (every image goes through a Rust download-to-disk cache), so +//! the avatars follow that route rather than inventing a second one. +//! +//! # Divergences from the Slint reference, and why +//! +//! - **Platform label reads `"Linux (Qt)"`** (`about.rs:34-49` reads +//! `"Linux (Slint)"`). The reference brands the FRONTEND on purpose — the +//! Tauri build said `"(Tauri 2.0)"` — and this string ends up in bug +//! reports, where knowing which frontend produced them matters. +//! - **The avatars are fetched on the FIRST OPEN, not at install.** The +//! reference dispatches twelve unauthenticated `github.com/.png` +//! requests at shell setup (`about.rs:126`), i.e. on every app start whether +//! or not anyone ever opens the modal. There is no `install()` seam on this +//! side to hang them from anyway, and deferring them costs nothing: the +//! first open paints the blank circles the reference paints too, then fills +//! them in as they land. Cached files make every later open instant. +//! - **`CONTRIBUTORS_PER_ROW` is 5** (`about.rs:76`). Both Slint comments that +//! say "4 per row" (`state.slint:6091`, `AboutModal.slint:434`) are stale; +//! the code is what ships. The rows are kept in the JSON even though QML has +//! `Flow` and does not need pre-grouping, so both frontends read ONE document +//! shape; `AboutModal.qml` flattens them. +//! +//! # The codename is a hand-set literal — a SECOND place to bump per release +//! +//! [`CODENAME`] mirrors `AboutModal.slint:371`. Neither is derived from +//! anything, so a release now updates two literals, not one. That is recorded +//! in the delivery doc; the alternative (a tiny shared crate both frontends +//! read) is a larger change and the owner's call. + +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{LazyLock, Mutex}; + +use cxx_qt_lib::QString; +use serde::Serialize; + +use crate::about_bridge::ui; + +/// The real, displayed app version. `qbz-qt` inherits `version.workspace`, the +/// same source `crates/qbz` reads, so this is the REAL release version. +pub fn app_version() -> &'static str { + env!("CARGO_PKG_VERSION") +} + +/// Build date (`YYYY-MM-DD`) embedded by `build.rs`; empty if unavailable. +fn build_date() -> &'static str { + env!("QBZ_BUILD_DATE") +} + +/// Short git commit embedded by `build.rs`; empty in offline source builds. +fn build_commit() -> &'static str { + env!("QBZ_BUILD_COMMIT") +} + +/// The release codename shown in the build-info grid. +/// +/// HAND-SET, exactly like `AboutModal.slint:371`. Bump it with the version. +pub const CODENAME: &str = "Rebuild 破 (You Can (Not) Advance)"; + +/// Platform label for the build-info grid. This is the Qt port, so the label +/// reads "(Qt)" rather than the Slint build's "(Slint)". +fn platform_label() -> &'static str { + #[cfg(target_os = "macos")] + { + "macOS (Qt)" + } + #[cfg(target_os = "windows")] + { + "Windows (Qt)" + } + #[cfg(not(any(target_os = "macos", target_os = "windows")))] + { + "Linux (Qt)" + } +} + +/// The app author's GitHub handle (the single Author chip). +const AUTHOR_HANDLE: &str = "vicrodh"; + +/// The contributor handles. Copied VERBATIM from `about.rs:58-70`, order +/// included: first the Tauri About modal's order, then the Slint-era +/// external-PR contributors — `hoyon` (classical "work" grouping, PR #536), +/// `mxnix` (Russian translation, PR #517) and `TerminalTilt`. Do not reorder. +const CONTRIBUTORS: &[&str] = &[ + "vorce", + "boxdot", + "arminfelder", + "afonsojramos", + "GwendalBeaumont", + "AdamArstall", + "Vudgekek", + "DoubleGate", + "hoyon", + "mxnix", + "TerminalTilt", +]; + +/// How many contributor chips per wrap row (`about.rs:76`). Slint has no +/// flex-wrap and needs the pre-grouping; QML uses `Flow` and flattens these +/// again. The grouping stays in the document so both frontends read one shape. +const CONTRIBUTORS_PER_ROW: usize = 5; + +static OPEN: AtomicBool = AtomicBool::new(false); +/// Guards the one-shot avatar dispatch (see the module header — first open, +/// not install). +static AVATARS_STARTED: AtomicBool = AtomicBool::new(false); +/// handle -> `file://` path of its cached avatar. Absent = blank circle. +static AVATARS: LazyLock>> = + LazyLock::new(|| Mutex::new(HashMap::new())); + +#[derive(Serialize)] +struct Chip { + name: String, + url: String, + /// `file://` path, or "" while it has not landed (QML draws the blank + /// circle, exactly like `AboutModal.slint:98-99`). + avatar: String, +} + +#[derive(Serialize)] +struct Doc { + open: bool, + version: &'static str, + #[serde(rename = "platformLabel")] + platform_label: &'static str, + #[serde(rename = "buildDate")] + build_date: &'static str, + #[serde(rename = "buildCommit")] + build_commit: &'static str, + #[serde(rename = "releaseUrl")] + release_url: String, + codename: &'static str, + #[serde(rename = "authorName")] + author_name: &'static str, + #[serde(rename = "authorUrl")] + author_url: String, + #[serde(rename = "authorAvatar")] + author_avatar: String, + #[serde(rename = "contributorRows")] + contributor_rows: Vec>, +} + +fn profile_url(handle: &str) -> String { + format!("https://github.com/{handle}") +} + +fn avatar_of(handle: &str) -> String { + AVATARS + .lock() + .unwrap_or_else(|e| e.into_inner()) + .get(handle) + .cloned() + .unwrap_or_default() +} + +fn snapshot_doc() -> Doc { + let version = app_version(); + Doc { + open: OPEN.load(Ordering::SeqCst), + version, + platform_label: platform_label(), + build_date: build_date(), + build_commit: build_commit(), + release_url: format!("https://github.com/vicrodh/qbz/releases/tag/v{version}"), + codename: CODENAME, + author_name: AUTHOR_HANDLE, + author_url: profile_url(AUTHOR_HANDLE), + author_avatar: avatar_of(AUTHOR_HANDLE), + contributor_rows: CONTRIBUTORS + .chunks(CONTRIBUTORS_PER_ROW) + .map(|chunk| { + chunk + .iter() + .map(|handle| Chip { + name: (*handle).to_string(), + url: profile_url(handle), + avatar: avatar_of(handle), + }) + .collect() + }) + .collect(), + } +} + +pub fn publish() { + let json = serde_json::to_string(&snapshot_doc()).unwrap_or_else(|_| "{}".into()); + ui(move |mut b| b.as_mut().set_about_json(QString::from(json.as_str()))); +} + +pub fn open() { + OPEN.store(true, Ordering::SeqCst); + publish(); + start_avatar_loads(); +} + +pub fn close() { + OPEN.store(false, Ordering::SeqCst); + publish(); +} + +// --------------------------------------------------------------------------- +// Avatars +// --------------------------------------------------------------------------- + +/// `/qbz/gh-avatars/` — beside the artwork cache, not inside it: the +/// artwork cache is a SQLite-keyed store for catalog art with its own eviction, +/// and twelve fixed 64px PNGs have nothing to do with it. +fn avatar_dir() -> Option { + dirs::cache_dir().map(|d| d.join("qbz").join("gh-avatars")) +} + +/// The GitHub avatar URL for a handle (64px PNG, matching the reference). +fn avatar_url(handle: &str) -> String { + format!("https://github.com/{handle}.png?size=64") +} + +/// Resolve one avatar to a `file://` path, downloading it if the cache misses. +/// `None` on any network / decode / IO failure — the caller leaves the chip's +/// blank circle in place, no crash and no retry. +async fn ensure_avatar(client: &reqwest::Client, handle: &str) -> Option { + let dir = avatar_dir()?; + let out = dir.join(format!("{handle}.png")); + // A zero-byte file is a failed write that would otherwise count as a hit + // forever (the artwork cache's documented trap) — treat it as a miss. + if std::fs::metadata(&out).map(|m| m.len() > 0).unwrap_or(false) { + return Some(crate::artwork_qt::file_url(&out.to_string_lossy())); + } + std::fs::create_dir_all(&dir).ok()?; + + let resp = client.get(avatar_url(handle)).send().await.ok()?; + if !resp.status().is_success() { + return None; + } + let bytes = resp.bytes().await.ok()?; + let img = image::load_from_memory(&bytes).ok()?.thumbnail(64, 64); + // ATOMIC: encode into a unique sibling, then rename — a half-written PNG + // under the final name is a permanently broken chip (artwork_qt.rs:594). + let tmp = dir.join(format!(".{handle}.{}.png", std::process::id())); + if img.save_with_format(&tmp, image::ImageFormat::Png).is_err() + || std::fs::rename(&tmp, &out).is_err() + { + let _ = std::fs::remove_file(&tmp); + return None; + } + Some(crate::artwork_qt::file_url(&out.to_string_lossy())) +} + +/// Fetch every avatar (author + contributors) once, off the UI thread, and +/// republish the document as each one lands. +fn start_avatar_loads() { + if AVATARS_STARTED.swap(true, Ordering::SeqCst) { + return; + } + let client = match reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(8)) + // GitHub rejects requests without one. + .user_agent("qbz") + .build() + { + Ok(c) => c, + Err(e) => { + log::warn!("[qbz-qt] about avatar client build failed: {e}"); + return; + } + }; + + for handle in std::iter::once(&AUTHOR_HANDLE).chain(CONTRIBUTORS.iter()) { + let client = client.clone(); + let handle = handle.to_string(); + crate::spawn(async move { + if let Some(path) = ensure_avatar(&client, &handle).await { + // The guard is scoped EXPLICITLY: `publish()` re-locks + // AVATARS through `avatar_of`, and std's Mutex is not + // re-entrant — holding it across the call would hard-hang the + // download task on itself. + { + AVATARS + .lock() + .unwrap_or_else(|e| e.into_inner()) + .insert(handle, path); + } + publish(); + } + }); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn contributor_rows_are_chunks_of_five_in_source_order() { + let doc = snapshot_doc(); + assert_eq!(doc.contributor_rows.len(), 3); + assert_eq!(doc.contributor_rows[0].len(), 5); + assert_eq!(doc.contributor_rows[1].len(), 5); + assert_eq!(doc.contributor_rows[2].len(), 1); + assert_eq!(doc.contributor_rows[0][0].name, "vorce"); + assert_eq!(doc.contributor_rows[2][0].name, "TerminalTilt"); + assert_eq!( + doc.contributor_rows[2][0].url, + "https://github.com/TerminalTilt" + ); + } + + #[test] + fn release_url_is_the_v_prefixed_tag_for_the_running_version() { + let doc = snapshot_doc(); + assert_eq!( + doc.release_url, + format!("https://github.com/vicrodh/qbz/releases/tag/v{}", app_version()) + ); + } + + #[test] + fn platform_label_brands_the_frontend() { + assert!(platform_label().ends_with("(Qt)"), "{}", platform_label()); + } +} diff --git a/crates/qbz-qt/src/artist_bridge.rs b/crates/qbz-qt/src/artist_bridge.rs index c049f86c1..bb11afb9b 100644 --- a/crates/qbz-qt/src/artist_bridge.rs +++ b/crates/qbz-qt/src/artist_bridge.rs @@ -9,7 +9,8 @@ //! Props: the one artist-view JSON document + its loading flag, and the one //! discography-page document (artistReleasesJson — the sub-view reached from //! "See discography", state and all). -//! Invokables: openArtist, resolveMusician, share, loadReleaseSection +//! Invokables: openArtist, resolveMusician, share, the portrait menu's three +//! (imageAddCustom / imageRemoveCustom / imageSaveAs), loadReleaseSection //! (per-bucket "Load more"), setSectionSort (per-bucket release sort, persisted //! by release_type), and the discography page's four — //! openReleases / releasesLoadMore / releasesSetSort / releasesRetry. @@ -80,6 +81,23 @@ pub mod qbz_artist_bridge { #[qinvokable] fn share(self: Pin<&mut QbzArtist>, artist_id: QString); + /// Portrait right-click menu (cover_artwork_qt.rs): pick / clear the + /// custom artist image, save the portrait to disk. The store is keyed + /// by artist NAME (`ArtistPageView.slint:312`), which is why the name + /// travels instead of the id; `artwork_url` is the Qobuz portrait URL, + /// needed for the hash -> override link and as the save-as fallback + /// source. "Open in browser" is NOT here — it reuses + /// `QbzShell.openExternalUrl`, exactly as the album cover menu does. + /// + /// `#[auto_cxx_name]` makes the QML names QbzArtist.imageAddCustom / + /// .imageRemoveCustom / .imageSaveAs. + #[qinvokable] + fn image_add_custom(self: Pin<&mut QbzArtist>, name: QString, artwork_url: QString); + #[qinvokable] + fn image_remove_custom(self: Pin<&mut QbzArtist>, name: QString, artwork_url: QString); + #[qinvokable] + fn image_save_as(self: Pin<&mut QbzArtist>, name: QString, artwork_url: QString); + /// ArtistView per-section "Load more" — the next releases page. #[qinvokable] fn load_release_section(self: Pin<&mut QbzArtist>, artist_id: QString, release_type: QString, offset: i32); @@ -207,6 +225,24 @@ impl qbz_artist_bridge::QbzArtist { crate::artist_qt::resort_section(&release_type.to_string(), &sort.to_string()); } + // Portrait menu — straight through to `cover_artwork_qt`, no `crate::` + // forwarder: these mutate the artwork store and repaint through + // `artist_qt::apply_custom_image`, and touch neither nav nor AppRuntime. + pub fn image_add_custom(self: Pin<&mut Self>, name: QString, artwork_url: QString) { + crate::cover_artwork_qt::add_custom_artist_image(name.to_string(), artwork_url.to_string()); + } + + pub fn image_remove_custom(self: Pin<&mut Self>, name: QString, artwork_url: QString) { + crate::cover_artwork_qt::remove_custom_artist_image( + name.to_string(), + artwork_url.to_string(), + ); + } + + pub fn image_save_as(self: Pin<&mut Self>, name: QString, artwork_url: QString) { + crate::cover_artwork_qt::save_artist_image_as(name.to_string(), artwork_url.to_string()); + } + pub fn resolve_musician(self: Pin<&mut Self>, name: QString, role: QString) { crate::resolve_musician(name.to_string(), role.to_string()); } diff --git a/crates/qbz-qt/src/artist_qt.rs b/crates/qbz-qt/src/artist_qt.rs index cd360f126..da02774e9 100644 --- a/crates/qbz-qt/src/artist_qt.rs +++ b/crates/qbz-qt/src/artist_qt.rs @@ -130,6 +130,11 @@ pub struct ArtistSimilar { pub name: String, } +/// One row of the artist page's "Playlists" rail. The shape is +/// `cards/PlaylistCard.qml`'s item contract — that card is what the reference +/// mounts here too (`ArtistPageView.slint:985-995` -> `PlaylistCarousel` -> +/// `discover/PlaylistCard.slint`), so this row publishes the state the card +/// draws instead of leaving it to guess. #[derive(Clone, Serialize)] pub struct ArtistPlaylist { pub id: String, @@ -137,6 +142,34 @@ pub struct ArtistPlaylist { pub subtitle: String, #[serde(rename = "artUrl")] pub art_url: String, + /// Pin badge state, from the per-user store. Without it the glyph reads + /// "unpinned" for a playlist that IS pinned and the first click un-pins it + /// (the exact defect already fixed for this view's album grids). + #[serde(rename = "isPinned")] + pub is_pinned: bool, + /// The library heart (the qbz-local library.db flag). + #[serde(rename = "isFavorite")] + pub is_favorite: bool, + /// The overlay tri-state, same reasoning as `home_qt::map_playlist`: the + /// Slint reference hard-writes both false (`qbz/src/artist.rs:901-903`) + /// because its `PlaylistSlim` carries no owner id — `PageArtistPlaylist` + /// DOES, so this port draws the state the reference guesses at. Strictly a + /// superset: an editorial playlist's owner is Qobuz, so it still resolves + /// to the foreign follow arm. Both sets are empty until the first + /// `get_user_playlists` lands, i.e. a cold page may draw `user-plus` for a + /// playlist the user owns, exactly as the Home rails do. + #[serde(rename = "playlistOwned")] + pub playlist_owned: bool, + #[serde(rename = "playlistFollowing")] + pub playlist_following: bool, + /// True when `artUrl` is the playlist's OWN Qobuz graphic. It always is on + /// this page — the payload carries `images.rectangle` and nothing else — + /// and the flag makes the card render it CONTAIN deterministically instead + /// of measuring the ratio. Those banners are 2.11:1 (800x380) and cropping + /// them into the 200px square is what the owner reported as "gigantic". + /// The serde name follows `library_qt.rs:110-114`, the only other producer. + #[serde(rename = "playlistOwnImage")] + pub playlist_own_image: bool, } /// Magazine story teaser (Qobuz editorial — `/artist/getStory`). Not an @@ -297,6 +330,22 @@ pub struct ArtistViewData { pub bio_source: String, #[serde(rename = "artUrl")] pub artwork_url: String, + /// A user-picked portrait is registered for this artist. Flips the + /// portrait menu between "Add image" and "Change image"+"Remove image" + /// (`ArtistPageView.slint:307-330`). + #[serde(rename = "hasCustomImage")] + pub has_custom_image: bool, + /// Absolute path of that portrait ("" when none). The view prefers it over + /// the url-keyed pipeline image, and it is also what the header tint and + /// the atmosphere are derived from — so the flag is never decorative. + #[serde(rename = "customImagePath")] + pub custom_image_path: String, + /// The same file as an ESCAPED `file://` URI, which is what QML must bind. + /// `"file://" + path` is not equivalent: a portrait picked out of a folder + /// holding `%`, `#` or `?` renders blank, because QUrl eats those. The raw + /// path stays published beside it because the Rust side needs to open it. + #[serde(rename = "customImageUrl")] + pub custom_image_url: String, #[serde(rename = "isFollowing")] pub is_following: bool, #[serde(rename = "isPinned")] @@ -571,6 +620,22 @@ fn map_artist(page: PageArtistResponse) -> ArtistViewData { }) .unwrap_or_default(); + // Custom portrait — the SHARED `custom_artwork.json` store, keyed by + // artist NAME exactly as `ArtistPageView.slint:312` keys it (see the + // module header of cover_artwork_qt.rs for why id-keying is wrong here). + // The `is_file` filter is the reference's accepted behaviour: an override + // whose file the user has since moved simply stops applying. + let custom_image_path = crate::cover_artwork_qt::artist_image(&name) + .filter(|p| std::path::Path::new(p).is_file()) + .unwrap_or_default(); + let has_custom_image = !custom_image_path.is_empty(); + // Backfill the hash -> override link (the same backfill album_qt.rs does + // on album load) so a portrait set in the Slint build, or before this map + // existed, propagates to every OTHER surface that only knows the URL. + if has_custom_image && !artwork_url.is_empty() { + crate::cover_artwork_qt::note_override_key(&artwork_url, &custom_image_path); + } + let top_tracks = page .top_tracks .unwrap_or_default() @@ -638,6 +703,11 @@ fn map_artist(page: PageArtistResponse) -> ArtistViewData { p.items .into_iter() .map(|pl| { + // `owner` carries BOTH halves this row needs — the display + // name for the subtitle and the id for the ownership + // check — so read the id off the borrow before the name + // moves it out. + let owner_id = pl.owner.as_ref().map(|o| o.id).unwrap_or(0); let owner = pl .owner .and_then(|o| o.name) @@ -650,6 +720,14 @@ fn map_artist(page: PageArtistResponse) -> ArtistViewData { .and_then(|rects| rects.into_iter().find(|s| !s.is_empty())) .unwrap_or_default(); ArtistPlaylist { + is_pinned: crate::sidebar_qt::is_pinned("playlist", &pl.id.to_string()), + is_favorite: crate::fav_cache_qt::is_playlist_favorite(pl.id), + playlist_owned: crate::playlist_qt::owns(owner_id), + playlist_following: crate::playlist_qt::is_following(pl.id), + // `images.rectangle` is the playlist's own graphic by + // definition — `PageArtistPlaylistImages` has no other + // field (qbz-models types.rs:1436-1439). + playlist_own_image: !art_url.is_empty(), id: pl.id.to_string(), title: pl.title.unwrap_or_default(), subtitle: format!("{owner} · {track_count}"), @@ -753,6 +831,13 @@ fn map_artist(page: PageArtistResponse) -> ArtistViewData { bio_truncated, bio_source, artwork_url, + has_custom_image, + custom_image_url: if custom_image_path.is_empty() { + String::new() + } else { + crate::artwork_qt::file_url(&custom_image_path) + }, + custom_image_path, is_following, is_pinned: crate::sidebar_qt::is_pinned("artist", &page.id.to_string()), is_blacklisted: crate::artist_blacklist::is_blacklisted(page.id), @@ -1052,7 +1137,16 @@ pub async fn load_artist_view( data.stories_loading = true; // Header atmosphere — see the ArtistViewData field comments. data.header_gradient = crate::settings_qt::pref_bool("album_header_gradient", true); - let header_art = data.artwork_url.clone(); + // Tint + atmosphere come from the image the user actually SEES. The + // override's raw path is handed over directly rather than relying on the + // hash link, because an artist Qobuz has no portrait for has an empty + // `artwork_url` — the very case a custom portrait exists for, and the one + // `spawn_header_color` early-returns on. + let header_art = if data.custom_image_path.is_empty() { + data.artwork_url.clone() + } else { + data.custom_image_path.clone() + }; let json = serde_json::to_string(&data).map_err(|e| e.to_string())?; log::info!( @@ -1128,6 +1222,72 @@ fn publish_patch(generation: u64, f: impl FnOnce(&mut serde_json::Value)) { }); } +/// Repaint the OPEN artist page after its custom portrait was set or removed +/// (`cover_artwork_qt::add_custom_artist_image` / `remove_custom_artist_image`). +/// +/// DELIBERATELY NOT `crate::open_artist`. That router +/// (`main.rs:1066-1075`) records a search-ranking interaction, then returns +/// early when the session is offline, then records a nav entry — so re-opening +/// would (a) do NOTHING at all offline, leaving a picked portrait invisible, +/// (b) push a duplicate nav entry so Back re-lands on the same artist, and +/// (c) log a page interaction the user never performed. This patches the two +/// published fields into the stashed document and re-publishes it through +/// `publish_patch`, the port's ONE transport for this view — the same shape +/// `resort_section` uses, and the same instinct as the Slint reference, which +/// applies the new image in place without a reload +/// (`crates/qbz/src/main.rs:23184-23187`). +/// +/// Guarded on the artist NAME rather than the generation counter, for the +/// reason `resort_section` reads its generation off the stash: the click was +/// aimed at the page on screen, and if the user has navigated on since the +/// file picker opened, the stash belongs to somebody else and the edit is +/// dropped. +pub(crate) fn apply_custom_image(artist_name: &str) { + let path = crate::cover_artwork_qt::artist_image(artist_name) + .filter(|p| std::path::Path::new(p).is_file()) + .unwrap_or_default(); + // The guard is dropped before `publish_patch` takes the same lock. + let stash = { + let Ok(guard) = ARTIST_DOC.lock() else { + return; + }; + let Some((generation, doc)) = guard.as_ref() else { + return; + }; + if doc.get("name").and_then(|v| v.as_str()) != Some(artist_name) { + return; + } + ( + *generation, + doc.get("artUrl") + .and_then(|v| v.as_str()) + .unwrap_or_default() + .to_string(), + ) + }; + let (generation, artwork_url) = stash; + // Re-derive the header tint + atmosphere from whatever the portrait is + // NOW. With the override registered, `cached_path(artwork_url)` already + // resolves to the custom file; the explicit path arm is for the artists + // that have no Qobuz portrait to key on. + let header_art = if path.is_empty() { + artwork_url + } else { + path.clone() + }; + let published = path.clone(); + publish_patch(generation, move |doc| { + doc["hasCustomImage"] = json!(!published.is_empty()); + doc["customImageUrl"] = json!(if published.is_empty() { + String::new() + } else { + crate::artwork_qt::file_url(&published) + }); + doc["customImagePath"] = json!(published); + }); + spawn_header_color(generation, header_art); +} + /// Resolve the artist portrait (downloading it if needed), reduce it to the /// header tint (`album_qt::header_tint_hex` — ONE colour pipeline for both /// detail pages, exactly as the Slint shares `artwork::header_tint`) and @@ -1148,7 +1308,7 @@ fn spawn_header_color(generation: u64, artwork_url: String) { if path.is_empty() { return; } - let path = path.trim_start_matches("file://").to_string(); + let path = crate::artwork_qt::local_path(&path); // One decode, two products: the flat tint (Slint's FALLBACK arm) and // the blurred atmosphere it actually renders behind the header. let p2 = path.clone(); diff --git a/crates/qbz-qt/src/artwork_qt.rs b/crates/qbz-qt/src/artwork_qt.rs index be6ea06cc..88e57b9a7 100644 --- a/crates/qbz-qt/src/artwork_qt.rs +++ b/crates/qbz-qt/src/artwork_qt.rs @@ -181,6 +181,20 @@ pub fn classify(url: &str) -> ArtUrl { /// raw. Percent-encode exactly those two plus `%` itself (first, or the /// escapes we add would be double-decoded). Anything already carrying the /// scheme is passed through untouched. +/// The inverse of [`file_url`]: a `file://` URI back to a filesystem path. +/// +/// `trim_start_matches("file://")` is NOT the inverse — it leaves the three +/// percent-escapes in place, so any path holding `%`, `#` or `?` comes back as +/// a name that does not exist on disk. That silently broke whatever opened the +/// result (a tint decode, a copy) for exactly the files the escaping exists to +/// support. +pub fn local_path(url: &str) -> String { + let raw = url.strip_prefix("file://").unwrap_or(url); + raw.replace("%23", "#") + .replace("%3F", "?") + .replace("%25", "%") +} + pub fn file_url(path: &str) -> String { if path.starts_with("file://") { return path.to_string(); diff --git a/crates/qbz-qt/src/cast_qt.rs b/crates/qbz-qt/src/cast_qt.rs index 3f9eac930..eaecc7af6 100644 --- a/crates/qbz-qt/src/cast_qt.rs +++ b/crates/qbz-qt/src/cast_qt.rs @@ -266,6 +266,37 @@ impl CastService { set_scanning(false); } + /// `(chromecast_count, dlna_count, [(protocol, name)])` for the Settings > + /// Developer Diagnostics panel's Cast Discovery section. + /// + /// The reference reads the `CastState` Slint global on the UI thread + /// (`crates/qbz/src/diagnostics.rs:253-270`) because that is where its + /// picker's device list lives. Here the lists live in `inner`, so the + /// panel reads them straight from the service instead of round-tripping + /// through the bridge — same numbers, one fewer hop, and no oneshot. + /// PURE read: it never starts or stops discovery. + pub(crate) async fn diag_devices(&self) -> (i32, i32, Vec<(String, String)>) { + let inner = self.inner.lock().await; + let cc = inner + .chromecast_discovery + .as_ref() + .map(|d| d.get_discovered_devices()) + .unwrap_or_default(); + let dl = inner + .dlna_discovery + .as_ref() + .map(|d| d.get_discovered_devices()) + .unwrap_or_default(); + let mut rows: Vec<(String, String)> = Vec::with_capacity(cc.len() + dl.len()); + for d in &cc { + rows.push(("chromecast".to_string(), d.name.clone())); + } + for d in &dl { + rows.push(("dlna".to_string(), d.name.clone())); + } + (cc.len() as i32, dl.len() as i32, rows) + } + /// Snapshot both device lists and push them to the picker. async fn refresh_devices(&self) { let (chromecast, dlna) = { @@ -1328,9 +1359,25 @@ impl CastService { // Entry points for the bridge (each hops onto the tokio runtime) // --------------------------------------------------------------------------- +/// Whether the cast picker currently owns discovery. +/// +/// Rust-side mirror of the `picker_open` qproperty, kept here because the +/// Diagnostics panel's on-demand scan has to decide whether stopping discovery +/// would kill a LIVE picker's device list — the reference reads +/// `CastState.picker-open` on the UI thread for exactly that gate +/// (`crates/qbz/src/diagnostics.rs:284-291`), and a qproperty cannot be read +/// off the Qt thread. +static PICKER_OPEN: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); + +/// See [`PICKER_OPEN`]. +pub(crate) fn picker_open() -> bool { + PICKER_OPEN.load(std::sync::atomic::Ordering::SeqCst) +} + /// Picker opened: arm discovery. This is the ONLY trigger — nothing scans in /// the background and nothing touches the network before this runs. pub(crate) fn open_picker() { + PICKER_OPEN.store(true, std::sync::atomic::Ordering::SeqCst); set_error(String::new()); let svc = service(); crate::spawn(async move { @@ -1340,6 +1387,7 @@ pub(crate) fn open_picker() { /// Picker closed: stop both discoveries. An active cast is untouched. pub(crate) fn close_picker() { + PICKER_OPEN.store(false, std::sync::atomic::Ordering::SeqCst); let svc = service(); crate::spawn(async move { svc.stop_discovery().await; diff --git a/crates/qbz-qt/src/cover_artwork_qt.rs b/crates/qbz-qt/src/cover_artwork_qt.rs index 84bd0c613..396013c89 100644 --- a/crates/qbz-qt/src/cover_artwork_qt.rs +++ b/crates/qbz-qt/src/cover_artwork_qt.rs @@ -1,13 +1,21 @@ -//! Album custom covers + cover file actions — the Qt port of the album half -//! of `crates/qbz/src/custom_artwork.rs` and the cover-menu Rust arms -//! (`main.rs:23167-23239` add/remove, `:23096-23159` open-in-browser/save-as). +//! Custom album covers and artist portraits + the artwork file actions — the +//! Qt port of `crates/qbz/src/custom_artwork.rs` and the cover/portrait menu +//! Rust arms (`main.rs:23167-23239` add/remove, `:23096-23159` +//! open-in-browser/save-as). //! //! The store is the SAME file and format the Slint app uses -//! (`/qbz/custom_artwork.json`, `albums: album_id → absolute -//! path`), so a cover set in either app shows in both. The store records the -//! picked file's own path (Slint does not copy/resize into the cache); if -//! the user later moves or deletes that file the override silently stops -//! applying — the reference's accepted behaviour, kept on purpose. +//! (`/qbz/custom_artwork.json`, `albums: album_id → absolute path` +//! and `artists: artist NAME → absolute path`), so an override set in either +//! app shows in both. The store records the picked file's own path (Slint does +//! not copy/resize into the cache); if the user later moves or deletes that +//! file the override silently stops applying — the reference's accepted +//! behaviour, kept on purpose. +//! +//! The artist half is keyed by DISPLAY NAME, not id, because that is what +//! `artist/ArtistPageView.slint:312` writes. Two artists sharing a name +//! collide and an upstream rename orphans the override; "improving" this to +//! id-keying would stop the file round-tripping with the shipping Slint build, +//! which is the entire point of sharing it. use std::collections::HashMap; use std::path::PathBuf; @@ -16,8 +24,9 @@ use serde::{Deserialize, Serialize}; #[derive(Default, Serialize, Deserialize)] struct Store { - // Artist images share this file in the Slint store; the key must round- - // trip even though this port only writes album covers. + /// Artist portraits, keyed by DISPLAY NAME (the Slint key — see the + /// module header). Written by the artist page's portrait menu since the + /// 2026-08-14 round; before that this port only round-tripped the map. #[serde(default)] artists: HashMap, #[serde(default)] @@ -198,6 +207,187 @@ pub fn save_cover_as(album_id: String, title: String, artwork_url: String) { }); } +// --------------------------------------------------------------------------- +// Artist portraits (the artist half of the SHARED store) +// --------------------------------------------------------------------------- +// 1:1 with `crates/qbz/src/custom_artwork.rs:69-86` — same file, same map, +// same NAME key, so a portrait set in either build shows in both. + +/// The absolute path of the user-picked portrait for `name`, if registered. +/// `artist_qt::load_artist` calls this on every build to seed the header's +/// `hasCustomImage` / `customImagePath`. +pub fn artist_image(name: &str) -> Option { + load_store().artists.get(name).cloned() +} + +fn set_artist_image(name: &str, path: &str) { + let mut store = load_store(); + store.artists.insert(name.to_string(), path.to_string()); + write_store(&store); +} + +fn remove_artist_image(name: &str) { + let mut store = load_store(); + if store.artists.remove(name).is_some() { + write_store(&store); + } +} + +/// "Add image" / "Change image": native picker, persist the picked path, then +/// repaint the OPEN artist page. +/// +/// The repaint is `artist_qt::apply_custom_image`, NOT `crate::open_artist`: +/// that router returns early when the session is offline (`main.rs:1071`), so +/// re-opening would leave a portrait picked offline invisible until a restart; +/// it also records a second nav entry (Back would land on the same artist) and +/// feeds the search-ranking interaction log with a click the user never made. +/// The Slint reference has the same instinct for a different reason — it +/// applies the decoded image in place without a reload +/// (`crates/qbz/src/main.rs:23184-23187`) precisely because the artists this +/// feature exists for are the ones Qobuz has no portrait for. +pub fn add_custom_artist_image(artist_name: String, artwork_url: String) { + if artist_name.is_empty() { + return; + } + crate::spawn(async move { + let Some(file) = rfd::AsyncFileDialog::new() + .add_filter("Images", &["png", "jpg", "jpeg", "webp"]) + .pick_file() + .await + else { + return; + }; + let path = file.path().to_string_lossy().to_string(); + // The hash -> override link is what carries this portrait to the rest + // of the app (search cards, similar-artist rails, the sidebar dock). + // It is a no-op when the artist has no Qobuz portrait to key on — that + // artist's override then applies on the artist page alone, which is + // still strictly better than today's nothing. + note_override_key(&artwork_url, &path); + let name = artist_name.clone(); + if tokio::task::spawn_blocking(move || { + set_artist_image(&name, &path); + }) + .await + .is_ok() + { + crate::artist_qt::apply_custom_image(&artist_name); + } + }); +} + +/// "Remove image": drop the override (and its hash link) and repaint, so the +/// header reverts to the Qobuz portrait. +/// +/// Dropping the hash link is what the album twin cannot do — `cover_remove_ +/// custom` takes only the album id and so has no url to compute the key from, +/// which leaves a removed album cover still overriding every card. Fixing that +/// means widening an invokable this lane does not own; the artist arm is built +/// without the hole rather than made symmetric with it. +pub fn remove_custom_artist_image(artist_name: String, artwork_url: String) { + if artist_name.is_empty() { + return; + } + let name = artist_name.clone(); + let url = artwork_url.clone(); + crate::spawn(async move { + let _ = tokio::task::spawn_blocking(move || { + remove_artist_image(&name); + forget_override_key(&url); + }) + .await; + crate::artist_qt::apply_custom_image(&artist_name); + }); +} + +/// "Save as…": native save dialog seeded `{name}.jpg` (the reference's default, +/// `ArtistPageView.slint:349`). Local sources first — the override file, then +/// the artwork cache — else one GET of the portrait URL. +pub fn save_artist_image_as(artist_name: String, artwork_url: String) { + crate::spawn(async move { + // Resolve the source BEFORE opening the dialog, so the seeded filename + // can carry the real extension. A PNG or WebP portrait written out as + // `.jpg` is a file that lies about itself. + // + // The override arm is filtered on `is_file()` for the same reason the + // two callers in `artist_qt` are: an override whose file has since been + // moved is `Some`, and without the filter it short-circuits the whole + // chain — the copy then fails into a `log::warn!` and the user gets no + // file, no error and no fallback to the cached portrait. + let local = artist_image(&artist_name) + .filter(|p| !p.is_empty() && std::path::Path::new(p).is_file()) + .or_else(|| { + let cached = crate::artwork_qt::cached_path(&artwork_url); + if cached.is_empty() { + None + } else { + // `cached_path` hands back a file:// URL; the override map + // hands back a raw path. `tokio::fs::copy` takes neither + // form on faith, and trimming the scheme is not enough — + // the URL is percent-escaped. + Some(crate::artwork_qt::local_path(&cached)) + } + }); + + let ext = local + .as_deref() + .and_then(|p| std::path::Path::new(p).extension()) + .and_then(|e| e.to_str()) + .map(|e| e.to_ascii_lowercase()) + .filter(|e| matches!(e.as_str(), "jpg" | "jpeg" | "png" | "webp")) + .unwrap_or_else(|| "jpg".to_string()); + + let default_name = format!( + "{}.{ext}", + if artist_name.is_empty() { + "artist" + } else { + &artist_name + } + ); + let Some(dest) = rfd::AsyncFileDialog::new() + .set_file_name(&default_name) + .add_filter("Image", &["jpg", "jpeg", "png", "webp"]) + .save_file() + .await + else { + return; + }; + let dest_path = dest.path().to_path_buf(); + + if let Some(src) = local { + if let Err(e) = tokio::fs::copy(&src, &dest_path).await { + log::warn!("[qbz-qt] artist image save-as copy failed: {e}"); + } + return; + } + if artwork_url.is_empty() { + return; + } + let client = match reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(30)) + .build() + { + Ok(c) => c, + Err(e) => { + log::warn!("[qbz-qt] artist image save-as client error: {e}"); + return; + } + }; + match client.get(&artwork_url).send().await.and_then(|r| r.error_for_status()) { + Ok(resp) => match resp.bytes().await { + Ok(bytes) => { + if let Err(e) = tokio::fs::write(&dest_path, &bytes).await { + log::warn!("[qbz-qt] artist image save-as write failed: {e}"); + } + } + Err(e) => log::warn!("[qbz-qt] artist image save-as read failed: {e}"), + }, + Err(e) => log::warn!("[qbz-qt] artist image save-as fetch failed: {e}"), + } + }); +} + // --------------------------------------------------------------------------- // Playlist custom covers (Qt-first; no Slint counterpart yet) // --------------------------------------------------------------------------- @@ -345,8 +535,20 @@ fn write_key_store(store: &KeyStore) { } } -/// The artwork hash of a Qobuz cover URL: the filename stem before the size -/// suffix (`_600.jpg` -> ``). None for non-cover URLs. +/// The artwork hash of a Qobuz artwork URL: the filename stem before the size +/// suffix (`_600.jpg` -> ``). None for non-artwork URLs. +/// +/// ARTIST PORTRAITS CARRY NO SIZE SUFFIX. Their URL is +/// `.../images/artists/covers/{small|medium|large}/.jpg` — the size is a +/// PATH segment — so the `_` rule returns None for them and both +/// `note_override_key` and `override_for_url` were silent no-ops on the artist +/// axis. The suffix-less arm is scoped to that one path prefix on purpose: a +/// global loosening would key every stray URL on its bare filename and let two +/// unrelated images collide on one override. +/// +/// The `_` arm is UNCHANGED, so the rows already written into +/// `custom_cover_keys.json` under the old rule keep resolving exactly as +/// before — this widening only adds keys that could never have been written. fn art_key(url: &str) -> Option { let stem = url .rsplit('/') @@ -356,11 +558,10 @@ fn art_key(url: &str) -> Option { if stem.is_empty() { return None; } - let (hash, _size) = stem.rsplit_once('_')?; - if hash.is_empty() { - None - } else { - Some(hash.to_string()) + match stem.rsplit_once('_') { + Some((hash, _size)) if !hash.is_empty() => Some(hash.to_string()), + _ if url.contains("/images/artists/covers/") => Some(stem.to_string()), + _ => None, } } @@ -379,6 +580,20 @@ pub fn note_override_key(artwork_url: &str, override_path: &str) { write_key_store(&store); } +/// Drop the `hash -> override path` link for `artwork_url`. The counterpart of +/// [`note_override_key`], called when an override is REMOVED: without it the +/// artwork layer keeps serving the custom file to every card long after the +/// header has reverted. +fn forget_override_key(artwork_url: &str) { + let Some(key) = art_key(artwork_url) else { + return; + }; + let mut store = load_key_store(); + if store.keys.remove(&key).is_some() { + write_key_store(&store); + } +} + /// The override path for any art URL whose hash has one ("" when none or the /// file is gone). `artwork_qt::cached_path` consults this BEFORE the network /// cache, so every surface renders the custom art. @@ -391,3 +606,44 @@ pub fn override_for_url(url: &str) -> String { _ => String::new(), } } + +#[cfg(test)] +mod tests { + use super::art_key; + + #[test] + fn album_covers_key_on_the_stem_before_the_size_suffix() { + assert_eq!( + art_key("https://static.qobuz.com/images/covers/tr/9l/abc123_600.jpg").as_deref(), + Some("abc123") + ); + // Every size variant of one artwork resolves to the SAME key — that is + // what makes an override apply on cards, the NPB and the queue alike. + assert_eq!( + art_key("https://static.qobuz.com/images/covers/tr/9l/abc123_50.jpg"), + art_key("https://static.qobuz.com/images/covers/tr/9l/abc123_230.jpg") + ); + } + + #[test] + fn artist_portraits_key_on_the_bare_stem_across_every_size_segment() { + assert_eq!( + art_key("https://static.qobuz.com/images/artists/covers/large/784ec128.jpg").as_deref(), + Some("784ec128") + ); + assert_eq!( + art_key("https://static.qobuz.com/images/artists/covers/large/784ec128.jpg"), + art_key("https://static.qobuz.com/images/artists/covers/medium/784ec128.png") + ); + } + + #[test] + fn suffixless_urls_outside_the_artist_path_stay_unkeyed() { + // The whole point of scoping the widening: an arbitrary URL must not + // start colliding with another one on its bare filename. + assert_eq!(art_key("https://example.com/thing/photo.jpg"), None); + assert_eq!(art_key("https://static.qobuz.com/images/covers/tr/9l/abc123.jpg"), None); + assert_eq!(art_key(""), None); + assert_eq!(art_key("https://static.qobuz.com/images/artists/covers/large/"), None); + } +} diff --git a/crates/qbz-qt/src/custom_theme_qt.rs b/crates/qbz-qt/src/custom_theme_qt.rs new file mode 100644 index 000000000..0342ee61b --- /dev/null +++ b/crates/qbz-qt/src/custom_theme_qt.rs @@ -0,0 +1,549 @@ +//! Custom-theme editor controller — the Qt port of `crates/qbz/src/ +//! custom_theme.rs`, minus the Slint-property plumbing. +//! +//! The user-editable base is `qbz_theme::CustomThemeBase` (11 opaque colour +//! tokens + `is_dark`); the full 55-value `ThemeColors` contract is DERIVED +//! from it by the shared `qbz_theme::theme_from_base`. Neither the maths nor +//! the schema is re-implemented here — this module only owns the in-memory +//! base, the persistence and the two bridge documents. +//! +//! # The file is SHARED with the shipping Slint build +//! +//! `/qbz/custom_theme.json` is the SAME document the Slint +//! custom-theme editor writes, and both sides serialize the SAME struct +//! through the SAME serde impl, so byte-compatibility is satisfied by +//! construction — there is no second file and no second format. Two +//! consequences, both deliberate: +//! +//! * Whole-document read-modify-write with no other owner: last writer wins +//! for the WHOLE theme, and there is no cross-key clobber hazard (unlike +//! `ui_prefs.json`, which is why `settings_qt` documents an additive-patch +//! discipline for that file). The other process keeps its stale derived +//! palette until it re-reads: on this side the memo below is dropped when +//! the theme row enters "custom" (`crate::theme_set`), which is the only +//! moment a foreign write can matter. +//! * Qt CREATING this file for the first time (the first-selection seed in +//! `crate::theme_set`) flips `custom_theme::exists()` for the shipping +//! Slint binary on that profile, so ITS first-selection branch takes +//! `seed_state + apply_startup` from our file instead of seeding from what +//! it is showing. Known, accepted: one shared file is the requirement. +//! +//! # Two deliberate improvements on the reference +//! +//! * **Atomic publish.** The reference persists with a truncating +//! `std::fs::write` (`custom_theme.rs:144`), which can hand a concurrent +//! reader — including the shipping Slint build — a half document that +//! parses as garbage and degrades to OLED. We write a sibling temp file +//! and `rename(2)` it on, the same mechanic `settings_qt:: +//! write_json_object_atomic` uses for `ui_prefs.json`. It is NOT that +//! function: that one takes a `serde_json::Map`, which is a `BTreeMap` +//! here (no `preserve_order` feature), so it would reorder the keys +//! alphabetically. Serializing the STRUCT keeps the reference's field +//! order, i.e. byte-identical output. +//! * **Debounced write.** The reference persists inside `apply_live`, i.e. +//! potentially on every drag frame. Re-derive + republish stays live (pure +//! maths, no I/O); only the WRITE is debounced to ~250 ms idle, and the +//! picker's drag-end calls `flush()` so a settled drag lands immediately. +//! Discrete actions (polarity toggle, seed) write straight away. +//! +//! # Two encodings, and they must not mix +//! +//! * `#rrggbb` — 6 digits, lowercase — on the EDIT and PERSIST path. That is +//! what `Rgba::to_hex` writes and what the file contains. +//! * `#aarrggbb` — on the RENDER/DISPLAY path (`custom_theme_json`), like +//! every other Qt theme token. +//! +//! Copying `theme_qt::argb()` into the edit path is the bug this note exists +//! to prevent. + +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::Mutex; +use std::time::Duration; + +use cxx_qt_lib::QString; +use qbz_theme::{CustomThemeBase, Rgba, ThemeColors}; + +/// The stable token keys — kebab-case, the SAME vocabulary the Slint editor +/// rows use (`custom_theme.rs:56-68`). The on-disk JSON keys are snake_case +/// (they are the Rust field names); these are the UI/bridge keys. Order is the +/// editor's reading order (SURFACES, TEXT, ACCENT, STATUS, OTHER). +pub(crate) const TOKEN_KEYS: [&str; 11] = [ + "surface-main", + "surface-card", + "surface-elevated", + "text-primary", + "text-secondary", + "accent", + "danger", + "warning", + "success", + "border", + "favorite", +]; + +/// `/qbz/custom_theme.json` — sibling of `ui_prefs.json`, and the +/// one spelling of this path in the crate (`theme_qt` reads it through here). +pub(crate) fn custom_theme_path() -> Option { + Some(dirs::data_dir()?.join("qbz").join("custom_theme.json")) +} + +/// The in-memory base — the Qt analogue of "the swatch properties are the +/// source of truth while the editor is open" (`custom_theme.rs:32-34`). Filled +/// lazily from disk on first use so a per-drag edit never reconstructs the +/// base by reading the file back. +static BASE: Mutex> = Mutex::new(None); + +fn lock() -> std::sync::MutexGuard<'static, Option> { + BASE.lock().unwrap_or_else(|e| e.into_inner()) +} + +/// Read + parse the file. `None` = absent, unreadable, or corrupt (logged); +/// the caller degrades to the OLED default, never errors — a hand-edited file +/// can never take the app down (`custom_theme.rs:116-127`). +fn read_file() -> Option { + let path = custom_theme_path()?; + let text = std::fs::read_to_string(&path).ok()?; + match serde_json::from_str::(&text) { + Ok(base) => Some(base), + Err(e) => { + log::warn!("[qbz-qt] custom_theme.json parse failed, using default: {e}"); + None + } + } +} + +/// The current editable base. Non-persisting: a missing file yields the OLED +/// default WITHOUT creating anything (the file is only born when the user +/// actually selects or edits the Custom theme). +pub(crate) fn load() -> CustomThemeBase { + let mut guard = lock(); + if guard.is_none() { + *guard = Some(read_file().unwrap_or_else(CustomThemeBase::default_oled)); + } + (*guard) + .clone() + .unwrap_or_else(CustomThemeBase::default_oled) +} + +/// Drop the memo so the next `load()` goes back to disk. +/// +/// The cache is what keeps a per-drag edit from reading the file back, but it +/// is filled at bridge construction for EVERY user, whatever their theme — so +/// a process that booted without the file would keep serving the OLED default +/// it cached even after the file appeared. That is not hypothetical: this file +/// is SHARED with the shipping Slint build, which can write it while this +/// process is running, and `exists()` is a live disk check, so the +/// first-selection seed would be skipped in favour of a cache that never saw +/// the user's theme. Called when the theme row enters "custom" — a user +/// action, never on the drag path. +pub(crate) fn invalidate() { + *lock() = None; +} + +/// True when a persisted custom base exists on disk — the first-selection +/// check (`custom_theme.rs:155-157`). +pub(crate) fn exists() -> bool { + custom_theme_path().map(|p| p.exists()).unwrap_or(false) +} + +// --------------------------------------------------------------------------- +// Persistence — atomic temp + rename, byte-identical content to the reference +// --------------------------------------------------------------------------- + +fn save(base: &CustomThemeBase) { + use std::io::Write as _; + + let Some(path) = custom_theme_path() else { + log::warn!("[qbz-qt] custom_theme.json: data dir unavailable, not saving"); + return; + }; + // `to_string_pretty` on the STRUCT: same serializer, same field order and + // same 2-space indent the shipping build writes. + let Ok(text) = serde_json::to_string_pretty(base) else { + log::warn!("[qbz-qt] custom_theme.json: serialize failed — not writing"); + return; + }; + if let Some(parent) = path.parent() { + if let Err(e) = std::fs::create_dir_all(parent) { + log::warn!("[qbz-qt] custom_theme.json: parent directory unavailable ({e})"); + return; + } + } + // `..tmp`, built off the FULL path so it lands in the same + // directory — rename is only atomic within one filesystem. + let mut tmp = path.as_os_str().to_owned(); + tmp.push(format!(".{}.tmp", std::process::id())); + let tmp = std::path::PathBuf::from(tmp); + let written = std::fs::File::create(&tmp).and_then(|mut f| { + f.write_all(text.as_bytes())?; + f.sync_all() + }); + if let Err(e) = written { + log::warn!("[qbz-qt] custom_theme.json: temp write failed: {e}"); + let _ = std::fs::remove_file(&tmp); + return; + } + if let Err(e) = std::fs::rename(&tmp, &path) { + log::warn!("[qbz-qt] custom_theme.json: atomic rename failed: {e}"); + let _ = std::fs::remove_file(&tmp); + } +} + +/// Idle window before a live edit is persisted. A drag emits one edit per +/// mouse-move frame; the picker also calls [`flush`] on release, so this only +/// covers the "stopped moving but still holding" case and keyboard hex edits. +const WRITE_DEBOUNCE: Duration = Duration::from_millis(250); + +/// Bumped by every live edit. The debounced writer only publishes when the +/// value it captured is still the newest — the newest edit wins and the +/// intermediate frames never touch the disk. +static WRITE_SEQ: AtomicU64 = AtomicU64::new(0); +/// Set by a live edit, cleared by an actual write, so [`flush`] on a drag that +/// moved nothing does not rewrite an unchanged file. +static DIRTY: AtomicBool = AtomicBool::new(false); + +fn save_now() { + DIRTY.store(false, Ordering::SeqCst); + let base = load(); + save(&base); +} + +/// Mark the in-memory base dirty and schedule the debounced write. +fn schedule_save() { + DIRTY.store(true, Ordering::SeqCst); + let seq = WRITE_SEQ.fetch_add(1, Ordering::SeqCst) + 1; + crate::spawn(async move { + tokio::time::sleep(WRITE_DEBOUNCE).await; + if WRITE_SEQ.load(Ordering::SeqCst) == seq && DIRTY.load(Ordering::SeqCst) { + save_now(); + } + }); +} + +/// Drag-end / commit flush: persist NOW if anything is pending. Bumping the +/// sequence first makes any in-flight debounce task drop its own write. +pub(crate) fn flush() { + WRITE_SEQ.fetch_add(1, Ordering::SeqCst); + if DIRTY.load(Ordering::SeqCst) { + save_now(); + } +} + +// --------------------------------------------------------------------------- +// Token access by key +// --------------------------------------------------------------------------- + +fn field<'a>(base: &'a CustomThemeBase, key: &str) -> Option<&'a String> { + Some(match key { + "surface-main" => &base.surface_main, + "surface-card" => &base.surface_card, + "surface-elevated" => &base.surface_elevated, + "text-primary" => &base.text_primary, + "text-secondary" => &base.text_secondary, + "accent" => &base.accent, + "danger" => &base.danger, + "warning" => &base.warning, + "success" => &base.success, + "border" => &base.border, + "favorite" => &base.favorite, + _ => return None, + }) +} + +fn field_mut<'a>(base: &'a mut CustomThemeBase, key: &str) -> Option<&'a mut String> { + Some(match key { + "surface-main" => &mut base.surface_main, + "surface-card" => &mut base.surface_card, + "surface-elevated" => &mut base.surface_elevated, + "text-primary" => &mut base.text_primary, + "text-secondary" => &mut base.text_secondary, + "accent" => &mut base.accent, + "danger" => &mut base.danger, + "warning" => &mut base.warning, + "success" => &mut base.success, + "border" => &mut base.border, + "favorite" => &mut base.favorite, + _ => return None, + }) +} + +// --------------------------------------------------------------------------- +// The bridge documents +// --------------------------------------------------------------------------- + +/// DISPLAY encoding for a stored base token: `#aarrggbb`, what QbzTheme.qml +/// and every other Qt theme token use. Base tokens are opaque by contract, so +/// the alpha byte is always `ff`; a malformed stored value shows as opaque +/// black here while the derivation applies its own per-token fallback. +fn display_hex(hex6: &str) -> String { + let c = Rgba::from_hex(hex6).unwrap_or(Rgba::rgb(0, 0, 0)); + format!("#ff{:02x}{:02x}{:02x}", c.r, c.g, c.b) +} + +/// `{"isDark":bool,"tokens":{"":"#aarrggbb"}}` — the swatch grid's +/// whole state. A MAP and not eleven named fields so QML indexes it by the +/// same token key the cells carry, instead of an eleven-arm ternary chain. +fn state_json_of(base: &CustomThemeBase) -> String { + let mut tokens = serde_json::Map::new(); + for key in TOKEN_KEYS { + let hex6 = field(base, key).map(String::as_str).unwrap_or("#000000"); + tokens.insert( + key.to_string(), + serde_json::Value::String(display_hex(hex6)), + ); + } + let mut doc = serde_json::Map::new(); + doc.insert("isDark".into(), serde_json::Value::Bool(base.is_dark)); + doc.insert("tokens".into(), serde_json::Value::Object(tokens)); + serde_json::to_string(&doc).unwrap_or_else(|_| "{}".into()) +} + +/// The editor state for the CURRENT base (used to seed the bridge property at +/// construction — a post-boot push would be a frame late, same reason +/// `theme_json` is seeded there). +pub(crate) fn state_json() -> String { + state_json_of(&load()) +} + +fn publish_state_of(base: &CustomThemeBase) { + let json = state_json_of(base); + crate::shell_bridge::ui(move |mut b| { + b.as_mut() + .set_custom_theme_json(QString::from(json.as_str())); + }); +} + +/// Republish the editor state from whatever the base currently is. +pub(crate) fn publish_state() { + publish_state_of(&load()); +} + +/// Derive + push the palette live. Pure maths, no I/O — this is the Qt +/// `theme::push_colors` half of the reference's `apply_live` +/// (`custom_theme.rs:96-100`); the persistence half is debounced separately. +/// +/// It republishes from the IN-MEMORY base rather than going through +/// `theme_qt::publish_theme()`, which re-resolves the slug off disk and would +/// therefore show the last PERSISTED colours during the debounce window. +fn apply_live(base: &CustomThemeBase) { + crate::theme_qt::publish_colors(&qbz_theme::theme_from_base(base)); +} + +// --------------------------------------------------------------------------- +// Editor actions (the bridge invokables) +// --------------------------------------------------------------------------- + +/// Set one base token from a `#rrggbb` (or `#rrggbbaa`, alpha dropped) string +/// — both the live drag path and the HEX-field commit. Malformed input and +/// unknown keys are IGNORED, 1:1 with `set_token_hex` (`custom_theme.rs: +/// 222-227`): a half-typed hex must not repaint the app. +pub(crate) fn set_token(key: &str, hex: &str) { + let Some(c) = Rgba::from_hex(hex) else { + log::debug!("[qbz-qt] custom theme: ignoring malformed hex '{hex}'"); + return; + }; + // Alpha is dropped: base tokens are opaque by contract, and the file is + // 6-digit. `to_hex` IS the persist encoding — never `theme_qt::argb`. + let hex6 = Rgba::rgb(c.r, c.g, c.b).to_hex(); + let base = { + let mut guard = lock(); + if guard.is_none() { + *guard = Some(read_file().unwrap_or_else(CustomThemeBase::default_oled)); + } + let Some(base) = guard.as_mut() else { + return; + }; + let Some(slot) = field_mut(base, key) else { + log::debug!("[qbz-qt] custom theme: unknown token key '{key}'"); + return; + }; + if *slot == hex6 { + return; + } + *slot = hex6; + base.clone() + }; + apply_live(&base); + publish_state_of(&base); + schedule_save(); +} + +/// Flip the polarity. The base token colours are unchanged; only `is_dark` and +/// everything derived from it (alpha ramp, translucent edges, shade +/// directions) flip. A discrete click, so it persists immediately. +pub(crate) fn toggle_dark(is_dark: bool) { + let base = { + let mut guard = lock(); + if guard.is_none() { + *guard = Some(read_file().unwrap_or_else(CustomThemeBase::default_oled)); + } + let Some(base) = guard.as_mut() else { + return; + }; + if base.is_dark == is_dark { + return; + } + base.is_dark = is_dark; + base.clone() + }; + apply_live(&base); + publish_state_of(&base); + DIRTY.store(true, Ordering::SeqCst); + flush(); +} + +/// "Start from current theme": reduce an applied palette to the editable base +/// and PERSIST it. Does not publish anything — the caller decides, because the +/// two callers want different follow-ups (the button applies live; the +/// first-selection seed lets `theme_set`'s own publish do it once). +/// +/// `prev` is passed IN rather than read here, and that is load-bearing for the +/// first-selection seed: `theme_set` persists the new slug BEFORE it could +/// call us, and `theme_qt::current_slug()` re-reads the pref FROM DISK — so a +/// self-service read would resolve `"custom"`, find no file, and snapshot OLED +/// black, which is exactly the bug the seed exists to prevent. Slint gets away +/// with reading live state because it has an in-memory palette global; the Qt +/// bridge carries only `#aarrggbb` text. +/// +/// Two subtleties ported verbatim from `custom_theme.rs:244-276`: `is_dark` is +/// INFERRED from the surface luminance (not read from a pref), and `border` +/// prefers `border_subtle` only when it is opaque — `base_from_theme` +/// implements that second rule, so it is reused rather than re-hand-rolled. +pub(crate) fn seed_from_current(prev: &ThemeColors) -> CustomThemeBase { + let sm = prev.surface_main; + let is_dark = qbz_theme::relative_luminance(Rgba::rgb(sm.r, sm.g, sm.b)) < 0.5; + let base = qbz_theme::base_from_theme(prev, is_dark); + *lock() = Some(base.clone()); + // A discrete action: no debounce, and no stale pending write left behind. + DIRTY.store(false, Ordering::SeqCst); + WRITE_SEQ.fetch_add(1, Ordering::SeqCst); + save(&base); + base +} + +/// The "Use current colors" button: snapshot whatever palette is applied right +/// now (which, since the editor is only reachable under the Custom theme, is +/// the custom palette itself — 1:1 with the reference, whose seed is +/// idempotent by `custom.rs`'s own round-trip test). +pub(crate) fn seed_from_applied() { + let colors = crate::theme_qt::colors_for_slug(&crate::theme_qt::current_slug()); + let base = seed_from_current(&colors); + apply_live(&base); + publish_state_of(&base); +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample() -> CustomThemeBase { + CustomThemeBase::default_oled() + } + + #[test] + fn token_keys_all_resolve_to_a_field() { + let base = sample(); + for key in TOKEN_KEYS { + assert!(field(&base, key).is_some(), "no field for '{key}'"); + } + assert!(field(&base, "nope").is_none()); + assert!(field(&base, "is-dark").is_none()); + } + + #[test] + fn token_keys_cover_every_editable_field() { + // Mutating through every key must touch a DISTINCT field: 11 keys, + // 11 changed values. Catches a copy-paste arm pointing at the wrong + // token (which would silently edit the wrong swatch). + let mut base = sample(); + for (i, key) in TOKEN_KEYS.iter().enumerate() { + *field_mut(&mut base, key).unwrap() = format!("#0000{:02x}", i as u8); + } + let mut seen: Vec<&String> = TOKEN_KEYS + .iter() + .map(|k| field(&base, k).unwrap()) + .collect(); + seen.sort(); + seen.dedup(); + assert_eq!(seen.len(), TOKEN_KEYS.len()); + } + + #[test] + fn display_hex_is_argb_and_edit_hex_is_rgb() { + // The whole two-encoding discipline in one assertion. + assert_eq!(display_hex("#4285f4"), "#ff4285f4"); + assert_eq!(Rgba::rgb(0x42, 0x85, 0xf4).to_hex(), "#4285f4"); + // Alpha in, alpha dropped on the persist path. + let c = Rgba::from_hex("#4285f480").unwrap(); + assert_eq!(Rgba::rgb(c.r, c.g, c.b).to_hex(), "#4285f4"); + // Malformed stored token displays as opaque black, never panics. + assert_eq!(display_hex("not-a-color"), "#ff000000"); + } + + #[test] + fn state_json_carries_all_eleven_tokens_plus_polarity() { + let doc: serde_json::Value = serde_json::from_str(&state_json_of(&sample())).unwrap(); + assert_eq!(doc["isDark"], serde_json::Value::Bool(true)); + let tokens = doc["tokens"].as_object().unwrap(); + assert_eq!(tokens.len(), TOKEN_KEYS.len()); + for key in TOKEN_KEYS { + let v = tokens[key].as_str().unwrap(); + assert_eq!(v.len(), 9, "{key} is not #aarrggbb"); + assert!(v.starts_with("#ff"), "{key} base tokens must be opaque"); + } + assert_eq!(tokens["surface-main"].as_str().unwrap(), "#ff000000"); + assert_eq!(tokens["accent"].as_str().unwrap(), "#ff4285f4"); + } + + #[test] + fn persisted_shape_matches_the_reference() { + // The on-disk document: flat, snake_case, 12 keys, 6-digit colours, + // no version field. The shipping Slint build parses THIS. + let text = serde_json::to_string_pretty(&sample()).unwrap(); + let doc: serde_json::Value = serde_json::from_str(&text).unwrap(); + let obj = doc.as_object().unwrap(); + assert_eq!(obj.len(), 12); + assert!(obj["is_dark"].is_boolean()); + for key in [ + "surface_main", + "surface_card", + "surface_elevated", + "text_primary", + "text_secondary", + "accent", + "danger", + "warning", + "success", + "border", + "favorite", + ] { + let v = obj[key].as_str().unwrap(); + assert_eq!(v.len(), 7, "{key} must be #rrggbb"); + assert_eq!(v, v.to_lowercase()); + } + assert!(obj.get("version").is_none()); + // And it round-trips back through the shared struct. + let back: CustomThemeBase = serde_json::from_str(&text).unwrap(); + assert_eq!(back, sample()); + } + + #[test] + fn seed_infers_polarity_from_surface_luminance() { + // The rule the reference applies at custom_theme.rs:247-251. + let dark = qbz_theme::palette(qbz_theme::ThemeId::Oled); + assert!(qbz_theme::relative_luminance(dark.surface_main) < 0.5); + let light = qbz_theme::palette(qbz_theme::ThemeId::Light); + assert!(qbz_theme::relative_luminance(light.surface_main) >= 0.5); + } + + #[test] + fn seed_border_prefers_the_opaque_edge() { + // base_from_theme owns the rule; assert it holds for a palette whose + // border_subtle is translucent (the legacy P1 hairline). + let mut colors = qbz_theme::palette(qbz_theme::ThemeId::Oled); + colors.border_subtle = Rgba::rgba(0xff, 0xff, 0xff, 0x14); + colors.border_strong = Rgba::rgb(0x3a, 0x3a, 0x3a); + let base = qbz_theme::base_from_theme(&colors, true); + assert_eq!(base.border, "#3a3a3a"); + } +} diff --git a/crates/qbz-qt/src/diagnostics_qt.rs b/crates/qbz-qt/src/diagnostics_qt.rs new file mode 100644 index 000000000..6eb361c07 --- /dev/null +++ b/crates/qbz-qt/src/diagnostics_qt.rs @@ -0,0 +1,1180 @@ +//! Settings > Developer — the system-diagnostics panel and the body of the +//! bug-report bundle. +//! +//! Port of `crates/qbz/src/diagnostics.rs` (the Slint controller) shaped like +//! `log_viewer_qt.rs`: statics for the flags, a `Mutex` cache for the export, +//! one `snapshot_doc()` serialized into ONE JSON property +//! (`QbzShell.diagnosticsJson`), and a closed panel that publishes nothing. +//! +//! It has TWO consumers, and that is the point: +//! 1. `DiagnosticsPanel.qml` — the seven saved-vs-runtime tables. +//! 2. `log_viewer_qt::bundle_text()` — [`report_markdown`] is the body every +//! Copy-bundle and every paste.rs upload now carries. Before this landed +//! a Qt bug report shipped EIGHT header fields where the Slint one ships +//! ~70, 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. +//! +//! ── WHERE THE CODE LIVES, AND WHY IT IS NOT SHARED ──────────────────────── +//! +//! The producers below (`gather`, the row builders, `collect_output_sinks`, +//! `active_sink_format`, `redact_id_like`) DUPLICATE their twins in +//! `crates/qbz/src/diagnostics.rs`. That is deliberate, not an oversight: that +//! crate is the frozen Slint frontend and it pulls `qbz-ui`, a single ~1.6 M-line +//! compilation unit that peaks 20-30 GB — so moving the functions there into a +//! shared crate could not be compile-checked without a build that freezes this +//! machine. The Slint copy dies with that tree in 2.1. Everything that ALREADY +//! lives in a shared crate is called, not copied: `qbz_app::diagnostics` +//! (`runtime_diagnostics` / `system_info` / `detect_graphics_runtime`), +//! `qbz_audio::output_sinks::list_output_sinks`, and Qt's own `renderer_qt` / +//! `settings_qt` / `cast_qt` / `qconnect_qt`. +//! +//! ── ROWS THE REFERENCE HAS THAT THIS PANEL DELIBERATELY DOES NOT ────────── +//! +//! CUT, because in a Qt process they can only ever render `—` (their libraries +//! are not mapped, so `detect_loaded_lib_version` returns `None`) AND their +//! "Saved" column would come out of a `graphics.json` this binary never reads +//! or writes — presenting stale foreign state as this app's configuration: +//! +//! System WebKit2GTK, GTK +//! Graphics Hardware Acceleration, Force DMA-BUF, Force X11, GSK Renderer, +//! GDK Scale, GDK DPI Scale, Compositing Mode, Using Fallback +//! Environment WEBKIT_DISABLE_DMABUF_RENDERER, WEBKIT_DISABLE_COMPOSITING_MODE, +//! GDK_BACKEND, GSK_RENDERER +//! +//! Do not "restore parity" by adding them back — every one of them is a Tauri-era +//! fossil that Slint carried forward verbatim, and this port's standing rule is +//! no dead rows (`DeveloperSettings.qml:6`). +//! +//! ALSO CUT: `UI Loop Latency`. `crates/qbz/src/ui_watchdog.rs` measures Slint's +//! `upgrade_in_event_loop` dispatch latency; a Qt analogue would be a new module, +//! not a port. +//! +//! REPLACED, not dropped — the Qt truths that stand where a GDK/GSK row stood: +//! `Renderer (Qt)` (saved pref vs the resolved RHI api), `GPU Adapters` (the +//! Vulkan enumeration `QT_VK_PHYSICAL_DEVICE_INDEX` counts), `GPU Tier`, and the +//! `QSG_RHI_BACKEND` / `QT_QUICK_BACKEND` / `QT_VK_PHYSICAL_DEVICE_INDEX` +//! environment rows — which this binary SETS itself (`renderer_qt.rs:491`), so +//! they are the most load-bearing env vars it has. +//! +//! Per-row LABELS are plain untranslated Rust strings, exactly as in the +//! reference (`DiagnosticsPanel.slint:11-13`); only section titles, column +//! headers and buttons go through `@tr` — QML-side. ZERO new msgids. + +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{LazyLock, Mutex}; + +use cxx_qt_lib::QString; +use serde::Serialize; +use serde_json::{json, Value}; + +use crate::shell_bridge::ui; + +/// Whether the master collapsible is expanded. A closed panel publishes +/// NOTHING (the `log_viewer_qt.rs:189` discipline). +static OPEN: AtomicBool = AtomicBool::new(false); +static LOADED: AtomicBool = AtomicBool::new(false); +static LOADING: AtomicBool = AtomicBool::new(false); +/// The export button's transient confirmation, flashed for 1500 ms — the +/// reference's number (`crates/qbz/src/diagnostics.rs:217`). +static COPIED: AtomicBool = AtomicBool::new(false); +static CAST_SCANNING: AtomicBool = AtomicBool::new(false); + +static ERROR: LazyLock> = LazyLock::new(|| Mutex::new(String::new())); +static SECTIONS: LazyLock> = LazyLock::new(|| Mutex::new(Sections::default())); +/// Export base cached on each `refresh()`; `castScan` + `exportedAt` are merged +/// in at export time, like the reference. +static EXPORT: LazyLock>> = LazyLock::new(|| Mutex::new(None)); +static LAST_CAST: LazyLock>> = LazyLock::new(|| Mutex::new(None)); + +/// How long the on-demand cast scan waits before reading the device lists. +/// The reference's number (`crates/qbz/src/diagnostics.rs:246`). +const CAST_SCAN_SECS: u64 = 10; + +// --------------------------------------------------------------------------- +// The published document +// --------------------------------------------------------------------------- + +/// One diagnostics row. `status`: 0 info | 1 match | 2 mismatch — the glyph +/// mapping `DiagnosticsPanel.qml` renders (`·` / `✓` / `✗`). +#[derive(Serialize, Clone, Default)] +struct Row { + label: String, + saved: String, + runtime: String, + status: i32, +} + +#[derive(Serialize, Clone, Default)] +struct Sections { + system: Vec, + playback: Vec, + qconnect: Vec, + cast: Vec, + audio: Vec, + graphics: Vec, + env: Vec, +} + +#[derive(Serialize)] +struct Doc { + open: bool, + loaded: bool, + loading: bool, + error: String, + #[serde(rename = "appVersion")] + app_version: String, + copied: bool, + #[serde(rename = "castScanning")] + cast_scanning: bool, + #[serde(flatten)] + sections: Sections, +} + +/// The full shape, so the bridge can seed the property with a document every +/// binding in the QML can read on the pre-publish frame. +pub fn empty_doc_json() -> String { + serde_json::to_string(&Doc { + open: false, + loaded: false, + loading: false, + error: String::new(), + app_version: env!("CARGO_PKG_VERSION").to_string(), + copied: false, + cast_scanning: false, + sections: Sections::default(), + }) + .unwrap_or_else(|_| "{}".into()) +} + +fn snapshot_doc() -> Doc { + Doc { + open: OPEN.load(Ordering::SeqCst), + loaded: LOADED.load(Ordering::SeqCst), + loading: LOADING.load(Ordering::SeqCst), + error: ERROR.lock().unwrap_or_else(|e| e.into_inner()).clone(), + app_version: env!("CARGO_PKG_VERSION").to_string(), + copied: COPIED.load(Ordering::SeqCst), + cast_scanning: CAST_SCANNING.load(Ordering::SeqCst), + sections: SECTIONS.lock().unwrap_or_else(|e| e.into_inner()).clone(), + } +} + +fn publish_now() { + let json = serde_json::to_string(&snapshot_doc()).unwrap_or_else(|_| "{}".into()); + ui(move |mut b| b.as_mut().set_diagnostics_json(QString::from(json.as_str()))); +} + +/// Publish the document — but only while the panel is expanded. A collapsed +/// panel is not reading, and every publish wakes the Qt thread and re-parses +/// a document that can carry a hundred rows. +pub fn publish() { + if !OPEN.load(Ordering::SeqCst) { + return; + } + publish_now(); +} + +/// The master collapsible's open state. The reference keeps this private to the +/// component (`DiagnosticsPanel.slint:190`); here it has to reach Rust because +/// that is what gates [`publish`]. +pub fn set_open(open: bool) { + OPEN.store(open, Ordering::SeqCst); + // Unconditional: the closing publish is the one that tells the view the + // panel is closed, and it is the last one it will get. + publish_now(); +} + +// --------------------------------------------------------------------------- +// Gather +// --------------------------------------------------------------------------- + +/// Everything one refresh reads, in one struct — so the panel and the markdown +/// report render off the SAME gather instead of drifting. +struct Gathered { + diag: qbz_app::diagnostics::RuntimeDiagnostics, + sys: qbz_app::diagnostics::SystemInfo, + active_output: Option, + available_outputs: Vec, + /// `(rate, format)` of the live default sink, e.g. `("44100 Hz", "s32le · 2ch")`. + active_fmt: Option<(String, String)>, + /// Settings > Appearance preference vs the RHI api that actually resolved. + renderer_saved: String, + renderer_runtime: String, + gpu_adapters: String, + gpu_tier: bool, + pb: qbz_player::PlaybackState, + track: Option, + qc: crate::qconnect_qt::QconnectDiagSnapshot, +} + +/// The blocking half: settings stores, `/proc`, `/sys`, `pactl` and the CPAL +/// sink enumeration. Never on the async path. +struct Blocking { + diag: qbz_app::diagnostics::RuntimeDiagnostics, + sys: qbz_app::diagnostics::SystemInfo, + active_output: Option, + available_outputs: Vec, + active_fmt: Option<(String, String)>, + renderer_saved: String, + renderer_runtime: String, + gpu_adapters: String, + gpu_tier: bool, +} + +fn gather_blocking() -> Blocking { + // The process-wide audio store handle, NOT a second `AudioSettingsStore`: + // two handles over one SQLite file is the defect `settings_qt::audio_settings` + // exists to prevent (settings_qt.rs:68-80). + let audio = crate::settings_qt::audio_settings(); + + // Graphics + developer settings are passed as DEFAULTS on purpose. They + // back `graphics.json`, which this binary never reads or writes, so their + // values would be foreign state — and NO row below is sourced from them + // (see the CUT list in the file header). They are here only because + // `runtime_diagnostics` needs the three structs to produce the fields that + // ARE Qt-true: the GPU vendor flags, the GPU name, the desktop + // environment, the Wayland/VM flags and the environment variables. + let graphics = qbz_app::settings::graphics::GraphicsSettings::default(); + let developer = qbz_app::settings::developer::DeveloperSettings::default(); + let gfx = qbz_app::diagnostics::detect_graphics_runtime(&graphics, false); + let diag = qbz_app::diagnostics::runtime_diagnostics(&qbz_app::diagnostics::DiagnosticsInputs { + audio: &audio, + graphics: &graphics, + developer: &developer, + gfx, + app_version: env!("CARGO_PKG_VERSION"), + }); + let sys = qbz_app::diagnostics::system_info(); + let (active_output, available_outputs, active_fmt) = collect_output_sinks(); + + let renderer_saved = crate::settings_qt::pref_str("renderer", "auto"); + let renderer_runtime = crate::renderer_qt::active_api(); + let gpus = crate::renderer_qt::gpus(); + let gpu_adapters = if gpus.is_empty() { + "—".to_string() + } else { + gpus.iter() + .map(|g| format!("[{}] {}", g.index, g.label())) + .collect::>() + .join(", ") + }; + + Blocking { + diag, + sys, + active_output, + available_outputs, + active_fmt, + renderer_saved, + renderer_runtime, + gpu_adapters, + gpu_tier: crate::renderer_qt::gpu_tier(), + } +} + +async fn gather() -> Result { + let b = tokio::task::spawn_blocking(gather_blocking) + .await + .map_err(|e| format!("{e}"))?; + + let runtime = crate::app(); + let pb = runtime.core().get_playback_state(); + let track = runtime.core().current_track().await; + + // LIVE QConnect snapshot (no discovery; default when not running). + let qc = match crate::qconnect_qt::service() { + Some(s) => s.diagnostics_snapshot().await, + None => Default::default(), + }; + + Ok(Gathered { + diag: b.diag, + sys: b.sys, + active_output: b.active_output, + available_outputs: b.available_outputs, + active_fmt: b.active_fmt, + renderer_saved: b.renderer_saved, + renderer_runtime: b.renderer_runtime, + gpu_adapters: b.gpu_adapters, + gpu_tier: b.gpu_tier, + pb, + track, + qc, + }) +} + +// --------------------------------------------------------------------------- +// Public entry points +// --------------------------------------------------------------------------- + +/// Rebuild every section. Re-entrancy is guarded by an atomic swap — the same +/// shape `log_viewer_qt::upload` uses — because a refresh re-runs the `pactl` +/// shell-outs and the CPAL enumeration, and the reference debounces it with +/// nothing but its `loading` flag. +pub fn refresh() { + if LOADING.swap(true, Ordering::SeqCst) { + return; + } + publish(); + crate::spawn(async move { + match gather().await { + Ok(g) => { + let sections = build_sections(&g); + let export = build_export_json(&g); + { + let mut slot = SECTIONS.lock().unwrap_or_else(|e| e.into_inner()); + // The cast rows are NOT part of a refresh — they are the + // on-demand scan's output and must survive one. + let cast = std::mem::take(&mut slot.cast); + *slot = sections; + slot.cast = cast; + } + *EXPORT.lock().unwrap_or_else(|e| e.into_inner()) = Some(export); + ERROR.lock().unwrap_or_else(|e| e.into_inner()).clear(); + LOADED.store(true, Ordering::SeqCst); + } + Err(e) => { + log::warn!("[qbz-qt] diagnostics: gather failed: {e}"); + // Untranslated, exactly like the reference + // (`crates/qbz/src/diagnostics.rs:119` sets a plain literal): + // translating it would be a NEW msgid in eight catalogs for a + // line only reachable when a settings store panicked. + *ERROR.lock().unwrap_or_else(|e| e.into_inner()) = + "Failed to read diagnostics".to_string(); + } + } + LOADING.store(false, Ordering::SeqCst); + publish(); + }); +} + +/// Serialize the cached snapshot (+ the last cast scan + `exportedAt`) to the +/// clipboard, then flash the button's confirmation for 1500 ms. +pub fn export_clipboard() { + let base = EXPORT.lock().unwrap_or_else(|e| e.into_inner()).clone(); + let Some(mut value) = base else { + return; + }; + if let Some(map) = value.as_object_mut() { + let cast = LAST_CAST.lock().unwrap_or_else(|e| e.into_inner()).clone(); + map.insert("castScan".to_string(), cast.unwrap_or(Value::Null)); + map.insert("exportedAt".to_string(), Value::String(now_utc_rfc3339())); + } + crate::share_qt::copy_to_clipboard(serde_json::to_string_pretty(&value).unwrap_or_default()); + + COPIED.store(true, Ordering::SeqCst); + publish(); + crate::spawn(async move { + tokio::time::sleep(std::time::Duration::from_millis(1500)).await; + COPIED.store(false, Ordering::SeqCst); + publish(); + }); +} + +/// On-demand Cast discovery scan: reuse the existing `CastService`, wait +/// [`CAST_SCAN_SECS`], read the device lists, then stop discovery ONLY if the +/// picker is not relying on it (stopping under a live picker would kill its +/// device list). +pub fn cast_scan() { + if CAST_SCANNING.swap(true, Ordering::SeqCst) { + return; + } + publish(); + crate::spawn(async move { + let svc = crate::cast_qt::service(); + svc.start_discovery().await; + tokio::time::sleep(std::time::Duration::from_secs(CAST_SCAN_SECS)).await; + let (cc, dl, devices) = svc.diag_devices().await; + + let mut rows = vec![ + row("Chromecast devices", "—", &cc.to_string(), 0), + row("DLNA devices", "—", &dl.to_string(), 0), + ]; + let mut device_json: Vec = Vec::with_capacity(devices.len()); + for (protocol, name) in devices { + rows.push(row(&format!("• {protocol}"), "—", &name, 0)); + device_json.push(json!({ "name": name, "protocol": protocol })); + } + *LAST_CAST.lock().unwrap_or_else(|e| e.into_inner()) = Some(json!({ + "chromecastCount": cc, + "dlnaCount": dl, + "devices": device_json, + })); + SECTIONS.lock().unwrap_or_else(|e| e.into_inner()).cast = rows; + + CAST_SCANNING.store(false, Ordering::SeqCst); + publish(); + + if !crate::cast_qt::picker_open() { + svc.stop_discovery().await; + } + }); +} + +/// The COMPLETE markdown diagnostics report — the body of every log bundle and +/// every paste.rs upload (`log_viewer_qt::bundle_text`). +/// +/// It re-runs the gather rather than reading the panel's cache, exactly like +/// the reference (`crates/qbz/src/diagnostics.rs:314`): a bug report must +/// describe the machine at the moment it is filed, and the panel may never +/// have been opened at all. +pub async fn report_markdown() -> String { + match gather().await { + Ok(g) => build_report(&g), + Err(e) => format!( + "# qbz diagnostics\n\n- **Version:** {}\n\nFailed to gather diagnostics: {e}\n", + env!("CARGO_PKG_VERSION") + ), + } +} + +// --------------------------------------------------------------------------- +// Row builders (1:1 with the reference's, minus the CUT rows) +// --------------------------------------------------------------------------- + +fn row(label: &str, saved: &str, runtime: &str, status: i32) -> Row { + Row { + label: label.to_string(), + saved: saved.to_string(), + runtime: runtime.to_string(), + status, + } +} + +/// `ON`/`OFF`, mirroring the Tauri `bool()` helper. +fn yn(value: bool) -> &'static str { + if value { + "ON" + } else { + "OFF" + } +} + +/// `Some -> value`, `None -> "—"`, mirroring the Tauri `str()` helper. +fn opt(value: &Option) -> String { + value.clone().unwrap_or_else(|| "—".to_string()) +} + +/// Format a kHz value without a trailing ".0" (96.0 -> "96", 44.1 -> "44.1"). +fn trim_khz(khz: f64) -> String { + if khz.fract().abs() < f64::EPSILON { + format!("{}", khz as i64) + } else { + format!("{khz:.1}") + } +} + +fn env_opt(name: &str) -> String { + std::env::var(name).unwrap_or_else(|_| "—".to_string()) +} + +fn build_sections(g: &Gathered) -> Sections { + Sections { + system: build_system_rows(&g.sys), + playback: build_playback_rows(&g.pb, g.track.as_ref()), + qconnect: build_qconnect_rows(&g.qc), + cast: Vec::new(), + audio: build_audio_rows(g), + graphics: build_graphics_rows(g), + env: build_env_rows(&g.diag), + } +} + +fn build_system_rows(s: &qbz_app::diagnostics::SystemInfo) -> Vec { + let mut rows = vec![ + row("OS", "—", &s.os, 0), + row("Arch", "—", &s.arch, 0), + row("Kernel", "—", &opt(&s.kernel_version), 0), + row("Distro", "—", &opt(&s.distro_pretty_name), 0), + row("Distro ID", "—", &opt(&s.distro_id), 0), + row("Distro Version", "—", &opt(&s.distro_version_id), 0), + row("Install Method", "—", &s.install_method, 0), + ]; + if let Some(runtime) = &s.flatpak_runtime { + rows.push(row( + "Flatpak Runtime", + "—", + &format!("{} {}", runtime, opt(&s.flatpak_runtime_version)), + 0, + )); + } + // WebKit2GTK / GTK are CUT here — see the file header. + rows.push(row("glibc", "—", &opt(&s.glibc_version), 0)); + rows.push(row("ALSA", "—", &opt(&s.alsa_version), 0)); + rows.push(row("PipeWire", "—", &opt(&s.pipewire_version), 0)); + rows.push(row("PulseAudio", "—", &opt(&s.pulseaudio_version), 0)); + rows +} + +fn build_audio_rows(g: &Gathered) -> Vec { + let d = &g.diag; + let sample_rate = match d.audio_preferred_sample_rate { + Some(hz) => format!("{hz} Hz"), + None => "Auto".to_string(), + }; + + // Output Device: saved id (may be a stale/unplugged DAC) vs the live active + // output. Match (1) when the active equals OR is contained/suffixed by the + // saved value; mismatch (2) when an active device exists but differs (so the + // stale-saved-vs-live discrepancy is visible); info (0) when no live device. + let saved_output = opt(&d.audio_output_device); + let active_output = g.active_output.as_deref(); + let output_runtime = active_output.unwrap_or("—"); + let output_status = match active_output { + Some(active) => { + if saved_output == active + || saved_output.contains(active) + || saved_output.ends_with(active) + { + 1 + } else { + 2 + } + } + None => 0, + }; + let available_runtime = if g.available_outputs.is_empty() { + "—".to_string() + } else { + g.available_outputs.join(", ") + }; + let active_rate = g + .active_fmt + .as_ref() + .map(|(r, _)| r.as_str()) + .filter(|s| !s.is_empty()) + .unwrap_or("—"); + let active_fmt = g + .active_fmt + .as_ref() + .map(|(_, f)| f.as_str()) + .filter(|s| !s.is_empty()) + .unwrap_or("—"); + + vec![ + row("Output Device", &saved_output, output_runtime, output_status), + row("Backend", &opt(&d.audio_backend_type), "—", 0), + row("Exclusive Mode", yn(d.audio_exclusive_mode), "—", 0), + row("DAC Passthrough", yn(d.audio_dac_passthrough), "—", 0), + row("Preferred Sample Rate", &sample_rate, active_rate, 0), + row("Active Format", "—", active_fmt, 0), + row("ALSA Plugin", &opt(&d.audio_alsa_plugin), "—", 0), + row("ALSA HW Volume", yn(d.audio_alsa_hardware_volume), "—", 0), + row("Normalization", yn(d.audio_normalization_enabled), "—", 0), + row( + "Normalization Target", + &format!("{} LUFS", d.audio_normalization_target_lufs), + "—", + 0, + ), + row("Gapless", yn(d.audio_gapless_enabled), "—", 0), + row("PW Force Bitperfect", yn(d.audio_pw_force_bitperfect), "—", 0), + row( + "Stream Buffer", + &format!("{}s", d.audio_stream_buffer_seconds), + "—", + 0, + ), + row("Streaming Only", yn(d.audio_streaming_only), "—", 0), + row("Available Outputs", "—", &available_runtime, 0), + ] +} + +fn build_graphics_rows(g: &Gathered) -> Vec { + let d = &g.diag; + vec![ + // The Qt analogue of the reference's "Renderer (Slint)": the saved + // Settings > Appearance preference vs the RHI api that actually + // resolved at startup (`renderer_qt::active_api`). + row( + "Renderer (Qt)", + &g.renderer_saved, + &g.renderer_runtime, + 0, + ), + // Stands where the reference's `renderer_decision_summary()` adapter + // string does. NOT byte-identical: this is the Vulkan enumeration in + // `QT_VK_PHYSICAL_DEVICE_INDEX` order, not wgpu's adapter list. + row("GPU Adapters", "—", &g.gpu_adapters, 0), + // Shaders + the dynamic background need this; false means the scenes + // stay hidden and reduce-motion is forced on, which is exactly the + // condition a bug report needs to carry. + row( + "GPU Tier", + "—", + yn(g.gpu_tier), + if g.gpu_tier { 0 } else { 2 }, + ), + row( + "GPU", + "—", + if d.runtime_gpu_name.is_empty() { + "Unknown" + } else { + &d.runtime_gpu_name + }, + 0, + ), + row( + "GPU: NVIDIA", + "—", + if d.runtime_has_nvidia { "Detected" } else { "No" }, + 0, + ), + row( + "GPU: Intel", + "—", + if d.runtime_has_intel { "Detected" } else { "No" }, + 0, + ), + row( + "GPU: AMD", + "—", + if d.runtime_has_amd { "Detected" } else { "No" }, + 0, + ), + row( + "Desktop Environment", + "—", + if d.runtime_desktop_environment.is_empty() { + "Unknown" + } else { + &d.runtime_desktop_environment + }, + 0, + ), + row( + "Wayland", + "—", + if d.runtime_is_wayland { + "Yes" + } else { + "No (X11)" + }, + 0, + ), + row("VM", "—", if d.runtime_is_vm { "Yes" } else { "No" }, 0), + ] +} + +fn build_env_rows(d: &qbz_app::diagnostics::RuntimeDiagnostics) -> Vec { + vec![ + row( + "LIBGL_ALWAYS_SOFTWARE", + "—", + &opt(&d.env_libgl_always_software), + 0, + ), + row("WAYLAND_DISPLAY", "—", &opt(&d.env_wayland_display), 0), + row("XDG_SESSION_TYPE", "—", &opt(&d.env_xdg_session_type), 0), + // The Qt renderer env vars, in place of the reference's GDK/GSK pair. + // This binary SETS the first two itself (main.rs:2626-2653, + // renderer_qt.rs:491), so what they hold at runtime is the single most + // useful line in a rendering bug report. + row("QSG_RHI_BACKEND", "—", &env_opt("QSG_RHI_BACKEND"), 0), + row("QT_QUICK_BACKEND", "—", &env_opt("QT_QUICK_BACKEND"), 0), + row( + "QT_VK_PHYSICAL_DEVICE_INDEX", + "—", + &env_opt("QT_VK_PHYSICAL_DEVICE_INDEX"), + 0, + ), + ] +} + +fn build_playback_rows( + pb: &qbz_player::PlaybackState, + track: Option<&qbz_models::QueueTrack>, +) -> Vec { + let volume_percent = (pb.volume * 100.0).round() as i64; + let bit_depth = track + .and_then(|t| t.bit_depth) + .map(|d| format!("{d}-bit")) + .unwrap_or_else(|| "—".to_string()); + let sample_rate = track + .and_then(|t| t.sample_rate) + .map(|r| format!("{} kHz", trim_khz(r))) + .unwrap_or_else(|| "—".to_string()); + let is_local = match track { + Some(t) => yn(t.is_local).to_string(), + None => "—".to_string(), + }; + + vec![ + row("Playing", "—", yn(pb.is_playing), 0), + row("Volume", "—", &format!("{volume_percent}%"), 0), + row( + "Position / Duration", + "—", + &format!("{}s / {}s", pb.position, pb.duration), + 0, + ), + row("Has Track", "—", yn(track.is_some()), 0), + row("Track Title", "—", &opt(&track.map(|t| t.title.clone())), 0), + row("Track Artist", "—", &opt(&track.map(|t| t.artist.clone())), 0), + row("Track Album", "—", &opt(&track.map(|t| t.album.clone())), 0), + row( + "Track Source", + "—", + &opt(&track.and_then(|t| t.source.clone())), + 0, + ), + row("Track Is Local", "—", &is_local, 0), + // No quality/format field on QueueTrack — "—" is faithful to the data, + // exactly as in the reference. + row("Track Quality", "—", "—", 0), + row("Track Format", "—", "—", 0), + row("Track Bit Depth", "—", &bit_depth, 0), + row("Track Sample Rate", "—", &sample_rate, 0), + ] +} + +fn build_qconnect_rows(q: &crate::qconnect_qt::QconnectDiagSnapshot) -> Vec { + let role = if q.role.is_empty() { "none" } else { q.role }; + let last_error = q + .last_error + .as_deref() + .map(redact_id_like) + .unwrap_or_else(|| "—".to_string()); + vec![ + row("Running", "—", yn(q.running), 0), + row("Transport Connected", "—", yn(q.transport_connected), 0), + row("Has Endpoint", "—", yn(q.has_endpoint), 0), + row("Role", "—", role, 0), + row("Active Renderer", "—", &opt(&q.active_name), 0), + row("Renderer Brand", "—", &opt(&q.active_brand), 0), + row("Renderer Model", "—", &opt(&q.active_model), 0), + row("Visible Renderers", "—", &q.renderer_count.to_string(), 0), + row("Last Error", "—", &last_error, 0), + ] +} + +// --------------------------------------------------------------------------- +// Markdown report (the log bundle's body) +// --------------------------------------------------------------------------- + +/// Append a `- **key:** value` markdown bullet (one self-contained line, so it +/// renders correctly without relying on trailing-whitespace hard breaks). +fn md_line(out: &mut String, key: &str, value: &str) { + out.push_str("- **"); + out.push_str(key); + out.push_str(":** "); + out.push_str(value); + out.push('\n'); +} + +/// Render one section's rows as bullets. The panel and the report therefore +/// cannot disagree about which rows exist — the whole reason the CUT list is +/// applied in ONE place (the row builders) and not twice. +fn md_section(out: &mut String, title: &str, rows: &[Row], show_saved: bool) { + out.push_str("\n## "); + out.push_str(title); + out.push_str("\n\n"); + for r in rows { + let value = if show_saved && r.saved != "—" && r.runtime != "—" { + format!("saved {} / runtime {}", r.saved, r.runtime) + } else if r.runtime != "—" { + r.runtime.clone() + } else { + r.saved.clone() + }; + md_line(out, &r.label, &value); + } +} + +fn build_report(g: &Gathered) -> String { + let s = build_sections(g); + let mut out = String::new(); + out.push_str("# qbz diagnostics\n\n"); + md_line(&mut out, "Version", env!("CARGO_PKG_VERSION")); + md_line(&mut out, "Frontend", "Qt/QML"); + md_line(&mut out, "Generated", &now_utc_rfc3339()); + + md_section(&mut out, "System", &s.system, false); + md_section(&mut out, "Audio", &s.audio, true); + md_section(&mut out, "Graphics", &s.graphics, true); + md_section(&mut out, "Environment", &s.env, false); + md_section(&mut out, "Playback", &s.playback, false); + md_section(&mut out, "Qobuz Connect", &s.qconnect, false); + // The Cast section is deliberately absent: it exists only after the user + // presses "Scan for devices", and a bug-report bundle must never trigger a + // 10 s network discovery on its own. + out +} + +// --------------------------------------------------------------------------- +// Export JSON +// --------------------------------------------------------------------------- + +/// The clipboard export. +/// +/// DELIBERATE DIVERGENCE from the reference, which flattens the whole +/// `RuntimeDiagnostics` struct into the object +/// (`crates/qbz/src/diagnostics.rs:160-164`). Doing that here would emit +/// `gfxHardwareAcceleration`, `devForceDmabuf`, `envWebkit*` and friends — the +/// exact fossil fields the panel CUT, filled from defaults this binary never +/// persisted. An export that carries invented configuration is worse than one +/// that carries less, so this builds the object from the fields that are +/// genuinely true of a Qt process. +fn build_export_json(g: &Gathered) -> Value { + let d = &g.diag; + let s = &g.sys; + let (active_rate, active_fmt) = match &g.active_fmt { + Some((r, f)) => (Some(r.clone()), Some(f.clone())), + None => (None, None), + }; + json!({ + "appVersion": env!("CARGO_PKG_VERSION"), + "frontend": "qt", + "systemInfo": { + "os": s.os, + "arch": s.arch, + "kernelVersion": s.kernel_version, + "distroId": s.distro_id, + "distroVersionId": s.distro_version_id, + "distroPrettyName": s.distro_pretty_name, + "installMethod": s.install_method, + "flatpakRuntime": s.flatpak_runtime, + "flatpakRuntimeVersion": s.flatpak_runtime_version, + "glibcVersion": s.glibc_version, + "alsaVersion": s.alsa_version, + "pipewireVersion": s.pipewire_version, + "pulseaudioVersion": s.pulseaudio_version, + }, + "audio": { + "outputDevice": d.audio_output_device, + "backendType": d.audio_backend_type, + "exclusiveMode": d.audio_exclusive_mode, + "dacPassthrough": d.audio_dac_passthrough, + "preferredSampleRate": d.audio_preferred_sample_rate, + "alsaPlugin": d.audio_alsa_plugin, + "alsaHardwareVolume": d.audio_alsa_hardware_volume, + "normalizationEnabled": d.audio_normalization_enabled, + "normalizationTargetLufs": d.audio_normalization_target_lufs, + "gaplessEnabled": d.audio_gapless_enabled, + "pwForceBitperfect": d.audio_pw_force_bitperfect, + "streamBufferSeconds": d.audio_stream_buffer_seconds, + "streamingOnly": d.audio_streaming_only, + "activeOutput": g.active_output, + "activeRate": active_rate, + "activeFormat": active_fmt, + "availableOutputs": g.available_outputs, + }, + "graphics": { + "rendererSaved": g.renderer_saved, + "rendererRuntime": g.renderer_runtime, + "gpuAdapters": g.gpu_adapters, + "gpuTier": g.gpu_tier, + "gpuName": d.runtime_gpu_name, + "hasNvidia": d.runtime_has_nvidia, + "hasIntel": d.runtime_has_intel, + "hasAmd": d.runtime_has_amd, + "desktopEnvironment": d.runtime_desktop_environment, + "isWayland": d.runtime_is_wayland, + "isVm": d.runtime_is_vm, + }, + "environment": { + "LIBGL_ALWAYS_SOFTWARE": d.env_libgl_always_software, + "WAYLAND_DISPLAY": d.env_wayland_display, + "XDG_SESSION_TYPE": d.env_xdg_session_type, + "QSG_RHI_BACKEND": std::env::var("QSG_RHI_BACKEND").ok(), + "QT_QUICK_BACKEND": std::env::var("QT_QUICK_BACKEND").ok(), + "QT_VK_PHYSICAL_DEVICE_INDEX": std::env::var("QT_VK_PHYSICAL_DEVICE_INDEX").ok(), + }, + "playback": { + "isPlaying": g.pb.is_playing, + "volumePercent": (g.pb.volume * 100.0).round() as i64, + "positionSecs": g.pb.position, + "durationSecs": g.pb.duration, + "hasTrack": g.track.is_some(), + "trackTitle": g.track.as_ref().map(|t| t.title.clone()), + "trackArtist": g.track.as_ref().map(|t| t.artist.clone()), + "trackAlbum": g.track.as_ref().map(|t| t.album.clone()), + "trackQuality": Value::Null, + "trackFormat": Value::Null, + "trackBitDepth": g.track.as_ref().and_then(|t| t.bit_depth), + "trackSamplingRate": g.track.as_ref().and_then(|t| t.sample_rate), + "trackIsLocal": g.track.as_ref().map(|t| t.is_local), + "trackSource": g.track.as_ref().and_then(|t| t.source.clone()), + }, + "qconnect": { + "running": g.qc.running, + "transportConnected": g.qc.transport_connected, + "hasEndpoint": g.qc.has_endpoint, + "lastError": g.qc.last_error.as_deref().map(redact_id_like), + "role": if g.qc.role.is_empty() { "none" } else { g.qc.role }, + "activeRendererName": g.qc.active_name, + "activeRendererBrand": g.qc.active_brand, + "activeRendererModel": g.qc.active_model, + "rendererCount": g.qc.renderer_count, + }, + }) +} + +// --------------------------------------------------------------------------- +// Live output sinks +// --------------------------------------------------------------------------- + +/// Query the live output sinks (BLOCKING — CPAL enumeration). Must be called +/// inside a `spawn_blocking`. `active_output` is the description (fallback +/// name) of the default sink; `available_outputs` is every sink's. +/// READ-ONLY — never touches the protected audio backend. +fn collect_output_sinks() -> (Option, Vec, Option<(String, String)>) { + let label = |s: &qbz_audio::output_sinks::OutputSinkInfo| -> String { + if s.description.is_empty() { + s.name.clone() + } else { + s.description.clone() + } + }; + let fmt = active_sink_format(); + match qbz_audio::output_sinks::list_output_sinks() { + Ok(sinks) => { + let active = sinks.iter().find(|s| s.is_default).map(&label); + let available = sinks.iter().map(&label).collect(); + (active, available, fmt) + } + Err(e) => { + log::warn!("[qbz-qt] diagnostics: list_output_sinks failed: {e}"); + (None, Vec::new(), fmt) + } + } +} + +/// Best-effort LIVE sample format of the active (default) output sink, parsed +/// from `pactl list sinks short`. Returns `(rate, format)` like +/// `("44100 Hz", "s32le · 2ch")` — the rate the device is ACTUALLY running at +/// right now (vs the saved "Preferred Sample Rate"). Linux/PipeWire/Pulse only; +/// `None` when pactl is unavailable (a Flatpak/Snap sandbox without it simply +/// degrades to "—", same as the reference). READ-ONLY. +fn active_sink_format() -> Option<(String, String)> { + use std::process::Command; + let default = Command::new("pactl") + .arg("get-default-sink") + .output() + .ok() + .filter(|o| o.status.success()) + .and_then(|o| String::from_utf8(o.stdout).ok()) + .map(|s| s.trim().to_string()); + + let out = Command::new("pactl") + .args(["list", "sinks", "short"]) + .output() + .ok()?; + if !out.status.success() { + return None; + } + let text = String::from_utf8(out.stdout).ok()?; + + // sample-spec token like "s32le 2ch 44100Hz" -> ("44100 Hz", "s32le · 2ch"). + let parse_spec = |spec: &str| -> (String, String) { + let (mut rate, mut chans, mut fmt) = (String::new(), String::new(), String::new()); + for tok in spec.split_whitespace() { + if let Some(hz) = tok.strip_suffix("Hz") { + rate = format!("{hz} Hz"); + } else if tok.ends_with("ch") { + chans = tok.to_string(); + } else { + fmt = tok.to_string(); + } + } + let format = match (fmt.is_empty(), chans.is_empty()) { + (false, false) => format!("{fmt} · {chans}"), + (false, true) => fmt, + (true, false) => chans, + (true, true) => spec.trim().to_string(), + }; + (rate, format) + }; + + // Prefer the default sink; fall back to the first RUNNING sink. + let mut running: Option<(String, String)> = None; + for line in text.lines() { + let cols: Vec<&str> = line.split('\t').collect(); + if cols.len() < 5 { + continue; + } + let (name, spec, state) = (cols[1], cols[3], cols[4]); + if let Some(d) = &default { + if name == d { + return Some(parse_spec(spec)); + } + } + if state.eq_ignore_ascii_case("RUNNING") && running.is_none() { + running = Some(parse_spec(spec)); + } + } + running +} + +// --------------------------------------------------------------------------- +// Redaction + time +// --------------------------------------------------------------------------- + +/// Redact UUID + long-hex substrings. Applied to the QConnect `Last Error`, +/// which is the one diagnostics field that can carry a session id. Operates on +/// chars so it is UTF-8 safe. +/// +/// The ring's own `qbz_log::redact` still runs over every log line in the +/// bundle; this is the diagnostics-side complement, same as the reference. +fn redact_id_like(value: &str) -> String { + let chars: Vec = value.chars().collect(); + let n = chars.len(); + let is_word = |c: char| c.is_ascii_alphanumeric() || c == '_'; + let mut out = String::with_capacity(value.len()); + let mut i = 0; + while i < n { + // UUID shape (8-4-4-4-12 hex), word-boundary delimited. + if uuid_at(&chars, i) + && (i == 0 || !is_word(chars[i - 1])) + && (i + 36 >= n || !is_word(chars[i + 36])) + { + out.push_str(""); + i += 36; + continue; + } + // A maximal word token that is entirely hex and >= 32 chars long. + if chars[i].is_ascii_hexdigit() && (i == 0 || !is_word(chars[i - 1])) { + let mut j = i; + while j < n && is_word(chars[j]) { + j += 1; + } + if j - i >= 32 && chars[i..j].iter().all(|c| c.is_ascii_hexdigit()) { + out.push_str(""); + i = j; + continue; + } + } + out.push(chars[i]); + i += 1; + } + out +} + +/// Whether a 36-char `8-4-4-4-12` hex UUID starts at `i`. +fn uuid_at(chars: &[char], i: usize) -> bool { + let groups = [8usize, 4, 4, 4, 12]; + let mut p = i; + for (gi, &len) in groups.iter().enumerate() { + for _ in 0..len { + if p >= chars.len() || !chars[p].is_ascii_hexdigit() { + return false; + } + p += 1; + } + if gi < 4 { + if p >= chars.len() || chars[p] != '-' { + return false; + } + p += 1; + } + } + true +} + +/// `YYYY-MM-DDTHH:MM:SSZ`. The reference uses `chrono`; this crate does not +/// carry it, and pulling a dependency for one timestamp is not worth it — +/// `build.rs` already solves the same problem the same way (Hinnant's +/// days→civil, `format_ymd`). +fn now_utc_rfc3339() -> String { + let secs = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0); + let days = secs.div_euclid(86_400); + let tod = secs.rem_euclid(86_400); + let (h, m, s) = (tod / 3600, (tod % 3600) / 60, tod % 60); + + // days_from_civil inverse (Hinnant, "chrono-Compatible Low-Level Date + // Algorithms") — the same routine as `build.rs::format_ymd`. + let z = days + 719_468; + let era = if z >= 0 { z } else { z - 146_096 } / 146_097; + let doe = z - era * 146_097; + let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365; + let y = yoe + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = doy - (153 * mp + 2) / 5 + 1; + let mo = if mp < 10 { mp + 3 } else { mp - 9 }; + let year = if mo <= 2 { y + 1 } else { y }; + format!("{year:04}-{mo:02}-{d:02}T{h:02}:{m:02}:{s:02}Z") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn redacts_uuid_and_long_hex() { + let s = "session 550e8400-e29b-41d4-a716-446655440000 token \ + 0123456789abcdef0123456789abcdef ok"; + let out = redact_id_like(s); + assert!(out.contains(""), "{out}"); + assert!(out.contains(""), "{out}"); + assert!(out.contains("session")); + assert!(out.contains("ok")); + } + + #[test] + fn leaves_short_hex_alone() { + // 8 hex chars (a SONAME-ish short id) is below the 32-char threshold. + assert_eq!(redact_id_like("abc123 deadbeef end"), "abc123 deadbeef end"); + } + + #[test] + fn trims_whole_khz_but_keeps_fractions() { + assert_eq!(trim_khz(96.0), "96"); + assert_eq!(trim_khz(44.1), "44.1"); + } + + #[test] + fn rfc3339_shape() { + let ts = now_utc_rfc3339(); + assert_eq!(ts.len(), 20, "{ts}"); + assert!(ts.ends_with('Z'), "{ts}"); + assert_eq!(&ts[4..5], "-"); + assert_eq!(&ts[10..11], "T"); + } + + #[test] + fn empty_doc_carries_every_section_key() { + let v: serde_json::Value = serde_json::from_str(&empty_doc_json()).expect("valid json"); + for key in [ + "open", + "loaded", + "loading", + "error", + "appVersion", + "copied", + "castScanning", + "system", + "playback", + "qconnect", + "cast", + "audio", + "graphics", + "env", + ] { + assert!(v.get(key).is_some(), "seed document is missing `{key}`"); + } + } + + /// The report renders off the SAME rows the panel does, so a row cut in + /// one place is cut in both. This pins the section headings and the + /// saved/runtime rendering rule. + #[test] + fn md_section_renders_saved_and_runtime() { + let rows = vec![ + row("Output Device", "hw:1,0", "USB DAC", 2), + row("Kernel", "—", "7.1.6", 0), + row("Exclusive Mode", "ON", "—", 0), + ]; + let mut out = String::new(); + md_section(&mut out, "Audio", &rows, true); + assert!(out.starts_with("\n## Audio\n\n"), "{out}"); + assert!(out.contains("- **Output Device:** saved hw:1,0 / runtime USB DAC")); + assert!(out.contains("- **Kernel:** 7.1.6")); + assert!(out.contains("- **Exclusive Mode:** ON")); + } +} diff --git a/crates/qbz-qt/src/icon_tint_qt.rs b/crates/qbz-qt/src/icon_tint_qt.rs index ed00b1acf..1f128b78b 100644 --- a/crates/qbz-qt/src/icon_tint_qt.rs +++ b/crates/qbz-qt/src/icon_tint_qt.rs @@ -144,10 +144,10 @@ pub struct QbzIconTintRust { /// Every icon the app can ask for, as the `primary/` bake's source text. /// -/// This IS the shipped `qml/assets/icons/primary/` directory (115 files), +/// This IS the shipped `qml/assets/icons/primary/` directory (124 files), /// compiled in. Two consequences worth knowing: /// - every runtime tint is COMPLETE, so the partial-directory trap the qrc -/// bakes have (`favorite/` = 57 files, `amber/` = 6, plus the two +/// bakes have (`favorite/` = 58 files, `amber/` = 6, plus the two /// single-glyph brand dirs `green/` and `orange/`) does not exist here; /// - a new icon dropped into `primary/` needs a line ADDED here, otherwise /// that one glyph silently drops back to its qrc bake (visible, just not @@ -289,6 +289,8 @@ const MASTERS: &[(&str, &str)] = &[ ("user", include_str!("../qml/assets/icons/primary/user.svg")), ("volume-2", include_str!("../qml/assets/icons/primary/volume-2.svg")), ("volume-x", include_str!("../qml/assets/icons/primary/volume-x.svg")), + // The header menu's "What's New" glyph (HeaderBar.slint:1222). + ("wand-sparkles", include_str!("../qml/assets/icons/primary/wand-sparkles.svg")), ("wifi", include_str!("../qml/assets/icons/primary/wifi.svg")), ("x", include_str!("../qml/assets/icons/primary/x.svg")), ]; diff --git a/crates/qbz-qt/src/log_viewer_qt.rs b/crates/qbz-qt/src/log_viewer_qt.rs index c431accb9..783762103 100644 --- a/crates/qbz-qt/src/log_viewer_qt.rs +++ b/crates/qbz-qt/src/log_viewer_qt.rs @@ -212,18 +212,30 @@ pub fn copy_all() { flash_copied(); } -/// The GitHub-ready bundle: the shared formatter's `
` header plus the -/// last [`BUNDLE_LINES`] lines. +/// The GitHub-ready bundle: the shared formatter's `
` header, the FULL +/// diagnostics report, then the last [`BUNDLE_LINES`] lines. /// -/// DELIBERATE NARROWING vs the reference, recorded rather than hidden: its -/// bundle prepends `diagnostics::build_full_report` (system + the LIVE audio -/// device + graphics + playback + qconnect), which is the DiagnosticsPanel's -/// backend and is not ported. This uses `qbz_log::bundle`, the shared -/// formatter both frontends already have, so the bundle is complete for its -/// own header fields and simply lacks the diagnostics report. It becomes 1:1 -/// the day the panel lands. -fn bundle_text() -> String { +/// This used to carry the eight header fields alone. The reference's bundle +/// (`crates/qbz/src/log_viewer.rs:260-277`) prepends the diagnostics report as +/// well — roughly seventy fields — so until the panel landed 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. +/// [`crate::diagnostics_qt::report_markdown`] is that body, and this is the day +/// the narrowing ends. +/// +/// It is injected INTO `format_diagnostics_bundle` rather than replacing it: +/// the reference's `build_share_text` emits no `
` wrapper, and +/// dropping it would make a pasted bundle expand the whole log inline in the +/// GitHub issue. +/// +/// ASYNC, and that is load-bearing: the report re-runs the settings reads, the +/// `pactl` shell-outs and the CPAL enumeration, so every caller has to be on +/// the tokio runtime — which is why `copy_bundle` is async and its invokable +/// takes the `log_upload` shape. The clipboard therefore lands one tick after +/// the click. +async fn bundle_text() -> String { let lines = qbz_log::ring::snapshot(); + let report = crate::diagnostics_qt::report_markdown().await; let fields = qbz_log::bundle::DiagFields { app_version: env!("CARGO_PKG_VERSION"), os: std::env::consts::OS, @@ -234,11 +246,11 @@ fn bundle_text() -> String { locale: &qbz_i18n::current_language(), log_level: &std::env::var("RUST_LOG").unwrap_or_else(|_| "info".into()), }; - qbz_log::bundle::format_diagnostics_bundle(&fields, &lines, BUNDLE_LINES) + qbz_log::bundle::format_diagnostics_bundle_with_report(&fields, &report, &lines, BUNDLE_LINES) } -pub fn copy_bundle() { - crate::share_qt::copy_to_clipboard(bundle_text()); +pub async fn copy_bundle() { + crate::share_qt::copy_to_clipboard(bundle_text().await); flash_copied(); } @@ -258,7 +270,7 @@ pub async fn upload() { .clear(); publish(); - let body = bundle_text(); + let body = bundle_text().await; let url = match reqwest::Client::new() .post("https://paste.rs/") .body(body) diff --git a/crates/qbz-qt/src/main.rs b/crates/qbz-qt/src/main.rs index d564f706a..5a4edbd93 100644 --- a/crates/qbz-qt/src/main.rs +++ b/crates/qbz-qt/src/main.rs @@ -103,6 +103,8 @@ mod kiosk_profile_qt; mod artwork_qt; mod atmosphere_qt; mod bridge; +mod custom_theme_qt; +mod diagnostics_qt; mod discover_config_qt; mod fav_cache_qt; // The folder-modal controller. A plain module — it declares no @@ -130,6 +132,12 @@ mod recently_qt; // "Share logs" from an `open::that(path)` handoff into a filterable view with // copy / bundle / upload. mod log_viewer_qt; +// About QBZ + What's New (the header menu's last two rows). `about_bridge` +// declares the #[cxx_qt::bridge] and IS listed in build.rs's rust_files; the +// other two are plain controller modules and must NOT be. +mod about_bridge; +mod about_qt; +mod whats_new_qt; mod recommendations_qt; mod library_db_qt; mod library_qt; @@ -2221,8 +2229,37 @@ pub(crate) fn refresh_devices() { /// Appearance > Theme row: persist the slug + republish the token document /// (live switch — QbzTheme.qml rebinds every consumer). pub(crate) fn theme_set(slug: String) { + // FIRST-EVER selection of "Custom": seed the editable base from the + // palette the user is looking at RIGHT NOW, so picking Custom customizes + // what they see instead of jumping to OLED black (1:1 with the reference, + // crates/qbz/src/main.rs:11414-11422). + // + // The snapshot is taken BEFORE `set_theme`, and that ordering is the whole + // fix: `set_theme` persists the slug and `current_slug()` re-reads it from + // disk, so a seed taken afterwards would resolve "custom", find no file, + // and snapshot the default it was supposed to replace. + // Entering Custom re-reads the file. The in-memory base is memoized for + // the life of the process (so a drag does not re-read it), and it is + // filled at bridge construction for every user — including one who booted + // with no file and whose theme the SHIPPING SLINT BUILD wrote afterwards. + // Without this the cached OLED default would win over their actual theme. + if slug == "custom" { + custom_theme_qt::invalidate(); + } + let seed_from = (slug == "custom" && !custom_theme_qt::exists()) + .then(|| theme_qt::colors_for_slug(&theme_qt::current_slug())); theme_qt::set_theme(&slug); + if let Some(prev) = seed_from { + custom_theme_qt::seed_from_current(&prev); + } theme_qt::publish_theme(); + // The editor's swatches ride their own document: republish so the grid is + // correct the instant the rows appear (and after a seed rewrote the base). + // Only under "custom" — anywhere else the editor is not mounted, and the + // call would lazily read custom_theme.json for nothing. + if slug == "custom" { + custom_theme_qt::publish_state(); + } } /// Appearance > theme filter cycle (0 All / 1 Dark / 2 Light). diff --git a/crates/qbz-qt/src/qconnect_qt.rs b/crates/qbz-qt/src/qconnect_qt.rs index ff70a5d5d..f09b552c7 100644 --- a/crates/qbz-qt/src/qconnect_qt.rs +++ b/crates/qbz-qt/src/qconnect_qt.rs @@ -384,10 +384,11 @@ pub fn dev_clear() { struct QtQconnectRuntime { app: Arc, - #[allow(dead_code)] // retained for status/endpoint reporting in the UI step + // Read by `diagnostics_snapshot` (the "Has Endpoint" row). config: WsTransportConfig, event_loop: tokio::task::JoinHandle<()>, - #[allow(dead_code)] // shared with the app + sink; kept for future snapshot queries + // Shared with the app + sink; read by the renderer snapshots and by + // `diagnostics_snapshot` (session topology). sync_state: Arc>, } @@ -684,10 +685,95 @@ fn build_reorder_payload( })) } -// §9 D10: the reference's `diagnostics_snapshot` / `QconnectDiagSnapshot` is -// SKIPPED — its only Slint consumers (the settings Diagnostics panel tables + -// markdown export) do not exist in Qt. The Connect diagnostics modal is served -// by the dev_* state above, not by a snapshot read. +/// Pure observation of the live session, for the Settings > Developer +/// Diagnostics panel and the log bundle's markdown report. +/// +/// Ported from `crates/qbz/src/qconnect_service.rs:572` (§9 D10 used to say +/// this was SKIPPED "because its only Slint consumers do not exist in Qt" — +/// they exist as of 2026-08-14, so it is ported). +/// +/// Every field is a PURE read of state the running service already maintains +/// (lock -> read -> clone -> unlock). It never touches discovery, guard or +/// transport logic — QConnect is control-fragile, so this only observes. +/// `last_error` is returned raw; the diagnostics controller redacts id-like +/// substrings before it reaches the UI or the export. +#[derive(Default)] +pub struct QconnectDiagSnapshot { + pub running: bool, + pub transport_connected: bool, + pub has_endpoint: bool, + pub last_error: Option, + pub role: &'static str, + pub active_name: Option, + pub active_brand: Option, + pub active_model: Option, + pub renderer_count: usize, +} + +impl QtQconnectService { + /// See [`QconnectDiagSnapshot`]. Returns a default snapshot (role "none") + /// when the service is not running. + pub(crate) async fn diagnostics_snapshot(&self) -> QconnectDiagSnapshot { + // Pull the app + sync-state handles (and the static fields) under a + // brief inner lock, then release it before awaiting the per-handle + // locks — the reference's ordering, kept verbatim. + let (app, sync_state, last_error, has_endpoint, running) = { + let guard = self.inner.lock().await; + match guard.runtime.as_ref() { + Some(rt) => ( + Some(Arc::clone(&rt.app)), + Some(Arc::clone(&rt.sync_state)), + guard.last_error.clone(), + !rt.config.endpoint_url.is_empty(), + true, + ), + None => (None, None, guard.last_error.clone(), false, false), + } + }; + + let mut snap = QconnectDiagSnapshot { + running, + has_endpoint, + last_error, + role: "none", + ..Default::default() + }; + + let (Some(app), Some(sync_state)) = (app, sync_state) else { + return snap; + }; + + // Transport-connected mirror (same source the event sink reads). + snap.transport_connected = app.state_handle().lock().await.transport_connected; + + // Session topology: renderer count + the active renderer's identity. + let state = sync_state.lock().await; + let session = &state.session; + snap.renderer_count = session.renderers.len(); + let active_id = session.active_renderer_id; + let local_id = session.local_renderer_id; + if let Some(active_id) = active_id { + if let Some(renderer) = session.renderers.iter().find(|r| r.renderer_id == active_id) { + snap.active_name = renderer.friendly_name.clone(); + snap.active_brand = renderer.brand.clone(); + snap.active_model = renderer.model.clone(); + } + // active == local -> we render locally; active != local -> we + // control a peer. Mirrors the Tauri role rule. + snap.role = if Some(active_id) == local_id { + "local-renderer" + } else { + "controller" + }; + } else if snap.running || snap.transport_connected { + snap.role = "observer"; + } else { + snap.role = "none"; + } + + snap + } +} impl QtQconnectService { pub fn new(runtime: Runtime) -> Self { diff --git a/crates/qbz-qt/src/shell_bridge.rs b/crates/qbz-qt/src/shell_bridge.rs index 5bd4da966..f0e74a2f9 100644 --- a/crates/qbz-qt/src/shell_bridge.rs +++ b/crates/qbz-qt/src/shell_bridge.rs @@ -92,12 +92,29 @@ pub mod qbz_shell { #[qproperty(QString, theme_list_json)] // Dropdown filter: 0 All / 1 Dark / 2 Light (ui_prefs theme_filter). #[qproperty(i32, theme_filter)] + // The custom-theme EDITOR state (custom_theme_qt.rs): + // {"isDark":bool,"tokens":{"":"#aarrggbb"}} — the eleven + // editable base tokens plus the polarity, mirroring the Slint + // AppearanceState.custom-* swatch properties. The token colours are + // `#aarrggbb` because QML binds them as `color` fills, like every + // other theme token; the EDIT/PERSIST side of the same feature is + // 6-digit `#rrggbb` (see the custom_theme_qt header). Seeded at + // construction next to `theme_json`, for the same reason. + // + // `custom-open-token` (state.slint:3205) is deliberately NOT here: + // which swatch has the picker open is pure view state and lives in + // CustomThemeEditor.qml. + #[qproperty(QString, custom_theme_json)] // --- Log viewer (log_viewer_qt.rs) -------------------------------- // ONE document: {open, rows[], total, shown, filterLevel, search, // autoTail, uploading, uploadedUrl}. The ring can hold thousands of // lines and the view shows at most 500, so the FILTER runs Rust-side // and only the survivors cross the bridge. + /// The Diagnostics panel's whole document (Settings > Developer). + /// Seeded with the full shape so every binding reads a real object on + /// the pre-publish frame. + #[qproperty(QString, diagnostics_json)] #[qproperty(QString, log_viewer_json)] // --- Lyrics panel (phase 9) ---------------------------------------- @@ -483,6 +500,20 @@ pub mod qbz_shell { /// The GitHub-ready `
` bundle. #[qinvokable] fn log_copy_bundle(self: Pin<&mut QbzShell>); + /// Diagnostics panel (Settings > Developer). The open state reaches + /// Rust because it is what gates the publisher — a collapsed panel is + /// not reading and a publish can carry a hundred rows. + #[qinvokable] + fn diag_set_open(self: Pin<&mut QbzShell>, open: bool); + #[qinvokable] + fn diag_refresh(self: Pin<&mut QbzShell>); + #[qinvokable] + fn diag_export_clipboard(self: Pin<&mut QbzShell>); + #[qinvokable] + fn diag_cast_scan(self: Pin<&mut QbzShell>); + /// The header menu's "Report an Issue" row — the GitHub bug template. + #[qinvokable] + fn report_issue_open(self: Pin<&mut QbzShell>); /// Upload the bundle to a public paste; the url lands in the document. #[qinvokable] fn log_upload(self: Pin<&mut QbzShell>); @@ -514,6 +545,28 @@ pub mod qbz_shell { #[qinvokable] fn theme_set_filter(self: Pin<&mut QbzShell>, index: i32); + // --- Custom-theme editor (custom_theme_qt.rs) ---------------------- + /// Set one editable base token from a `#rrggbb` string — BOTH the live + /// ColorPicker drag and the HEX-field commit (the reference splits + /// these into `custom-set-token` / `custom-set-token-hex`, which is a + /// Slint artifact: there a colour and a string are different types). + /// Re-derives and republishes the whole palette live; the disk write + /// is debounced. Malformed hex and unknown keys are ignored. + #[qinvokable] + fn custom_set_token(self: Pin<&mut QbzShell>, key: QString, hex: QString); + /// Persist NOW if a live edit is pending — called on colour-drag end + /// and on a HEX commit, so a settled edit never waits out the debounce. + #[qinvokable] + fn custom_flush(self: Pin<&mut QbzShell>); + /// Flip the custom theme's light/dark polarity (derived shades, + /// borders and overlays follow; the base colours do not change). + #[qinvokable] + fn custom_toggle_dark(self: Pin<&mut QbzShell>, is_dark: bool); + /// "Use current colors": snapshot the applied palette into the + /// editable base, persist, apply and republish the swatches. + #[qinvokable] + fn custom_seed_from_current(self: Pin<&mut QbzShell>); + /// Main.qml, debounced off every settled resize / visibility flip and /// fired once more on close: persist the FLOATING size plus the /// maximized flag. The whole rule set (floating-only sizes, the app @@ -654,6 +707,8 @@ pub struct QbzShellRust { theme_slug: QString, theme_list_json: QString, theme_filter: i32, + custom_theme_json: QString, + diagnostics_json: QString, log_viewer_json: QString, lyrics_open: bool, lyrics_json: QString, @@ -741,6 +796,10 @@ impl Default for QbzShellRust { theme_slug: QString::from(crate::theme_qt::current_slug().as_str()), theme_list_json: QString::from(crate::theme_qt::theme_list_json().as_str()), theme_filter: crate::theme_qt::theme_filter(), + custom_theme_json: QString::from(crate::custom_theme_qt::state_json().as_str()), + diagnostics_json: QString::from( + crate::diagnostics_qt::empty_doc_json().as_str(), + ), log_viewer_json: QString::from("{}"), lyrics_open: false, lyrics_json: QString::from("{}"), @@ -1073,7 +1132,29 @@ impl qbz_shell::QbzShell { crate::log_viewer_qt::copy_all(); } pub fn log_copy_bundle(self: Pin<&mut Self>) { - crate::log_viewer_qt::copy_bundle(); + // The bundle now carries the full diagnostics report, which re-runs the + // settings reads and the `pactl` shell-outs — so it is async, and this + // takes the `log_upload` shape. The clipboard lands one tick later. + crate::spawn(async move { crate::log_viewer_qt::copy_bundle().await }); + } + pub fn diag_set_open(self: Pin<&mut Self>, open: bool) { + crate::diagnostics_qt::set_open(open); + } + pub fn diag_refresh(self: Pin<&mut Self>) { + crate::diagnostics_qt::refresh(); + } + pub fn diag_export_clipboard(self: Pin<&mut Self>) { + crate::diagnostics_qt::export_clipboard(); + } + pub fn diag_cast_scan(self: Pin<&mut Self>) { + crate::diagnostics_qt::cast_scan(); + } + pub fn report_issue_open(self: Pin<&mut Self>) { + // Same URL the reference opens (crates/qbz/src/main.rs:14619-14624): + // the repo's bug-report template, no prefilled body. + if let Err(e) = open::that("https://github.com/vicrodh/qbz/issues/new?template=bug_report.yml") { + log::warn!("[qbz-qt] could not open the issue template: {e}"); + } } pub fn log_upload(self: Pin<&mut Self>) { crate::spawn(async move { crate::log_viewer_qt::upload().await }); @@ -1097,6 +1178,22 @@ impl qbz_shell::QbzShell { crate::theme_set_filter(index); } + pub fn custom_set_token(self: Pin<&mut Self>, key: QString, hex: QString) { + crate::custom_theme_qt::set_token(&key.to_string(), &hex.to_string()); + } + + pub fn custom_flush(self: Pin<&mut Self>) { + crate::custom_theme_qt::flush(); + } + + pub fn custom_toggle_dark(self: Pin<&mut Self>, is_dark: bool) { + crate::custom_theme_qt::toggle_dark(is_dark); + } + + pub fn custom_seed_from_current(self: Pin<&mut Self>) { + crate::custom_theme_qt::seed_from_applied(); + } + pub fn save_window_geometry( self: Pin<&mut Self>, width: f32, diff --git a/crates/qbz-qt/src/theme_qt.rs b/crates/qbz-qt/src/theme_qt.rs index 199ad5e0a..536e198b6 100644 --- a/crates/qbz-qt/src/theme_qt.rs +++ b/crates/qbz-qt/src/theme_qt.rs @@ -7,7 +7,8 @@ //! Synthetic slugs (theme.rs): "auto" (AutoSource::System regeneration — //! falls back to OLED when headless generation fails) and "custom" //! (`/qbz/custom_theme.json`, the SAME file the Slint custom-theme -//! editor writes; read-only apply here — POC-NOTE: no token editor). The +//! editor writes — the editor and the write path live in `custom_theme_qt`, +//! which also owns the path and the in-memory base this module reads). The //! "system" registry theme resolves via the OS palette in Slint; the POC //! maps it to the Dark palette (POC-NOTE). @@ -65,16 +66,13 @@ pub fn set_theme(slug: &str) { // Slug -> ThemeColors (theme.rs apply_theme / auto / custom startup dispatch) // --------------------------------------------------------------------------- -fn custom_theme_path() -> Option { - Some(dirs::data_dir()?.join("qbz").join("custom_theme.json")) -} - +/// The user-authored theme: the 11 editable base tokens expanded through the +/// SHARED derivation. The base (and the file path, and the write path) belong +/// to `custom_theme_qt` — reading it back off disk here would show the last +/// PERSISTED colours during the editor's debounce window instead of the live +/// ones. fn custom_colors() -> ThemeColors { - let base = custom_theme_path() - .and_then(|p| std::fs::read_to_string(p).ok()) - .and_then(|text| serde_json::from_str::(&text).ok()) - .unwrap_or_else(qbz_theme::CustomThemeBase::default_oled); - qbz_theme::theme_from_base(&base) + qbz_theme::theme_from_base(&crate::custom_theme_qt::load()) } /// Build the generator's source from the persisted pref — 1:1 with the @@ -149,7 +147,7 @@ const ALPHA_PCTS: [u32; 24] = [ #[derive(Serialize)] struct ThemeTokens { - // 30 named colors (ThemeColors contract). + // 31 named colors (ThemeColors contract). #[serde(rename = "surfaceMain")] surface_main: String, #[serde(rename = "surfaceCard")] @@ -288,6 +286,17 @@ pub fn theme_json() -> String { serde_json::to_string(&tokens_for(&colors)).unwrap_or_else(|_| "{}".into()) } +/// Push an ALREADY-RESOLVED palette — the custom-theme editor's live preview +/// (`custom_theme_qt::apply_live`). Deliberately does NOT touch `theme_slug` +/// and deliberately does NOT re-resolve the slug: during a colour drag the +/// authority is the in-memory base, not the (debounced) file on disk. +pub(crate) fn publish_colors(colors: &ThemeColors) { + let json = serde_json::to_string(&tokens_for(colors)).unwrap_or_else(|_| "{}".into()); + crate::shell_bridge::ui(move |mut b| { + b.as_mut().set_theme_json(QString::from(json.as_str())); + }); +} + /// Re-resolve + republish after a slug change (the push_colors moment). pub fn publish_theme() { let json = theme_json(); diff --git a/crates/qbz-qt/src/whats_new_qt.rs b/crates/qbz-qt/src/whats_new_qt.rs new file mode 100644 index 000000000..79fa3631f --- /dev/null +++ b/crates/qbz-qt/src/whats_new_qt.rs @@ -0,0 +1,651 @@ +//! What's New modal controller + release-notes markdown renderer — the Qt port +//! of `crates/qbz/src/whats_new.rs`. +//! +//! On open, fetches the GitHub release whose tag matches the running version +//! (`https://api.github.com/repos/vicrodh/qbz/releases/tags/v{version}`) off +//! the UI thread, parses its markdown `body` into a flat block model, and +//! publishes it as `QbzAbout.whatsNewJson`. There is NO on-disk cache and no +//! retry: closing and reopening re-fetches, exactly like the reference. +//! +//! # THE MARKDOWN SUBSET IS NOT MARKDOWN +//! +//! [`render_markdown`] is a line-for-line transcription of +//! `whats_new.rs:337-412`, which is itself a 1:1 port of the Tauri +//! `renderMarkdownWithToc`. Its rules are surprising ON PURPOSE and must not be +//! "improved": +//! +//! - `#` and `##` collapse to the SAME level (0) and both push a TOC entry; +//! `###` is level 1 and pushes nothing. +//! - An **indent-0 `- item` is NOT a bullet** — it becomes a level-0 SECTION +//! plus a TOC entry. This is the rule a naive renderer always gets wrong. +//! - Bullet level is `floor(leading_spaces / 2)`, a tab counting as 2. +//! - `**` and backticks are STRIPPED, not styled; inline `[text](url)` is +//! reduced to `text`. There is no bold, italic, inline code or inline link +//! anywhere in the rendered body. +//! - A bullet or paragraph that is ENTIRELY one markdown link becomes a +//! clickable `KIND_LINK` block — that is how TAG_DETAILS.md's final +//! full-changelog line renders as an accent row. +//! +//! `Text.MarkdownText` is a DIFFERENT subset and was deliberately not used: it +//! would style the markers the reference strips, bullet the indent-0 items the +//! reference promotes to sections, and render links inline. Off the same source +//! it would produce a visibly different document from the Slint app and from +//! the website. +//! +//! # Divergences from the reference +//! +//! - `format_release_date` is rewritten WITHOUT chrono (`qbz-qt` has no chrono +//! dependency and is not gaining one for a date the GitHub API always emits +//! in a fixed RFC3339 shape). Same output, same raw-string fallback. +//! - The blocks are plain `Serialize` structs instead of Slint model rows; the +//! field names match `WhatsNewState` (`state.slint:6141-6149`) so both +//! frontends read one document shape. + +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{LazyLock, Mutex}; + +use cxx_qt_lib::QString; +use serde::{Deserialize, Serialize}; + +use crate::about_bridge::ui; + +const GITHUB_RELEASES_URL: &str = "https://api.github.com/repos/vicrodh/qbz/releases"; + +/// Block kinds, shared with the Slint model (`whats_new.rs:24-28`). +const KIND_SECTION: i32 = 0; +const KIND_BULLET: i32 = 1; +const KIND_PARAGRAPH: i32 = 2; +/// A whole-line markdown link `[text](url)` — rendered as a clickable link. +const KIND_LINK: i32 = 3; + +/// GitHub release JSON (only the fields the modal needs). +#[derive(Debug, Clone, Deserialize)] +struct GithubRelease { + tag_name: String, + published_at: String, + body: Option, + #[serde(default)] + draft: bool, + #[serde(default)] + prerelease: bool, +} + +/// The parsed release the controller applies to the document. +struct FetchedRelease { + version: String, + date: String, + body: Option, +} + +#[derive(Serialize, Debug, Clone, PartialEq, Eq)] +pub struct Block { + pub kind: i32, + pub level: i32, + pub text: String, + pub id: String, + pub url: String, +} + +#[derive(Serialize, Debug, Clone, PartialEq, Eq)] +pub struct TocEntry { + pub id: String, + pub label: String, +} + +#[derive(Default)] +struct State { + version: String, + date: String, + has_body: bool, + blocks: Vec, + toc: Vec, +} + +static OPEN: AtomicBool = AtomicBool::new(false); +static LOADING: AtomicBool = AtomicBool::new(false); +static STATE: LazyLock> = LazyLock::new(|| Mutex::new(State::default())); + +#[derive(Serialize)] +struct Doc<'a> { + open: bool, + loading: bool, + version: &'a str, + date: &'a str, + #[serde(rename = "hasBody")] + has_body: bool, + toc: &'a [TocEntry], + blocks: &'a [Block], +} + +pub fn publish() { + let st = STATE.lock().unwrap_or_else(|e| e.into_inner()); + let doc = Doc { + open: OPEN.load(Ordering::SeqCst), + loading: LOADING.load(Ordering::SeqCst), + version: &st.version, + date: &st.date, + has_body: st.has_body, + toc: &st.toc, + blocks: &st.blocks, + }; + let json = serde_json::to_string(&doc).unwrap_or_else(|_| "{}".into()); + drop(st); + ui(move |mut b| b.as_mut().set_whats_new_json(QString::from(json.as_str()))); +} + +/// Show the modal in its loading state, then fetch + render off the UI thread. +pub fn open() { + let version = crate::about_qt::app_version().to_string(); + OPEN.store(true, Ordering::SeqCst); + LOADING.store(true, Ordering::SeqCst); + { + let mut st = STATE.lock().unwrap_or_else(|e| e.into_inner()); + st.version = version.clone(); + st.date.clear(); + st.has_body = false; + st.blocks.clear(); + st.toc.clear(); + } + publish(); + + crate::spawn(async move { + let fetched = fetch_release_for_version(&version).await; + apply(fetched); + }); +} + +pub fn close() { + OPEN.store(false, Ordering::SeqCst); + publish(); +} + +/// Apply the fetched release (or its absence) to the document. +fn apply(fetched: Option) { + LOADING.store(false, Ordering::SeqCst); + { + let mut st = STATE.lock().unwrap_or_else(|e| e.into_inner()); + match fetched { + None => st.has_body = false, + Some(rel) => { + st.version = rel.version; + st.date = rel.date; + let (blocks, toc) = render_markdown(&rel.body.unwrap_or_default()); + if blocks.is_empty() { + st.has_body = false; + } else { + st.blocks = blocks; + st.toc = toc; + st.has_body = true; + } + } + } + } + publish(); +} + +/// Fetch the release for `version` by exact tag (`v{version}`), with the +/// GitHub-required `User-Agent`. `None` on any network/parse failure or for +/// draft/prerelease tags — which is why every DEV build shows the empty state. +async fn fetch_release_for_version(version: &str) -> Option { + let tag = if version.starts_with('v') { + version.to_string() + } else { + format!("v{version}") + }; + let url = format!("{GITHUB_RELEASES_URL}/tags/{tag}"); + + let client = match reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(8)) + .user_agent("qbz") + .build() + { + Ok(c) => c, + Err(e) => { + log::warn!("[qbz-qt] whats-new client build failed: {e}"); + return None; + } + }; + + let resp = match client.get(&url).send().await { + Ok(r) => r, + Err(e) => { + log::warn!("[qbz-qt] whats-new fetch failed for {tag}: {e}"); + return None; + } + }; + if !resp.status().is_success() { + log::warn!("[qbz-qt] whats-new fetch HTTP {} for {tag}", resp.status()); + return None; + } + + let release: GithubRelease = match resp.json().await { + Ok(r) => r, + Err(e) => { + log::warn!("[qbz-qt] whats-new JSON parse failed for {tag}: {e}"); + return None; + } + }; + if release.draft || release.prerelease { + return None; + } + + Some(FetchedRelease { + version: normalize_version_tag(&release.tag_name), + date: format_release_date(&release.published_at), + body: release.body, + }) +} + +fn normalize_version_tag(tag: &str) -> String { + tag.trim().trim_start_matches('v').to_string() +} + +const MONTHS: [&str; 12] = [ + "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", +]; + +/// Format an RFC3339 timestamp as "Mon D, YYYY" (en-US short). Falls back to +/// the raw string on anything malformed. +/// +/// CHRONO-FREE (the reference uses `DateTime::parse_from_rfc3339`, and this +/// crate has no chrono dependency). GitHub's `published_at` is always +/// `YYYY-MM-DDTHH:MM:SSZ`, and chrono's `year()/month0()/day()` on a parsed +/// `DateTime` report the date AS WRITTEN in the string — so +/// slicing the first ten ASCII bytes is behaviour-identical, offset included. +fn format_release_date(iso: &str) -> String { + let bytes = iso.as_bytes(); + if bytes.len() < 10 || bytes[4] != b'-' || bytes[7] != b'-' { + return iso.to_string(); + } + let num = |a: usize, b: usize| iso[a..b].parse::().ok(); + let (Some(year), Some(month), Some(day)) = (num(0, 4), num(5, 7), num(8, 10)) else { + return iso.to_string(); + }; + let Some(month_name) = month + .checked_sub(1) + .and_then(|m| MONTHS.get(m as usize).copied()) + else { + return iso.to_string(); + }; + format!("{month_name} {day}, {year}") +} + +// ==================== Markdown → blocks + TOC ==================== +// +// Everything below is a transcription of whats_new.rs:201-412. The only +// changes are the Slint model types (`WhatsNewBlock`/`WhatsNewTocEntry`) giving +// way to the plain structs above, and `.into()` on the string fields becoming +// `.to_string()`. The LOGIC is byte-for-byte the reference's. + +/// Strip inline `**bold**` / `` `code` `` markers and reduce inline markdown +/// links `[text](url)` to just their `text`. +fn strip_inline(text: &str) -> String { + strip_markdown_links(text).replace("**", "").replace('`', "") +} + +/// If `s[start..]` begins with a markdown link `[label](url)`, return +/// `(label, url, byte-index just past the ')')`. The `[](` `)` delimiters are +/// ASCII, so all returned slices sit on char boundaries. No nested brackets. +fn parse_link_at(s: &str, start: usize) -> Option<(&str, &str, usize)> { + let rest = &s[start..]; + if !rest.starts_with('[') { + return None; + } + let close_br = rest.find(']')?; + if rest.as_bytes().get(close_br + 1) != Some(&b'(') { + return None; + } + let open_paren = close_br + 1; + let close_paren = open_paren + rest[open_paren..].find(')')?; + let label = &rest[1..close_br]; + let url = &rest[open_paren + 1..close_paren]; + if url.is_empty() { + return None; + } + Some((label, url, start + close_paren + 1)) +} + +/// Replace every inline `[text](url)` in a string with just its `text`. +fn strip_markdown_links(text: &str) -> String { + let mut out = String::with_capacity(text.len()); + let mut i = 0; + while i < text.len() { + if text.as_bytes()[i] == b'[' { + if let Some((label, _url, end)) = parse_link_at(text, i) { + out.push_str(label); + i = end; + continue; + } + } + let ch = text[i..].chars().next().unwrap(); + out.push(ch); + i += ch.len_utf8(); + } + out +} + +/// If the whole trimmed line is exactly one markdown link, return `(text, url)`. +fn parse_standalone_link(s: &str) -> Option<(&str, &str)> { + let t = s.trim(); + let (label, url, end) = parse_link_at(t, 0)?; + (end == t.len()).then_some((label, url)) +} + +/// Slugify a heading label into a TOC anchor id (port of the Tauri `slugify`). +fn slugify(input: &str) -> String { + let lowered = input.trim().to_lowercase(); + let mut cleaned = String::with_capacity(lowered.len()); + for ch in lowered.chars() { + if matches!(ch, '`' | '*' | '_' | '~') { + continue; + } + if ch.is_ascii_alphanumeric() || ch == ' ' || ch == '-' { + cleaned.push(ch); + } + } + let mut out = String::with_capacity(cleaned.len()); + let mut last_hyphen = false; + for ch in cleaned.chars() { + if ch == ' ' || ch == '-' { + if !last_hyphen { + out.push('-'); + last_hyphen = true; + } + } else { + out.push(ch); + last_hyphen = false; + } + } + out.trim_matches('-').to_string() +} + +/// Count leading-space indentation (a tab counts as 2). +fn count_leading_spaces(line: &str) -> usize { + let mut count = 0; + for ch in line.chars() { + match ch { + ' ' => count += 1, + '\t' => count += 2, + _ => break, + } + } + count +} + +/// Push a section heading block; level-0 sections also become TOC entries. +fn push_heading(label: &str, level: i32, blocks: &mut Vec, toc: &mut Vec) { + let clean = label.trim(); + if clean.is_empty() { + return; + } + let id = slugify(clean); + if level == 0 { + toc.push(TocEntry { + id: id.clone(), + label: clean.to_string(), + }); + } + blocks.push(Block { + kind: KIND_SECTION, + level, + text: strip_inline(clean), + id, + url: String::new(), + }); +} + +/// A clickable whole-line link block. +fn link_block(label: &str, url: &str) -> Block { + Block { + kind: KIND_LINK, + level: 0, + text: strip_inline(label), + id: String::new(), + url: url.to_string(), + } +} + +/// Render the release-notes markdown into a flat block model + a TOC of the +/// level-0 section headings. 1:1 with `whats_new.rs::render_markdown`. +pub fn render_markdown(markdown: &str) -> (Vec, Vec) { + let mut blocks: Vec = Vec::new(); + let mut toc: Vec = Vec::new(); + + if markdown.trim().is_empty() { + return (blocks, toc); + } + + for line in markdown.split('\n') { + let trimmed = line.trim(); + + if trimmed.is_empty() { + continue; + } + + // Headings (#, ##, ###). + if let Some(rest) = trimmed.strip_prefix("# ") { + push_heading(rest, 0, &mut blocks, &mut toc); + continue; + } + if let Some(rest) = trimmed.strip_prefix("## ") { + push_heading(rest, 0, &mut blocks, &mut toc); + continue; + } + if let Some(rest) = trimmed.strip_prefix("### ") { + push_heading(rest, 1, &mut blocks, &mut toc); + continue; + } + + // List items with indentation-based nesting. + let is_list = trimmed.starts_with("- ") || trimmed.starts_with("* "); + if is_list { + let indent = count_leading_spaces(line); + let level = (indent / 2) as i32; + let content = trimmed[2..].trim(); + + if level == 0 { + // Top-level bullets become section headings (no bullet glyph). + push_heading(content, 0, &mut blocks, &mut toc); + continue; + } + + // A bullet that is nothing but a link renders as a clickable link. + if let Some((label, url)) = parse_standalone_link(content) { + blocks.push(link_block(label, url)); + continue; + } + + blocks.push(Block { + kind: KIND_BULLET, + level, + text: strip_inline(content), + id: String::new(), + url: String::new(), + }); + continue; + } + + // A paragraph that is nothing but a link renders as a clickable link. + if let Some((label, url)) = parse_standalone_link(trimmed) { + blocks.push(link_block(label, url)); + continue; + } + + // Paragraph. + blocks.push(Block { + kind: KIND_PARAGRAPH, + level: 0, + text: strip_inline(trimmed), + id: String::new(), + url: String::new(), + }); + } + + (blocks, toc) +} + +// --------------------------------------------------------------------------- +// Tests +// +// The renderer is the ONE piece of this lane with real logic, and it CANNOT be +// eyeballed: `fetch_release_for_version` returns None for draft/prerelease +// tags, so any build off an unreleased version shows the empty state. These +// tests are the only thing standing between a "helpful" rewrite and a modal +// that quietly disagrees with the website off the same source. +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + /// A REAL shipped release body. TAG_DETAILS.md is what gets pasted into the + /// GitHub release the app fetches, so this is the exact text the renderer + /// sees in production. + const REAL_BODY: &str = include_str!("../../../docs/release-2.0.2/TAG_DETAILS.md"); + + #[test] + fn h1_and_h2_collapse_to_the_same_level_and_both_enter_the_toc() { + let (blocks, toc) = render_markdown("# Alpha\n## Beta\n### Gamma\n"); + assert_eq!(blocks.len(), 3); + assert_eq!((blocks[0].kind, blocks[0].level), (KIND_SECTION, 0)); + assert_eq!((blocks[1].kind, blocks[1].level), (KIND_SECTION, 0)); + // ### is a SUB-section and does NOT enter the TOC. + assert_eq!((blocks[2].kind, blocks[2].level), (KIND_SECTION, 1)); + assert_eq!( + toc, + vec![ + TocEntry { id: "alpha".into(), label: "Alpha".into() }, + TocEntry { id: "beta".into(), label: "Beta".into() }, + ] + ); + } + + #[test] + fn an_indent_zero_bullet_becomes_a_section_plus_a_toc_entry() { + // THE rule a naive markdown renderer gets wrong. + let (blocks, toc) = render_markdown("- Top level item\n"); + assert_eq!(blocks.len(), 1); + assert_eq!(blocks[0].kind, KIND_SECTION); + assert_eq!(blocks[0].level, 0); + assert_eq!(blocks[0].text, "Top level item"); + assert_eq!(toc.len(), 1); + assert_eq!(toc[0].id, "top-level-item"); + } + + #[test] + fn bullet_level_is_leading_spaces_over_two_with_tabs_worth_two() { + let (blocks, _) = render_markdown(" - one\n - two\n\t- tab\n * six\n"); + let levels: Vec = blocks.iter().map(|b| b.level).collect(); + assert_eq!(levels, vec![1, 2, 1, 3]); + assert!(blocks.iter().all(|b| b.kind == KIND_BULLET)); + // `* ` is a bullet marker too. + assert_eq!(blocks[3].text, "six"); + } + + #[test] + fn inline_markers_are_stripped_not_styled() { + let (blocks, _) = render_markdown(" - **Bold** and `code` and [label](https://x.dev)\n"); + assert_eq!(blocks[0].kind, KIND_BULLET); + assert_eq!(blocks[0].text, "Bold and code and label"); + assert!(blocks[0].url.is_empty()); + } + + #[test] + fn a_whole_line_link_becomes_a_clickable_link_block() { + let (blocks, _) = render_markdown( + "[Read the guide](https://github.com/vicrodh/qbz/wiki)\n - [Nested](https://n.dev)\n", + ); + assert_eq!(blocks.len(), 2); + assert_eq!(blocks[0].kind, KIND_LINK); + assert_eq!(blocks[0].text, "Read the guide"); + assert_eq!(blocks[0].url, "https://github.com/vicrodh/qbz/wiki"); + // A bullet whose whole content is a link is a link too, NOT a bullet. + assert_eq!(blocks[1].kind, KIND_LINK); + assert_eq!(blocks[1].url, "https://n.dev"); + } + + #[test] + fn a_line_that_is_a_link_plus_trailing_text_stays_a_paragraph() { + let (blocks, _) = render_markdown("[label](https://x.dev) and more\n"); + assert_eq!(blocks[0].kind, KIND_PARAGRAPH); + assert_eq!(blocks[0].text, "label and more"); + } + + #[test] + fn blank_lines_are_dropped_and_empty_input_renders_nothing() { + let (blocks, toc) = render_markdown("\n\n \n"); + assert!(blocks.is_empty()); + assert!(toc.is_empty()); + assert_eq!(render_markdown("").0.len(), 0); + } + + #[test] + fn slugify_collapses_runs_and_drops_punctuation() { + assert_eq!(slugify("The headless daemon (qbzd)"), "the-headless-daemon-qbzd"); + // Emphasis chars vanish; the comma and the `&` are dropped and the + // whitespace run they leave behind collapses to ONE hyphen. + assert_eq!(slugify(" **Library, home & discovery** "), "library-home-discovery"); + assert_eq!(slugify("---"), ""); + } + + #[test] + fn release_dates_format_like_the_reference_and_fall_back_raw() { + assert_eq!(format_release_date("2026-07-04T18:22:01Z"), "Jul 4, 2026"); + assert_eq!(format_release_date("2026-12-31T00:00:00+02:00"), "Dec 31, 2026"); + assert_eq!(format_release_date("2026-01-09T00:00:00Z"), "Jan 9, 2026"); + // Malformed input is echoed verbatim, never blanked. + assert_eq!(format_release_date("not a date"), "not a date"); + assert_eq!(format_release_date("2026/07/04"), "2026/07/04"); + assert_eq!(format_release_date("2026-13-04T00:00:00Z"), "2026-13-04T00:00:00Z"); + assert_eq!(format_release_date(""), ""); + } + + #[test] + fn version_tags_normalize() { + assert_eq!(normalize_version_tag(" v2.0.2 "), "2.0.2"); + assert_eq!(normalize_version_tag("2.0.2"), "2.0.2"); + } + + #[test] + fn the_real_2_0_2_release_body_renders_the_shape_the_modal_expects() { + let (blocks, toc) = render_markdown(REAL_BODY); + assert!(!blocks.is_empty()); + + // The h1 title is the first block AND the first TOC entry. + assert_eq!(blocks[0].kind, KIND_SECTION); + assert_eq!(blocks[0].level, 0); + assert_eq!(blocks[0].text, "2.0.2 — Rebuild 破 (You Can (Not) Advance)"); + assert_eq!(toc[0].label, blocks[0].text); + + // Every `##` heading is a level-0 section and a TOC entry. + assert!(toc.iter().any(|e| e.label == "The headless daemon (qbzd)")); + assert!(toc.len() > 3, "toc = {toc:?}"); + + // The two-space bullets of TAG_DETAILS.md are level-1 bullets whose + // `**lead-in**` has been stripped. + let daemon_bullet = blocks + .iter() + .find(|b| b.text.starts_with("A player with no window")) + .expect("the daemon bullet"); + assert_eq!(daemon_bullet.kind, KIND_BULLET); + assert_eq!(daemon_bullet.level, 1); + assert!(!daemon_bullet.text.contains("**")); + + // The standalone wiki link is a clickable block, not a paragraph. + let wiki = blocks + .iter() + .find(|b| b.kind == KIND_LINK) + .expect("a link block"); + assert!(wiki.url.starts_with("https://"), "{wiki:?}"); + + // No block carries un-stripped inline markers. + assert!(blocks.iter().all(|b| !b.text.contains("**") && !b.text.contains('`'))); + // Nothing is a KIND_SECTION with a level above 1, and no bullet is + // level 0 (that combination is what the indent-0 rule prevents). + assert!(blocks.iter().all(|b| b.kind != KIND_BULLET || b.level >= 1)); + assert!(blocks.iter().all(|b| b.kind != KIND_SECTION || b.level <= 1)); + // Paragraphs exist (the intro prose, and the `---` separators the + // reference deliberately does not special-case). + assert!(blocks.iter().any(|b| b.kind == KIND_PARAGRAPH)); + } +} diff --git a/crates/qbz-ui/translations/de/LC_MESSAGES/qbz-ui.po b/crates/qbz-ui/translations/de/LC_MESSAGES/qbz-ui.po index f3109e966..9d1ca4043 100644 --- a/crates/qbz-ui/translations/de/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-ui/translations/de/LC_MESSAGES/qbz-ui.po @@ -5109,6 +5109,12 @@ msgstr "Desktopmodus" msgid "View cover" msgstr "Cover ansehen" +# ArtistView portrait menu / lightbox (Qt port, 2026-08-14) — the +# artist twin of "View cover". + +msgid "View image" +msgstr "Bild ansehen" + msgid "Could not load the image" msgstr "Das Bild konnte nicht geladen werden" diff --git a/crates/qbz-ui/translations/es/LC_MESSAGES/qbz-ui.po b/crates/qbz-ui/translations/es/LC_MESSAGES/qbz-ui.po index 42c9e4936..13185adda 100644 --- a/crates/qbz-ui/translations/es/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-ui/translations/es/LC_MESSAGES/qbz-ui.po @@ -5117,6 +5117,12 @@ msgstr "Modo escritorio" msgid "View cover" msgstr "Ver carátula" +# ArtistView portrait menu / lightbox (Qt port, 2026-08-14) — the +# artist twin of "View cover". + +msgid "View image" +msgstr "Ver imagen" + msgid "Could not load the image" msgstr "No se pudo cargar la imagen" diff --git a/crates/qbz-ui/translations/fr/LC_MESSAGES/qbz-ui.po b/crates/qbz-ui/translations/fr/LC_MESSAGES/qbz-ui.po index babf6557d..20f041439 100644 --- a/crates/qbz-ui/translations/fr/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-ui/translations/fr/LC_MESSAGES/qbz-ui.po @@ -5109,6 +5109,12 @@ msgstr "Mode bureau" msgid "View cover" msgstr "Voir la pochette" +# ArtistView portrait menu / lightbox (Qt port, 2026-08-14) — the +# artist twin of "View cover". + +msgid "View image" +msgstr "Voir l'image" + msgid "Could not load the image" msgstr "Impossible de charger l'image" diff --git a/crates/qbz-ui/translations/ja/LC_MESSAGES/qbz-ui.po b/crates/qbz-ui/translations/ja/LC_MESSAGES/qbz-ui.po index 0345ff732..be36f0667 100644 --- a/crates/qbz-ui/translations/ja/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-ui/translations/ja/LC_MESSAGES/qbz-ui.po @@ -5094,6 +5094,12 @@ msgstr "デスクトップモード" msgid "View cover" msgstr "カバーを表示" +# ArtistView portrait menu / lightbox (Qt port, 2026-08-14) — the +# artist twin of "View cover". + +msgid "View image" +msgstr "画像を表示" + msgid "Could not load the image" msgstr "画像を読み込めませんでした" diff --git a/crates/qbz-ui/translations/nl/LC_MESSAGES/qbz-ui.po b/crates/qbz-ui/translations/nl/LC_MESSAGES/qbz-ui.po index 0c35d9041..ebb86c8ef 100644 --- a/crates/qbz-ui/translations/nl/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-ui/translations/nl/LC_MESSAGES/qbz-ui.po @@ -5099,6 +5099,12 @@ msgstr "Bureaubladmodus" msgid "View cover" msgstr "Hoes bekijken" +# ArtistView portrait menu / lightbox (Qt port, 2026-08-14) — the +# artist twin of "View cover". + +msgid "View image" +msgstr "Afbeelding bekijken" + msgid "Could not load the image" msgstr "De afbeelding kon niet worden geladen" diff --git a/crates/qbz-ui/translations/pt/LC_MESSAGES/qbz-ui.po b/crates/qbz-ui/translations/pt/LC_MESSAGES/qbz-ui.po index b1326849b..fbcbb42ff 100644 --- a/crates/qbz-ui/translations/pt/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-ui/translations/pt/LC_MESSAGES/qbz-ui.po @@ -5109,6 +5109,12 @@ msgstr "Modo desktop" msgid "View cover" msgstr "Ver capa" +# ArtistView portrait menu / lightbox (Qt port, 2026-08-14) — the +# artist twin of "View cover". + +msgid "View image" +msgstr "Ver imagem" + msgid "Could not load the image" msgstr "Não foi possível carregar a imagem" diff --git a/crates/qbz-ui/translations/ru/LC_MESSAGES/qbz-ui.po b/crates/qbz-ui/translations/ru/LC_MESSAGES/qbz-ui.po index e9605b030..8deb94d2a 100644 --- a/crates/qbz-ui/translations/ru/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-ui/translations/ru/LC_MESSAGES/qbz-ui.po @@ -5126,6 +5126,12 @@ msgstr "Режим рабочего стола" msgid "View cover" msgstr "Показать обложку" +# ArtistView portrait menu / lightbox (Qt port, 2026-08-14) — the +# artist twin of "View cover". + +msgid "View image" +msgstr "Показать изображение" + msgid "Could not load the image" msgstr "Не удалось загрузить изображение"