use std::collections::HashMap; use std::path::Path; use std::thread; use std::time::{Duration, Instant, SystemTime}; use amane::{ Center, Column, Full, Horizontal, Image, LayerWindow, Layer, Margin, Monitor, Notification, Notifications, Pointer, Rectangle, Row, ScrollArea, Service, SpaceBetween, Start, Text, Urgency, Vertical, Weight, Widget, Zone, }; use crate::config::appearance::{overlays, text}; use crate::config::control::pill as control_pill; use crate::config::island::pill as island_pill; use crate::config::notifications::{apps, popups as settings}; use crate::glyph::Glyph; use crate::island::{self, faded}; use crate::parse::{self, Value}; use crate::{config, pods, surface, theme, tray}; /* * notifications: amane's own server (org.freedesktop.Notifications, which dunst held * before) keeps the list; this adds the popups, the unread count on the bell and the * history card. a watcher reads the list four times a second (memory, not d-bus), finds * each new one's picture once and times the popups out by urgency */ #[derive(Default)] pub struct Notices { // shown as popups, newest first, with when each (re)arrived popups: Vec<(u32, Instant)>, // each notification's arrival time as last seen, to tell new and replaced ones apart known: HashMap, // a picture file per notification: its image, else its icon found in the themes pictures: HashMap, unread: usize, // the pointer over the popups: they don't time out meanwhile hovered: bool, } impl Service for Notices { fn new() -> Self { Self::default() } fn listen() { loop { watch(); thread::sleep(Duration::from_millis(250)); } } } pub fn unread() -> usize { Notices::read().unread } // the history was looked at pub fn mark_read() { let mut notices = Notices::write(); if notices.unread > 0 { notices.unread = 0; } } fn timeout(urgency: Urgency) -> Option { match urgency { Urgency::Low => Some(Duration::from_millis(settings::low_timeout_ms())), Urgency::Critical if settings::critical_stays() => None, _ => Some(Duration::from_millis(settings::timeout_ms())), } } fn watch() { let current: Vec<(u32, SystemTime, Urgency, String, String, String)> = Notifications::read() .list() .iter() .map(|notice| (notice.id(), notice.received(), notice.urgency(), notice.icon().to_string(), notice.image().to_string(), notice.app_name().to_string())) .collect(); let (arrived, gone, expired) = { let notices = Notices::read(); let arrived: Vec<_> = current.iter().filter(|(id, received, ..)| notices.known.get(id) != Some(received)).cloned().collect(); let gone: Vec = notices.known.keys().filter(|id| !current.iter().any(|(known, ..)| known == *id)).copied().collect(); let expired: Vec = if notices.hovered { Vec::new() } else { notices .popups .iter() .filter(|(id, since)| current.iter().find(|(known, ..)| known == id).and_then(|(_, _, urgency, ..)| timeout(*urgency)).is_some_and(|stay| since.elapsed() >= stay)) .map(|(id, _)| *id) .collect() }; (arrived, gone, expired) }; if arrived.is_empty() && gone.is_empty() && expired.is_empty() { return; } // a blocked app's notifications are dropped at once; a muted one's stay in the history without popping up let (blocked, muted) = (apps::blocked(), apps::muted()); let (dropped, arrived): (Vec<_>, Vec<_>) = arrived.into_iter().partition(|(.., app)| listed(&blocked, app)); for (id, ..) in &dropped { Notifications::dismiss(*id); } // icon lookups walk the themes: here, not under the lock and never in a view let pictures: Vec<(u32, Option)> = arrived.iter().map(|(id, _, _, icon, image, _)| (*id, picture(icon, image))).collect(); let looking = pods::open_card() == Some(pods::Pod::Notifications); let popping = settings::enabled(); let mut notices = Notices::write(); for (id, received, .., app) in &arrived { let fresh = !notices.known.contains_key(id); let quiet = listed(&muted, app); notices.known.insert(*id, *received); notices.popups.retain(|(shown, _)| shown != id); if popping && !quiet { notices.popups.insert(0, (*id, Instant::now())); } if fresh && !looking && !quiet { notices.unread += 1; } } for (id, file) in pictures { match file { Some(file) => notices.pictures.insert(id, file), None => notices.pictures.remove(&id), }; } for id in gone { notices.known.remove(&id); notices.pictures.remove(&id); notices.popups.retain(|(shown, _)| *shown != id); } notices.popups.retain(|(shown, _)| !expired.contains(shown)); notices.unread = notices.unread.min(notices.known.len()); } // whether `app` is in a comma-separated list of app names, in any case fn listed(list: &str, app: &str) -> bool { !app.is_empty() && list.split(',').any(|name| name.trim().eq_ignore_ascii_case(app)) } // puts an app into the list at `path` in config.toml, or takes it out fn relist(path: &str, list: String, app: &str, on: bool) { let mut names: Vec<&str> = list.split(',').map(str::trim).filter(|name| !name.is_empty() && !name.eq_ignore_ascii_case(app)).collect(); if on { names.push(app); } let _ = config::set(path, Value::Text(names.join(", "))); } // Mute app: its notifications stop popping up (its popups now go too) but stay in the history fn mute(app: &str, on: bool) { relist("notifications.apps.muted", apps::muted(), app, on); if on { hide_app(app); } } // Block app: its notifications are dropped from now on fn block(app: &str, on: bool) { relist("notifications.apps.blocked", apps::blocked(), app, on); if on { hide_app(app); } } fn hide_app(app: &str) { let ids: Vec = Notifications::read().list().iter().filter(|notice| notice.app_name().eq_ignore_ascii_case(app)).map(Notification::id).collect(); Notices::write().popups.retain(|(shown, _)| !ids.contains(shown)); } // a notification's picture as a file: its image (a path or file:// url), else its icon (a path or a theme name) fn picture(icon: &str, image: &str) -> Option { for wanted in [image, icon] { let path = wanted.strip_prefix("file://").unwrap_or(wanted); if path.is_empty() { continue; } if path.starts_with('/') { if Path::new(path).is_file() { return Some(path.to_string()); } continue; } if let Some(file) = tray::icon_file(path, "") { return Some(file.to_string_lossy().into_owned()); } } None } fn hide(id: u32) { Notices::write().popups.retain(|(shown, _)| *shown != id); } fn hovering(inside: bool) { let mut notices = Notices::write(); notices.hovered = inside; // leaving starts every popup's time anew if !inside { for (_, since) in &mut notices.popups { *since = Instant::now(); } } } fn ago(received: SystemTime) -> String { let seconds = received.elapsed().map_or(0, |since| since.as_secs()); match seconds { 0..60 => String::from("now"), 60..3600 => format!("{} min", seconds / 60), 3600..86400 => format!("{} h", seconds / 3600), _ => format!("{} d", seconds / 86400), } } // the picture, or the app's first letter on a disc fn picture_widget(file: Option<&String>, app: &str, size: f32) -> Box { let theme = theme::now(); match file { Some(file) => Box::new(Rectangle::new().width(size).height(size).radius(size * 0.22).fill(Image::contain(file.clone()))), None => { let letter: String = app.chars().next().map(|letter| letter.to_uppercase().collect()).unwrap_or_else(|| String::from("•")); Box::new(Rectangle::new().width(size).height(size).radius(Full).fill(faded(theme.accent, 70)).align_child(Center, Center).child(Text::new(letter).size(size * 0.42).weight(Weight::Bold).color(theme.accent))) } } } // the buttons a notification asks for fn actions(notice: &Notification) -> Option> { if notice.actions().is_empty() { return None; } let id = notice.id(); let chips: Vec> = notice .actions() .iter() .map(|action| { let key = action.key().to_string(); Box::new(pods::chip(action.label(), None, move || Notifications::invoke(id, &key))) as Box }) .collect(); Some(Box::new(Row::new(chips).gap(6.0))) } /* * one notification's face, for a popup and for the history: picture, app and time, * summary, body; `lines` caps the body; `close` is the cross at its top right */ fn face(notice: &Notification, file: Option<&String>, width: f32, lines: usize, close: Box) -> (Rectangle, f32) { let theme = theme::now(); let picture_size = settings::icon(); let text_w = width - picture_size - 12.0 - 30.0; let body = parse::plain_body(notice.body()); let body = Text::new(body).size(text::small()).color(theme.dim).wrap().max_lines(lines); let body_h = if notice.body().is_empty() { 0.0 } else { body.height_in(text_w) }; let line_h = text::tiny() + 6.0; let summary_h = text::body() + 6.0; let words_h = line_h + summary_h + body_h; let actions = actions(notice); let actions_h = if actions.is_some() { 26.0 + 8.0 } else { 0.0 }; let height = words_h.max(picture_size) + actions_h; let app = if notice.app_name().is_empty() { String::from("Notification") } else { notice.app_name().to_string() }; let words = Column::new(vec![ Box::new(Rectangle::new().width(text_w).height(line_h).align_child(Start, Center).child(Text::new(format!("{app} · {}", ago(notice.received()))).size(text::tiny()).color(theme.dim).elide())) as Box, Box::new(Rectangle::new().width(text_w).height(summary_h).align_child(Start, Center).child(Text::new(notice.summary()).size(text::body()).weight(Weight::Bold).color(theme.text).elide())), Box::new(Rectangle::new().width(text_w).height(body_h).child(body)), ]); let top = Row::new(vec![ Box::new(Row::new(vec![picture_widget(file, &app, picture_size), Box::new(words)]).gap(12.0)) as Box, Box::new(pods::button(Glyph::Cross, close)), ]) .width(width) .justify(SpaceBetween); let mut parts: Vec> = vec![Box::new(Rectangle::new().width(width).height(words_h.max(picture_size)).child(top))]; if let Some(actions) = actions { parts.push(Box::new(Rectangle::new().width(width).height(26.0).padding(amane::Padding { top: 0.0, right: 0.0, bottom: 0.0, left: picture_size + 12.0 }).child(Column::new(vec![actions])))); } (Rectangle::new().width(width).height(height).child(Column::new(parts).gap(8.0)), height) } /* * the popups, top right under the bar on the focused monitor, newest on top; a click * runs the notification's default action (or just hides the popup), the cross hides * it, both leave it in the history */ pub fn popups(monitor: &Monitor) -> LayerWindow { theme::begin(); let notices = Notices::read(); let list = Notifications::read(); let shadow = island_pill::shadow(); let width = settings::width(); let pad = 14.0; // the right edge lines up with the control centre's pill above; the shadow there runs off the screen let right = control_pill::right(); let window = |height: f32| { LayerWindow::new() .width(width + shadow + right) .height(height) .anchor_vertical(Vertical::Top) .anchor_horizontal(Horizontal::Right) .margin(Margin { top: (island_pill::top_gap() + island_pill::height() + 8.0 - shadow).max(0.0) as i32, right: 0, bottom: 0, left: 0 }) .layer(Layer::Overlay) .space(Zone::Ignore) .namespace("magic-notifications") }; let focused = island::focused_monitor().is_none_or(|name| name == monitor.name); let shown: Vec<(&Notification, Instant)> = notices.popups.iter().filter_map(|(id, since)| list.list().iter().find(|notice| notice.id() == *id).map(|notice| (notice, *since))).take(settings::count()).collect(); if !focused || shown.is_empty() { return window(1.0).click_through().child(Rectangle::new().width(0.0).height(0.0)); } let colors = theme::now().surfaces(); let mut cards: Vec> = Vec::new(); let mut total = 0.0; let mut arriving = false; for (notice, since) in shown { let id = notice.id(); let (content, height) = face(notice, notices.pictures.get(&id), width - 2.0 * pad, 3, Box::new(move || hide(id))); let height = height + 2.0 * pad; // slides in from the right over its first quarter second let t = (since.elapsed().as_secs_f32() / 0.25).min(1.0); arriving |= t < 1.0; let content = Rectangle::new().width(width).height(height).padding(pad).child(content); let card = surface::shell(width, height, 18.0, &colors, if notice.urgency() == Urgency::Critical { 1.0 } else { 0.0 }, content); let card = if notice.urgency() == Urgency::Critical { card.border(1.5, crate::config::appearance::overlays::low_battery()) } else { card }; let has_default = notice.has_default_action(); cards.push(Box::new(card.cursor(Pointer).on_click(move |_| if has_default { Notifications::click(id) } else { hide(id) }).translate((1.0 - t * t * (3.0 - 2.0 * t)) * 40.0, 0.0))); total += height + 10.0; } if arriving { amane::request_frame(); } window(total + 2.0 * shadow).child( Rectangle::new() .width(width + shadow + right) .height(total + 2.0 * shadow) .padding(amane::Padding { top: shadow, right, bottom: shadow, left: shadow }) .on_hover(hovering) .child(Column::new(cards).gap(10.0)), ) } // `pods notices list|dismiss |clear`: the history without the mouse pub fn command(words: &[&str]) -> String { match words { ["dismiss", id] => match id.parse() { Ok(id) => { Notifications::dismiss(id); String::from("ok") } Err(_) => String::from("dismiss takes a notification's number"), }, ["clear"] => { Notifications::clear(); String::from("ok") } _ => { let notifications = Notifications::read(); let notices = Notices::read(); let lines: Vec = notifications .list() .iter() .map(|notice| { let popup = if notices.popups.iter().any(|(id, _)| *id == notice.id()) { " popup" } else { "" }; let picture = notices.pictures.get(¬ice.id()).map_or(String::from("-"), Clone::clone); format!("{} [{:?}{popup}] {}: {} | {} | picture {picture} | actions {}", notice.id(), notice.urgency(), notice.app_name(), notice.summary(), parse::plain_body(notice.body()), notice.actions().len()) }) .collect(); format!("running={} unread={}\n{}", notifications.running(), notices.unread, lines.join("\n")) } } } // do not disturb: the popups off (or on again), kept in config.toml fn toggle_quiet() { let _ = config::set("notifications.popups.enabled", Value::Bool(!settings::enabled())); } // the history card: every notification still open, newest first, each with its cross to delete it pub fn card(width: f32, height: f32) -> Rectangle { let notices = Notices::read(); let list = Notifications::read(); let theme = theme::now(); let mut buttons: Vec> = vec![Box::new(pods::chip(if settings::enabled() { "Do not disturb" } else { "Popups on" }, None, toggle_quiet))]; if !list.list().is_empty() { buttons.push(Box::new(pods::chip("Clear all", None, Notifications::clear))); } let header = pods::header("Notifications", buttons, width); // the muted and blocked apps, each a chip that takes it off its list again let (muted, blocked) = (apps::muted(), apps::blocked()); let names = |list: &str| list.split(',').map(str::trim).filter(|name| !name.is_empty()).map(str::to_string).collect::>(); let mut undo: Vec> = Vec::new(); for app in names(&muted) { undo.push(Box::new(pods::chip(&format!("Unmute {app}"), Some(Glyph::Muted), move || mute(&app, false)))); } for app in names(&blocked) { undo.push(Box::new(pods::chip(&format!("Unblock {app}"), Some(Glyph::Cross), move || block(&app, false)))); } let undo_h = if undo.is_empty() { 0.0 } else { 26.0 + 10.0 }; let list_h = height - pods::header_height() - 10.0 - undo_h; let body: Box = if !list.running() { Box::new( Rectangle::new() .width(width) .height(list_h) .align_child(Center, Center) .child(Text::new("Another notification daemon (dunst?) holds the bus name: stop it and restart the shell").size(text::small()).color(theme.dim).wrap()), ) } else if list.list().is_empty() { Box::new( Rectangle::new().width(width).height(list_h).align_child(Center, Center).child( Column::new(vec![ Box::new(Rectangle::new().width(width).height(40.0).align_child(Center, Center).child(crate::glyph::icon(Glyph::Bell, 32.0, faded(theme.dim, 120)))) as Box, Box::new(Rectangle::new().width(width).height(text::small() + 8.0).align_child(Center, Center).child(Text::new("No notifications").size(text::small()).color(theme.dim))), ]) .gap(8.0), ), ) } else { let item_w = width - 8.0; let mut items: Vec> = Vec::new(); for notice in list.list().iter().rev() { let id = notice.id(); let (content, inner_h) = face(notice, notices.pictures.get(&id), item_w - 24.0, 4, Box::new(move || Notifications::dismiss(id))); let has_default = notice.has_default_action(); // Mute app keeps its notifications quiet in the history, Block app drops them let app = notice.app_name().to_string(); let content: Box = if app.is_empty() { Box::new(content) } else { let quiet = listed(&muted, &app); let (for_mute, for_block) = (app.clone(), app.clone()); let choices = Row::new(vec![ Box::new(pods::chip(if quiet { "Unmute app" } else { "Mute app" }, Some(Glyph::Muted), move || mute(&for_mute, !quiet))) as Box, Box::new(pods::chip("Block app", Some(Glyph::Cross), move || block(&for_block, true))), ]) .gap(6.0); Box::new(Column::new(vec![Box::new(content) as Box, Box::new(Rectangle::new().width(item_w - 24.0).height(26.0).align_child(amane::End, Center).child(choices))]).gap(8.0)) }; let inner_h = if app.is_empty() { inner_h } else { inner_h + 26.0 + 8.0 }; let item = Rectangle::new().width(item_w).height(inner_h + 24.0).radius(16.0).fill(overlays::chip()).padding(12.0).child(Column::new(vec![content])); let item = if has_default { item.cursor(Pointer).on_click(move |_| Notifications::click(id)) } else { item }; items.push(Box::new(item)); } Box::new(ScrollArea::new("notices-history", Column::new(items).gap(8.0)).width(width).height(list_h)) }; let mut parts: Vec> = vec![Box::new(header)]; if !undo.is_empty() { // ponytail: one row, cut at the card's edge; the config window has the whole lists parts.push(Box::new(Rectangle::new().width(width).height(26.0).clip().child(Row::new(undo).gap(6.0)))); } parts.push(body); Rectangle::new().width(width).height(height).child(Column::new(parts).gap(10.0)) }