use gtk4::prelude::*; use gtk4::subclass::prelude::*; use gtk4::{gio, glib}; use webkit6::prelude::{PolicyDecisionExt, WebViewExt}; glib::wrapper! { pub struct FeedTheMonkeyWindow(ObjectSubclass) @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, #[template_child] pub paned: TemplateChild, #[template_child] pub sidebar_toolbar: TemplateChild, #[template_child] pub refresh_stack: TemplateChild, #[template_child] pub refresh_button: TemplateChild, #[template_child] pub article_menu_button: TemplateChild, #[template_child] pub sidebar_content: TemplateChild, #[template_child] pub article_list_view: TemplateChild, #[template_child] pub content_stack: TemplateChild, #[template_child] pub web_view: TemplateChild, #[template_child] pub error_status: TemplateChild, pub filter_rules: RefCell>, pub api: RefCell>, pub write_token: RefCell>, pub article_store: RefCell>, pub selection: RefCell>, pub current_article_id: RefCell>, pub mark_unread_guard: RefCell, pub pending_restore_id: RefCell>, pub sidebar_zoom_css: std::cell::OnceCell, } #[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.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() }); 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::())); }); } fn instance_init(obj: &glib::subclass::InitializingObject) { 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( >k4::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::(); 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::().unwrap(); let row = crate::article_row::ArticleRow::new(); item.set_child(Some(&row)); }); factory.connect_bind(|_, item| { let item = item.downcast_ref::().unwrap(); if let Some(obj) = item.item().and_downcast::() { let row = item.child().and_downcast::().unwrap(); row.bind(&obj); } }); factory.connect_unbind(|_, item| { let item = item.downcast_ref::().unwrap(); if let Some(row) = item.child().and_downcast::() { 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::() { 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::() { 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 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::() { 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::().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::prelude::GtkWindowExt::focus(&win); if focused.is_some_and(|w| { w.is::() || w.is::() || w.is::() }) { 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::() .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 = (0..store.n_items()) .filter_map(|i| store.item(i).and_downcast::()) .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::().unwrap(); let username = args[2].get::().unwrap(); let password = args[3].get::().unwrap(); if let Some(win) = win_weak.upgrade() { win.imp().do_login(server_url, username, password, true); } None }); dialog.present(Some(win.upcast_ref::())); } 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::())); } 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::())); } // ── 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::() .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 }; self.do_mark_article_unread(id); } 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::() { if obj.article().id == id { obj.set_unread(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(); 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::() { 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 {{ 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, |_| {}); }