Side pills: local AI chat, system monitor, home (clock, Munich weather, iCloud calendar), notifications daemon with popups and history
This commit is contained in:
+443
@@ -0,0 +1,443 @@
|
||||
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::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)> =
|
||||
Notifications::read().list().iter().map(|notice| (notice.id(), notice.received(), notice.urgency(), notice.icon().to_string(), notice.image().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;
|
||||
}
|
||||
|
||||
// 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, ..) in &arrived {
|
||||
let fresh = !notices.known.contains_key(id);
|
||||
|
||||
notices.known.insert(*id, *received);
|
||||
notices.popups.retain(|(shown, _)| shown != id);
|
||||
|
||||
if popping {
|
||||
notices.popups.insert(0, (*id, Instant::now()));
|
||||
}
|
||||
|
||||
if fresh && !looking {
|
||||
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());
|
||||
}
|
||||
|
||||
// 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() { "Mute" } else { "Unmute" }, 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);
|
||||
let list_h = height - pods::header_height() - 10.0;
|
||||
|
||||
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();
|
||||
|
||||
let item = Rectangle::new().width(item_w).height(inner_h + 24.0).radius(16.0).fill(overlays::chip()).padding(12.0).child(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))
|
||||
};
|
||||
|
||||
Rectangle::new().width(width).height(height).child(Column::new(vec![Box::new(header) as Box<dyn Widget>, body]).gap(10.0))
|
||||
}
|
||||
Reference in New Issue
Block a user