diff --git a/crates/qbz-qt/qml/settings/AppearanceSettings.qml b/crates/qbz-qt/qml/settings/AppearanceSettings.qml index 84f85c8d1..d6ada3960 100644 --- a/crates/qbz-qt/qml/settings/AppearanceSettings.qml +++ b/crates/qbz-qt/qml/settings/AppearanceSettings.qml @@ -309,7 +309,7 @@ Column { } SettingRow { label: QbzSession.tr("Click on menu item navigates to first tab", QbzSession.trRev) - description: QbzSession.tr("Clicking a section in the sidebar or title bar opens it on its first tab — Discover on Home, Library on All — instead of only showing its menu", QbzSession.trRev) + description: QbzSession.tr("Clicking a section in the sidebar or title bar opens its first tab — including your chosen Local Library default — instead of only showing its menu", QbzSession.trRev) QbzToggle { checked: root.doc.navClickFirstTab === true onToggled: function (v) { QbzBridge.settingsBool("nav-click-first-tab", v) } diff --git a/crates/qbz-qt/src/nav_qt.rs b/crates/qbz-qt/src/nav_qt.rs index 84013a658..6aedd195b 100644 --- a/crates/qbz-qt/src/nav_qt.rs +++ b/crates/qbz-qt/src/nav_qt.rs @@ -78,6 +78,10 @@ struct Entry { /// `viewport-y`; nothing crosses between the two, so this side keeps the /// sign its own Flickables use.) scroll: f32, + /// Opaque view-owned state (tab, query, sort, filters, view mode). Like the + /// scroll scope, Rust stores and returns it without interpreting it. + state_scope: String, + state_json: String, } struct NavHistory { @@ -98,6 +102,7 @@ static HISTORY: Mutex> = Mutex::new(None); /// reused in place rather than reassigned, so a scroll frame allocates nothing /// once the page has reported once. static LIVE: Mutex<(String, f32)> = Mutex::new((String::new(), 0.0)); +static LIVE_STATE: Mutex<(String, String)> = Mutex::new((String::new(), String::new())); /// Record the on-screen scroll container's current `contentY` under its scope. pub fn set_live_scroll(scope: &str, y: f32) { @@ -120,6 +125,25 @@ fn reset_live_scroll() { live.1 = 0.0; } +pub fn set_live_state(scope: &str, json: &str) { + let mut live = LIVE_STATE.lock().unwrap(); + live.0.clear(); + live.0.push_str(scope); + live.1.clear(); + live.1.push_str(json); +} + +fn live_state() -> (String, String) { + let live = LIVE_STATE.lock().unwrap(); + (live.0.clone(), live.1.clone()) +} + +fn reset_live_state() { + let mut live = LIVE_STATE.lock().unwrap(); + live.0.clear(); + live.1.clear(); +} + fn with_history(f: impl FnOnce(&mut NavHistory) -> R) -> R { let mut guard = HISTORY.lock().unwrap(); let history = guard.get_or_insert_with(|| NavHistory { @@ -130,6 +154,8 @@ fn with_history(f: impl FnOnce(&mut NavHistory) -> R) -> R { view: startup_view(), scope: String::new(), scroll: 0.0, + state_scope: String::new(), + state_json: String::new(), }], index: 0, }); @@ -176,7 +202,10 @@ const VIEW_TO_PREF: &[(&str, &str)] = &[ ]; fn pref_for_view(view: &str) -> Option<&'static str> { - VIEW_TO_PREF.iter().find(|(v, _)| *v == view).map(|(_, p)| *p) + VIEW_TO_PREF + .iter() + .find(|(v, _)| *v == view) + .map(|(_, p)| *p) } fn view_for_pref(pref: &str) -> Option<&'static str> { @@ -187,7 +216,11 @@ fn view_for_pref(pref: &str) -> Option<&'static str> { // The reference's `discover` has no separate route here: this port's // Discover IS `home`. Accept it on READ so a prefs file written by the // Slint build restores somewhere sensible instead of falling to Home. - .or(if pref == "discover" { Some("home") } else { None }) + .or(if pref == "discover" { + Some("home") + } else { + None + }) } /// Crash-chain guard for the restore paths. @@ -241,7 +274,9 @@ pub fn mark_startup_healthy() { /// This boot's chain level (1 = the previous shutdown was clean). pub fn crash_level() -> u8 { - CRASH_LEVEL.load(std::sync::atomic::Ordering::Relaxed).max(1) + CRASH_LEVEL + .load(std::sync::atomic::Ordering::Relaxed) + .max(1) } /// The view to open on: `startup_page` = "home" always Home, "remember" the last @@ -304,11 +339,16 @@ pub fn record(view: &str) { ); h.entries[h.index].scope = scope; h.entries[h.index].scroll = y; + let (state_scope, state_json) = live_state(); + h.entries[h.index].state_scope = state_scope; + h.entries[h.index].state_json = state_json; h.entries.truncate(h.index + 1); h.entries.push(Entry { view: view.to_string(), scope: String::new(), scroll: 0.0, + state_scope: String::new(), + state_json: String::new(), }); h.index += 1; pushed = true; @@ -320,12 +360,21 @@ pub fn record(view: &str) { // the user moves it. if pushed { reset_live_scroll(); + reset_live_state(); } // A FORWARD navigation never restores — and it must actively DISARM, or a // scope armed by a back() that its destination never consumed (a page // whose list stayed shorter than the viewport, so the guard never passed) // would fire on whatever mounts next. - publish(can_back, can_forward, current, String::new(), 0.0); + publish( + can_back, + can_forward, + current, + String::new(), + 0.0, + String::new(), + String::new(), + ); } /// The id of the entry the user is standing on. @@ -355,23 +404,36 @@ pub fn forward() { /// move the cursor, and hand the destination's saved position to the shell so /// its scroll container can pick it up when it lays out. fn step(delta: isize) { - let (can_back, can_forward, current, scope, scroll) = with_history(|h| { - let next = h.index as isize + delta; - if next >= 0 && (next as usize) < h.entries.len() { - // Stamp the page we are leaving before stepping away. - let (scope, y) = live_scroll(); - h.entries[h.index].scope = scope; - h.entries[h.index].scroll = y; - h.index = next as usize; - } - let (b, f, c) = snapshot(h); - let e = &h.entries[h.index]; - (b, f, c, e.scope.clone(), e.scroll) - }); + let (can_back, can_forward, current, scope, scroll, state_scope, state_json) = + with_history(|h| { + let next = h.index as isize + delta; + if next >= 0 && (next as usize) < h.entries.len() { + // Stamp the page we are leaving before stepping away. + let (scope, y) = live_scroll(); + h.entries[h.index].scope = scope; + h.entries[h.index].scroll = y; + let (state_scope, state_json) = live_state(); + h.entries[h.index].state_scope = state_scope; + h.entries[h.index].state_json = state_json; + h.index = next as usize; + } + let (b, f, c) = snapshot(h); + let e = &h.entries[h.index]; + ( + b, + f, + c, + e.scope.clone(), + e.scroll, + e.state_scope.clone(), + e.state_json.clone(), + ) + }); // The destination is now the live page: seed the live pair with what it is // about to restore to, so leaving it again before it has reported anything // does not stamp the OUTGOING page's offset onto it. set_live_scroll(&scope, scroll); + set_live_state(&state_scope, &state_json); // Arm only for a position worth restoring. A page that was at the top // needs no restore, and arming for 0 would leave a scope standing until // some container happened to clear it. @@ -381,7 +443,15 @@ fn step(delta: isize) { String::new() }; log::debug!("[qbz-qt] scroll: {current:?} arms scope {armed:?} y={scroll}"); - publish(can_back, can_forward, current, armed, scroll); + publish( + can_back, + can_forward, + current, + armed, + scroll, + state_scope, + state_json, + ); } fn snapshot(h: &NavHistory) -> (bool, bool, String) { @@ -403,6 +473,8 @@ fn publish( current: String, restore_scope: String, scroll_restore: f32, + restore_state_scope: String, + state_restore: String, ) { crate::shell_bridge::ui(move |mut b| { b.as_mut().set_can_back(can_back); @@ -410,19 +482,34 @@ fn publish( b.as_mut() .set_restore_scope(QString::from(restore_scope.as_str())); b.as_mut().set_scroll_restore(scroll_restore); + b.as_mut() + .set_restore_state_scope(QString::from(restore_state_scope.as_str())); + b.as_mut() + .set_state_restore(QString::from(state_restore.as_str())); b.as_mut().set_current_view(QString::from(current.as_str())); }); } #[cfg(test)] mod tests { - // The history logic is exercised through the Mutex global; these tests - // run serially within one process, so drive it through the public API - // and re-derive expectations from fresh state each time. (The bridge - // publish hop is a no-op off the Qt thread — QT_THREAD unset.) + use std::sync::Mutex; + + // Rust's test runner is parallel by default. These tests intentionally + // exercise the process-global history, so serialize them and clear every + // global seam first. (The bridge publish hop is a no-op off the Qt thread + // — QT_THREAD unset.) + static TEST_LOCK: Mutex<()> = Mutex::new(()); + + fn reset() { + *super::HISTORY.lock().unwrap() = None; + super::reset_live_scroll(); + super::reset_live_state(); + } #[test] fn record_back_forward_cycle() { + let _guard = TEST_LOCK.lock().unwrap(); + reset(); super::record("home"); super::record("album"); super::record("artist"); @@ -443,6 +530,8 @@ mod tests { /// does not inherit it. #[test] fn scroll_is_stamped_on_leave_and_returned_on_back() { + let _guard = TEST_LOCK.lock().unwrap(); + reset(); super::record("home"); super::set_live_scroll("home:home", 1234.0); super::record("album"); @@ -458,4 +547,32 @@ mod tests { assert_eq!(super::current_view(), "album"); assert_eq!(super::live_scroll(), ("album".to_string(), 88.0)); } + + #[test] + fn opaque_view_state_is_stamped_and_restored_in_both_directions() { + let _guard = TEST_LOCK.lock().unwrap(); + reset(); + super::record("local"); + let local = r#"{"activeTab":"albums","albumsSearch":"beatles","albumsSort":"year-desc"}"#; + super::set_live_state("local", local); + + super::record("album"); + assert_eq!(super::live_state(), (String::new(), String::new())); + let detail = r#"{"version":2}"#; + super::set_live_state("localalbum", detail); + + super::back(); + assert_eq!(super::current_view(), "local"); + assert_eq!( + super::live_state(), + ("local".to_string(), local.to_string()) + ); + + super::forward(); + assert_eq!(super::current_view(), "album"); + assert_eq!( + super::live_state(), + ("localalbum".to_string(), detail.to_string()) + ); + } } diff --git a/crates/qbz-qt/src/shell_bridge.rs b/crates/qbz-qt/src/shell_bridge.rs index aca7979d0..6fca2b707 100644 --- a/crates/qbz-qt/src/shell_bridge.rs +++ b/crates/qbz-qt/src/shell_bridge.rs @@ -30,7 +30,6 @@ pub mod qbz_shell { // (1:1 Slint: persist-sidebar-state). #[qproperty(i32, sidebar_state)] #[qproperty(bool, queue_open)] - // --- Section-nav placement (Slint ShellState) --------------------- // ON = the Discover / Library / Local Library / My QBZ rows live in // the SIDEBAR (with a hairline under them); the header shows the @@ -66,6 +65,10 @@ pub mod qbz_shell { // Slint uses (`NavState.restore-scope = ""` at the apply site). #[qproperty(QString, restore_scope)] #[qproperty(f32, scroll_restore)] + // Opaque view-state companion: a routed view reports one JSON + // snapshot and consumes it on back/forward before it mounts data. + #[qproperty(QString, restore_state_scope)] + #[qproperty(QString, state_restore)] // --- Flyout landing tab (NavFlyout rows carry view + tab) ---------- // The tab a nav-flyout click must land on: Discover > For You is // nav_tab_view="home" + nav_tab="forYou". Written by @@ -76,11 +79,9 @@ pub mod qbz_shell { #[qproperty(QString, nav_tab)] #[qproperty(QString, nav_tab_view)] #[qproperty(i32, nav_tab_seq)] - // Now-playing bar mode (phase 18): 0 New / 1 Classic / 2 Small / // 3 Large — the ui_prefs npb_mode key, live-switchable. #[qproperty(i32, npb_mode)] - // One persisted toggle shared by Appearance, the view-mode flyout and // both seekbar mounts. #[qproperty(bool, seekbar_waveform)] @@ -98,7 +99,6 @@ pub mod qbz_shell { // `height - largeDockHeight`. Both consumers read THIS — never a // literal (Sidebar.slint:1145 is the same contract). #[qproperty(f32, large_dock_height)] - // --- Theme (phase 19) -------------------------------------------- // ONE JSON token document (theme_qt.rs ThemeTokens: 30 colors + 24 // alpha tiers + ambient derivations + isDark) — QbzTheme.qml binds @@ -136,7 +136,6 @@ pub mod qbz_shell { // 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 @@ -147,13 +146,11 @@ pub mod qbz_shell { /// the pre-publish frame. #[qproperty(QString, diagnostics_json)] #[qproperty(QString, log_viewer_json)] - // --- Lyrics panel (phase 9) ---------------------------------------- #[qproperty(bool, lyrics_open)] // One JSON document (lyrics_qt.rs LyricsDoc: status/lines/synced/ // provider/error). #[qproperty(QString, lyrics_json)] - // --- Sidebar playlist tree --------------------------------------- // One JSON document: the flattened entries (folders + playlists, // expand/sort/search applied Rust-side — sidebar_qt.rs). @@ -168,7 +165,6 @@ pub mod qbz_shell { // reference does not have. The default is the FULL SHAPE, never "{}": // `JSON.parse("{}").rows.length` throws in the pre-publish frame. #[qproperty(QString, sidebar_folder_popup_json)] - // --- Window chrome (phase 12) -------------------------------------- // The APPLIED titlebar mode (the ui_prefs `use_system_title_bar` // value read at startup — drives the window flags; never mutated at @@ -210,7 +206,6 @@ pub mod qbz_shell { // `cfg!`, and AppearanceSettings.slint:1011-1043 gates the whole // group AND the Preferred-GPU row on it). #[qproperty(bool, is_linux)] - // --- Main-window geometry ----------------------------------------- // The restored LOGICAL size + maximized flag from the shared // ui_prefs.json (settings_qt::window_size / window_maximized; the @@ -227,7 +222,6 @@ pub mod qbz_shell { // save gate that use it in settings_qt. #[qproperty(f32, window_min_width)] #[qproperty(f32, window_min_height)] - // --- Ambient background (phase 14) -------------------------------- // 0 = off, 1 = on (the ambient look; the owner's store carries // "ambient"). Live — toggling applies immediately (pure QML @@ -352,7 +346,6 @@ pub mod qbz_shell { #[qproperty(i32, pulse_ms)] #[qproperty(f32, ambient_surface_alpha)] #[qproperty(f32, ambient_bar_alpha)] - // --- Rounded-cover render arm (manual override) -------------------- // `RoundedImage` masks covers with a MultiEffect layer and falls back // to its CPU Canvas raster automatically when `GraphicsInfo.api` @@ -363,7 +356,6 @@ pub mod qbz_shell { // instantiate lazily and the first access is a `RoundedImage` // `_useCanvas` read, which can precede `QbzShell.boot()`. #[qproperty(bool, force_canvas_art)] - // --- In-app toasts (shared) --------------------------------------- // ONE JSON document (toast_qt.rs ToastDoc: seq / kind / message / // persistent), rendered by the single `controls/QbzToast.qml` host @@ -377,7 +369,6 @@ pub mod qbz_shell { // overlay. Rust owns no timer — the auto-hide delay is keyed off `seq` // in QML, so a toast never needs a hide round-trip. #[qproperty(QString, toast_json)] - // --- Drag & drop (phase 17) ---------------------------------------- // The shared drag state (DragState in state.slint): the ghost reads // count/title/subtitle + the window-coord pointer; sidebar playlist @@ -425,7 +416,6 @@ pub mod qbz_shell { #[qproperty(bool, art_preview_show)] #[qproperty(f32, art_preview_x)] #[qproperty(f32, art_preview_y)] - // --- Multi-select seam (2026-08-03 hotkeys-port contract §4.6) ---- // QML-REPORTED — selection state is in-view QML JS // (`library_bulk.rs:8`: "select-all / clear never reach Rust"), so the @@ -439,7 +429,6 @@ pub mod qbz_shell { // properties. #[qproperty(bool, multi_select_active, READ, WRITE = set_multi_select_active, NOTIFY)] #[qproperty(bool, multi_select_capable, READ, WRITE = set_multi_select_capable, NOTIFY)] - type QbzShell = super::QbzShellRust; /// Registers this object's Qt-thread hop (Main.qml boots EVERY @@ -499,6 +488,8 @@ pub mod qbz_shell { /// from the route here the way Slint derives it from the entry.) #[qinvokable] fn report_scroll(self: Pin<&mut QbzShell>, scope: QString, y: f32); + #[qinvokable] + fn report_nav_state(self: Pin<&mut QbzShell>, scope: QString, state: QString); /// Sidebar navigation: record a content view ("home" | "library") /// and lazy-load its data on first visit. @@ -686,7 +677,14 @@ pub mod qbz_shell { // --- Drag & drop (phase 17) ---------------------------------------- /// Track-row press-drag start (row id, ghost texts, window coords). #[qinvokable] - fn drag_start(self: Pin<&mut QbzShell>, track_id: QString, title: QString, subtitle: QString, x: f32, y: f32); + fn drag_start( + self: Pin<&mut QbzShell>, + track_id: QString, + title: QString, + subtitle: QString, + x: f32, + y: f32, + ); #[qinvokable] fn drag_move(self: Pin<&mut QbzShell>, x: f32, y: f32); /// A sidebar playlist row claims / releases the drop target. @@ -753,6 +751,8 @@ pub struct QbzShellRust { can_forward: bool, restore_scope: QString, scroll_restore: f32, + restore_state_scope: QString, + state_restore: QString, nav_tab: QString, nav_tab_view: QString, nav_tab_seq: i32, @@ -848,6 +848,8 @@ impl Default for QbzShellRust { can_back: false, restore_scope: QString::default(), scroll_restore: 0.0, + restore_state_scope: QString::default(), + state_restore: QString::default(), nav_tab: QString::default(), nav_tab_view: QString::default(), nav_tab_seq: 0, @@ -861,13 +863,9 @@ 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(), - app_font_family: QString::from( - crate::settings_qt::app_font_family().as_str(), - ), + app_font_family: QString::from(crate::settings_qt::app_font_family().as_str()), 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(), - ), + diagnostics_json: QString::from(crate::diagnostics_qt::empty_doc_json().as_str()), log_viewer_json: QString::from("{}"), lyrics_open: false, lyrics_json: QString::from("{}"), @@ -1164,6 +1162,10 @@ impl qbz_shell::QbzShell { crate::nav_qt::set_live_scroll(&scope.to_string(), y); } + pub fn report_nav_state(self: Pin<&mut Self>, scope: QString, state: QString) { + crate::nav_qt::set_live_state(&scope.to_string(), &state.to_string()); + } + pub fn navigate_to(self: Pin<&mut Self>, view: QString) { crate::navigate_to(&view.to_string()); } @@ -1229,7 +1231,9 @@ impl qbz_shell::QbzShell { 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") { + 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}"); } } @@ -1314,8 +1318,21 @@ impl qbz_shell::QbzShell { crate::sidebar_artwork_window(urls_json.to_string()); } - pub fn drag_start(self: Pin<&mut Self>, track_id: QString, title: QString, subtitle: QString, x: f32, y: f32) { - crate::drag_start(track_id.to_string(), title.to_string(), subtitle.to_string(), x, y); + pub fn drag_start( + self: Pin<&mut Self>, + track_id: QString, + title: QString, + subtitle: QString, + x: f32, + y: f32, + ) { + crate::drag_start( + track_id.to_string(), + title.to_string(), + subtitle.to_string(), + x, + y, + ); } pub fn drag_move(self: Pin<&mut Self>, x: f32, y: f32) { @@ -1375,5 +1392,4 @@ impl qbz_shell::QbzShell { log::warn!("[qbz-qt] failed to open '{url}': {e}"); } } - }