Compare commits
6 commits
c19c2cbd1d
...
2c5217e744
| Author | SHA1 | Date | |
|---|---|---|---|
| 2c5217e744 | |||
| 9a4bf4b9f8 | |||
| 571d80fa6b | |||
| 81439edf87 | |||
| e5f2d5c941 | |||
| b63549ae0a |
6 changed files with 135 additions and 28 deletions
|
|
@ -32,6 +32,7 @@ template $ArticleRow : Gtk.Box {
|
||||||
wrap: true;
|
wrap: true;
|
||||||
lines: 2;
|
lines: 2;
|
||||||
ellipsize: end;
|
ellipsize: end;
|
||||||
|
styles ["article-title"]
|
||||||
}
|
}
|
||||||
|
|
||||||
Label excerpt_label {
|
Label excerpt_label {
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,9 @@ corresponding .blp file and regenerate this file with blueprint-compiler.
|
||||||
<property name="wrap">true</property>
|
<property name="wrap">true</property>
|
||||||
<property name="lines">2</property>
|
<property name="lines">2</property>
|
||||||
<property name="ellipsize">3</property>
|
<property name="ellipsize">3</property>
|
||||||
|
<style>
|
||||||
|
<class name="article-title"/>
|
||||||
|
</style>
|
||||||
</object>
|
</object>
|
||||||
</child>
|
</child>
|
||||||
<child>
|
<child>
|
||||||
|
|
|
||||||
19
src/api.rs
19
src/api.rs
|
|
@ -148,7 +148,7 @@ impl Api {
|
||||||
Ok(stream.items.into_iter().map(|item| {
|
Ok(stream.items.into_iter().map(|item| {
|
||||||
let unread = !item.categories.as_deref().unwrap_or_default()
|
let unread = !item.categories.as_deref().unwrap_or_default()
|
||||||
.iter()
|
.iter()
|
||||||
.any(|c| c.contains("user/-/state/com.google/read"));
|
.any(|c| c == "user/-/state/com.google/read");
|
||||||
let content = item.summary
|
let content = item.summary
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(|s| s.content.clone())
|
.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::<Vec<_>>().join(" ");
|
let collapsed: String = out.split_whitespace().collect::<Vec<_>>().join(" ");
|
||||||
if collapsed.chars().count() <= max_chars {
|
let decoded = decode_html_entities(&collapsed);
|
||||||
collapsed
|
if decoded.chars().count() <= max_chars {
|
||||||
|
decoded
|
||||||
} else {
|
} else {
|
||||||
collapsed.chars().take(max_chars).collect::<String>() + "…"
|
decoded.chars().take(max_chars).collect::<String>() + "…"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn decode_html_entities(s: &str) -> String {
|
||||||
|
s.replace("&", "&")
|
||||||
|
.replace("<", "<")
|
||||||
|
.replace(">", ">")
|
||||||
|
.replace(""", "\"")
|
||||||
|
.replace("'", "'")
|
||||||
|
.replace("'", "'")
|
||||||
|
.replace(" ", " ")
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -71,8 +71,8 @@ mod imp {
|
||||||
.sidebar-content row:selected {
|
.sidebar-content row:selected {
|
||||||
background-color: alpha(@window_fg_color, 0.22);
|
background-color: alpha(@window_fg_color, 0.22);
|
||||||
}
|
}
|
||||||
.unread-title {
|
.article-title {
|
||||||
font-weight: bold;
|
font-size: 1.05em;
|
||||||
}"
|
}"
|
||||||
);
|
);
|
||||||
gtk4::style_context_add_provider_for_display(
|
gtk4::style_context_add_provider_for_display(
|
||||||
|
|
|
||||||
|
|
@ -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,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 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(>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) {
|
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.title_label.set_text(&article.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));
|
||||||
|
|
||||||
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!("<b>{escaped}</b>"));
|
||||||
|
} else {
|
||||||
|
self.title_label.add_css_class("dim-label");
|
||||||
|
self.title_label.set_markup(&escaped);
|
||||||
|
}
|
||||||
drop(article);
|
drop(article);
|
||||||
|
|
||||||
// Watch for unread state changes
|
// Connect handler for future unread state changes.
|
||||||
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 unread = obj.article().unread;
|
let article = obj.article();
|
||||||
if unread {
|
let escaped = glib::markup_escape_text(&article.title);
|
||||||
|
if article.unread {
|
||||||
title_label.remove_css_class("dim-label");
|
title_label.remove_css_class("dim-label");
|
||||||
title_label.add_css_class("unread-title");
|
title_label.set_markup(&format!("<b>{escaped}</b>"));
|
||||||
} else {
|
} else {
|
||||||
title_label.add_css_class("dim-label");
|
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));
|
*self.unread_handler.borrow_mut() = Some((obj.clone(), id));
|
||||||
|
|
@ -100,16 +167,6 @@ mod imp {
|
||||||
b.unbind();
|
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");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
});
|
});
|
||||||
|
|
@ -101,6 +106,14 @@ pub mod imp {
|
||||||
});
|
});
|
||||||
klass.install_action("win.preferences", None, |win, _, _| {
|
klass.install_action("win.preferences", None, |win, _, _| {
|
||||||
let dialog = crate::preferences_dialog::PreferencesDialog::new();
|
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::<gtk4::Widget>()));
|
dialog.present(Some(win.upcast_ref::<gtk4::Widget>()));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -234,6 +247,21 @@ pub mod imp {
|
||||||
obj.set_unread(false);
|
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::<ArticleObject>() {
|
||||||
|
if obj.article().id == id {
|
||||||
|
self.load_article_in_webview(&obj.article().clone());
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn load_article_in_webview(&self, article: &crate::model::Article) {
|
fn load_article_in_webview(&self, article: &crate::model::Article) {
|
||||||
let rules = self.filter_rules.borrow();
|
let rules = self.filter_rules.borrow();
|
||||||
let content = crate::filters::apply(&rules, &article.id, &article.link, &article.content);
|
let content = crate::filters::apply(&rules, &article.id, &article.link, &article.content);
|
||||||
|
|
@ -727,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>() {
|
||||||
|
|
@ -739,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();
|
||||||
|
|
@ -808,7 +843,7 @@ pub mod imp {
|
||||||
fn update_sidebar_zoom(&self, level: f64) {
|
fn update_sidebar_zoom(&self, level: f64) {
|
||||||
if let Some(css) = self.sidebar_zoom_css.get() {
|
if let Some(css) = self.sidebar_zoom_css.get() {
|
||||||
css.load_from_string(&format!(
|
css.load_from_string(&format!(
|
||||||
".sidebar-content label {{ font-size: {level}em; }}"
|
".sidebar-content {{ font-size: {level}em; }}"
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue