Compare commits

...

3 commits

Author SHA1 Message Date
41312f48f3 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.
2026-03-28 22:57:04 +00:00
d39c7f824b 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.
2026-03-27 23:35:39 +00:00
07b41c7407 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.
2026-03-27 23:35:32 +00:00

View file

@ -137,6 +137,7 @@ pub mod imp {
self.setup_capture_keys();
self.restore_from_cache();
self.auto_login();
self.web_view.grab_focus();
}
}
@ -205,6 +206,8 @@ pub mod imp {
fn setup_list(&self) {
let store = gio::ListStore::new::<ArticleObject>();
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();
@ -243,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);
}
}
@ -254,13 +259,30 @@ 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);
obj.set_unread(false);
// 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);
}
}
/// 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::<ArticleObject>() {
if obj.article().id == article_id {
obj.set_unread(false);
break;
}
}
}
}
}
fn reload_current_article(&self) {
@ -714,39 +736,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::<ArticleObject>()
// 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::<ArticleObject>()
.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.
@ -845,7 +892,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)