Files
amane-config/src/notices.rs
T

538 lines
20 KiB
Rust

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<u32, SystemTime>,
// a picture file per notification: its image, else its icon found in the themes
pictures: HashMap<u32, String>,
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<Duration> {
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<u32> = notices.known.keys().filter(|id| !current.iter().any(|(known, ..)| known == *id)).copied().collect();
let expired: Vec<u32> = 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<String>)> = 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<u32> = 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<String> {
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<dyn Widget> {
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<Box<dyn Widget>> {
if notice.actions().is_empty() {
return None;
}
let id = notice.id();
let chips: Vec<Box<dyn Widget>> = 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<dyn Widget>
})
.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<dyn Fn()>) -> (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<dyn Widget>,
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<dyn Widget>,
Box::new(pods::button(Glyph::Cross, close)),
])
.width(width)
.justify(SpaceBetween);
let mut parts: Vec<Box<dyn Widget>> = 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<Box<dyn Widget>> = 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 <id>|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<String> = notifications
.list()
.iter()
.map(|notice| {
let popup = if notices.popups.iter().any(|(id, _)| *id == notice.id()) { " popup" } else { "" };
let picture = notices.pictures.get(&notice.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<Box<dyn Widget>> = 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::<Vec<_>>();
let mut undo: Vec<Box<dyn Widget>> = 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<dyn Widget> = 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<dyn Widget>,
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<Box<dyn Widget>> = 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<dyn Widget> = 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<dyn Widget>,
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<dyn Widget>, 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<Box<dyn Widget>> = 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))
}