From b63549ae0a59134f1cc21dc95f174a7bbf58a3f8 Mon Sep 17 00:00:00 2001 From: Jeena Date: Sun, 22 Mar 2026 00:34:51 +0000 Subject: [PATCH 1/6] sidebar: fix unread bold via notify, larger title font, sidebar zoom --- data/ui/article_row.blp | 1 + data/ui/article_row.ui | 3 +++ src/app.rs | 3 +++ src/article_row.rs | 15 +++------------ 4 files changed, 10 insertions(+), 12 deletions(-) diff --git a/data/ui/article_row.blp b/data/ui/article_row.blp index ea4c08d..07c4cbe 100644 --- a/data/ui/article_row.blp +++ b/data/ui/article_row.blp @@ -32,6 +32,7 @@ template $ArticleRow : Gtk.Box { wrap: true; lines: 2; ellipsize: end; + styles ["article-title"] } Label excerpt_label { diff --git a/data/ui/article_row.ui b/data/ui/article_row.ui index e159ff1..d5eeab9 100644 --- a/data/ui/article_row.ui +++ b/data/ui/article_row.ui @@ -46,6 +46,9 @@ corresponding .blp file and regenerate this file with blueprint-compiler. true 2 3 + diff --git a/src/app.rs b/src/app.rs index 6e287f0..6d4e591 100644 --- a/src/app.rs +++ b/src/app.rs @@ -73,6 +73,9 @@ mod imp { } .unread-title { font-weight: bold; + } + .article-title { + font-size: 1.05em; }" ); gtk4::style_context_add_provider_for_display( diff --git a/src/article_row.rs b/src/article_row.rs index d8877ce..2e6a6a2 100644 --- a/src/article_row.rs +++ b/src/article_row.rs @@ -73,11 +73,10 @@ mod imp { self.title_label.set_text(&article.title); self.excerpt_label.set_text(&article.excerpt); self.date_label.set_text(&relative_time(article.published)); - - self.update_read_style(article.unread); drop(article); - // Watch for unread state changes + // Register the style handler first, then fire it immediately so + // the initial state uses the exact same code path as later changes. let title_label = self.title_label.clone(); let id = obj.connect_notify_local(Some("unread"), move |obj, _| { let unread = obj.article().unread; @@ -90,6 +89,7 @@ mod imp { } }); *self.unread_handler.borrow_mut() = Some((obj.clone(), id)); + obj.notify("unread"); } pub fn unbind(&self) { @@ -101,15 +101,6 @@ mod imp { } } - fn update_read_style(&self, unread: bool) { - if unread { - self.title_label.remove_css_class("dim-label"); - self.title_label.add_css_class("unread-title"); - } else { - self.title_label.add_css_class("dim-label"); - self.title_label.remove_css_class("unread-title"); - } - } } } From e5f2d5c9414b4f4d0ca5c6137579c2355a948d6f Mon Sep 17 00:00:00 2001 From: Jeena Date: Sun, 22 Mar 2026 00:37:10 +0000 Subject: [PATCH 2/6] filters: reload current article immediately when preferences closes --- src/window.rs | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/src/window.rs b/src/window.rs index 91181c8..f4905d5 100644 --- a/src/window.rs +++ b/src/window.rs @@ -101,6 +101,14 @@ pub mod imp { }); klass.install_action("win.preferences", None, |win, _, _| { let dialog = crate::preferences_dialog::PreferencesDialog::new(); + let win_weak = win.downgrade(); + dialog.connect_closed(move |_| { + if let Some(win) = win_weak.upgrade() { + let imp = win.imp(); + *imp.filter_rules.borrow_mut() = crate::filters::load_rules(); + imp.reload_current_article(); + } + }); dialog.present(Some(win.upcast_ref::())); }); } @@ -234,6 +242,21 @@ pub mod imp { obj.set_unread(false); } + fn reload_current_article(&self) { + let id = self.current_article_id.borrow().clone(); + let Some(id) = id else { return }; + 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 == id { + self.load_article_in_webview(&obj.article().clone()); + break; + } + } + } + } + } + fn load_article_in_webview(&self, article: &crate::model::Article) { let rules = self.filter_rules.borrow(); let content = crate::filters::apply(&rules, &article.id, &article.link, &article.content); From 81439edf8797bfae8de04e8c39b58a6f02c17207 Mon Sep 17 00:00:00 2001 From: Jeena Date: Sun, 22 Mar 2026 00:40:20 +0000 Subject: [PATCH 3/6] sidebar: use Pango markup for bold, fix zoom CSS specificity --- src/app.rs | 3 --- src/article_row.rs | 15 +++++++-------- src/window.rs | 2 +- 3 files changed, 8 insertions(+), 12 deletions(-) diff --git a/src/app.rs b/src/app.rs index 6d4e591..8e966e9 100644 --- a/src/app.rs +++ b/src/app.rs @@ -71,9 +71,6 @@ mod imp { .sidebar-content row:selected { background-color: alpha(@window_fg_color, 0.22); } - .unread-title { - font-weight: bold; - } .article-title { font-size: 1.05em; }" diff --git a/src/article_row.rs b/src/article_row.rs index 2e6a6a2..23c6c1b 100644 --- a/src/article_row.rs +++ b/src/article_row.rs @@ -68,24 +68,23 @@ mod imp { impl ArticleRow { pub fn bind(&self, obj: &ArticleObject) { let article = obj.article(); - self.feed_title_label.set_text(&article.feed_title); - self.title_label.set_text(&article.title); self.excerpt_label.set_text(&article.excerpt); self.date_label.set_text(&relative_time(article.published)); drop(article); - // Register the style handler first, then fire it immediately so - // the initial state uses the exact same code path as later changes. + // Register handler first, then trigger it for the initial state. + // Using Pango markup for bold avoids CSS specificity issues. let title_label = self.title_label.clone(); let id = obj.connect_notify_local(Some("unread"), move |obj, _| { - let unread = obj.article().unread; - if unread { + let article = obj.article(); + let escaped = glib::markup_escape_text(&article.title); + if article.unread { title_label.remove_css_class("dim-label"); - title_label.add_css_class("unread-title"); + title_label.set_markup(&format!("{escaped}")); } else { title_label.add_css_class("dim-label"); - title_label.remove_css_class("unread-title"); + title_label.set_markup(&escaped); } }); *self.unread_handler.borrow_mut() = Some((obj.clone(), id)); diff --git a/src/window.rs b/src/window.rs index f4905d5..683e046 100644 --- a/src/window.rs +++ b/src/window.rs @@ -831,7 +831,7 @@ pub mod imp { fn update_sidebar_zoom(&self, level: f64) { if let Some(css) = self.sidebar_zoom_css.get() { css.load_from_string(&format!( - ".sidebar-content label {{ font-size: {level}em; }}" + ".sidebar-content {{ font-size: {level}em; }}" )); } } From 571d80fa6b6f8d78188f9812ae934b37c83668af Mon Sep 17 00:00:00 2001 From: Jeena Date: Sun, 22 Mar 2026 01:17:49 +0000 Subject: [PATCH 4/6] api: decode HTML entities in article excerpts --- src/api.rs | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/src/api.rs b/src/api.rs index d683e8f..59a96a3 100644 --- a/src/api.rs +++ b/src/api.rs @@ -249,9 +249,20 @@ fn plain_text_excerpt(html: &str, max_chars: usize) -> String { } } let collapsed: String = out.split_whitespace().collect::>().join(" "); - if collapsed.chars().count() <= max_chars { - collapsed + let decoded = decode_html_entities(&collapsed); + if decoded.chars().count() <= max_chars { + decoded } else { - collapsed.chars().take(max_chars).collect::() + "…" + decoded.chars().take(max_chars).collect::() + "…" } } + +fn decode_html_entities(s: &str) -> String { + s.replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace(""", "\"") + .replace("'", "'") + .replace("'", "'") + .replace(" ", " ") +} From 9a4bf4b9f8c3626ec95e01a8b8ab6af358d4e189 Mon Sep 17 00:00:00 2001 From: Jeena Date: Sun, 22 Mar 2026 01:51:12 +0000 Subject: [PATCH 5/6] article-row: fix bold on initial load, add right-click menu Set unread bold state directly in bind() instead of relying on obj.notify("unread"), which was unreliable during list factory binding (GLib may defer or drop notifications during initial bind). Also add a right-click context menu on each article row with a single "Mark as Unread" item. The menu is a GtkPopover positioned at the cursor. Clicking it activates the new win.mark-article-unread action, which takes the article ID as a string parameter and reuses the existing mark-unread logic. Refactor do_mark_unread() to delegate to the new do_mark_article_unread() so the behaviour is consistent whether triggered from the toolbar button, keyboard shortcut, or right-click menu. --- src/article_row.rs | 77 +++++++++++++++++++++++++++++++++++++++++++--- src/window.rs | 16 ++++++++-- 2 files changed, 86 insertions(+), 7 deletions(-) diff --git a/src/article_row.rs b/src/article_row.rs index 23c6c1b..bb239b2 100644 --- a/src/article_row.rs +++ b/src/article_row.rs @@ -44,6 +44,7 @@ mod imp { pub bindings: RefCell>, pub unread_handler: RefCell>, + pub context_menu: std::cell::OnceCell, } #[glib::object_subclass] @@ -61,20 +62,88 @@ mod imp { } } - impl ObjectImpl for ArticleRow {} + impl ObjectImpl for ArticleRow { + fn constructed(&self) { + self.parent_constructed(); + self.setup_context_menu(); + } + + fn dispose(&self) { + if let Some(popover) = self.context_menu.get() { + popover.unparent(); + } + } + } impl WidgetImpl for ArticleRow {} impl BoxImpl for ArticleRow {} impl ArticleRow { + fn setup_context_menu(&self) { + let button = gtk4::Button::with_label("Mark as Unread"); + button.set_has_frame(false); + + let popover = gtk4::Popover::new(); + popover.set_child(Some(&button)); + popover.set_parent(&*self.obj()); + self.context_menu.set(popover.clone()).ok(); + + // Close popover and activate action when button is clicked. + let imp_weak = self.downgrade(); + let popover_weak = popover.downgrade(); + button.connect_clicked(move |_| { + if let Some(popover) = popover_weak.upgrade() { + popover.popdown(); + } + let Some(imp) = imp_weak.upgrade() else { return }; + let handler = imp.unread_handler.borrow(); + let Some((obj, _)) = handler.as_ref() else { return }; + let article_id = obj.article().id.clone(); + drop(handler); + imp.obj() + .activate_action( + "win.mark-article-unread", + Some(&article_id.to_variant()), + ) + .ok(); + }); + + // Right-click gesture to show the popover at cursor position. + let gesture = gtk4::GestureClick::new(); + gesture.set_button(3); + let popover_weak2 = popover.downgrade(); + gesture.connect_pressed(move |gesture, _, x, y| { + gesture.set_state(gtk4::EventSequenceState::Claimed); + let Some(popover) = popover_weak2.upgrade() else { return }; + popover.set_pointing_to(Some(>k4::gdk::Rectangle::new( + x as i32, + y as i32, + 1, + 1, + ))); + popover.popup(); + }); + self.obj().add_controller(gesture); + } + pub fn bind(&self, obj: &ArticleObject) { let article = obj.article(); self.feed_title_label.set_text(&article.feed_title); self.excerpt_label.set_text(&article.excerpt); self.date_label.set_text(&relative_time(article.published)); + + // Set initial bold state directly (using Pango markup to avoid + // CSS specificity issues with the zoom font-size provider). + let escaped = glib::markup_escape_text(&article.title); + if article.unread { + self.title_label.remove_css_class("dim-label"); + self.title_label.set_markup(&format!("{escaped}")); + } else { + self.title_label.add_css_class("dim-label"); + self.title_label.set_markup(&escaped); + } drop(article); - // Register handler first, then trigger it for the initial state. - // Using Pango markup for bold avoids CSS specificity issues. + // Connect handler for future unread state changes. let title_label = self.title_label.clone(); let id = obj.connect_notify_local(Some("unread"), move |obj, _| { let article = obj.article(); @@ -88,7 +157,6 @@ mod imp { } }); *self.unread_handler.borrow_mut() = Some((obj.clone(), id)); - obj.notify("unread"); } pub fn unbind(&self) { @@ -99,7 +167,6 @@ mod imp { b.unbind(); } } - } } diff --git a/src/window.rs b/src/window.rs index 683e046..c3c0699 100644 --- a/src/window.rs +++ b/src/window.rs @@ -77,6 +77,11 @@ pub mod imp { klass.install_action("win.reload", None, |win, _, _| win.imp().do_reload()); klass.install_action("win.logout", None, |win, _, _| win.imp().do_logout()); klass.install_action("win.mark-unread", None, |win, _, _| win.imp().do_mark_unread()); + klass.install_action("win.mark-article-unread", Some(glib::VariantTy::STRING), |win, _, param| { + if let Some(id) = param.and_then(|p| p.get::()) { + win.imp().do_mark_article_unread(id); + } + }); klass.install_action("win.open-in-browser", None, |win, _, _| { win.imp().do_open_in_browser() }); @@ -750,8 +755,11 @@ pub mod imp { fn do_mark_unread(&self) { let id = self.current_article_id.borrow().clone(); let Some(id) = id else { return }; + self.do_mark_article_unread(id); + } - // Find the ArticleObject in the store and set unread=true + fn do_mark_article_unread(&self, id: String) { + // Find the ArticleObject in the store and set unread=true. 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::() { @@ -762,7 +770,11 @@ pub mod imp { } } - *self.mark_unread_guard.borrow_mut() = true; + // If this is the currently displayed article, guard against it + // being immediately re-marked read when the selection fires. + if self.current_article_id.borrow().as_deref() == Some(&*id) { + *self.mark_unread_guard.borrow_mut() = true; + } let api = self.api.borrow().clone(); let wt = self.write_token.borrow().clone(); From 2c5217e744307758e25b69d871949536b8d3f11e Mon Sep 17 00:00:00 2001 From: Jeena Date: Sun, 22 Mar 2026 01:58:51 +0000 Subject: [PATCH 6/6] api: fix unread detection broken by substring match MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The check `.contains("user/-/state/com.google/read")` was a substring match, which also matched "user/-/state/com.google/reading-list" — a category present on every article fetched from the reading list. This caused all articles to be treated as read, so nothing ever appeared bold in the sidebar. Fix by using == for exact string comparison. --- src/api.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/api.rs b/src/api.rs index 59a96a3..c3db913 100644 --- a/src/api.rs +++ b/src/api.rs @@ -148,7 +148,7 @@ impl Api { Ok(stream.items.into_iter().map(|item| { let unread = !item.categories.as_deref().unwrap_or_default() .iter() - .any(|c| c.contains("user/-/state/com.google/read")); + .any(|c| c == "user/-/state/com.google/read"); let content = item.summary .as_ref() .and_then(|s| s.content.clone())