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.
This commit is contained in:
Jeena 2026-03-22 01:51:12 +00:00
parent 571d80fa6b
commit 9a4bf4b9f8
2 changed files with 86 additions and 7 deletions

View file

@ -44,6 +44,7 @@ mod imp {
pub bindings: RefCell<Vec<glib::Binding>>, pub bindings: RefCell<Vec<glib::Binding>>,
pub unread_handler: RefCell<Option<(ArticleObject, glib::SignalHandlerId)>>, pub unread_handler: RefCell<Option<(ArticleObject, glib::SignalHandlerId)>>,
pub context_menu: std::cell::OnceCell<gtk4::Popover>,
} }
#[glib::object_subclass] #[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 WidgetImpl for ArticleRow {}
impl BoxImpl for ArticleRow {} impl BoxImpl for ArticleRow {}
impl 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(&gtk4::gdk::Rectangle::new(
x as i32,
y as i32,
1,
1,
)));
popover.popup();
});
self.obj().add_controller(gesture);
}
pub fn bind(&self, obj: &ArticleObject) { pub fn bind(&self, obj: &ArticleObject) {
let article = obj.article(); let article = obj.article();
self.feed_title_label.set_text(&article.feed_title); self.feed_title_label.set_text(&article.feed_title);
self.excerpt_label.set_text(&article.excerpt); self.excerpt_label.set_text(&article.excerpt);
self.date_label.set_text(&relative_time(article.published)); 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!("<b>{escaped}</b>"));
} else {
self.title_label.add_css_class("dim-label");
self.title_label.set_markup(&escaped);
}
drop(article); drop(article);
// Register handler first, then trigger it for the initial state. // Connect handler for future unread state changes.
// Using Pango markup for bold avoids CSS specificity issues.
let title_label = self.title_label.clone(); let title_label = self.title_label.clone();
let id = obj.connect_notify_local(Some("unread"), move |obj, _| { let id = obj.connect_notify_local(Some("unread"), move |obj, _| {
let article = obj.article(); let article = obj.article();
@ -88,7 +157,6 @@ mod imp {
} }
}); });
*self.unread_handler.borrow_mut() = Some((obj.clone(), id)); *self.unread_handler.borrow_mut() = Some((obj.clone(), id));
obj.notify("unread");
} }
pub fn unbind(&self) { pub fn unbind(&self) {
@ -99,7 +167,6 @@ mod imp {
b.unbind(); b.unbind();
} }
} }
} }
} }

View file

@ -77,6 +77,11 @@ pub mod imp {
klass.install_action("win.reload", None, |win, _, _| win.imp().do_reload()); 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.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-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::<String>()) {
win.imp().do_mark_article_unread(id);
}
});
klass.install_action("win.open-in-browser", None, |win, _, _| { klass.install_action("win.open-in-browser", None, |win, _, _| {
win.imp().do_open_in_browser() win.imp().do_open_in_browser()
}); });
@ -750,8 +755,11 @@ pub mod imp {
fn do_mark_unread(&self) { fn do_mark_unread(&self) {
let id = self.current_article_id.borrow().clone(); let id = self.current_article_id.borrow().clone();
let Some(id) = id else { return }; 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() { if let Some(store) = self.article_store.borrow().as_ref() {
for i in 0..store.n_items() { for i in 0..store.n_items() {
if let Some(obj) = store.item(i).and_downcast::<ArticleObject>() { if let Some(obj) = store.item(i).and_downcast::<ArticleObject>() {
@ -762,7 +770,11 @@ pub mod imp {
} }
} }
// 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; *self.mark_unread_guard.borrow_mut() = true;
}
let api = self.api.borrow().clone(); let api = self.api.borrow().clone();
let wt = self.write_token.borrow().clone(); let wt = self.write_token.borrow().clone();