From d39c7f824b4b302e92d87fdf758da88955d6431f Mon Sep 17 00:00:00 2001 From: Jeena Date: Fri, 27 Mar 2026 23:35:39 +0000 Subject: [PATCH] window: preserve reading position across server reloads After a server refresh the article list is rebuilt, which previously always reloaded the WebView and reset the scroll position. - Disable SingleSelection autoselect so store rebuilds don't trigger spurious selection changes. - Skip the WebView reload in on_article_selected when the same article is re-selected, preserving the user's scroll position. - When the previously-read article is no longer in the server response (read on another device), leave the sidebar unselected and keep the old article visible so the user can finish reading. - Handle no-selection state in navigate_by() so j/k still work. - Show a toast with the unread count after every successful fetch. --- src/window.rs | 82 ++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 58 insertions(+), 24 deletions(-) diff --git a/src/window.rs b/src/window.rs index f54e6de..ea1efac 100644 --- a/src/window.rs +++ b/src/window.rs @@ -206,6 +206,8 @@ pub mod imp { fn setup_list(&self) { let store = gio::ListStore::new::(); let selection = gtk4::SingleSelection::new(Some(store.clone())); + selection.set_autoselect(false); + selection.set_can_unselect(true); *self.article_store.borrow_mut() = Some(store); let factory = gtk4::SignalListItemFactory::new(); @@ -255,12 +257,16 @@ pub mod imp { *self.mark_unread_guard.borrow_mut() = false; let article = obj.article().clone(); + let same_article = self.current_article_id.borrow().as_deref() == Some(&*article.id); *self.current_article_id.borrow_mut() = Some(article.id.clone()); self.article_menu_button.set_visible(true); - // Load in webview - self.load_article_in_webview(&article); + // Skip WebView reload when re-selecting the same article (e.g. after + // a server refresh) so the user's scroll position is preserved. + if !same_article { + self.load_article_in_webview(&article); + } obj.set_unread(false); } @@ -715,39 +721,64 @@ pub mod imp { store.append(&ArticleObject::new(a.clone())); } - // Save cache and clean up unreferenced images. - let sel_id = saved_id.as_deref().unwrap_or(""); - crate::cache::save(&articles, sel_id); crate::image_cache::cleanup(&articles); - if store.n_items() == 0 { + let n = store.n_items(); + if n == 0 { imp.sidebar_content.set_visible_child_name("empty"); *imp.current_article_id.borrow_mut() = None; imp.article_menu_button.set_visible(false); imp.content_stack.set_visible_child_name("empty"); + crate::cache::save(&articles, ""); } else { - // Try to re-select the same article; fall back to first. - let mut select_idx = 0u32; - if let Some(ref id) = saved_id { - for i in 0..store.n_items() { - if store.item(i).and_downcast::() + // Try to re-select the same article the user was reading. + let found_idx = saved_id.as_ref().and_then(|id| { + (0..n).find(|&i| { + store.item(i).and_downcast::() .map(|o| o.article().id == *id) .unwrap_or(false) - { - select_idx = i; - break; - } - } + }) + }); + + if let Some(idx) = found_idx { + // Article still unread — re-select it without + // reloading the WebView (preserves scroll position). + *imp.mark_unread_guard.borrow_mut() = true; + sel.set_selected(idx); + imp.article_list_view.scroll_to( + idx, + gtk4::ListScrollFlags::SELECT, + None, + ); + crate::cache::save(&articles, + saved_id.as_deref().unwrap_or("")); + } else if saved_id.is_some() { + // Article was read elsewhere — keep it in the + // WebView so the user can finish reading but + // leave the sidebar list unselected. + crate::cache::save(&articles, ""); + } else { + // No previous article (first load) — select the + // first article. + sel.set_selected(0); + imp.article_list_view.scroll_to( + 0, + gtk4::ListScrollFlags::SELECT, + None, + ); + crate::cache::save(&articles, ""); } - *imp.mark_unread_guard.borrow_mut() = true; - sel.set_selected(select_idx); - imp.article_list_view.scroll_to( - select_idx, - gtk4::ListScrollFlags::SELECT, - None, - ); imp.sidebar_content.set_visible_child_name("list"); } + + // Always notify the user that the fetch finished. + let msg = if n == 1 { + String::from("1 unread article") + } else { + format!("{n} unread articles") + }; + let toast = libadwaita::Toast::new(&msg); + imp.toast_overlay.add_toast(toast); } Err(e) => { // If we already have cached articles, just show a toast. @@ -846,7 +877,10 @@ pub mod imp { let n = sel.n_items(); if n == 0 { return } let current = sel.selected(); - let next = if delta > 0 { + let next = if current == gtk4::INVALID_LIST_POSITION { + // Nothing selected — pick the first or last article. + if delta > 0 { 0 } else { n - 1 } + } else if delta > 0 { (current + 1).min(n - 1) } else { current.saturating_sub(1)