diff --git a/Cargo.lock b/Cargo.lock index c8506de605..bcdd7187ae 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11016,20 +11016,6 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" -[[package]] -name = "mac-notification-sys" -version = "0.6.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd604973958ddcc11b561193c0fb96ba146506ef2f231ef2e7c35fd2cbc9beca" -dependencies = [ - "cc", - "log", - "objc2", - "objc2-foundation", - "time", - "uuid", -] - [[package]] name = "mac_address2" version = "2.0.2" @@ -11752,6 +11738,7 @@ dependencies = [ "notification-interface", "notification-linux", "notification-macos", + "notification-windows", "serde", "tracing", ] @@ -11769,14 +11756,14 @@ name = "notification-linux" version = "0.1.0" dependencies = [ "gdk", + "gdk-pixbuf", "glib", "gtk", "indexmap 2.14.0", "notification-interface", - "open", "pango", "tokio", - "uuid", + "tracing", ] [[package]] @@ -11806,6 +11793,16 @@ dependencies = [ "uuid", ] +[[package]] +name = "notification-windows" +version = "0.1.0" +dependencies = [ + "indexmap 2.14.0", + "notification-interface", + "tracing", + "windows 0.62.2", +] + [[package]] name = "notification-worker" version = "0.1.0" @@ -11854,20 +11851,6 @@ dependencies = [ "walkdir", ] -[[package]] -name = "notify-rust" -version = "4.11.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6442248665a5aa2514e794af3b39661a8e73033b1cc5e59899e1276117ee4400" -dependencies = [ - "futures-lite", - "log", - "mac-notification-sys", - "serde", - "tauri-winrt-notification", - "zbus", -] - [[package]] name = "notify-types" version = "2.1.0" @@ -18986,7 +18969,6 @@ dependencies = [ "host", "intercept", "notification", - "notify-rust", "serde", "specta", "specta-typescript", @@ -18998,9 +18980,7 @@ dependencies = [ "tauri-plugin-windows", "tauri-specta", "thiserror 2.0.18", - "tokio", "tracing", - "windows 0.62.2", ] [[package]] @@ -19727,17 +19707,6 @@ dependencies = [ "toml 0.9.12+spec-1.1.0", ] -[[package]] -name = "tauri-winrt-notification" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ed071c670382e85fc2f48ae706492d8c338f4f89bf72520d32f8abfe880aade" -dependencies = [ - "thiserror 2.0.18", - "windows 0.61.3", - "windows-version", -] - [[package]] name = "tcc" version = "0.1.0" diff --git a/crates/notification-interface/src/lib.rs b/crates/notification-interface/src/lib.rs index 6eafbe7519..a557aea65c 100644 --- a/crates/notification-interface/src/lib.rs +++ b/crates/notification-interface/src/lib.rs @@ -1,4 +1,5 @@ use std::collections::BTreeSet; +use std::time::{Duration, Instant}; #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize, specta::Type)] pub enum NotificationEvent { @@ -177,6 +178,76 @@ pub struct Notification { pub icon: Option, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PrimaryAction<'a> { + Accept { label: &'a str, destructive: bool }, + Options(&'a [String]), +} + +#[derive(Debug, Clone)] +pub struct DismissTimer { + total: Duration, + remaining: Duration, + running_since: Option, +} + +impl DismissTimer { + pub fn new(total: Duration) -> Self { + Self::at(total, Instant::now()) + } + + pub fn at(total: Duration, now: Instant) -> Self { + Self { + total, + remaining: total, + running_since: Some(now), + } + } + + pub fn total(&self) -> Duration { + self.total + } + + pub fn is_running(&self) -> bool { + self.running_since.is_some() + } + + pub fn remaining(&self, now: Instant) -> Duration { + match self.running_since { + Some(started) => self + .remaining + .saturating_sub(now.saturating_duration_since(started)), + None => self.remaining, + } + } + + pub fn progress_ratio(&self, now: Instant) -> f64 { + if self.total.is_zero() { + return 0.0; + } + + self.remaining(now).as_secs_f64() / self.total.as_secs_f64() + } + + pub fn is_expired(&self, now: Instant) -> bool { + self.remaining(now).is_zero() + } + + pub fn pause(&mut self, now: Instant) { + if let Some(started) = self.running_since.take() { + self.remaining = self + .remaining + .saturating_sub(now.saturating_duration_since(started)); + } + } + + pub fn resume(&mut self, now: Instant) { + if self.running_since.is_none() && !self.remaining.is_zero() { + self.running_since = Some(now); + } + } +} + impl Notification { pub fn builder() -> NotificationBuilder { NotificationBuilder::default() @@ -185,6 +256,110 @@ impl Notification { pub fn is_persistent(&self) -> bool { self.timeout.is_none() } + + pub fn is_destructive_action(&self) -> bool { + matches!( + self.action_variant, + Some(NotificationActionVariant::Destructive) + ) + } + + pub fn shows_stop_countdown(&self) -> bool { + self.is_destructive_action() + && self.action_label.as_deref() == Some("Stop") + && self.timeout.is_some_and(|timeout| !timeout.is_zero()) + } + + pub fn has_options(&self) -> bool { + self.options + .as_deref() + .is_some_and(|options| !options.is_empty()) + } + + pub fn has_expandable_content(&self) -> bool { + if matches!(self.source, Some(NotificationSource::CalendarEvent { .. })) { + return false; + } + + self.participants + .as_ref() + .is_some_and(|participants| !participants.is_empty()) + || self.event_details.is_some() + } + + pub fn default_action_label(&self) -> &str { + self.action_label.as_deref().unwrap_or("Open Anarlog") + } + + pub fn expanded_action_label(&self) -> &str { + self.action_label.as_deref().unwrap_or("Accept") + } + + pub fn primary_action(&self) -> PrimaryAction<'_> { + if let Some(options) = self + .options + .as_deref() + .filter(|options| !options.is_empty()) + { + return PrimaryAction::Options(options); + } + + PrimaryAction::Accept { + label: self.default_action_label(), + destructive: self.is_destructive_action(), + } + } + + pub fn compact_title(&self) -> &str { + self.title.as_str() + } + + pub fn expanded_title(&self) -> &str { + self.event_details + .as_ref() + .map(|details| details.what.as_str()) + .filter(|title| !title.is_empty()) + .unwrap_or(self.title.as_str()) + } + + pub fn compact_message(&self, remaining: Option) -> String { + if self.start_time.is_some() { + return match remaining { + Some(value) if value.is_zero() => "Started".to_string(), + Some(value) => compact_schedule_text(value), + None => "Starting soon".to_string(), + }; + } + + if self.shows_stop_countdown() { + return stop_countdown_text(remaining.unwrap_or(Duration::ZERO)); + } + + self.message.clone() + } +} + +pub fn compact_schedule_text(remaining: Duration) -> String { + let minutes = (remaining.as_secs_f64() / 60.0).ceil().max(1.0) as u64; + if minutes == 1 { + "Starting in 1 minute".to_string() + } else { + format!("Starting in {minutes} minutes") + } +} + +pub fn expanded_schedule_text(remaining: Duration) -> String { + if remaining.is_zero() { + return "Started".to_string(); + } + + let total_seconds = remaining.as_secs(); + format!("Begins in {}:{:02}", total_seconds / 60, total_seconds % 60) +} + +pub fn stop_countdown_text(remaining: Duration) -> String { + let seconds = remaining.as_secs_f64().ceil() as u64; + format!("Anarlog will stop listening in {seconds} seconds.") } impl NotificationSource { @@ -430,4 +605,111 @@ mod tests { Some("YES") ); } + + #[test] + fn calendar_events_are_not_expandable() { + let notification = Notification::builder() + .title("Standup") + .message("Starting soon") + .source(NotificationSource::CalendarEvent { + event_id: "evt-1".to_string(), + }) + .participants(vec![Participant { + name: Some("Ada".to_string()), + email: "ada@example.com".to_string(), + status: ParticipantStatus::Accepted, + }]) + .event_details(EventDetails { + what: "Standup".to_string(), + timezone: None, + location: None, + }) + .build(); + + assert!(!notification.has_expandable_content()); + } + + #[test] + fn session_notifications_expand_when_event_details_are_present() { + let notification = Notification::builder() + .title("Design sync") + .message("") + .source(NotificationSource::Session { + session_id: "sess-1".to_string(), + }) + .event_details(EventDetails { + what: "Design sync".to_string(), + timezone: Some("America/Los_Angeles".to_string()), + location: Some("Zoom".to_string()), + }) + .build(); + + assert!(notification.has_expandable_content()); + assert_eq!(notification.expanded_title(), "Design sync"); + } + + #[test] + fn options_override_the_accept_action() { + let notification = Notification::builder() + .title("Choose a meeting") + .message("") + .action_label("Ignored") + .options(vec!["Design sync".to_string(), "Planning".to_string()]) + .build(); + + assert_eq!( + notification.primary_action(), + PrimaryAction::Options(&["Design sync".to_string(), "Planning".to_string()]) + ); + } + + #[test] + fn stop_countdown_copy_matches_macos() { + let notification = Notification::builder() + .title("Did your meeting end?") + .message("Anarlog will stop listening soon.") + .action_label("Stop") + .action_variant(NotificationActionVariant::Destructive) + .timeout(Duration::from_secs(30)) + .build(); + + assert!(notification.shows_stop_countdown()); + assert_eq!( + notification.compact_message(Some(Duration::from_secs_f64(4.2))), + "Anarlog will stop listening in 5 seconds." + ); + assert_eq!( + compact_schedule_text(Duration::from_secs(90)), + "Starting in 2 minutes" + ); + assert_eq!( + expanded_schedule_text(Duration::from_secs(75)), + "Begins in 1:15" + ); + } + + #[test] + fn dismiss_timer_pauses_and_resumes_without_losing_progress() { + let start = Instant::now(); + let mut timer = DismissTimer::at(Duration::from_secs(10), start); + + assert!((timer.progress_ratio(start) - 1.0).abs() < f64::EPSILON); + + let halfway = start + Duration::from_secs(4); + timer.pause(halfway); + assert!(!timer.is_running()); + assert_eq!( + timer.remaining(halfway + Duration::from_secs(30)), + Duration::from_secs(6) + ); + + let resumed = halfway + Duration::from_secs(8); + timer.resume(resumed); + assert_eq!( + timer.remaining(resumed + Duration::from_secs(2)), + Duration::from_secs(4) + ); + assert!((timer.progress_ratio(resumed + Duration::from_secs(2)) - 0.4).abs() < 1e-9); + assert!(timer.is_expired(resumed + Duration::from_secs(6))); + } } diff --git a/crates/notification-linux/Cargo.toml b/crates/notification-linux/Cargo.toml index f368513349..35d3a4673d 100644 --- a/crates/notification-linux/Cargo.toml +++ b/crates/notification-linux/Cargo.toml @@ -18,9 +18,9 @@ anlg-notification-interface = { workspace = true } [target.'cfg(target_os = "linux")'.dependencies] gdk = "0.18" +gdk-pixbuf = "0.18" glib = "0.18" gtk = "0.18" pango = "0.18" indexmap = "2.6" -open = { workspace = true } -uuid = { workspace = true, features = ["v4"] } +tracing = { workspace = true } diff --git a/crates/notification-linux/src/callbacks.rs b/crates/notification-linux/src/callbacks.rs index bf28aab0ed..b6a947b204 100644 --- a/crates/notification-linux/src/callbacks.rs +++ b/crates/notification-linux/src/callbacks.rs @@ -10,35 +10,6 @@ static TIMEOUT_CB: NotificationCallback = Mutex::new(None); static OPTION_SELECTED_CB: NotificationOptionCallback = Mutex::new(None); static FOOTER_ACTION_CB: NotificationCallback = Mutex::new(None); -#[derive(Debug, PartialEq, Eq)] -pub(crate) enum PrimaryAction<'a> { - Accept { label: &'a str, destructive: bool }, - Options(&'a [String]), -} - -pub(crate) fn primary_action( - notification: &anlg_notification_interface::Notification, -) -> PrimaryAction<'_> { - if let Some(options) = notification - .options - .as_deref() - .filter(|options| !options.is_empty()) - { - return PrimaryAction::Options(options); - } - - PrimaryAction::Accept { - label: notification - .action_label - .as_deref() - .unwrap_or("Open Anarlog"), - destructive: matches!( - notification.action_variant, - Some(anlg_notification_interface::NotificationActionVariant::Destructive) - ), - } -} - pub fn setup_notification_confirm_handler(f: F) where F: Fn(String) + Send + Sync + 'static, @@ -123,46 +94,6 @@ mod tests { use super::*; - #[test] - fn resolves_linux_primary_actions_from_the_shared_notification_contract() { - let default_action = anlg_notification_interface::Notification::builder() - .title("Upcoming event") - .message("Starting soon") - .build(); - assert_eq!( - primary_action(&default_action), - PrimaryAction::Accept { - label: "Open Anarlog", - destructive: false, - } - ); - - let destructive_action = anlg_notification_interface::Notification::builder() - .title("Did your meeting end?") - .message("Anarlog will stop listening soon.") - .action_label("Stop") - .action_variant(anlg_notification_interface::NotificationActionVariant::Destructive) - .build(); - assert_eq!( - primary_action(&destructive_action), - PrimaryAction::Accept { - label: "Stop", - destructive: true, - } - ); - - let options_action = anlg_notification_interface::Notification::builder() - .title("Choose a meeting") - .message("") - .action_label("Ignored when options are present") - .options(vec!["Design sync".to_string(), "Planning".to_string()]) - .build(); - assert_eq!( - primary_action(&options_action), - PrimaryAction::Options(&["Design sync".to_string(), "Planning".to_string()]) - ); - } - #[test] fn routes_each_linux_notification_action_to_its_registered_handler() { let events = Arc::new(Mutex::new(Vec::new())); diff --git a/crates/notification-linux/src/icon.rs b/crates/notification-linux/src/icon.rs new file mode 100644 index 0000000000..8b37844c23 --- /dev/null +++ b/crates/notification-linux/src/icon.rs @@ -0,0 +1,135 @@ +use gdk_pixbuf::{InterpType, Pixbuf}; +use gtk::IconTheme; +use gtk::prelude::IconThemeExt; + +use anlg_notification_interface::{NotificationIcon, NotificationIconAsset}; + +const ICON_SIZE: i32 = 28; + +pub(crate) fn pixbuf_for_icon(icon: Option<&NotificationIcon>) -> Option { + match icon { + None => default_app_icon(), + Some(NotificationIcon::Hidden) => None, + Some(NotificationIcon::BundleId { bundle_id }) => { + icon_theme_pixbuf(bundle_id).or_else(default_app_icon) + } + Some(NotificationIcon::SystemSymbol { name }) => { + icon_theme_pixbuf(system_symbol_icon_name(name)).or_else(default_app_icon) + } + Some(NotificationIcon::Path { path }) => pixbuf_from_path(path).or_else(default_app_icon), + Some(NotificationIcon::Overlay { base, badge }) => { + let base_image = pixbuf_for_asset(base).or_else(default_app_icon)?; + match pixbuf_for_asset(badge) { + Some(badge_image) => Some(compose_overlay(&base_image, &badge_image)), + None => Some(base_image), + } + } + } +} + +fn pixbuf_for_asset(asset: &NotificationIconAsset) -> Option { + match asset { + NotificationIconAsset::AppIcon => default_app_icon(), + NotificationIconAsset::Calendar => icon_theme_pixbuf("x-office-calendar") + .or_else(|| icon_theme_pixbuf("office-calendar")) + .or_else(|| icon_theme_pixbuf("calendar")), + NotificationIconAsset::SystemSymbol { name } => { + icon_theme_pixbuf(system_symbol_icon_name(name)) + } + NotificationIconAsset::BundleId { bundle_id } => icon_theme_pixbuf(bundle_id), + NotificationIconAsset::Path { path } => pixbuf_from_path(path), + } +} + +fn pixbuf_from_path(path: &str) -> Option { + let expanded = expand_home(path); + Pixbuf::from_file_at_scale(&expanded, ICON_SIZE, ICON_SIZE, true).ok() +} + +fn expand_home(path: &str) -> String { + if let Some(rest) = path.strip_prefix("~/") + && let Ok(home) = std::env::var("HOME") + { + return format!("{home}/{rest}"); + } + path.to_string() +} + +fn default_app_icon() -> Option { + icon_theme_pixbuf("com.hyprnote.dev") + .or_else(|| icon_theme_pixbuf("anarlog")) + .or_else(|| icon_theme_pixbuf("hyprnote")) + .or_else(|| icon_theme_pixbuf("application-x-executable")) +} + +fn icon_theme_pixbuf(name: &str) -> Option { + let theme = IconTheme::default()?; + if let Ok(Some(pixbuf)) = theme.load_icon(name, ICON_SIZE, gtk::IconLookupFlags::FORCE_SIZE) { + return Some(pixbuf); + } + + let normalized = name.rsplit('.').next().unwrap_or(name).to_ascii_lowercase(); + theme + .load_icon(&normalized, ICON_SIZE, gtk::IconLookupFlags::FORCE_SIZE) + .ok() + .flatten() +} + +fn system_symbol_icon_name(name: &str) -> &str { + match name { + "phone.fill" | "phone" => "phone", + "video.fill" | "video" => "camera-web", + "calendar" => "x-office-calendar", + _ => "dialog-information", + } +} + +fn compose_overlay(base: &Pixbuf, badge: &Pixbuf) -> Pixbuf { + let Some(dest) = base.copy() else { + return base.clone(); + }; + + let badge_size = ((base.width().max(1) as f64) * 0.54).round() as i32; + let Some(scaled) = badge.scale_simple(badge_size, badge_size, InterpType::Bilinear) else { + return dest; + }; + + let x = (base.width() - badge_size).max(0); + let y = (base.height() - badge_size).max(0); + scaled.composite( + &dest, + x, + y, + badge_size, + badge_size, + f64::from(x), + f64::from(y), + 1.0, + 1.0, + InterpType::Bilinear, + 255, + ); + dest +} + +#[cfg(test)] +mod tests { + use super::expand_home; + + #[test] + fn expands_home_prefixed_icon_paths() { + let previous = std::env::var("HOME").ok(); + unsafe { + std::env::set_var("HOME", "/home/anarlog"); + } + assert_eq!( + expand_home("~/icons/zoom.svg"), + "/home/anarlog/icons/zoom.svg" + ); + assert_eq!(expand_home("/usr/share/zoom.png"), "/usr/share/zoom.png"); + match previous { + Some(home) => unsafe { std::env::set_var("HOME", home) }, + None => unsafe { std::env::remove_var("HOME") }, + } + } +} diff --git a/crates/notification-linux/src/impl.rs b/crates/notification-linux/src/impl.rs index 0436610a7f..0b035f0802 100644 --- a/crates/notification-linux/src/impl.rs +++ b/crates/notification-linux/src/impl.rs @@ -1,222 +1,174 @@ use std::cell::RefCell; -use std::time::Duration; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; +use gdk::prelude::*; +use gtk::gdk::NotifyType; use gtk::prelude::*; use gtk::{ Align, Box as GtkBox, Button, CssProvider, EventBox, Image, Label, Menu, MenuButton, MenuItem, - Orientation, StyleContext, Window, WindowType, + Orientation, Overlay, ProgressBar, StyleContext, Window, WindowType, }; use indexmap::IndexMap; +use anlg_notification_interface::{ + DismissTimer, ParticipantStatus, PrimaryAction, expanded_schedule_text, +}; + use crate::callbacks; +use crate::icon; + +const NOTIFICATION_WIDTH: i32 = 344; +const COMPACT_HEIGHT: i32 = 64; +const COMPACT_FOOTER_HEIGHT: i32 = 28; +const EXPANDED_HEIGHT: i32 = 380; +const RIGHT_MARGIN: i32 = 15; +const TOP_MARGIN: i32 = 15; +const NOTIFICATION_SPACING: i32 = 10; +const MAX_NOTIFICATIONS: usize = 5; +const TICK_INTERVAL: Duration = Duration::from_millis(50); thread_local! { - static NOTIFICATION_MANAGER: RefCell = RefCell::new(NotificationManager::new()); + static NOTIFICATION_MANAGER: RefCell = + RefCell::new(NotificationManager::new()); } struct NotificationInstance { key: String, + payload: anlg_notification_interface::Notification, window: Window, timeout_source: Option, + dismiss_timer: Option, + is_hovered: bool, + is_expanded: bool, + message_label: Option