fix(navigation): restore routed view state

Persist view-owned search, sort, filter and tab state alongside scroll positions across back and forward navigation.

cx
This commit is contained in:
vicrodh committed 2026-08-25 18:24:35 -06:00
1 parent e0dc288659
commit 52fd887f15
3 files changed
+182 -49

No files matched your search

@@ -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) }
+139 -22
View File
@@ -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<Option<NavHistory>> = 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<R>(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<R>(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())
);
}
}
+42 -26
View File
@@ -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}");
}
}
}