FeedTheMonkey/src/window.rs

874 lines
39 KiB
Rust

use gtk4::prelude::*;
use gtk4::subclass::prelude::*;
use gtk4::{gio, glib};
use webkit6::prelude::{PolicyDecisionExt, WebViewExt};
glib::wrapper! {
pub struct FeedTheMonkeyWindow(ObjectSubclass<imp::FeedTheMonkeyWindow>)
@extends libadwaita::ApplicationWindow, gtk4::ApplicationWindow, gtk4::Window, gtk4::Widget,
@implements gio::ActionGroup, gio::ActionMap, gtk4::Accessible, gtk4::Buildable,
gtk4::ConstraintTarget, gtk4::Native, gtk4::Root, gtk4::ShortcutManager;
}
impl FeedTheMonkeyWindow {
pub fn new(app: &libadwaita::Application) -> Self {
glib::Object::builder()
.property("application", app)
.build()
}
}
pub mod imp {
use super::*;
use crate::api::Api;
use crate::credentials;
use crate::login_dialog::LoginDialog;
use crate::model::ArticleObject;
use gtk4::CompositeTemplate;
use libadwaita::prelude::*;
use libadwaita::subclass::prelude::*;
use std::cell::RefCell;
#[derive(CompositeTemplate, Default)]
#[template(resource = "/net/jeena/FeedTheMonkey/ui/window.ui")]
pub struct FeedTheMonkeyWindow {
#[template_child]
pub toast_overlay: TemplateChild<libadwaita::ToastOverlay>,
#[template_child]
pub paned: TemplateChild<gtk4::Paned>,
#[template_child]
pub sidebar_toolbar: TemplateChild<libadwaita::ToolbarView>,
#[template_child]
pub refresh_stack: TemplateChild<gtk4::Stack>,
#[template_child]
pub refresh_button: TemplateChild<gtk4::Button>,
#[template_child]
pub article_menu_button: TemplateChild<gtk4::MenuButton>,
#[template_child]
pub sidebar_content: TemplateChild<gtk4::Stack>,
#[template_child]
pub article_list_view: TemplateChild<gtk4::ListView>,
#[template_child]
pub content_stack: TemplateChild<gtk4::Stack>,
#[template_child]
pub web_view: TemplateChild<webkit6::WebView>,
#[template_child]
pub error_status: TemplateChild<libadwaita::StatusPage>,
pub filter_rules: RefCell<Vec<crate::filters::Rule>>,
pub api: RefCell<Option<Api>>,
pub write_token: RefCell<Option<String>>,
pub article_store: RefCell<Option<gio::ListStore>>,
pub selection: RefCell<Option<gtk4::SingleSelection>>,
pub current_article_id: RefCell<Option<String>>,
pub mark_unread_guard: RefCell<bool>,
pub pending_restore_id: RefCell<Option<String>>,
pub sidebar_zoom_css: std::cell::OnceCell<gtk4::CssProvider>,
}
#[glib::object_subclass]
impl ObjectSubclass for FeedTheMonkeyWindow {
const NAME: &'static str = "FeedTheMonkeyWindow";
type Type = super::FeedTheMonkeyWindow;
type ParentType = libadwaita::ApplicationWindow;
fn class_init(klass: &mut Self::Class) {
klass.bind_template();
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.open-in-browser", None, |win, _, _| {
win.imp().do_open_in_browser()
});
klass.install_action("win.next-article", None, |win, _, _| {
win.imp().navigate_by(1)
});
klass.install_action("win.prev-article", None, |win, _, _| {
win.imp().navigate_by(-1)
});
klass.install_action("win.zoom-in", None, |win, _, _| win.imp().zoom(1.1));
klass.install_action("win.zoom-out", None, |win, _, _| win.imp().zoom(1.0 / 1.1));
klass.install_action("win.zoom-reset", None, |win, _, _| win.imp().zoom_reset());
klass.install_action("win.toggle-fullscreen", None, |win, _, _| {
if win.is_fullscreen() {
win.unfullscreen();
} else {
win.fullscreen();
}
});
klass.install_action("win.toggle-sidebar", None, |win, _, _| {
win.imp().do_toggle_sidebar();
});
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::<gtk4::Widget>()));
});
}
fn instance_init(obj: &glib::subclass::InitializingObject<Self>) {
obj.init_template();
}
}
impl ObjectImpl for FeedTheMonkeyWindow {
fn constructed(&self) {
self.parent_constructed();
self.setup_window_state();
self.setup_list();
self.setup_webview();
self.setup_sidebar_toggle();
self.setup_capture_keys();
self.restore_from_cache();
self.auto_login();
}
}
impl FeedTheMonkeyWindow {
// ── Window state ─────────────────────────────────────────────────────
fn setup_window_state(&self) {
let settings = gio::Settings::new("net.jeena.FeedTheMonkey");
let window = self.obj();
window.set_title(Some("FeedTheMonkey"));
let w = settings.int("window-width");
let h = settings.int("window-height");
window.set_default_size(w, h);
if settings.boolean("window-maximized") {
window.maximize();
}
self.paned.set_position(settings.int("sidebar-width"));
let zoom = settings.double("zoom-level");
self.web_view.set_zoom_level(zoom);
// Set up sidebar font zoom CSS provider
let zoom_css = gtk4::CssProvider::new();
gtk4::style_context_add_provider_for_display(
&gtk4::gdk::Display::default().unwrap(),
&zoom_css,
gtk4::STYLE_PROVIDER_PRIORITY_APPLICATION,
);
self.sidebar_zoom_css.set(zoom_css).ok();
self.update_sidebar_zoom(zoom);
// Persist sidebar width while dragging (only when visible)
let s2 = settings.clone();
let sidebar_weak = self.sidebar_toolbar.downgrade();
self.paned.connect_notify_local(Some("position"), move |paned, _| {
if sidebar_weak.upgrade().map(|s| s.is_visible()).unwrap_or(false) {
s2.set_int("sidebar-width", paned.position()).ok();
}
});
let s = settings.clone();
window.connect_close_request(move |win| {
if !win.is_maximized() {
s.set_int("window-width", win.width()).ok();
s.set_int("window-height", win.height()).ok();
}
s.set_boolean("window-maximized", win.is_maximized()).ok();
glib::Propagation::Proceed
});
}
// ── List view ─────────────────────────────────────────────────────────
fn setup_list(&self) {
let store = gio::ListStore::new::<ArticleObject>();
let selection = gtk4::SingleSelection::new(Some(store.clone()));
*self.article_store.borrow_mut() = Some(store);
let factory = gtk4::SignalListItemFactory::new();
factory.connect_setup(|_, item| {
let item = item.downcast_ref::<gtk4::ListItem>().unwrap();
let row = crate::article_row::ArticleRow::new();
item.set_child(Some(&row));
});
factory.connect_bind(|_, item| {
let item = item.downcast_ref::<gtk4::ListItem>().unwrap();
if let Some(obj) = item.item().and_downcast::<ArticleObject>() {
let row = item.child().and_downcast::<crate::article_row::ArticleRow>().unwrap();
row.bind(&obj);
}
});
factory.connect_unbind(|_, item| {
let item = item.downcast_ref::<gtk4::ListItem>().unwrap();
if let Some(row) = item.child().and_downcast::<crate::article_row::ArticleRow>() {
row.unbind();
}
});
self.article_list_view.set_factory(Some(&factory));
self.article_list_view.set_model(Some(&selection));
let win_weak = self.obj().downgrade();
selection.connect_selected_item_notify(move |sel| {
if let Some(win) = win_weak.upgrade() {
if let Some(obj) = sel.selected_item().and_downcast::<ArticleObject>() {
win.imp().on_article_selected(obj);
}
}
});
*self.selection.borrow_mut() = Some(selection);
}
fn on_article_selected(&self, obj: ArticleObject) {
// Mark previous article as read (unless guard is set)
if !*self.mark_unread_guard.borrow() {
if let Some(prev_id) = self.current_article_id.borrow().clone() {
if prev_id != obj.article().id {
self.bg_mark_read(prev_id);
}
}
}
*self.mark_unread_guard.borrow_mut() = false;
let article = obj.article().clone();
*self.current_article_id.borrow_mut() = Some(article.id.clone());
self.article_menu_button.set_visible(true);
// Load in webview
self.load_article_in_webview(&article);
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) {
let rules = self.filter_rules.borrow();
let content = crate::filters::apply(&rules, &article.id, &article.link, &article.content);
let json = serde_json::json!({
"id": article.id,
"title": article.title,
"feed_title": article.feed_title,
"link": article.link,
"updated": article.published,
"content": content,
"author": article.author,
"unread": article.unread,
});
let js = format!("window.setArticle({})", json);
self.web_view.evaluate_javascript(&js, None, None, gio::Cancellable::NONE, |_| {});
self.content_stack.set_visible_child_name("webview");
}
// ── WebView ───────────────────────────────────────────────────────────
fn setup_webview(&self) {
let wv = &*self.web_view;
if let Some(ctx) = wv.web_context() {
crate::image_cache::register_scheme(&ctx);
}
// Load content.html from GResource, inlining the CSS so WebKit
// doesn't need to fetch it over a custom scheme.
let load = |path: &str| {
String::from_utf8(
gio::resources_lookup_data(path, gio::ResourceLookupFlags::NONE)
.unwrap()
.to_vec(),
)
.unwrap()
};
let css = load("/net/jeena/FeedTheMonkey/html/content.css");
let html = load("/net/jeena/FeedTheMonkey/html/content.html")
.replace("/*INJECT_CSS*/", &css);
wv.load_html(&html, Some("feedthemonkey://localhost/"));
// Apply Adwaita color scheme: set data-dark on <html> so CSS
// custom properties (--bg, --fg, etc.) resolve to the right values.
let style_manager = libadwaita::StyleManager::default();
let wv_weak = wv.downgrade();
let apply_scheme = move |sm: &libadwaita::StyleManager| {
let is_dark = sm.is_dark();
let js = format!("setDark({})", if is_dark { "true" } else { "false" });
if let Some(wv) = wv_weak.upgrade() {
wv.evaluate_javascript(&js, None, None, gio::Cancellable::NONE, |_| {});
}
};
// Apply now (after load-changed fires, see below) and on every change
let sm_clone = style_manager.clone();
let wv_weak2 = wv.downgrade();
let win_weak = self.obj().downgrade();
wv.connect_load_changed(move |_, event| {
if event == webkit6::LoadEvent::Finished {
apply_scheme(&sm_clone);
// Restore cached article now that window.setArticle() exists.
if let Some(win) = win_weak.upgrade() {
let imp = win.imp();
if let Some(id) = imp.pending_restore_id.borrow_mut().take() {
if let Some(store) = imp.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 {
let article = obj.article().clone();
*imp.current_article_id.borrow_mut() = Some(article.id.clone());
imp.article_menu_button.set_visible(true);
imp.load_article_in_webview(&article);
imp.content_stack.set_visible_child_name("webview");
break;
}
}
}
}
}
}
}
});
let wv_weak3 = wv.downgrade();
style_manager.connect_notify_local(Some("dark"), move |sm, _| {
let is_dark = sm.is_dark();
let js = format!("setDark({})", if is_dark { "true" } else { "false" });
if let Some(wv) = wv_weak3.upgrade() {
wv.evaluate_javascript(&js, None, None, gio::Cancellable::NONE, |_| {});
}
});
let _ = wv_weak2; // suppress unused warning
// Disable the default WebKit context menu (right-click) — the
// reload/inspect items don't make sense in an embedded reader.
wv.connect_context_menu(|_, _, _| true);
// Handle navigation policy
let win_weak = self.obj().downgrade();
wv.connect_decide_policy(move |_, decision, decision_type| {
if decision_type != webkit6::PolicyDecisionType::NavigationAction {
return false;
}
let nav = decision.downcast_ref::<webkit6::NavigationPolicyDecision>().unwrap();
let uri = nav.navigation_action()
.and_then(|a| a.request())
.and_then(|r| r.uri())
.unwrap_or_default();
if uri.starts_with("feedthemonkey://localhost/") || uri.is_empty() {
return false; // allow initial load
}
// Handle in-page keyboard navigation commands (not user-gesture links)
if uri.starts_with("feedthemonkey:") {
nav.ignore();
if let Some(win) = win_weak.upgrade() {
match uri.as_str() {
"feedthemonkey:previous" => win.imp().navigate_by(-1),
"feedthemonkey:next" => win.imp().navigate_by(1),
"feedthemonkey:open" => win.imp().do_open_in_browser(),
_ => {}
}
}
return true;
}
// Only open external URLs in the browser when the user explicitly
// clicked a link (NavigationType::LinkClicked). Everything else —
// iframe/embed loads, programmatic navigation — stays in the WebView.
let is_link_click = nav.navigation_action()
.map(|a| a.navigation_type() == webkit6::NavigationType::LinkClicked)
.unwrap_or(false);
if is_link_click {
nav.ignore();
open_uri(&uri);
true
} else {
false // let iframes/embeds load normally
}
});
}
// ── Sidebar toggle ────────────────────────────────────────────────────
fn setup_sidebar_toggle(&self) {}
fn do_toggle_sidebar(&self) {
let sidebar = &*self.sidebar_toolbar;
if sidebar.is_visible() {
sidebar.set_visible(false);
} else {
sidebar.set_visible(true);
let settings = gio::Settings::new("net.jeena.FeedTheMonkey");
let saved = settings.int("sidebar-width");
self.paned.set_position(if saved > 0 { saved } else { 280 });
}
}
// ── Capture-phase key handler ─────────────────────────────────────────
// ShortcutScope::Global doesn't reliably intercept keys when a ListView
// has focus (the list view consumes them first). A Capture-phase
// EventControllerKey on the window fires before any widget sees the event.
fn setup_capture_keys(&self) {
let controller = gtk4::EventControllerKey::new();
controller.set_propagation_phase(gtk4::PropagationPhase::Capture);
let win_weak = self.obj().downgrade();
controller.connect_key_pressed(move |_, key, _, mods| {
use gtk4::gdk::Key;
if !mods.is_empty() {
return glib::Propagation::Proceed;
}
let Some(win) = win_weak.upgrade() else {
return glib::Propagation::Proceed;
};
// Don't steal keys from text inputs (login dialog, etc.)
let focused: Option<gtk4::Widget> = gtk4::prelude::GtkWindowExt::focus(&win);
if focused.is_some_and(|w| {
w.is::<gtk4::Text>() || w.is::<gtk4::Entry>() || w.is::<gtk4::SearchEntry>()
}) {
return glib::Propagation::Proceed;
}
match key {
Key::Left | Key::k => {
win.imp().navigate_by(-1);
glib::Propagation::Stop
}
Key::Right | Key::j => {
win.imp().navigate_by(1);
glib::Propagation::Stop
}
_ => glib::Propagation::Proceed,
}
});
self.obj().add_controller(controller);
}
// ── Cache ─────────────────────────────────────────────────────────────
fn restore_from_cache(&self) {
let Some(cache) = crate::cache::load() else { return };
if cache.articles.is_empty() { return; }
let store = self.article_store.borrow();
let store = store.as_ref().unwrap();
for a in &cache.articles {
store.append(&ArticleObject::new(a.clone()));
}
// Find and scroll to the previously selected item immediately so
// the list looks right, but defer loading the article into the
// WebView until the base HTML has finished loading (see setup_webview).
let mut select_idx = 0u32;
if !cache.selected_id.is_empty() {
for i in 0..store.n_items() {
if store.item(i).and_downcast::<ArticleObject>()
.map(|o| o.article().id == cache.selected_id)
.unwrap_or(false)
{
select_idx = i;
break;
}
}
*self.pending_restore_id.borrow_mut() = Some(cache.selected_id);
}
let sel = self.selection.borrow();
let sel = sel.as_ref().unwrap();
*self.mark_unread_guard.borrow_mut() = true;
sel.set_selected(select_idx);
self.article_list_view.scroll_to(select_idx, gtk4::ListScrollFlags::SELECT, None);
self.sidebar_content.set_visible_child_name("list");
}
fn save_cache(&self) {
let store = self.article_store.borrow();
let Some(store) = store.as_ref() else { return };
let articles: Vec<crate::model::Article> = (0..store.n_items())
.filter_map(|i| store.item(i).and_downcast::<ArticleObject>())
.map(|o| o.article().clone())
.collect();
let selected_id = self.current_article_id.borrow().clone().unwrap_or_default();
crate::cache::save(&articles, &selected_id);
}
// ── Login ─────────────────────────────────────────────────────────────
fn auto_login(&self) {
if let Some((server_url, username, password)) = credentials::load_credentials() {
self.do_login(server_url, username, password, false);
} else {
self.show_login_dialog();
}
}
fn show_login_dialog(&self) {
let dialog = LoginDialog::new();
let win = self.obj();
let win_weak = win.downgrade();
dialog.connect_local("logged-in", false, move |args| {
let server_url = args[1].get::<String>().unwrap();
let username = args[2].get::<String>().unwrap();
let password = args[3].get::<String>().unwrap();
if let Some(win) = win_weak.upgrade() {
win.imp().do_login(server_url, username, password, true);
}
None
});
dialog.present(Some(win.upcast_ref::<gtk4::Widget>()));
}
fn do_login(&self, server_url: String, username: String, password: String, store: bool) {
let is_auto = !store;
let win_weak = self.obj().downgrade();
crate::runtime::spawn(
async move { Api::login(&server_url, &username, &password).await
.map(|api| (api, server_url, username, password)) },
move |result| {
let Some(win) = win_weak.upgrade() else { return };
match result {
Ok((api, server_url, username, password)) => {
if store {
credentials::store_credentials(&server_url, &username, &password);
}
// Fetch write token in background (non-critical)
let api_clone = api.clone();
let win_weak2 = win.downgrade();
crate::runtime::spawn(
async move { api_clone.fetch_write_token().await },
move |wt_result| {
if let Some(win) = win_weak2.upgrade() {
match wt_result {
Ok(wt) => *win.imp().write_token.borrow_mut() = Some(wt),
Err(e) => eprintln!("Write token error: {e}"),
}
}
},
);
*win.imp().api.borrow_mut() = Some(api);
win.imp().fetch_articles();
}
Err(e) => {
let has_cache = win.imp().article_store.borrow()
.as_ref().map(|s| s.n_items() > 0).unwrap_or(false);
if is_auto && has_cache {
// Offline with cached articles — just show a toast.
let toast = libadwaita::Toast::new("Offline — showing cached articles");
win.imp().toast_overlay.add_toast(toast);
} else {
win.imp().show_login_dialog();
win.imp().show_error_dialog("Login Failed", &e);
}
}
}
},
);
}
fn show_error_dialog(&self, title: &str, body: &str) {
let dialog = libadwaita::AlertDialog::new(Some(title), Some(body));
dialog.add_response("ok", "_OK");
dialog.set_default_response(Some("ok"));
dialog.present(Some(self.obj().upcast_ref::<gtk4::Widget>()));
}
fn do_logout(&self) {
let win_weak = self.obj().downgrade();
let dialog = libadwaita::AlertDialog::new(
Some("Log Out?"),
Some("Are you sure you want to log out?"),
);
dialog.add_response("cancel", "_Cancel");
dialog.add_response("logout", "_Log Out");
dialog.set_response_appearance("logout", libadwaita::ResponseAppearance::Destructive);
dialog.set_default_response(Some("cancel"));
dialog.connect_response(None, move |_, response| {
if response == "logout" {
if let Some(win) = win_weak.upgrade() {
credentials::clear_credentials();
*win.imp().api.borrow_mut() = None;
*win.imp().write_token.borrow_mut() = None;
if let Some(store) = win.imp().article_store.borrow().as_ref() {
store.remove_all();
}
win.imp().sidebar_content.set_visible_child_name("placeholder");
win.imp().article_menu_button.set_visible(false);
win.imp().show_login_dialog();
}
}
});
dialog.present(Some(self.obj().upcast_ref::<gtk4::Widget>()));
}
// ── Fetch articles ────────────────────────────────────────────────────
fn do_reload(&self) {
self.fetch_articles();
}
fn fetch_articles(&self) {
let api = self.api.borrow().clone();
let Some(api) = api else { return };
// Reload filter rules on every refresh so edits take effect immediately.
*self.filter_rules.borrow_mut() = crate::filters::load_rules();
self.refresh_stack.set_visible_child_name("spinner");
// Only show the loading screen if there's nothing to show yet.
let has_articles = self.article_store.borrow()
.as_ref().map(|s| s.n_items() > 0).unwrap_or(false);
if !has_articles {
self.sidebar_content.set_visible_child_name("loading");
}
let saved_id = self.current_article_id.borrow().clone();
let settings = gio::Settings::new("net.jeena.FeedTheMonkey");
let cache_images = settings.boolean("cache-images")
&& !gio::NetworkMonitor::default().is_network_metered();
let write_token = self.write_token.borrow().clone();
let win_weak = self.obj().downgrade();
crate::runtime::spawn(
async move {
// Flush any read/unread actions that failed to sync earlier.
if let Some(ref wt) = write_token {
crate::pending_actions::flush(&api, wt).await;
}
let articles = api.fetch_unread().await?;
let articles = if cache_images {
let processed = crate::image_cache::process(articles);
crate::runtime::spawn_bg(crate::image_cache::prefetch(processed.clone()));
processed
} else {
articles
};
Ok::<_, String>(articles)
},
move |result| {
let Some(win) = win_weak.upgrade() else { return };
let imp = win.imp();
match result {
Ok(articles) => {
let store = imp.article_store.borrow();
let store = store.as_ref().unwrap();
let sel = imp.selection.borrow();
let sel = sel.as_ref().unwrap();
store.remove_all();
for a in &articles {
store.append(&ArticleObject::new(a.clone()));
}
// Save cache and clean up unreferenced images.
let sel_id = saved_id.as_deref().unwrap_or("");
crate::cache::save(&articles, sel_id);
crate::image_cache::cleanup(&articles);
if store.n_items() == 0 {
imp.sidebar_content.set_visible_child_name("empty");
*imp.current_article_id.borrow_mut() = None;
imp.article_menu_button.set_visible(false);
imp.content_stack.set_visible_child_name("empty");
} else {
// Try to re-select the same article; fall back to first.
let mut select_idx = 0u32;
if let Some(ref id) = saved_id {
for i in 0..store.n_items() {
if store.item(i).and_downcast::<ArticleObject>()
.map(|o| o.article().id == *id)
.unwrap_or(false)
{
select_idx = i;
break;
}
}
}
*imp.mark_unread_guard.borrow_mut() = true;
sel.set_selected(select_idx);
imp.article_list_view.scroll_to(
select_idx,
gtk4::ListScrollFlags::SELECT,
None,
);
imp.sidebar_content.set_visible_child_name("list");
}
}
Err(e) => {
// If we already have cached articles, just show a toast.
let has_articles = imp.article_store.borrow()
.as_ref().map(|s| s.n_items() > 0).unwrap_or(false);
if has_articles {
let toast = libadwaita::Toast::new(&format!("Refresh failed: {e}"));
imp.toast_overlay.add_toast(toast);
} else {
imp.error_status.set_description(Some(&e));
imp.sidebar_content.set_visible_child_name("error");
}
}
}
imp.refresh_stack.set_visible_child_name("button");
},
);
}
// ── Read state ────────────────────────────────────────────────────────
fn bg_mark_read(&self, item_id: String) {
let api = self.api.borrow().clone();
let wt = self.write_token.borrow().clone();
if let (Some(api), Some(wt)) = (api, wt) {
crate::runtime::spawn_bg(async move {
if api.mark_read(&wt, &item_id).await.is_err() {
crate::pending_actions::add(
crate::pending_actions::Action::Read,
&item_id,
);
}
});
} else {
// Offline or write token not yet available — queue for later.
crate::pending_actions::add(
crate::pending_actions::Action::Read,
&item_id,
);
}
}
fn do_mark_unread(&self) {
let id = self.current_article_id.borrow().clone();
let Some(id) = id else { return };
// 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::<ArticleObject>() {
if obj.article().id == id {
obj.set_unread(true);
}
}
}
}
*self.mark_unread_guard.borrow_mut() = true;
let api = self.api.borrow().clone();
let wt = self.write_token.borrow().clone();
if let (Some(api), Some(wt)) = (api, wt) {
let id_clone = id.clone();
crate::runtime::spawn_bg(async move {
if api.mark_unread(&wt, &id_clone).await.is_err() {
crate::pending_actions::add(
crate::pending_actions::Action::Unread,
&id_clone,
);
}
});
} else {
crate::pending_actions::add(
crate::pending_actions::Action::Unread,
&id,
);
}
let toast = libadwaita::Toast::new("Marked as unread");
self.toast_overlay.add_toast(toast);
}
// ── Navigation ────────────────────────────────────────────────────────
pub fn navigate_by(&self, delta: i32) {
let sel = self.selection.borrow();
let Some(sel) = sel.as_ref() else { return };
let n = sel.n_items();
if n == 0 { return }
let current = sel.selected();
let next = if delta > 0 {
(current + 1).min(n - 1)
} else {
current.saturating_sub(1)
};
if next != current {
sel.set_selected(next);
self.article_list_view.scroll_to(next, gtk4::ListScrollFlags::SELECT, None);
}
}
// ── Open in browser ───────────────────────────────────────────────────
fn do_open_in_browser(&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 {
let link = obj.article().link.clone();
if !link.is_empty() {
open_uri(&link);
}
break;
}
}
}
}
}
// ── Zoom ──────────────────────────────────────────────────────────────
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; }}"
));
}
}
fn zoom(&self, factor: f64) {
let wv = &*self.web_view;
let new_level = (wv.zoom_level() * factor).clamp(0.25, 5.0);
wv.set_zoom_level(new_level);
self.update_sidebar_zoom(new_level);
let settings = gio::Settings::new("net.jeena.FeedTheMonkey");
settings.set_double("zoom-level", new_level).ok();
}
fn zoom_reset(&self) {
self.web_view.set_zoom_level(1.0);
self.update_sidebar_zoom(1.0);
let settings = gio::Settings::new("net.jeena.FeedTheMonkey");
settings.set_double("zoom-level", 1.0).ok();
}
}
impl WidgetImpl for FeedTheMonkeyWindow {}
impl WindowImpl for FeedTheMonkeyWindow {
fn close_request(&self) -> glib::Propagation {
self.save_cache();
// Cancel any in-flight requests by dropping the Api
*self.api.borrow_mut() = None;
self.parent_close_request()
}
}
impl ApplicationWindowImpl for FeedTheMonkeyWindow {}
impl AdwApplicationWindowImpl for FeedTheMonkeyWindow {}
}
fn open_uri(uri: &str) {
let launcher = gtk4::UriLauncher::new(uri);
launcher.launch(gtk4::Window::NONE, gio::Cancellable::NONE, |_| {});
}