FeedTheMonkey/src/article_row.rs
Jeena 9a4bf4b9f8 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.
2026-03-22 01:51:12 +00:00

193 lines
6.5 KiB
Rust

use gtk4::glib;
use gtk4::subclass::prelude::ObjectSubclassIsExt;
glib::wrapper! {
pub struct ArticleRow(ObjectSubclass<imp::ArticleRow>)
@extends gtk4::Box, gtk4::Widget,
@implements gtk4::Accessible, gtk4::Buildable, gtk4::ConstraintTarget, gtk4::Orientable;
}
impl ArticleRow {
pub fn new() -> Self {
glib::Object::new()
}
pub fn bind(&self, obj: &crate::model::ArticleObject) {
self.imp().bind(obj);
}
pub fn unbind(&self) {
self.imp().unbind();
}
}
mod imp {
use super::*;
use crate::model::ArticleObject;
use gtk4::prelude::*;
use gtk4::subclass::prelude::*;
use gtk4::CompositeTemplate;
use glib::object::ObjectExt;
use std::cell::RefCell;
#[derive(CompositeTemplate, Default)]
#[template(resource = "/net/jeena/FeedTheMonkey/ui/article_row.ui")]
pub struct ArticleRow {
#[template_child]
pub feed_title_label: TemplateChild<gtk4::Label>,
#[template_child]
pub date_label: TemplateChild<gtk4::Label>,
#[template_child]
pub title_label: TemplateChild<gtk4::Label>,
#[template_child]
pub excerpt_label: TemplateChild<gtk4::Label>,
pub bindings: RefCell<Vec<glib::Binding>>,
pub unread_handler: RefCell<Option<(ArticleObject, glib::SignalHandlerId)>>,
pub context_menu: std::cell::OnceCell<gtk4::Popover>,
}
#[glib::object_subclass]
impl ObjectSubclass for ArticleRow {
const NAME: &'static str = "ArticleRow";
type Type = super::ArticleRow;
type ParentType = gtk4::Box;
fn class_init(klass: &mut Self::Class) {
klass.bind_template();
}
fn instance_init(obj: &glib::subclass::InitializingObject<Self>) {
obj.init_template();
}
}
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(&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) {
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!("<b>{escaped}</b>"));
} else {
self.title_label.add_css_class("dim-label");
self.title_label.set_markup(&escaped);
}
drop(article);
// 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();
let escaped = glib::markup_escape_text(&article.title);
if article.unread {
title_label.remove_css_class("dim-label");
title_label.set_markup(&format!("<b>{escaped}</b>"));
} else {
title_label.add_css_class("dim-label");
title_label.set_markup(&escaped);
}
});
*self.unread_handler.borrow_mut() = Some((obj.clone(), id));
}
pub fn unbind(&self) {
if let Some((obj, id)) = self.unread_handler.borrow_mut().take() {
obj.disconnect(id);
}
for b in self.bindings.borrow_mut().drain(..) {
b.unbind();
}
}
}
}
fn relative_time(unix: i64) -> String {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i64;
let diff = now - unix;
if diff < 60 {
"just now".to_string()
} else if diff < 3600 {
let m = diff / 60;
format!("{m}m ago")
} else if diff < 86400 {
let h = diff / 3600;
format!("{h}h ago")
} else if diff < 172800 {
"Yesterday".to_string()
} else {
let d = diff / 86400;
format!("{d}d ago")
}
}