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.
This commit is contained in:
Jeena 2026-03-27 23:35:39 +00:00
parent 07b41c7407
commit d39c7f824b

View file

@ -206,6 +206,8 @@ pub mod imp {
fn setup_list(&self) { fn setup_list(&self) {
let store = gio::ListStore::new::<ArticleObject>(); let store = gio::ListStore::new::<ArticleObject>();
let selection = gtk4::SingleSelection::new(Some(store.clone())); let selection = gtk4::SingleSelection::new(Some(store.clone()));
selection.set_autoselect(false);
selection.set_can_unselect(true);
*self.article_store.borrow_mut() = Some(store); *self.article_store.borrow_mut() = Some(store);
let factory = gtk4::SignalListItemFactory::new(); let factory = gtk4::SignalListItemFactory::new();
@ -255,12 +257,16 @@ pub mod imp {
*self.mark_unread_guard.borrow_mut() = false; *self.mark_unread_guard.borrow_mut() = false;
let article = obj.article().clone(); 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.current_article_id.borrow_mut() = Some(article.id.clone());
self.article_menu_button.set_visible(true); self.article_menu_button.set_visible(true);
// Load in webview // Skip WebView reload when re-selecting the same article (e.g. after
self.load_article_in_webview(&article); // a server refresh) so the user's scroll position is preserved.
if !same_article {
self.load_article_in_webview(&article);
}
obj.set_unread(false); obj.set_unread(false);
} }
@ -715,39 +721,64 @@ pub mod imp {
store.append(&ArticleObject::new(a.clone())); 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); 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.sidebar_content.set_visible_child_name("empty");
*imp.current_article_id.borrow_mut() = None; *imp.current_article_id.borrow_mut() = None;
imp.article_menu_button.set_visible(false); imp.article_menu_button.set_visible(false);
imp.content_stack.set_visible_child_name("empty"); imp.content_stack.set_visible_child_name("empty");
crate::cache::save(&articles, "");
} else { } else {
// Try to re-select the same article; fall back to first. // Try to re-select the same article the user was reading.
let mut select_idx = 0u32; let found_idx = saved_id.as_ref().and_then(|id| {
if let Some(ref id) = saved_id { (0..n).find(|&i| {
for i in 0..store.n_items() { store.item(i).and_downcast::<ArticleObject>()
if store.item(i).and_downcast::<ArticleObject>()
.map(|o| o.article().id == *id) .map(|o| o.article().id == *id)
.unwrap_or(false) .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"); 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) => { Err(e) => {
// If we already have cached articles, just show a toast. // If we already have cached articles, just show a toast.
@ -846,7 +877,10 @@ pub mod imp {
let n = sel.n_items(); let n = sel.n_items();
if n == 0 { return } if n == 0 { return }
let current = sel.selected(); 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) (current + 1).min(n - 1)
} else { } else {
current.saturating_sub(1) current.saturating_sub(1)