app: implement Epics 2–10
Add the full application logic on top of the Epic 1 skeleton:
Epic 2 — Authentication
- LoginDialog (AdwDialog, Blueprint template) with server URL,
username, and password fields; emits logged-in signal on submit
- credentials.rs: store/load/clear via libsecret (password_store_sync /
password_search_sync / password_clear_sync, v0_19 feature)
- api.rs: Api::login() parses Auth= token from ClientLogin response;
fetch_write_token() fetches the write token
- Auto-login on startup from stored credentials; logout with
AdwAlertDialog confirmation; login errors shown in AdwAlertDialog
Epic 3 — Article fetching
- model.rs: Article struct and ArticleObject GObject wrapper with
unread property for list store binding
- Api::fetch_unread() deserializes Google Reader JSON, derives unread
from categories, generates plain-text excerpt
- Sidebar uses a GtkStack with placeholder / loading / empty / error /
list pages; AdwSpinnerPaintable while fetching; Try Again button
Epic 4 — Sidebar
- article_row.blp: composite template with feed title, date, title,
and excerpt labels
- ArticleRow GObject subclass; binds ArticleObject, watches unread
notify to apply .dim-label on the title; relative timestamp format
Epic 5 — Content pane
- content.html updated: setArticle(), checkKey(), feedthemonkey: URI
navigation scheme; dark mode via prefers-color-scheme
- content.css: proper article layout, dark mode, code blocks
- WebView loaded from GResource; decide-policy intercepts
feedthemonkey:{next,previous,open} and all external links
Epic 6 — Read state
- Api::mark_read() / mark_unread() via edit-tag endpoint
- Optimistic unread toggle on ArticleObject; background API calls;
mark_unread_guard prevents re-marking on navigation
- AdwToast shown on mark-unread
Epic 7 — Keyboard shortcuts
- GtkShortcutController on window for all shortcuts from the backlog
- shortcuts.blp: AdwShortcutsWindow documenting all shortcuts
- F1 opens shortcuts dialog; Ctrl+W closes window; Ctrl+Q quits
Epic 8 — Zoom
- zoom_in/zoom_out/zoom_reset wired to Ctrl+±/0; zoom level saved to
and restored from GSettings zoom-level key
Epic 9 — Window state persistence
- Window width/height/maximized saved on close, restored on open
- (Sidebar width deferred — AdwNavigationSplitView fraction binding)
Epic 10 — Polish
- AdwAboutDialog with app name, version, GPL-3.0, website
- Logout confirmation AdwAlertDialog with destructive button
- Win.toggle-fullscreen action (F11)
- Api dropped on window close to cancel in-flight requests
This commit is contained in:
parent
8db0b16954
commit
813dda3579
22 changed files with 1838 additions and 42 deletions
466
src/window.rs
466
src/window.rs
|
|
@ -1,4 +1,7 @@
|
|||
use gtk4::prelude::*;
|
||||
use gtk4::subclass::prelude::*;
|
||||
use gtk4::{gio, glib};
|
||||
use webkit6::prelude::{PolicyDecisionExt, WebViewExt};
|
||||
|
||||
glib::wrapper! {
|
||||
pub struct FeedTheMonkeyWindow(ObjectSubclass<imp::FeedTheMonkeyWindow>)
|
||||
|
|
@ -15,10 +18,16 @@ impl FeedTheMonkeyWindow {
|
|||
}
|
||||
}
|
||||
|
||||
mod imp {
|
||||
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")]
|
||||
|
|
@ -35,6 +44,23 @@ mod imp {
|
|||
pub content_page: TemplateChild<libadwaita::NavigationPage>,
|
||||
#[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 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>,
|
||||
}
|
||||
|
||||
#[glib::object_subclass]
|
||||
|
|
@ -45,6 +71,28 @@ mod imp {
|
|||
|
||||
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();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn instance_init(obj: &glib::subclass::InitializingObject<Self>) {
|
||||
|
|
@ -55,42 +103,422 @@ mod imp {
|
|||
impl ObjectImpl for FeedTheMonkeyWindow {
|
||||
fn constructed(&self) {
|
||||
self.parent_constructed();
|
||||
self.restore_window_state();
|
||||
self.setup_window_state();
|
||||
self.setup_list();
|
||||
self.setup_webview();
|
||||
self.auto_login();
|
||||
}
|
||||
}
|
||||
|
||||
impl FeedTheMonkeyWindow {
|
||||
fn restore_window_state(&self) {
|
||||
// ── Window state ─────────────────────────────────────────────────────
|
||||
|
||||
fn setup_window_state(&self) {
|
||||
let settings = gio::Settings::new("net.jeena.FeedTheMonkey");
|
||||
let window = self.obj();
|
||||
|
||||
let width = settings.int("window-width");
|
||||
let height = settings.int("window-height");
|
||||
let maximized = settings.boolean("window-maximized");
|
||||
|
||||
window.set_default_size(width, height);
|
||||
if maximized {
|
||||
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();
|
||||
}
|
||||
|
||||
// Save state when the window closes
|
||||
let settings_clone = settings.clone();
|
||||
// Restore zoom
|
||||
let zoom = settings.double("zoom-level");
|
||||
self.web_view.set_zoom_level(zoom);
|
||||
|
||||
let s = settings.clone();
|
||||
window.connect_close_request(move |win| {
|
||||
if !win.is_maximized() {
|
||||
let (w, h) = (win.width(), win.height());
|
||||
settings_clone.set_int("window-width", w).ok();
|
||||
settings_clone.set_int("window-height", h).ok();
|
||||
s.set_int("window-width", win.width()).ok();
|
||||
s.set_int("window-height", win.height()).ok();
|
||||
}
|
||||
settings_clone
|
||||
.set_boolean("window-maximized", win.is_maximized())
|
||||
.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());
|
||||
|
||||
// Show content pane
|
||||
self.content_page.set_title(&article.title);
|
||||
self.article_menu_button.set_visible(true);
|
||||
self.split_view.set_show_content(true);
|
||||
|
||||
// Load in webview
|
||||
self.load_article_in_webview(&article);
|
||||
obj.set_unread(false);
|
||||
}
|
||||
|
||||
fn load_article_in_webview(&self, article: &crate::model::Article) {
|
||||
let json = serde_json::json!({
|
||||
"id": article.id,
|
||||
"title": article.title,
|
||||
"feed_title": article.feed_title,
|
||||
"link": article.link,
|
||||
"updated": article.published,
|
||||
"content": article.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;
|
||||
|
||||
// Load content.html from GResource
|
||||
let html = String::from_utf8(
|
||||
gio::resources_lookup_data(
|
||||
"/net/jeena/FeedTheMonkey/html/content.html",
|
||||
gio::ResourceLookupFlags::NONE,
|
||||
)
|
||||
.unwrap()
|
||||
.to_vec(),
|
||||
)
|
||||
.unwrap();
|
||||
wv.load_html(&html, Some("feedthemonkey://localhost/"));
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
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(),
|
||||
_ => { open_uri(&uri); }
|
||||
}
|
||||
}
|
||||
true
|
||||
});
|
||||
}
|
||||
|
||||
// ── Login ─────────────────────────────────────────────────────────────
|
||||
|
||||
fn auto_login(&self) {
|
||||
if let Some((server_url, username, password)) = credentials::load_credentials() {
|
||||
let win_weak = self.obj().downgrade();
|
||||
glib::spawn_future_local(async move {
|
||||
if let Some(win) = win_weak.upgrade() {
|
||||
win.imp().do_login(server_url, username, password, false).await;
|
||||
}
|
||||
});
|
||||
} 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() {
|
||||
glib::spawn_future_local(async move {
|
||||
win.imp().do_login(server_url, username, password, true).await;
|
||||
});
|
||||
}
|
||||
None
|
||||
});
|
||||
dialog.present(Some(win.upcast_ref::<gtk4::Widget>()));
|
||||
}
|
||||
|
||||
pub async fn do_login(
|
||||
&self,
|
||||
server_url: String,
|
||||
username: String,
|
||||
password: String,
|
||||
store: bool,
|
||||
) {
|
||||
match Api::login(&server_url, &username, &password).await {
|
||||
Ok(api) => {
|
||||
if store {
|
||||
credentials::store_credentials(&server_url, &username, &password);
|
||||
}
|
||||
match api.fetch_write_token().await {
|
||||
Ok(wt) => *self.write_token.borrow_mut() = Some(wt),
|
||||
Err(e) => eprintln!("Write token error: {e}"),
|
||||
}
|
||||
*self.api.borrow_mut() = Some(api);
|
||||
self.fetch_articles().await;
|
||||
}
|
||||
Err(e) => {
|
||||
self.show_login_dialog();
|
||||
self.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) {
|
||||
let win_weak = self.obj().downgrade();
|
||||
glib::spawn_future_local(async move {
|
||||
if let Some(win) = win_weak.upgrade() {
|
||||
win.imp().fetch_articles().await;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pub async fn fetch_articles(&self) {
|
||||
let api = self.api.borrow().clone();
|
||||
let Some(api) = api else { return };
|
||||
|
||||
self.refresh_stack.set_visible_child_name("spinner");
|
||||
self.sidebar_content.set_visible_child_name("loading");
|
||||
|
||||
match api.fetch_unread().await {
|
||||
Ok(articles) => {
|
||||
let store = self.article_store.borrow();
|
||||
let store = store.as_ref().unwrap();
|
||||
store.remove_all();
|
||||
for a in articles {
|
||||
store.append(&ArticleObject::new(a));
|
||||
}
|
||||
if store.n_items() == 0 {
|
||||
self.sidebar_content.set_visible_child_name("empty");
|
||||
} else {
|
||||
self.sidebar_content.set_visible_child_name("list");
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
self.error_status.set_description(Some(&e));
|
||||
self.sidebar_content.set_visible_child_name("error");
|
||||
}
|
||||
}
|
||||
|
||||
self.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) {
|
||||
glib::spawn_future_local(async move {
|
||||
if let Err(e) = api.mark_read(&wt, &item_id).await {
|
||||
eprintln!("mark_read error: {e}");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
glib::spawn_future_local(async move {
|
||||
if let Err(e) = api.mark_unread(&wt, &id_clone).await {
|
||||
eprintln!("mark_unread error: {e}");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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 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);
|
||||
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);
|
||||
let settings = gio::Settings::new("net.jeena.FeedTheMonkey");
|
||||
settings.set_double("zoom-level", 1.0).ok();
|
||||
}
|
||||
}
|
||||
|
||||
impl WidgetImpl for FeedTheMonkeyWindow {}
|
||||
impl WindowImpl for FeedTheMonkeyWindow {}
|
||||
|
||||
impl WindowImpl for FeedTheMonkeyWindow {
|
||||
fn close_request(&self) -> glib::Propagation {
|
||||
// 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, |_| {});
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue