feat(slint): scroll-position restore on back/forward (Local Library)

Scroll-position restore was never implemented (only a TODO in nav.rs);
back/forward tracked which page was visited, not where it was scrolled.

Add per-history-entry scroll memory: the mounted scroll container reports
its live viewport-y via NavState.report-scroll, so every navigation (fresh
record + back/forward) stamps the outgoing entry without touching the ~30
record call sites. go_back/go_forward hand the saved scroll back to the
shell, which arms NavState.restore-scope + scroll-restore before switching
the view. A container restores only when its scope matches, once per mount,
and only after content has laid out (viewport overflows) to dodge the
clamp-to-0 on async data load; it clears the scope once consumed.

Wired the four Local Library lists (albums, tracks, folders, artists rail).
Tracks is paginated, so its restore is best-effort within loaded pages.

cc
This commit is contained in:
vicrodh committed 2026-06-08 15:38:35 -06:00
1 parent b37c29af14
commit 641aa22c17
4 files changed
+243 -36

No files matched your search

+39 -2
View File
@@ -663,6 +663,34 @@ fn navigate_search(
}
/// Apply a history entry — set the view and re-load entity pages.
/// Stable scroll-restore id for an entry's primary list container, matching
/// the `scope` strings the Slint scroll containers compare against. Returns
/// `""` for views without a wired scroll memory (no container will match, so
/// nothing restores). Keep in sync with the `restore-scope` checks in the
/// `.slint` views.
fn scope_for(entry: &nav::NavEntry) -> &'static str {
match entry {
nav::NavEntry::LocalLibrary { tab } => match tab.as_str() {
"albums" => "ll:albums",
"tracks" => "ll:tracks",
"folders" => "ll:folders",
"artists" => "ll:artists",
_ => "",
},
_ => "",
}
}
/// Arm `NavState` so the destination scroll container restores its saved
/// position once it mounts. Must run before `apply_entry` switches the view.
fn arm_scroll_restore(weak: &slint::Weak<AppWindow>, entry: &nav::NavEntry, scroll: f32) {
if let Some(w) = weak.upgrade() {
let ns = w.global::<NavState>();
ns.set_restore_scope(scope_for(entry).into());
ns.set_scroll_restore(scroll);
}
}
fn apply_entry(
entry: nav::NavEntry,
runtime: &Arc<AppRuntime<SlintAdapter>>,
@@ -2156,7 +2184,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let handle = tokio_rt.handle().clone();
let image_cache = image_cache.clone();
window.global::<NavState>().on_request_back(move || {
if let Some(entry) = nav::go_back() {
if let Some((entry, scroll)) = nav::go_back() {
arm_scroll_restore(&weak, &entry, scroll);
apply_entry(entry, &runtime, &weak, &handle, &image_cache);
}
if let Some(w) = weak.upgrade() {
@@ -2170,7 +2199,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let handle = tokio_rt.handle().clone();
let image_cache = image_cache.clone();
window.global::<NavState>().on_request_forward(move || {
if let Some(entry) = nav::go_forward() {
if let Some((entry, scroll)) = nav::go_forward() {
arm_scroll_restore(&weak, &entry, scroll);
apply_entry(entry, &runtime, &weak, &handle, &image_cache);
}
if let Some(w) = weak.upgrade() {
@@ -2178,6 +2208,13 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
}
});
}
{
// The mounted scroll container reports its live viewport-y here so the
// nav module can stamp the outgoing entry on the next navigation.
window
.global::<NavState>()
.on_report_scroll(|y| nav::set_live_scroll(y));
}
// Log out: clear the session and return to the login screen.
{
+134 -33
View File
@@ -4,10 +4,16 @@
//! `[<] [>]` button pair (and the mouse back/forward buttons) walk the
//! stack. UI thread only, hence `thread_local`.
//!
//! Scroll-position restoration per entry is a planned follow-up; for now
//! the stack tracks which page was visited, not where it was scrolled.
//! Scroll-position restoration: each entry remembers the viewport-y of the
//! scroll container that was showing it. The mounted view continuously
//! reports its live scroll via [`set_live_scroll`] (a NavState callback), so
//! every navigation — fresh [`record`] or [`go_back`]/[`go_forward`] — can
//! stamp the outgoing entry without touching the ~30 `record` call sites.
//! `go_back`/`go_forward` hand the restored scroll back to the shell, which
//! arms `NavState.restore-scope` + `scroll-restore`; the destination's scroll
//! container picks it up once its content has laid out.
use std::cell::RefCell;
use std::cell::{Cell, RefCell};
/// One navigable destination.
#[derive(Clone, Debug, PartialEq)]
@@ -94,17 +100,41 @@ pub enum NavEntry {
},
}
/// One slot in the history stack: where we went, and how far it was
/// scrolled when we last left it.
#[derive(Clone, Debug)]
struct Entry {
nav: NavEntry,
/// Saved Flickable `viewport-y` (logical px; 0 at top, negative when
/// scrolled down — Slint's convention).
scroll: f32,
}
struct History {
entries: Vec<NavEntry>,
entries: Vec<Entry>,
/// Index of the entry currently shown.
cursor: usize,
}
thread_local! {
static HISTORY: RefCell<History> = RefCell::new(History {
entries: vec![NavEntry::Home],
entries: vec![Entry { nav: NavEntry::Home, scroll: 0.0 }],
cursor: 0,
});
/// Live `viewport-y` of the scroll container currently on screen, kept
/// fresh by the mounted view via [`set_live_scroll`]. Read when leaving
/// a page so its entry can be stamped without per-call-site plumbing.
static LIVE_SCROLL: Cell<f32> = const { Cell::new(0.0) };
}
/// Record the on-screen scroll container's current `viewport-y`. Wired to
/// `NavState.report-scroll`, fired from the view's `changed viewport-y`.
pub fn set_live_scroll(y: f32) {
LIVE_SCROLL.with(|s| s.set(y));
}
fn live_scroll() -> f32 {
LIVE_SCROLL.with(|s| s.get())
}
/// Push a Search history entry, OR replace the cursor entry in place
@@ -114,14 +144,25 @@ thread_local! {
pub fn push_or_replace_search(query: String) {
HISTORY.with(|h| {
let h = &mut *h.borrow_mut();
match h.entries.get(h.cursor) {
match h.entries.get(h.cursor).map(|e| &e.nav) {
Some(NavEntry::Search(_)) => {
// Replace in place: same Search page, keep its scroll.
let scroll = h.entries[h.cursor].scroll;
h.entries.truncate(h.cursor + 1);
h.entries[h.cursor] = NavEntry::Search(query);
h.entries[h.cursor] = Entry {
nav: NavEntry::Search(query),
scroll,
};
}
_ => {
if let Some(cur) = h.entries.get_mut(h.cursor) {
cur.scroll = live_scroll();
}
h.entries.truncate(h.cursor + 1);
h.entries.push(NavEntry::Search(query));
h.entries.push(Entry {
nav: NavEntry::Search(query),
scroll: 0.0,
});
h.cursor = h.entries.len() - 1;
}
}
@@ -132,41 +173,69 @@ pub fn push_or_replace_search(query: String) {
/// no-op when the destination already is the current entry, so repeated
/// clicks on the same page do not pile up.
pub fn record(entry: NavEntry) {
HISTORY.with(|h| {
let pushed = HISTORY.with(|h| {
let h = &mut *h.borrow_mut();
if h.entries.get(h.cursor) == Some(&entry) {
return;
if h.entries.get(h.cursor).map(|e| &e.nav) == Some(&entry) {
return false;
}
// Stamp the page we are leaving with its live scroll position.
if let Some(cur) = h.entries.get_mut(h.cursor) {
cur.scroll = live_scroll();
}
h.entries.truncate(h.cursor + 1);
h.entries.push(entry);
h.entries.push(Entry {
nav: entry,
scroll: 0.0,
});
h.cursor = h.entries.len() - 1;
true
});
// A fresh page starts at the top; the new view will report its own
// scroll as the user moves it.
if pushed {
set_live_scroll(0.0);
}
}
/// Step back; returns the entry that is now current, or `None` at the
/// start of the stack.
pub fn go_back() -> Option<NavEntry> {
HISTORY.with(|h| {
/// Step back; returns the entry that is now current plus its saved scroll
/// position, or `None` at the start of the stack.
pub fn go_back() -> Option<(NavEntry, f32)> {
let res = HISTORY.with(|h| {
let h = &mut *h.borrow_mut();
if h.cursor == 0 {
return None;
}
// Stamp the page we are leaving before stepping away.
if let Some(cur) = h.entries.get_mut(h.cursor) {
cur.scroll = live_scroll();
}
h.cursor -= 1;
h.entries.get(h.cursor).cloned()
})
h.entries.get(h.cursor).map(|e| (e.nav.clone(), e.scroll))
});
if let Some((_, scroll)) = &res {
set_live_scroll(*scroll);
}
res
}
/// Step forward; returns the entry that is now current, or `None` at the
/// end of the stack.
pub fn go_forward() -> Option<NavEntry> {
HISTORY.with(|h| {
/// Step forward; returns the entry that is now current plus its saved scroll
/// position, or `None` at the end of the stack.
pub fn go_forward() -> Option<(NavEntry, f32)> {
let res = HISTORY.with(|h| {
let h = &mut *h.borrow_mut();
if h.cursor + 1 >= h.entries.len() {
return None;
}
if let Some(cur) = h.entries.get_mut(h.cursor) {
cur.scroll = live_scroll();
}
h.cursor += 1;
h.entries.get(h.cursor).cloned()
})
h.entries.get(h.cursor).map(|e| (e.nav.clone(), e.scroll))
});
if let Some((_, scroll)) = &res {
set_live_scroll(*scroll);
}
res
}
/// Whether a back step is available.
@@ -189,10 +258,20 @@ mod tests {
fn reset() {
HISTORY.with(|h| {
*h.borrow_mut() = History {
entries: vec![NavEntry::Home],
entries: vec![Entry {
nav: NavEntry::Home,
scroll: 0.0,
}],
cursor: 0,
};
});
set_live_scroll(0.0);
}
/// Drop the scroll component for the assertions that only care about the
/// destination page.
fn nav_of(res: Option<(NavEntry, f32)>) -> Option<NavEntry> {
res.map(|(e, _)| e)
}
#[test]
@@ -203,10 +282,10 @@ mod tests {
record(NavEntry::Artist("2".into()));
assert!(can_back());
assert!(!can_forward());
assert_eq!(go_back(), Some(NavEntry::Album("1".into())));
assert_eq!(go_back(), Some(NavEntry::Home));
assert_eq!(go_back(), None);
assert_eq!(go_forward(), Some(NavEntry::Album("1".into())));
assert_eq!(nav_of(go_back()), Some(NavEntry::Album("1".into())));
assert_eq!(nav_of(go_back()), Some(NavEntry::Home));
assert_eq!(nav_of(go_back()), None);
assert_eq!(nav_of(go_forward()), Some(NavEntry::Album("1".into())));
}
#[test]
@@ -217,7 +296,7 @@ mod tests {
go_back();
record(NavEntry::Artist("3".into()));
assert!(!can_forward());
assert_eq!(go_back(), Some(NavEntry::Album("1".into())));
assert_eq!(nav_of(go_back()), Some(NavEntry::Album("1".into())));
}
#[test]
@@ -225,8 +304,11 @@ mod tests {
reset();
record(NavEntry::Search("metallica".into()));
record(NavEntry::Album("5".into()));
assert_eq!(go_back(), Some(NavEntry::Search("metallica".into())));
assert_eq!(go_back(), Some(NavEntry::Home));
assert_eq!(
nav_of(go_back()),
Some(NavEntry::Search("metallica".into()))
);
assert_eq!(nav_of(go_back()), Some(NavEntry::Home));
}
#[test]
@@ -234,6 +316,25 @@ mod tests {
reset();
record(NavEntry::Album("1".into()));
record(NavEntry::Album("1".into()));
assert_eq!(go_back(), Some(NavEntry::Home));
assert_eq!(nav_of(go_back()), Some(NavEntry::Home));
}
#[test]
fn scroll_is_stamped_on_leave_and_restored_on_return() {
reset();
// On Home, scroll down a bit, then navigate away.
set_live_scroll(-420.0);
record(NavEntry::Album("1".into()));
// Fresh page starts at the top.
assert_eq!(live_scroll(), 0.0);
// Scroll the album page, then go back to Home.
set_live_scroll(-90.0);
let (entry, scroll) = go_back().expect("back to Home");
assert_eq!(entry, NavEntry::Home);
assert_eq!(scroll, -420.0);
// Going forward returns to the album at its saved scroll.
let (entry, scroll) = go_forward().expect("forward to album");
assert_eq!(entry, NavEntry::Album("1".into()));
assert_eq!(scroll, -90.0);
}
}
@@ -18,7 +18,7 @@
import { Theme } from "../foundation/semantic-colors.slint";
import { Typography } from "../foundation/typography.slint";
import { LocalLibraryState, LocalLibraryActions, FolderNode, FolderSubcardItem, LocalArtistItem, AlphaJump, LibAlbumFilterState, AppearanceState } from "../state.slint";
import { LocalLibraryState, LocalLibraryActions, FolderNode, FolderSubcardItem, LocalArtistItem, AlphaJump, LibAlbumFilterState, AppearanceState, NavState } from "../state.slint";
import { NavButtons } from "../shell/NavButtons.slint";
import { QbzIcon } from "../primitives/QbzIcon.slint";
import { CircleAction } from "../primitives/CircleAction.slint";
@@ -1163,6 +1163,21 @@ export component LocalLibraryView inherits Rectangle {
albums-flick := Flickable {
viewport-width: self.width;
viewport-height: albums-page.preferred-height;
// Scroll-position restore on back/forward (nav::scope_for → "ll:albums").
property <bool> restored: false;
function restore-scroll() {
if (!self.restored && NavState.restore-scope == "ll:albums"
&& self.viewport-height > self.height) {
self.viewport-y = NavState.scroll-restore;
self.restored = true;
// Consume the arm so a later fresh re-mount of this
// tab does not replay a stale position.
NavState.restore-scope = "";
}
}
init => { self.restore-scroll(); }
changed viewport-height => { self.restore-scroll(); }
changed viewport-y => { NavState.report-scroll(self.viewport-y); }
albums-page := VerticalLayout {
width: parent.width;
padding-left: 32px;
@@ -1325,7 +1340,22 @@ export component LocalLibraryView inherits Rectangle {
tracks-list := ListView {
vertical-stretch: 1;
vertical-scrollbar-policy: ScrollBarPolicy.always-off;
// Scroll-position restore on back/forward (nav::scope_for → "ll:tracks").
// Best-effort on this paginated list: restores when the
// saved position falls within already-loaded pages.
property <bool> restored: false;
function restore-scroll() {
if (!self.restored && NavState.restore-scope == "ll:tracks"
&& self.viewport-height > self.height) {
self.viewport-y = NavState.scroll-restore;
self.restored = true;
NavState.restore-scope = "";
}
}
init => { self.restore-scroll(); }
changed viewport-height => { self.restore-scroll(); }
changed viewport-y => {
NavState.report-scroll(self.viewport-y);
if (-self.viewport-y + self.visible-height >= self.viewport-height - 600px
&& LocalLibraryState.tracks-has-more
&& !LocalLibraryState.tracks-loading-more
@@ -1462,6 +1492,19 @@ export component LocalLibraryView inherits Rectangle {
folders-flick := Flickable {
viewport-width: self.width;
viewport-height: folders-page.preferred-height;
// Scroll-position restore on back/forward (nav::scope_for → "ll:folders").
property <bool> restored: false;
function restore-scroll() {
if (!self.restored && NavState.restore-scope == "ll:folders"
&& self.viewport-height > self.height) {
self.viewport-y = NavState.scroll-restore;
self.restored = true;
NavState.restore-scope = "";
}
}
init => { self.restore-scroll(); }
changed viewport-height => { self.restore-scroll(); }
changed viewport-y => { NavState.report-scroll(self.viewport-y); }
folders-page := VerticalLayout {
width: parent.width;
padding-left: 32px;
@@ -2114,6 +2157,19 @@ export component LocalLibraryView inherits Rectangle {
rail-flick := Flickable {
viewport-width: self.width;
viewport-height: rail-col.preferred-height;
// Scroll-position restore on back/forward (nav::scope_for → "ll:artists").
property <bool> restored: false;
function restore-scroll() {
if (!self.restored && NavState.restore-scope == "ll:artists"
&& self.viewport-height > self.height) {
self.viewport-y = NavState.scroll-restore;
self.restored = true;
NavState.restore-scope = "";
}
}
init => { self.restore-scroll(); }
changed viewport-height => { self.restore-scroll(); }
changed viewport-y => { NavState.report-scroll(self.viewport-y); }
rail-col := VerticalLayout {
width: parent.width;
padding-right: 22px;
+13
View File
@@ -1692,6 +1692,19 @@ export global NavState {
callback request-back();
callback request-forward();
callback request-settings();
// ---- Scroll-position restore (browser-like, per history entry) ----
// The mounted scroll container reports its live viewport-y here on every
// `changed viewport-y`; the Rust nav module stamps it onto the outgoing
// entry so back/forward can put it back.
callback report-scroll(length /* viewport-y */);
// Armed by Rust right before a back/forward view switch: `restore-scope`
// names which scroll container should restore (a stable per-list id, see
// nav::scope_for), and `scroll-restore` is the viewport-y to apply. A
// container restores only when its own scope matches, exactly once per
// mount, and only after its content has laid out (viewport overflows).
in-out property <string> restore-scope: "";
in-out property <length> scroll-restore: 0;
}
// Artist detail screen state.