diff --git a/Cargo.lock b/Cargo.lock index 4fae0d2f..8b10b8a0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1148,6 +1148,7 @@ dependencies = [ "gstreamer-video", "gtk4", "indexmap", + "ksni", "libadwaita", "libglycin-gtk4-rebind", "libglycin-rebind", @@ -2455,6 +2456,19 @@ dependencies = [ "typewit", ] +[[package]] +name = "ksni" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5b29c089f14ce24c5b25d9bdcb265413b5e0c3df0871823e0d96bd83bc52a24" +dependencies = [ + "futures-util", + "pastey", + "serde", + "tokio", + "zbus", +] + [[package]] name = "kstring" version = "2.0.2" diff --git a/Cargo.toml b/Cargo.toml index cf9b30dd..9d168cc5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -72,6 +72,7 @@ gst_video = { version = "0.24", package = "gstreamer-video" } gtk = { version = "0.10", features = ["gnome_49"], package = "gtk4" } shumate = { version = "0.7", features = ["v1_1"], package = "libshumate" } sourceview = { version = "0.10", package = "sourceview5" } +ksni = "0.3" [dependencies.matrix-sdk] # version = "0.14" diff --git a/src/application.rs b/src/application.rs index 4c6c236c..1653b1a4 100644 --- a/src/application.rs +++ b/src/application.rs @@ -25,9 +25,10 @@ pub(crate) const APP_NAME: &str = "Fractal"; pub(crate) const APP_HOMEPAGE_URL: &str = "https://gitlab.gnome.org/World/fractal/"; mod imp { - use std::cell::Cell; + use std::cell::{Cell, OnceCell}; use super::*; + use crate::tray::{spawn_tray, TrayCommand, TrayHandle}; #[derive(Debug)] pub struct Application { @@ -39,6 +40,10 @@ mod imp { pub(super) session_list: SessionList, intent_handler: BoundObjectWeakRef, last_network_state: Cell, + /// Whether the app was started with --minimized flag. + pub start_minimized: Cell, + /// System tray handle. + pub tray_handle: OnceCell, } impl Default for Application { @@ -49,6 +54,8 @@ mod imp { session_list: Default::default(), intent_handler: Default::default(), last_network_state: Default::default(), + start_minimized: Cell::new(false), + tray_handle: OnceCell::new(), } } } @@ -89,6 +96,30 @@ mod imp { } )); + // Initialize system tray. + let mut tray_handle = spawn_tray(); + let tray_rx = tray_handle.take_receiver(); + let _ = self.tray_handle.set(tray_handle); + + if let Some(mut rx) = tray_rx { + spawn!(clone!( + #[weak(rename_to = obj)] + self.obj(), + async move { + while let Some(cmd) = rx.recv().await { + match cmd { + TrayCommand::Show => { + obj.imp().present_main_window(); + } + TrayCommand::Quit => { + obj.quit(); + } + } + } + } + )); + } + // Watch the network to log its state. let network_monitor = gio::NetworkMonitor::default(); network_monitor.connect_network_changed(clone!( @@ -149,6 +180,21 @@ mod imp { /// /// Returns the main window. fn present_main_window(&self) -> Window { + // Clear tray unread indicator when presenting window. + if let Some(tray) = self.tray_handle.get() { + tray.set_has_unread(false); + } + + // If started with --minimized, skip presentation on first call only. + if self.start_minimized.replace(false) { + let window = if let Some(window) = self.obj().active_window().and_downcast() { + window + } else { + Window::new(&self.obj()) + }; + return window; + } + let window = if let Some(window) = self.obj().active_window().and_downcast() { window } else { diff --git a/src/main.rs b/src/main.rs index 992feb20..f0d9d798 100644 --- a/src/main.rs +++ b/src/main.rs @@ -22,14 +22,18 @@ mod session; mod session_list; mod session_view; mod system_settings; +mod tray; mod user_facing_error; mod utils; mod window; -use std::sync::LazyLock; +use std::{ops::ControlFlow, sync::LazyLock}; use gettextrs::*; +use gio::prelude::*; use gtk::{IconTheme, gdk::Display, gio}; +use gtk::glib::{Char, OptionArg, OptionFlags}; +use gtk::subclass::prelude::ObjectSubclassIsExt; use tracing_subscriber::{EnvFilter, fmt, prelude::*}; use self::{application::*, config::*, i18n::*, utils::OneshotNotifier, window::Window}; @@ -77,5 +81,21 @@ fn main() { .add_resource_path("/org/gnome/Fractal/icons"); let app = Application::new(); + + // Register --minimized/-m option. + app.add_main_option( + "minimized", + Char::from(b'm'), + OptionFlags::NONE, + OptionArg::None, + "Start minimized to system tray", + None, + ); + + app.connect_handle_local_options(|app, options| { + app.imp().start_minimized.set(options.contains("minimized")); + ControlFlow::Continue(()) + }); + app.run(); } diff --git a/src/session/notifications/mod.rs b/src/session/notifications/mod.rs index fcf170af..c74d4d9a 100644 --- a/src/session/notifications/mod.rs +++ b/src/session/notifications/mod.rs @@ -13,7 +13,7 @@ use ruma::{ }, html::{HtmlSanitizerMode, RemoveReplyFallback}, }; -use tracing::{debug, warn}; +use tracing::{debug, info, warn}; mod notifications_settings; @@ -144,6 +144,14 @@ impl Notifications { } Application::default().send_notification(Some(id), ¬ification); + + // Update tray unread indicator. + info!("Notification sent, updating tray unread indicator"); + if let Some(tray) = Application::default().imp().tray_handle.get() { + tray.set_has_unread(true); + } else { + warn!("Tray handle not available"); + } } /// Ask the system to show the given push notification, if applicable. diff --git a/src/session_view/sidebar/mod.blp b/src/session_view/sidebar/mod.blp index f73d7692..32ef6dda 100644 --- a/src/session_view/sidebar/mod.blp +++ b/src/session_view/sidebar/mod.blp @@ -30,6 +30,13 @@ menu primary_menu { action: "app.about"; } } + + section { + item { + label: _("_Quit"); + action: "app.quit"; + } + } } menu room_row_menu { diff --git a/src/tray.rs b/src/tray.rs new file mode 100644 index 00000000..e6066dc2 --- /dev/null +++ b/src/tray.rs @@ -0,0 +1,193 @@ +use ksni::{menu::StandardItem, Icon, MenuItem, Tray, TrayMethods}; +use tokio::sync::mpsc; +use tracing::{info, warn}; + +use crate::RUNTIME; + +#[derive(Debug)] +pub enum TrayCommand { + Show, + Quit, +} + +pub struct FractalTray { + tx: mpsc::UnboundedSender, + pub has_unread: bool, +} + +impl FractalTray { + pub fn new(tx: mpsc::UnboundedSender) -> Self { + Self { + tx, + has_unread: false, + } + } +} + +impl Tray for FractalTray { + fn icon_name(&self) -> String { + if self.has_unread { + "mail-message-new".to_string() + } else { + "org.gnome.Fractal".to_string() + } + } + + fn overlay_icon_pixmap(&self) -> Vec { + if !self.has_unread { + return vec![]; + } + // Red notification dot overlay. + let size = 24; + let mut data = Vec::with_capacity((size * size * 4) as usize); + let dot_radius = 5i32; + let dot_center_x = size - dot_radius - 1; + let dot_center_y = dot_radius + 1; + + for y in 0..size { + for x in 0..size { + let dx = x - dot_center_x; + let dy = y - dot_center_y; + let in_dot = dx * dx + dy * dy <= dot_radius * dot_radius; + + if in_dot { + // Red dot. + data.push(255); // A + data.push(220); // R + data.push(50); // G + data.push(50); // B + } else { + // Transparent. + data.push(0); // A + data.push(0); // R + data.push(0); // G + data.push(0); // B + } + } + } + vec![Icon { + width: size, + height: size, + data, + }] + } + + fn title(&self) -> String { + "Fractal".to_string() + } + + fn id(&self) -> String { + "org.gnome.Fractal".to_string() + } + + fn category(&self) -> ksni::Category { + ksni::Category::Communications + } + + fn status(&self) -> ksni::Status { + if self.has_unread { + ksni::Status::NeedsAttention + } else { + ksni::Status::Active + } + } + + fn attention_icon_name(&self) -> String { + self.icon_name() + } + + fn tool_tip(&self) -> ksni::ToolTip { + let description = if self.has_unread { + "Unread messages" + } else { + "Fractal" + }; + ksni::ToolTip { + icon_name: self.icon_name(), + title: "Fractal".to_string(), + description: description.to_string(), + ..Default::default() + } + } + + fn activate(&mut self, _x: i32, _y: i32) { + info!("Tray icon activated"); + let _ = self.tx.send(TrayCommand::Show); + } + + fn menu(&self) -> Vec> { + let tx_show = self.tx.clone(); + let tx_quit = self.tx.clone(); + vec![ + StandardItem { + label: "Show Fractal".into(), + activate: Box::new(move |_| { + info!("Tray menu: Show clicked"); + let _ = tx_show.send(TrayCommand::Show); + }), + ..Default::default() + } + .into(), + MenuItem::Separator, + StandardItem { + label: "Quit".into(), + activate: Box::new(move |_| { + info!("Tray menu: Quit clicked"); + let _ = tx_quit.send(TrayCommand::Quit); + }), + ..Default::default() + } + .into(), + ] + } +} + +#[derive(Debug)] +pub struct TrayHandle { + rx: Option>, + update_tx: mpsc::UnboundedSender, +} + +impl TrayHandle { + pub fn take_receiver(&mut self) -> Option> { + self.rx.take() + } + + pub fn set_has_unread(&self, value: bool) { + info!("Tray set_has_unread: {}", value); + let _ = self.update_tx.send(value); + } +} + +pub fn spawn_tray() -> TrayHandle { + let (tx, rx) = mpsc::unbounded_channel(); + let (update_tx, mut update_rx) = mpsc::unbounded_channel::(); + let tray = FractalTray::new(tx); + + info!("Spawning system tray icon..."); + + RUNTIME.spawn(async move { + match tray.spawn().await { + Ok(handle) => { + info!("System tray icon created successfully"); + while let Some(has_unread) = update_rx.recv().await { + info!("Updating tray icon, has_unread: {}", has_unread); + handle + .update(|tray| { + tray.has_unread = has_unread; + }) + .await; + info!("Tray icon update complete"); + } + } + Err(e) => { + warn!("Failed to create tray icon: {e}"); + } + } + }); + + TrayHandle { + rx: Some(rx), + update_tx, + } +} diff --git a/src/window.rs b/src/window.rs index 2e115480..52503a80 100644 --- a/src/window.rs +++ b/src/window.rs @@ -203,7 +203,9 @@ mod imp { warn!("Could not save current session: {error}"); } - glib::Propagation::Proceed + // Hide to tray instead of closing. + self.obj().set_visible(false); + glib::Propagation::Stop } }