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/api.rs b/src/api.rs index d683e8f..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()) @@ -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(" ", " ") +} diff --git a/src/app.rs b/src/app.rs index 6e287f0..8e966e9 100644 --- a/src/app.rs +++ b/src/app.rs @@ -71,8 +71,8 @@ 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; }" ); gtk4::style_context_add_provider_for_display( diff --git a/src/article_row.rs b/src/article_row.rs index d8877ce..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,32 +62,98 @@ 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.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); + // 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); - // Watch for unread state changes + // 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 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)); @@ -100,16 +167,6 @@ mod imp { b.unbind(); } } - - 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"); - } - } } } diff --git a/src/window.rs b/src/window.rs index 91181c8..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() }); @@ -101,6 +106,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 +247,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); @@ -727,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::() { @@ -739,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(); @@ -808,7 +843,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; }}" )); } }