Side pills: swap and the eGPU in the system monitor (out of the control centre), coloured temperatures with icons, model switch, mute/block apps, cards close into their anchored pills; home: big calendar with event editing, birthdays, holidays, weather photos, no sign-in form

This commit is contained in:
server2
2026-10-15 23:25:54 +02:00
parent e8741b1cb3
commit d59a5bc81c
12 changed files with 2935 additions and 546 deletions
+105 -11
View File
@@ -11,7 +11,7 @@ use amane::{
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::config::notifications::{apps, popups as settings};
use crate::glyph::Glyph;
use crate::island::{self, faded};
use crate::parse::{self, Value};
@@ -72,8 +72,11 @@ fn timeout(urgency: Urgency) -> Option<Duration> {
}
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 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();
@@ -99,25 +102,34 @@ fn watch() {
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 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 {
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 {
if popping && !quiet {
notices.popups.insert(0, (*id, Instant::now()));
}
if fresh && !looking {
if fresh && !looking && !quiet {
notices.unread += 1;
}
}
@@ -139,6 +151,46 @@ fn watch() {
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] {
@@ -394,14 +446,29 @@ pub fn card(width: f32, height: f32) -> Rectangle {
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))];
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);
let list_h = height - pods::header_height() - 10.0;
// 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(
@@ -430,7 +497,25 @@ pub fn card(width: f32, height: f32) -> Rectangle {
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);
// 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));
@@ -439,5 +524,14 @@ pub fn card(width: f32, height: f32) -> Rectangle {
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))
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))
}