From 07b41c7407d232015329dad15f5abf75605e89c4 Mon Sep 17 00:00:00 2001 From: Jeena Date: Fri, 27 Mar 2026 23:35:32 +0000 Subject: [PATCH 1/3] window: focus content area instead of sidebar on startup Scroll events were going to the sidebar list because GTK's default focus traversal landed on the article_list_view. Call grab_focus() on the WebView at the end of constructed() so the content area receives input by default. --- src/window.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/window.rs b/src/window.rs index 8139c95..f54e6de 100644 --- a/src/window.rs +++ b/src/window.rs @@ -137,6 +137,7 @@ pub mod imp { self.setup_capture_keys(); self.restore_from_cache(); self.auto_login(); + self.web_view.grab_focus(); } } From d39c7f824b4b302e92d87fdf758da88955d6431f Mon Sep 17 00:00:00 2001 From: Jeena Date: Fri, 27 Mar 2026 23:35:39 +0000 Subject: [PATCH 2/3] 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) From 41312f48f3b96085735b3c276b554806b113ced6 Mon Sep 17 00:00:00 2001 From: Jeena Date: Sat, 28 Mar 2026 22:57:04 +0000 Subject: [PATCH 3/3] window: only mark articles as read when navigating away Previously the sidebar immediately showed an article as read the moment it was selected, while the server was only notified when the user moved to the next article. Align the two: mark the previous article as read in both the sidebar and on the server at the same time, when the user navigates away from it. --- src/window.rs | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/src/window.rs b/src/window.rs index ea1efac..a863fdb 100644 --- a/src/window.rs +++ b/src/window.rs @@ -246,10 +246,12 @@ pub mod imp { } fn on_article_selected(&self, obj: ArticleObject) { - // Mark previous article as read (unless guard is set) + // Mark the previous article as read — both on the server and in the + // sidebar — now that the user has navigated away from it. if !*self.mark_unread_guard.borrow() { if let Some(prev_id) = self.current_article_id.borrow().clone() { if prev_id != obj.article().id { + self.mark_read_in_list(&prev_id); self.bg_mark_read(prev_id); } } @@ -267,7 +269,20 @@ pub mod imp { if !same_article { self.load_article_in_webview(&article); } - obj.set_unread(false); + } + + /// Mark an article as read in the sidebar list (UI only). + fn mark_read_in_list(&self, article_id: &str) { + if let Some(store) = self.article_store.borrow().as_ref() { + for i in 0..store.n_items() { + if let Some(obj) = store.item(i).and_downcast::() { + if obj.article().id == article_id { + obj.set_unread(false); + break; + } + } + } + } } fn reload_current_article(&self) {