538 lines
20 KiB
Rust
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(¬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<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))
|
|
}
|