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
+4 -4
View File
@@ -115,10 +115,10 @@ Whether the open card takes the song cover's colours instead (`appearance.colors
Two small one-icon pills sit left of the island and two right of it, on every monitor; they move outward while the island opens. A click morphs a pill into its card. Only one card is open across the shell: opening the island closes a card, and opening a card closes the island. Esc, the cross or a click elsewhere closes it.
- **AI** (sparkle): a chat with a local Ollama model, `gemma4:e4b` at `http://localhost:11434` by default. Answers stream in through curl. When Ollama doesn't answer, the shell starts the OpenRC service with `doas rc-service ollama start` (`pills.ai.start_service`). Stop ends an answer and New chat starts over. Nothing leaves the machine.
- **System** (pulse): CPU, GPU clock, memory, network and disk read/write as graphs over the last minute, plus the hottest sensors. Everything is read from `/proc` and `/sys`, so there is no process list. Processes opens `ghostty --class=amane.monitor -e btm` on the special workspace `monitor`; a second press shows or hides it.
- **Home** (house): `~/.face` as the avatar (a person glyph until the file exists), a greeting, the date, a big clock, and Munich's weather now and for four days from open-meteo (no key). The month grid dots the days with events. The iCloud calendar shows the coming events once you sign in from the card with your Apple ID and an [app-specific password](https://account.apple.com). The password goes into the keyring through `secret-tool` (libsecret, so a Secret Service like gnome-keyring or KeePassXC has to run), never into `config.toml`. The calendar is read over CalDAV from `pills.calendar.server`, so other CalDAV servers work too.
- **Notifications** (bell, with the unread count): Amane is the notification daemon (`org.freedesktop.Notifications`), so dunst must not run; it is commented out in `~/.config/hypr/autocmds.lua` (the old file is beside it as `autocmds.lua.before-amane-notifications`). Popups appear at the top right of the focused monitor with the app's icon or image, summary, body (simple markup) and action buttons. They time out by urgency (critical ones stay) and wait while the pointer is over them. A click runs the default action. The card lists the history, newest first; each entry's cross deletes it, and Clear all deletes everything. Mute turns popups off.
- **AI** (sparkle): a chat with a local Ollama model, `gemma4:e4b` at `http://localhost:11434` by default; the model chip in its header lists the installed models and switches at once. Answers stream in through curl. When Ollama doesn't answer, the shell starts the OpenRC service with `doas rc-service ollama start` (`pills.ai.start_service`). Stop ends an answer and New chat starts over. Nothing leaves the machine.
- **System** (pulse): CPU, the integrated GPU's clock, the external GPU's load (with its video memory and the programs on it, formerly in the control centre), memory, swap, network and disk read/write as graphs over the last minute, plus the hottest sensors with icons, in cool/warm/hot colours (`pills.monitor.warm|hot`). Everything is read from `/proc` and `/sys`, so there is no process list. Processes opens `ghostty --class=amane.monitor -e btm` on the special workspace `monitor`; a second press shows or hides it.
- **Home** (house): `~/.face` as the avatar (a person glyph until the file exists), a greeting, the date, a big clock, and Munich's weather now and for four days from open-meteo (no key), over a photo of the current weather (`~/.config/amane/weather/<kind>-<day|night>.jpg`, downloaded once from Wikimedia Commons, credits in `weather/CREDITS.md`; swap any file, `pills.home.photo_opacity` tones it down). A big month grid marks events (dots in their calendars' colours), birthdays from your iCloud contacts (a cake) and the public holidays of `pills.calendar.holidays` (BY by default); click a day for its list. Events can be created, edited and deleted there (a series: title, place and calendar only). For iCloud, set `pills.calendar.account` to your Apple ID and store an [app-specific password](https://account.apple.com) in the keyring: `secret-tool store --label='Magic Island calendar' service amane-calendar account <Apple ID>` (gnome-keyring or another Secret Service has to run). The password never goes into `config.toml`. Other CalDAV/CardDAV servers work through `pills.calendar.server` and `contacts_server`.
- **Notifications** (bell, with the unread count): Amane is the notification daemon (`org.freedesktop.Notifications`), so dunst must not run; it is commented out in `~/.config/hypr/autocmds.lua` (the old file is beside it as `autocmds.lua.before-amane-notifications`). Popups appear at the top right of the focused monitor with the app's icon or image, summary, body (simple markup) and action buttons. They time out by urgency (critical ones stay) and wait while the pointer is over them. A click runs the default action. The card lists the history, newest first; each entry's cross deletes it, and Clear all deletes everything. Mute app keeps an app's notifications in the history without popups; Block app drops them (`notifications.apps.muted|blocked`, undone from chips at the top of the card). Do not disturb turns all popups off.
`amane ipc call pods open|toggle ai|monitor|home|notifications [monitor]`, `close`, `ask <question>`, `processes`, `notices list|dismiss <id>|clear`, `status`. Sizes, colours, poll intervals, the city and its coordinates, the model and the popup timeouts are under Side pills and Notifications in the [settings](#settings).
+117 -26
View File
@@ -36,11 +36,13 @@ pub struct Ai {
typed: String,
// bumped by Stop and New chat, so an answer still streaming stops writing
round: u32,
// the models ollama has, while the model list is open (empty while it loads)
models: Option<Vec<String>>,
}
impl Service for Ai {
fn new() -> Self {
Self { said: Vec::new(), busy: false, note: None, back: 0.0, typed: String::new(), round: 0 }
Self { said: Vec::new(), busy: false, note: None, back: 0.0, typed: String::new(), round: 0, models: None }
}
// changes only through input and the answering thread
@@ -116,28 +118,7 @@ fn new_chat() {
fn answer(round: u32) -> Result<(), String> {
let server = settings::server().trim_end_matches('/').to_string();
if !answers(&server) {
let local = server.contains("://localhost") || server.contains("://127.0.0.1");
if !(settings::start_service() && local) {
return Err(format!("Ollama doesn't answer at {server}"));
}
note(round, "Starting ollama…");
let _ = Command::new("doas").args(["-n", "rc-service", "ollama", "start"]).output();
let until = Instant::now() + Duration::from_secs(30);
while !answers(&server) {
if Instant::now() > until {
return Err(String::from("Ollama didn't start (doas rc-service ollama start)"));
}
thread::sleep(Duration::from_millis(500));
}
note(round, "");
}
wake(&server, |words| note(round, words))?;
let body = request();
@@ -189,6 +170,92 @@ fn answer(round: u32) -> Result<(), String> {
Ok(())
}
// starts the local ollama service when it doesn't answer, telling `say` what's going on
fn wake(server: &str, say: impl Fn(&str)) -> Result<(), String> {
if answers(server) {
return Ok(());
}
let local = server.contains("://localhost") || server.contains("://127.0.0.1");
if !(settings::start_service() && local) {
return Err(format!("Ollama doesn't answer at {server}"));
}
say("Starting ollama…");
let _ = Command::new("doas").args(["-n", "rc-service", "ollama", "start"]).output();
let until = Instant::now() + Duration::from_secs(30);
while !answers(server) {
if Instant::now() > until {
return Err(String::from("Ollama didn't start (doas rc-service ollama start)"));
}
thread::sleep(Duration::from_millis(500));
}
say("");
Ok(())
}
// the model list opens (asking ollama which models it has) or closes
fn pick() {
let mut ai = Ai::write();
if ai.models.take().is_some() {
return;
}
ai.models = Some(Vec::new());
drop(ai);
thread::spawn(|| {
let server = settings::server().trim_end_matches('/').to_string();
let say = |words: &str| Ai::write().note = (!words.is_empty()).then(|| words.to_string());
let names = wake(&server, say).and_then(|()| {
let done = Command::new("curl").args(["-s", "-m", "5", &format!("{server}/api/tags")]).output().map_err(|error| format!("curl: {error}"))?;
let tags = parse::json(&String::from_utf8_lossy(&done.stdout));
let names: Vec<String> = tags
.first()
.and_then(|tags| tags.get("models"))
.map(|models| match models {
parse::Value::List(models) => models.iter().filter_map(|model| model.get("name").and_then(parse::Value::text).map(str::to_string)).collect(),
_ => Vec::new(),
})
.unwrap_or_default();
if names.is_empty() { Err(String::from("Ollama has no models (ollama pull <model>)")) } else { Ok(names) }
});
let mut ai = Ai::write();
match names {
// only if the list wasn't closed meanwhile
Ok(names) => {
if ai.models.is_some() {
ai.models = Some(names);
}
}
Err(why) => {
ai.models = None;
ai.note = Some(why);
}
}
});
}
// another model from the list; kept in config.toml, used from the next question on
fn choose(name: String) {
Ai::write().models = None;
let _ = crate::config::set("pills.ai.model", parse::Value::Text(name));
}
// whether ollama answers at all, within a second
fn answers(server: &str) -> bool {
Command::new("curl")
@@ -253,7 +320,8 @@ pub fn card(width: f32, height: f32) -> Rectangle {
let ai = Ai::read();
let theme = theme::now();
let mut buttons: Vec<Box<dyn Widget>> = Vec::new();
// the model, as a chip that opens the list of the others
let mut buttons: Vec<Box<dyn Widget>> = vec![Box::new(pods::chip(&settings::model(), Some(Glyph::Chevron), pick))];
if ai.busy {
buttons.push(Box::new(pods::chip("Stop", None, stop)));
@@ -261,7 +329,7 @@ pub fn card(width: f32, height: f32) -> Rectangle {
buttons.push(Box::new(pods::chip("New chat", None, new_chat)));
}
let header = pods::header(&format!("AI · {}", settings::model()), buttons, width);
let header = pods::header("AI", buttons, width);
let field_h = 40.0;
let note_h = text::small() + 8.0;
@@ -294,7 +362,30 @@ pub fn card(width: f32, height: f32) -> Rectangle {
bubbles.push(Box::new(row.child(bubble)));
}
let chat: Box<dyn Widget> = if bubbles.is_empty() {
let chat: Box<dyn Widget> = if let Some(models) = &ai.models {
let current = settings::model();
let mut rows: Vec<Box<dyn Widget>> = vec![Box::new(Text::new(if models.is_empty() { "Asking ollama for its models…" } else { "Chat with" }).size(text::small()).color(theme.dim))];
for name in models {
let chosen = *name == current;
let picked = name.clone();
rows.push(Box::new(
Rectangle::new()
.width(width)
.height(34.0)
.radius(12.0)
.fill(if chosen { faded(theme.accent, 70) } else { overlays::chip() })
.padding(amane::Padding { top: 0.0, right: 12.0, bottom: 0.0, left: 12.0 })
.align_child(Start, Center)
.cursor(amane::Pointer)
.on_click(move |_| choose(picked.clone()))
.child(Text::new(name.as_str()).size(text::body()).color(theme.text).elide()),
));
}
Box::new(Rectangle::new().width(width).height(chat_h).clip().child(Column::new(rows).gap(6.0)))
} else if bubbles.is_empty() {
Box::new(Rectangle::new().width(width).height(chat_h).align_child(Center, Center).child(Text::new(format!("Ask {} anything; it runs on this computer", settings::model())).size(text::small()).color(theme.dim)))
} else {
// the column's bottom on the area's bottom, moved down by the scroll back; what's above is cut
+41 -8
View File
@@ -420,9 +420,6 @@ settings! {
slider_height: f32 = 28.0, 8..=100; "Height of a slider"
keys_height: f32 = 26.0, 8..=100; "Height of the keyboard light's buttons"
profile_height: f32 = 48.0, 16..=200; "Height of the performance profile buttons"
vram_height: f32 = 14.0, 4..=60; "Height of the external GPU's memory line"
row_height: f32 = 22.0, 8..=100; "Height of a program row under the external GPU"
max_rows: usize = 5, 1..=50; "Programs listed under the external GPU before \"and N more\""
}
menus {
pad: f32 = 12.0, 0..=100; "Padding inside a menu"
@@ -506,6 +503,7 @@ settings! {
open_ms: u64 = 420, 0..=5000; "How long a small pill takes to open into its card, and to close"
radius: f32 = 22.0, 0..=60; "Corner radius of the opened cards"
pad: f32 = 18.0, 0..=60; "Padding inside the opened cards"
reveal_at: f32 = 0.2, 0..=1; "How much of a closing card's growth is left when the pills beside it start to show again behind it (0 = only once it's closed)"
}
ai {
width: f32 = 440.0, 280..=1200; "Width of the AI card"
@@ -521,26 +519,57 @@ settings! {
history: usize = 60, 10..=600; "How many readings the graphs show"
graph_height: f32 = 34.0, 10..=200; "Height of each graph"
temperatures: usize = 6, 0..=20; "How many temperature sensors to show, hottest first"
warm: f32 = 65.0, 20..=120; "From this temperature (°C) a sensor shows in the warm colour"
hot: f32 = 85.0, 20..=130; "From this temperature (°C) a sensor shows in the hot colour"
cool_color: Color = Color::rgb(120, 200, 255); "Colour of a sensor below the warm temperature"
warm_color: Color = Color::rgb(255, 196, 92); "Colour of a warm sensor"
hot_color: Color = Color::rgb(255, 105, 97); "Colour of a hot sensor"
programs: usize = 5, 1..=50; "Programs on the external GPU listed before the rest are counted"
terminal: String = String::from("ghostty"); "The terminal that runs the process list"
processes: String = String::from("btm --default_widget_type proc --expanded"); "The process list command, opened on a special workspace"
workspace: String = String::from("processes"); "The special workspace the process list opens on"
}
home {
width: f32 = 460.0, 300..=1200; "Width of the home card"
width: f32 = 820.0, 300..=1600; "Width of the home card. It opens rightwards from beside the island, so on a 1920 px wide monitor about 820 fits"
clock_size: f32 = 64.0, 20..=200; "Size of the big clock"
seconds: bool = false; "Show seconds on the big clock"
avatar: String = String::from("~/.face"); "The picture shown as the user's avatar; a drawn person without one"
avatar_size: f32 = 48.0, 24..=120; "Size of the avatar; the greeting row is as tall"
gap: f32 = 12.0, 0..=60; "Space between the home card's parts"
city: String = String::from("Munich"); "The weather's place, as shown"
latitude: f32 = 48.137, -90.0..=90.0; "The weather's latitude"
longitude: f32 = 11.575, -180.0..=180.0; "The weather's longitude"
weather_minutes: u64 = 20, 5..=360; "How often the weather is fetched, in minutes"
weather_height: f32 = 60.0, 30..=200; "Height of the weather now"
weather_icon: f32 = 44.0, 16..=120; "Size of the icon of the weather now"
temperature_size: f32 = 28.0, 10..=100; "Size of the temperature now"
forecast_height: f32 = 64.0, 30..=200; "Height of the coming days' weather"
forecast_icon: f32 = 22.0, 10..=80; "Size of the coming days' weather icons"
photo: bool = true; "Show a photo of the weather now behind the home card"
photos: String = String::from("~/.config/amane/weather"); "Folder of the weather photos: <kind>-<day|night>.jpg for clear, partly-cloudy, cloudy, fog, rain, snow and storm. A missing one is downloaded once from Wikimedia Commons (credits in the repo's weather/CREDITS.md); a file that's there is never replaced, so put your own in its place (the shell shows a swapped file after a restart)"
photo_opacity: f32 = 0.45, 0..=1; "How much the weather photo shows through behind the home card: the card's colour veils the rest, 0 hides it"
}
calendar {
server: String = String::from("https://caldav.icloud.com/"); "The CalDAV server; iCloud's by default"
account: String = String::new(); "The Apple ID (or CalDAV user). Sign in from the home card; the app-specific password goes to the keyring, never here"
days: u64 = 14, 1..=90; "How many days ahead the calendar shows events"
sync_minutes: u64 = 15, 1..=360; "How often the calendar is synced, in minutes"
events: usize = 6, 1..=30; "How many upcoming events the home card lists"
contacts_server: String = String::from("https://contacts.icloud.com/"); "The CardDAV server the birthdays come from, with the same account; iCloud's by default"
account: String = String::new(); "Your Apple ID (or CalDAV user). Store its app-specific password (account.apple.com > Sign-In and Security > App-Specific Passwords) in the keyring with: secret-tool store --label='Magic Island calendar' service amane-calendar account <Apple ID>"
sync_minutes: u64 = 15, 1..=360; "How often the calendar and the birthdays are synced, in minutes"
holidays: String = String::from("BY"); "The German state whose public holidays the calendar marks: BW, BY, BE, BB, HB, HH, HE, MV, NI, NW, RP, SL, SN, ST, SH or TH; empty marks none"
birthdays: bool = true; "Show the birthdays of your contacts"
cell: f32 = 52.0, 32..=96; "Size of a day in the month grid; the card grows with it"
disc: f32 = 0.72, 0.3..=1.0; "Size of today's disc and the selected day's ring, as a share of a day"
ring: f32 = 2.0, 0.5..=6.0; "Thickness of the selected day's ring"
dot: f32 = 5.0, 2..=16; "Size of the dots under a day with events, one per calendar"
dots: usize = 3, 1..=6; "Most dots under one day"
mark: f32 = 13.0, 6..=30; "Size of the cake on a day with a birthday"
list_gap: f32 = 24.0, 0..=100; "Space between the month grid and the selected day's list"
item_gap: f32 = 8.0, 0..=40; "Space between the selected day's items"
field_height: f32 = 32.0, 20..=60; "Height of the event form's text fields"
length_minutes: u64 = 60, 5..=1440; "How long a new event lasts until you change its end"
holiday_color: Color = Color::rgb(255, 122, 104); "Colour of public holidays: the day's number, its tint and flag"
holiday_tint: f32 = 0.2, 0..=1; "How strongly a public holiday is tinted in its colour"
birthday_color: Color = Color::rgb(255, 138, 196); "Colour of birthdays: the cake on the day and in its list"
selected_color: Color = Color::rgba(255, 255, 255, 220); "Colour of the selected day's ring"
}
}
@@ -554,6 +583,10 @@ settings! {
count: usize = 4, 1..=10; "How many popups show at once"
icon: f32 = 40.0, 16..=128; "Size of a notification's icon or picture"
}
apps {
muted: String = String::new(); "Apps whose notifications don't pop up but stay in the history, comma-separated (Mute app on a notification adds it)"
blocked: String = String::new(); "Apps whose notifications are dropped at once, comma-separated (Block app on a notification adds it)"
}
history {
width: f32 = 420.0, 280..=1200; "Width of the notification card"
height: f32 = 560.0, 200..=1400; "Height of the notification card"
+6 -97
View File
@@ -2,7 +2,7 @@
* the control centre: a status pill in the top right corner of every monitor (wi-fi,
* vpn, bluetooth, battery) that opens, on the monitor it was clicked on, into a panel
* like macOS's: connection tiles, sound, the screen and keyboard light, the performance
* mode with the battery, and the external gpu with the programs using it. it opens on
* mode with the battery (the external gpu moved to the system monitor pill). it opens on
* the island's timeline (island::phases): pill out, the empty shape grows, panel in
*/
use std::collections::HashMap;
@@ -26,7 +26,7 @@ use crate::parse;
use crate::prompt;
use crate::settings;
use crate::sound::Sound;
use crate::system::{self, Charge, Gpu, Links, Power, VpnState};
use crate::system::{self, Charge, Links, Power, VpnState};
use crate::{surface, theme};
// every size and timing is a setting (config.rs: control.*); these follow from them
@@ -266,12 +266,11 @@ struct Layout {
keyboard: bool,
profiles: bool,
battery: bool,
gpu_rows: Option<usize>,
}
impl Layout {
fn largest() -> Layout {
Layout { ethernet: true, extra_output: true, screen: true, keyboard: true, profiles: true, battery: true, gpu_rows: Some(c::modules::max_rows()) }
Layout { ethernet: true, extra_output: true, screen: true, keyboard: true, profiles: true, battery: true }
}
fn sound(&self) -> f32 {
@@ -292,12 +291,8 @@ impl Layout {
module_height(&parts)
}
fn gpu(&self) -> Option<f32> {
self.gpu_rows.map(|rows| 2.0 * c::modules::pad() + c::modules::title_height() + c::modules::vram_height() + rows.max(1) as f32 * c::modules::row_height() + 2.0 * c::modules::line_gap())
}
fn height(&self) -> f32 {
let modules: f32 = [Some(self.sound()), self.display(), self.performance(), self.gpu()].into_iter().flatten().map(|height| c::panel::gap() + height).sum();
let modules: f32 = [Some(self.sound()), self.display(), self.performance()].into_iter().flatten().map(|height| c::panel::gap() + height).sum();
let tile_rows = if self.ethernet { 3.0 } else { 2.0 };
// the Settings button closes the panel
@@ -321,7 +316,6 @@ struct Scene<'a> {
bluetooth: &'a Bluetooth,
audio: &'a Audio,
screen: &'a Brightness,
gpu: &'a Gpu,
hotspot: &'a str,
extra_output: Option<&'a parse::Sink>,
layout: Layout,
@@ -375,7 +369,6 @@ pub fn view(monitor: &Monitor) -> LayerWindow {
let audio = open.then(Audio::read);
let screen = open.then(Brightness::read);
let gpu = open.then(Gpu::read);
let settings = open.then(|| (c::services::hotspot(), c::services::extra_output()));
let sound = open.then(Sound::read);
@@ -391,7 +384,6 @@ pub fn view(monitor: &Monitor) -> LayerWindow {
keyboard: power.keyboard.is_some(),
profiles: !power.profiles.is_empty(),
battery: power.battery.is_some(),
gpu_rows: gpu.as_deref().filter(|gpu| !gpu.name.is_empty()).map(|gpu| gpu.programs.len().min(c::modules::max_rows())),
};
/*
@@ -427,8 +419,8 @@ pub fn view(monitor: &Monitor) -> LayerWindow {
let colors = theme::now().surfaces();
// at most one face at a time, each faded by a veil instead of a gpu layer
let face = match (&audio, &screen, &gpu, &settings) {
(Some(audio), Some(screen), Some(gpu), Some((hotspot, _))) if reveal > 0.0 && fade > 0.0 => {
let face = match (&audio, &screen, &settings) {
(Some(audio), Some(screen), Some((hotspot, _))) if reveal > 0.0 && fade > 0.0 => {
let scene = Scene {
control: &control,
links: &links,
@@ -436,7 +428,6 @@ pub fn view(monitor: &Monitor) -> LayerWindow {
bluetooth: &bluetooth,
audio,
screen,
gpu,
hotspot,
extra_output,
layout,
@@ -880,10 +871,6 @@ fn panel(scene: &Scene) -> Rectangle {
blocks.push(Box::new(performance(scene, height)));
}
if let Some(height) = scene.layout.gpu() {
blocks.push(Box::new(gpu(scene, height)));
}
blocks.push(Box::new(settings_button(scene.control)));
let height = scene.layout.height();
@@ -1480,84 +1467,6 @@ fn battery_words(charge: &Charge) -> String {
}
}
// the external gpu: its load, memory and temperature, and who uses it
fn gpu(scene: &Scene, height: f32) -> Rectangle {
let theme = theme::now();
let gpu = scene.gpu;
let mut facts = vec![format!("{}%", gpu.busy)];
if let Some(celsius) = gpu.celsius {
facts.push(format!("{celsius}°C"));
}
if let Some(watts) = gpu.watts {
facts.push(format!("{watts:.0} W"));
}
let title = Row::new(children![icon(Glyph::Chip, 15.0, theme.accent), Text::new(gpu.name.as_str()).size(text::body()).weight(Weight::Bold).color(theme.text).elide()]).gap(8.0).align(Center);
let top = Row::new(children![title, note(facts.join(" · "))]).justify(SpaceBetween).align(Center).width(module_w()).height(c::modules::title_height());
let share = if gpu.vram_total > 0 { gpu.vram_used as f32 / gpu.vram_total as f32 } else { 0.0 };
let memory_label = format!("{} of {}", size(gpu.vram_used / 1024), size(gpu.vram_total / 1024));
let memory_w = label_width(&memory_label, 10.5, 4.0);
let bar_w = module_w() - memory_w - 10.0;
let memory = Row::new(children![
Rectangle::new().width(bar_w).height(6.0).radius(Full).fill(overlays::track()).child(Rectangle::new().width((bar_w * share).max(6.0)).height(6.0).radius(Full).fill(theme.accent)),
Text::new(memory_label).size(text::caption()).color(theme.dim),
])
.gap(10.0)
.align(Center)
.width(module_w())
.height(c::modules::vram_height());
let mut rows: Vec<Box<dyn Widget>> = Vec::new();
let shown = if gpu.programs.len() > c::modules::max_rows() { c::modules::max_rows() - 1 } else { c::modules::max_rows() };
for program in gpu.programs.iter().take(shown) {
let mut amount = size(program.vram_kib);
if let Some(busy) = program.busy {
amount = format!("{amount} · {busy}%");
}
rows.push(Box::new(
Row::new(children![
Text::new(program.name.as_str()).size(text::body()).color(theme.text).elide(),
note(amount),
])
.justify(SpaceBetween)
.align(Center)
.width(module_w())
.height(c::modules::row_height()),
));
}
if gpu.programs.len() > c::modules::max_rows() {
rows.push(Box::new(Rectangle::new().width(module_w()).height(c::modules::row_height()).align_child(Start, Center).child(note(format!("and {} more", gpu.programs.len() - shown)))));
}
if gpu.programs.is_empty() {
rows.push(Box::new(Rectangle::new().width(module_w()).height(c::modules::row_height()).align_child(Start, Center).child(note("No programs on it"))));
}
module(height, Column::new(children![top, memory, Column::new(rows)]).gap(c::modules::line_gap()))
}
// video memory in KiB, the way a person reads it
fn size(kib: u64) -> String {
match kib {
0..1024 => String::from("<1 MB"),
1024..1_048_576 => format!("{} MB", kib / 1024),
_ => format!("{:.1} GB", kib as f64 / 1_048_576.0),
}
}
/*
* the menus: a window of their own per monitor, over the panel, so the panel never
* re-lays out. a menu grows out of the tile or block that opened it (the island's
+37
View File
@@ -52,6 +52,15 @@ pub enum Glyph {
Snow,
Storm,
Fog,
Thermometer,
Drive,
Memory,
Gpu,
Plus,
Pencil,
Trash,
Cake,
Flag,
}
pub fn icon(glyph: Glyph, size: f32, color: Color) -> Canvas {
@@ -307,6 +316,34 @@ pub fn glyph_shapes(glyph: Glyph, size: f32, color: Color, weight: f32, origin:
line(0.5, 0.08, 0.5, 0.16),
],
Glyph::Cross => shapes![line(0.24, 0.24, 0.76, 0.76), line(0.76, 0.24, 0.24, 0.76)],
// a tube with its bulb
Glyph::Thermometer => shapes![
Path::new().move_to(x(0.4), y(0.62)).line_to(x(0.4), y(0.16)).quad_to(x(0.4), y(0.06), x(0.5), y(0.06)).quad_to(x(0.6), y(0.06), x(0.6), y(0.16)).line_to(x(0.6), y(0.62)).stroke(thin, color),
Circle::new().center(x(0.5), y(0.76)).radius(0.17 * s).fill(color),
line(0.5, 0.3, 0.5, 0.7),
],
// a flat drive with its connector
Glyph::Drive => shapes![frame(0.08, 0.3, 0.92, 0.7).stroke(thin, color), line(0.2, 0.5, 0.2, 0.5), line(0.66, 0.5, 0.8, 0.5)],
// a memory stick: a board with chips and pins
Glyph::Memory => shapes![frame(0.06, 0.28, 0.94, 0.62).stroke(thin, color), frame(0.18, 0.38, 0.34, 0.52).fill(color), frame(0.42, 0.38, 0.58, 0.52).fill(color), frame(0.66, 0.38, 0.82, 0.52).fill(color), line(0.2, 0.62, 0.2, 0.76), line(0.4, 0.62, 0.4, 0.76), line(0.6, 0.62, 0.6, 0.76), line(0.8, 0.62, 0.8, 0.76)],
// a graphics card: a board with a fan
Glyph::Gpu => shapes![frame(0.06, 0.24, 0.94, 0.72).stroke(thin, color), Circle::new().center(x(0.4), y(0.48)).radius(0.14 * s).stroke(thin, color), line(0.66, 0.4, 0.8, 0.4), line(0.66, 0.56, 0.8, 0.56), line(0.16, 0.72, 0.16, 0.86)],
Glyph::Plus => shapes![line(0.5, 0.2, 0.5, 0.8), line(0.2, 0.5, 0.8, 0.5)],
Glyph::Pencil => shapes![
Path::new().move_to(x(0.2), y(0.8)).line_to(x(0.24), y(0.62)).line_to(x(0.66), y(0.2)).line_to(x(0.8), y(0.34)).line_to(x(0.38), y(0.76)).close().stroke(thin, color),
line(0.58, 0.28, 0.72, 0.42),
],
Glyph::Trash => shapes![
Path::new().move_to(x(0.24), y(0.3)).line_to(x(0.3), y(0.88)).line_to(x(0.7), y(0.88)).line_to(x(0.76), y(0.3)).stroke(thin, color),
line(0.14, 0.3, 0.86, 0.3),
line(0.4, 0.14, 0.6, 0.14),
line(0.44, 0.44, 0.44, 0.74),
line(0.56, 0.44, 0.56, 0.74),
],
// a cake with a candle, for birthdays
Glyph::Cake => shapes![frame(0.14, 0.5, 0.86, 0.86).fill(color), line(0.5, 0.26, 0.5, 0.44), Circle::new().center(x(0.5), y(0.16)).radius(0.07 * s).fill(color)],
// a flag on a pole, for holidays
Glyph::Flag => shapes![line(0.22, 0.1, 0.22, 0.9), Path::new().move_to(x(0.22), y(0.12)).line_to(x(0.82), y(0.12)).line_to(x(0.68), y(0.3)).line_to(x(0.82), y(0.48)).line_to(x(0.22), y(0.48)).close().fill(color)],
Glyph::Send => shapes![
Path::new().move_to(x(0.1), y(0.14)).line_to(x(0.92), y(0.5)).line_to(x(0.1), y(0.86)).line_to(x(0.24), y(0.5)).close().fill(color)
],
+1449 -275
View File
File diff suppressed because it is too large Load Diff
+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))
}
+846 -15
View File
@@ -1632,41 +1632,49 @@ struct Draft {
summary: String,
location: String,
cancelled: bool,
uid: String,
recurring: bool,
}
impl Draft {
// the finished event, none without a start or when cancelled; with no end it lasts its duration, else a day or an hour
fn event(self) -> Option<Event> {
fn occurrence(self) -> Option<Occurrence> {
let (start, all_day) = self.start.filter(|_| !self.cancelled)?;
let end = self.end.or_else(|| self.duration.map(|duration| start.saturating_add(duration))).unwrap_or(start + if all_day { 86_400 } else { 3_600 });
Some(Event { start, end, all_day, summary: self.summary, location: self.location })
Some(Occurrence { event: Event { start, end, all_day, summary: self.summary, location: self.location }, uid: self.uid, recurring: self.recurring })
}
}
/*
* the events in an icalendar text (a caldav report's calendar-data): utc times as they are;
* dates, TZID and floating times as local time `offset_s` seconds east of utc. cancelled events
* are skipped, alarms can't overwrite their event, and recurrences are left to the server
* are skipped, alarms can't overwrite their event, and recurrences are left to the server.
* the shell reads ics_occurrences; this is the events alone, as the tests read them
*/
#[cfg(test)]
pub fn ics_events(ics: &str, offset_s: i64) -> Vec<Event> {
// a line starting with a space or tab continues the one before
let mut lines: Vec<String> = Vec::new();
for line in ics.lines().map(|line| line.trim_end_matches('\r')) {
match (line.strip_prefix([' ', '\t']), lines.last_mut()) {
(Some(more), Some(last)) => last.push_str(more),
_ => lines.push(line.to_string()),
}
ics_occurrences(ics, offset_s).into_iter().map(|occurrence| occurrence.event).collect()
}
let mut events = Vec::new();
// one event of an icalendar text with what changing it takes: its UID, and whether it's part of a series
#[derive(Debug, Clone, PartialEq)]
pub struct Occurrence {
pub event: Event,
pub uid: String,
// an RRULE or RDATE, or one instance the server expanded from a series (it has a RECURRENCE-ID)
pub recurring: bool,
}
// the events of an icalendar text like ics_events, each with its UID and whether it repeats
pub fn ics_occurrences(ics: &str, offset_s: i64) -> Vec<Occurrence> {
let mut occurrences = Vec::new();
let mut draft: Option<Draft> = None;
// how deep inside a VEVENT's own blocks, like VALARM, a line is
let mut nested = 0;
for line in &lines {
for line in &unfolded(ics) {
let Some((head, value)) = content_line(line) else {
continue;
};
@@ -1677,7 +1685,7 @@ pub fn ics_events(ics: &str, offset_s: i64) -> Vec<Event> {
("BEGIN", None) if value.eq_ignore_ascii_case("VEVENT") => draft = Some(Draft::default()),
("BEGIN", Some(_)) => nested += 1,
("END", Some(_)) if nested > 0 => nested -= 1,
("END", Some(_)) if value.eq_ignore_ascii_case("VEVENT") => events.extend(draft.take().and_then(Draft::event)),
("END", Some(_)) if value.eq_ignore_ascii_case("VEVENT") => occurrences.extend(draft.take().and_then(Draft::occurrence)),
(_, Some(_)) if nested > 0 => {}
("SUMMARY", Some(event)) => event.summary = ics_text(value),
("LOCATION", Some(event)) => event.location = ics_text(value),
@@ -1685,11 +1693,27 @@ pub fn ics_events(ics: &str, offset_s: i64) -> Vec<Event> {
("DTSTART", Some(event)) => event.start = ics_time(value, offset_s),
("DTEND", Some(event)) => event.end = ics_time(value, offset_s).map(|(time, _)| time),
("DURATION", Some(event)) => event.duration = ics_duration(value),
("UID", Some(event)) => event.uid = ics_text(value.trim()),
("RRULE" | "RDATE" | "RECURRENCE-ID", Some(event)) => event.recurring = true,
_ => {}
}
}
events
occurrences
}
// an icalendar or vcard text as whole content lines: a line starting with a space or tab continues the one before
fn unfolded(text: &str) -> Vec<String> {
let mut lines: Vec<String> = Vec::new();
for line in text.lines().map(|line| line.trim_end_matches('\r')) {
match (line.strip_prefix([' ', '\t']), lines.last_mut()) {
(Some(more), Some(last)) => last.push_str(more),
_ => lines.push(line.to_string()),
}
}
lines
}
// an ics line as (name with its parameters, value), split at the first ':' outside quotes
@@ -1825,6 +1849,563 @@ pub fn dav_time(unix: i64) -> String {
format!("{year:04}{month:02}{day:02}T{:02}{:02}{:02}Z", seconds / 3_600, seconds / 60 % 60, seconds % 60)
}
/*
* webdav answers beyond calendars' own: the href inside a property (current-user-principal,
* calendar-home-set, addressbook-home-set), a report's resources, address books, and the
* calendars the user may only read
*/
pub fn dav_href(xml: &str, property: &str) -> Option<String> {
xml_blocks(xml, property).iter().find_map(|block| xml_texts(block, "href").into_iter().find(|href| !href.is_empty()))
}
// one resource in a calendar-query or addressbook-query answer: its href, etag (quotes kept) and data
#[derive(Debug, Clone, PartialEq)]
pub struct Resource {
pub href: String,
pub etag: String,
pub data: String,
}
// the resources in a report answer, `data` naming the element that holds each one's text (calendar-data, address-data)
pub fn dav_resources(xml: &str, data: &str) -> Vec<Resource> {
xml_blocks(xml, "response")
.iter()
.filter_map(|response| {
let found: String = xml_blocks(response, "propstat").into_iter().filter(|propstat| xml_texts(propstat, "status").iter().all(|status| status.contains(" 200"))).collect();
let href = xml_texts(response, "href").into_iter().next()?;
let text = xml_texts(&found, data).into_iter().next().filter(|text| !text.is_empty())?;
let etag = xml_texts(&found, "getetag").into_iter().next().unwrap_or_default();
Some(Resource { href, etag, data: text })
})
.collect()
}
// the address books in a depth 1 propfind answer, by href
pub fn dav_addressbooks(xml: &str) -> Vec<String> {
xml_blocks(xml, "response")
.iter()
.filter(|response| xml_blocks(response, "resourcetype").iter().any(|kinds| !xml_blocks(kinds, "addressbook").is_empty()))
.filter_map(|response| xml_texts(response, "href").into_iter().next())
.collect()
}
// the calendars in a propfind answer whose current-user-privilege-set grants no writing; one without the property counts as writable
pub fn dav_read_only(xml: &str) -> Vec<String> {
xml_blocks(xml, "response")
.iter()
.filter(|response| {
let found: String = xml_blocks(response, "propstat").into_iter().filter(|propstat| xml_texts(propstat, "status").iter().all(|status| status.contains(" 200"))).collect();
let sets = xml_blocks(&found, "current-user-privilege-set");
!sets.is_empty() && sets.iter().all(|set| ["write", "write-content", "all"].iter().all(|right| xml_blocks(set, right).is_empty()))
})
.filter_map(|response| xml_texts(response, "href").into_iter().next())
.collect()
}
/*
* curl's output with `-w "\n%{http_code}\n%header{etag}"` after the body, as
* (body, status code, etag); the etag is empty when the server sent none, or when
* a curl older than 7.84 printed the variable as it was
*/
pub fn curl_answer(printed: &str) -> (&str, &str, &str) {
let mut parts = printed.rsplitn(3, '\n');
let etag = parts.next().unwrap_or("").trim();
let status = parts.next().unwrap_or("");
let body = parts.next().unwrap_or("");
(body, status.trim(), if etag.starts_with("%header") { "" } else { etag })
}
/*
* text as a quoted value in a curl config file (read with -K - from stdin, which is how the
* password and the bodies reach curl): backslashes, quotes and line breaks escaped
*/
pub fn curl_quoted(text: &str) -> String {
let mut quoted = String::with_capacity(text.len() + 2);
quoted.push('"');
for letter in text.chars() {
match letter {
'\\' => quoted.push_str("\\\\"),
'"' => quoted.push_str("\\\""),
'\n' => quoted.push_str("\\n"),
'\r' => quoted.push_str("\\r"),
'\t' => quoted.push_str("\\t"),
other => quoted.push(other),
}
}
quoted.push('"');
quoted
}
// a utc offset as `date +%z` prints it (+0200, -0930) in seconds east of utc
pub fn zone_offset(printed: &str) -> Option<i64> {
let printed = printed.trim();
let (sign, digits) = match printed.strip_prefix('-') {
Some(digits) => (-1, digits),
None => (1, printed.strip_prefix('+').unwrap_or(printed)),
};
if digits.len() != 4 || !digits.chars().all(|digit| digit.is_ascii_digit()) {
return None;
}
let hours: i64 = digits.get(..2)?.parse().ok()?;
let minutes: i64 = digits.get(2..)?.parse().ok()?;
Some(sign * (hours * 3_600 + minutes * 60))
}
// a time of day as typed (9, 930, 0930, 9:30, 09.30, 9h30) in minutes after midnight
pub fn clock_minutes(typed: &str) -> Option<u32> {
let typed = typed.trim();
let (hours, minutes) = match typed.find([':', '.', 'h', 'H']) {
Some(at) => (typed.get(..at)?, typed.get(at + 1..)?),
None if typed.len() <= 2 => (typed, "0"),
None if typed.len() <= 4 => typed.split_at(typed.len() - 2),
None => return None,
};
if hours.is_empty() || minutes.is_empty() || !hours.chars().chain(minutes.chars()).all(|digit| digit.is_ascii_digit()) || minutes.len() > 2 {
return None;
}
let (hours, minutes): (u32, u32) = (hours.parse().ok()?, minutes.parse().ok()?);
(hours < 24 && minutes < 60).then_some(hours * 60 + minutes)
}
// days since 1970 as an icalendar DATE, like "20261016"
pub fn ics_date(days: i64) -> String {
let (year, month, day) = civil_from_days(days);
format!("{year:04}{month:02}{day:02}")
}
// text for an icalendar TEXT value (RFC 5545 3.3.11): backslashes, commas, semicolons and line breaks escaped
pub fn ics_escape(text: &str) -> String {
let mut escaped = String::with_capacity(text.len() + 8);
let mut letters = text.chars().peekable();
while let Some(letter) = letters.next() {
match letter {
'\\' => escaped.push_str("\\\\"),
',' => escaped.push_str("\\,"),
';' => escaped.push_str("\\;"),
'\n' => escaped.push_str("\\n"),
'\r' => {
letters.next_if_eq(&'\n');
escaped.push_str("\\n");
}
other => escaped.push(other),
}
}
escaped
}
// one content line folded so no line is longer than 75 octets (RFC 5545 3.1), never inside a utf-8 character, ending in CRLF
pub fn ics_fold(line: &str) -> String {
let mut folded = String::with_capacity(line.len() + line.len() / 64 * 3 + 2);
let mut used = 0;
for letter in line.chars() {
if used + letter.len_utf8() > 75 {
folded.push_str("\r\n ");
used = 1;
}
folded.push(letter);
used += letter.len_utf8();
}
folded.push_str("\r\n");
folded
}
// when an event happens, as written: whole days from `first` (days since 1970) for `days` days, or utc unix seconds
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum When {
AllDay { first: i64, days: i64 },
Timed { start: i64, end: i64 },
}
impl When {
fn lines(self) -> [String; 2] {
match self {
Self::AllDay { first, days } => [format!("DTSTART;VALUE=DATE:{}", ics_date(first)), format!("DTEND;VALUE=DATE:{}", ics_date(first + days.max(1)))],
Self::Timed { start, end } => [format!("DTSTART:{}", dav_time(start)), format!("DTEND:{}", dav_time(end.max(start)))],
}
}
}
const PRODID: &str = "PRODID:-//Magic Island//amane-config//EN";
// a new calendar resource holding one event, `stamp` (unix seconds) as its creation time
pub fn ics_new(uid: &str, stamp: i64, summary: &str, location: &str, when: When) -> String {
let [start, end] = when.lines();
let stamp = dav_time(stamp);
let mut lines = vec![
String::from("BEGIN:VCALENDAR"),
String::from("VERSION:2.0"),
String::from(PRODID),
String::from("CALSCALE:GREGORIAN"),
String::from("BEGIN:VEVENT"),
format!("UID:{}", ics_escape(uid)),
format!("DTSTAMP:{stamp}"),
format!("CREATED:{stamp}"),
format!("LAST-MODIFIED:{stamp}"),
start,
end,
format!("SUMMARY:{}", ics_escape(summary.trim())),
];
if !location.trim().is_empty() {
lines.push(format!("LOCATION:{}", ics_escape(location.trim())));
}
lines.extend(["SEQUENCE:0", "END:VEVENT", "END:VCALENDAR"].map(String::from));
lines.iter().map(|line| ics_fold(line)).collect()
}
// what an edit sets in an existing event; `when` none keeps its days and times
#[derive(Debug, Clone, PartialEq)]
pub struct Change {
pub summary: String,
pub location: String,
pub when: Option<When>,
}
/*
* an existing calendar resource with the change made, `stamp` the time of the edit. every
* VEVENT in it (a series and its moved instances) gets the summary and location; the days
* and times go only to VEVENTs without a RECURRENCE-ID, replacing DTSTART, DTEND and
* DURATION. SEQUENCE goes up, DTSTAMP and LAST-MODIFIED are the stamp, Apple's structured
* location goes when the location changes. alarms and everything else stay as they were.
* none when the text holds no VEVENT
*/
pub fn ics_change(ics: &str, stamp: i64, change: &Change) -> Option<String> {
let stamp = dav_time(stamp);
let summary = format!("SUMMARY:{}", ics_escape(change.summary.trim()));
let location = (!change.location.trim().is_empty()).then(|| format!("LOCATION:{}", ics_escape(change.location.trim())));
let mut out: Vec<String> = Vec::new();
let mut events = 0;
// the VEVENT being copied: its lines so far, how deep in its own blocks (VALARM) a line is
let mut event: Option<Vec<String>> = None;
let mut nested = 0;
for line in unfolded(ics) {
if line.trim().is_empty() {
continue;
}
let Some((head, value)) = content_line(&line) else {
match event.as_mut() {
Some(lines) => lines.push(line),
None => out.push(line),
}
continue;
};
let key = head.split(';').next().unwrap_or(head).to_ascii_uppercase();
let Some(lines) = event.as_mut() else {
if key == "BEGIN" && value.trim().eq_ignore_ascii_case("VEVENT") {
event = Some(vec![line]);
nested = 0;
} else {
out.push(line);
}
continue;
};
match key.as_str() {
"BEGIN" => nested += 1,
"END" if nested > 0 => nested -= 1,
"END" => {
lines.push(line);
out.extend(changed_event(lines, &stamp, &summary, location.as_deref(), change));
event = None;
events += 1;
continue;
}
_ => {}
}
lines.push(line);
}
(events > 0).then(|| out.iter().map(|line| ics_fold(line)).collect())
}
// one VEVENT's lines (BEGIN to END) with the change made, see ics_change
fn changed_event(lines: &[String], stamp: &str, summary: &str, location: Option<&str>, change: &Change) -> Vec<String> {
let key_of = |line: &str| content_line(line).map(|(head, _)| head.split(';').next().unwrap_or(head).to_ascii_uppercase()).unwrap_or_default();
// its own properties come before its first nested block (or its END)
let body_end = lines.iter().skip(1).position(|line| matches!(key_of(line).as_str(), "BEGIN" | "END")).map_or(lines.len(), |at| at + 1);
let (own, rest) = lines.split_at(body_end);
let instance = own.iter().any(|line| key_of(line) == "RECURRENCE-ID");
let when = change.when.filter(|_| !instance);
let old_location = own.iter().find(|line| key_of(line) == "LOCATION").and_then(|line| content_line(line)).map(|(_, value)| ics_text(value)).unwrap_or_default();
let moved = old_location.trim() != change.location.trim();
let sequence = own.iter().find(|line| key_of(line) == "SEQUENCE").and_then(|line| content_line(line)?.1.trim().parse::<u32>().ok()).map_or(1, |number| number + 1);
let mut kept: Vec<String> = Vec::new();
for line in own {
let key = key_of(line);
let dropped = match key.as_str() {
"SUMMARY" | "LOCATION" | "SEQUENCE" | "DTSTAMP" | "LAST-MODIFIED" => true,
"X-APPLE-STRUCTURED-LOCATION" => moved,
"DTSTART" | "DTEND" | "DURATION" => when.is_some(),
_ => false,
};
if !dropped {
kept.push(line.clone());
}
}
kept.push(format!("DTSTAMP:{stamp}"));
kept.push(format!("LAST-MODIFIED:{stamp}"));
kept.push(format!("SEQUENCE:{sequence}"));
if let Some(when) = when {
kept.extend(when.lines());
}
kept.push(summary.to_string());
kept.extend(location.map(String::from));
kept.extend(rest.iter().cloned());
kept
}
// a contact's birthday; `year` none when the card leaves it out (Apple writes 1604 with X-APPLE-OMIT-YEAR)
#[derive(Debug, Clone, PartialEq)]
pub struct Birthday {
pub name: String,
pub month: u32,
pub day: u32,
pub year: Option<i64>,
}
/*
* the birthdays in vcards (one or many in a text, 3.0 or 4.0): BDAY as 19900315, 1990-03-15,
* --0315, --03-15, with VALUE=date or a time after it, and Apple's year-less 1604-03-15 with
* X-APPLE-OMIT-YEAR=1604. the name is FN, else N's given and family names, else ORG
*/
pub fn vcard_birthdays(text: &str) -> Vec<Birthday> {
#[derive(Default)]
struct Card {
full: String,
parts: String,
org: String,
date: Option<(Option<i64>, u32, u32)>,
}
let mut birthdays = Vec::new();
let mut card: Option<Card> = None;
for line in unfolded(text) {
let Some((head, value)) = content_line(&line) else {
continue;
};
let mut params = head.split(';');
// grouped properties like item1.X-ABDATE carry their group before a dot
let name = params.next().unwrap_or("").rsplit('.').next().unwrap_or("").to_ascii_uppercase();
match (name.as_str(), card.as_mut()) {
("BEGIN", _) if value.trim().eq_ignore_ascii_case("VCARD") => card = Some(Card::default()),
("END", Some(_)) if value.trim().eq_ignore_ascii_case("VCARD") => {
let Some(done) = card.take() else { continue };
let Some((year, month, day)) = done.date else { continue };
let given_family = {
let fields: Vec<String> = done.parts.split(';').map(ics_text).collect();
let given = fields.get(1).map_or("", |given| given.trim());
let family = fields.first().map_or("", |family| family.trim());
format!("{given} {family}").trim().to_string()
};
let name = [done.full.trim().to_string(), given_family, done.org.split(';').next().map(ics_text).unwrap_or_default().trim().to_string()].into_iter().find(|name| !name.is_empty());
if let Some(name) = name {
birthdays.push(Birthday { name, month, day, year });
}
}
("FN", Some(card)) => card.full = ics_text(value),
("N", Some(card)) => card.parts = value.to_string(),
("ORG", Some(card)) => card.org = value.to_string(),
("BDAY", Some(card)) => {
let omitted = params.find_map(|param| {
let (key, number) = param.split_once('=')?;
key.trim().eq_ignore_ascii_case("X-APPLE-OMIT-YEAR").then(|| number.trim().trim_matches('"').parse::<i64>().ok())?
});
card.date = birthday_date(value, omitted);
}
_ => {}
}
}
birthdays
}
// a BDAY value as (year if known, month, day)
fn birthday_date(value: &str, omitted_year: Option<i64>) -> Option<(Option<i64>, u32, u32)> {
let date = value.trim().split(['T', 't']).next().unwrap_or("");
let (year, digits) = match date.strip_prefix("--") {
Some(rest) => (None, rest.replace('-', "")),
None => {
let digits = date.replace('-', "");
let year: i64 = digits.get(..4)?.parse().ok()?;
(Some(year), digits.get(4..)?.to_string())
}
};
if digits.len() != 4 || !digits.chars().all(|digit| digit.is_ascii_digit()) {
return None;
}
let month: u32 = digits.get(..2)?.parse().ok()?;
let day: u32 = digits.get(2..)?.parse().ok()?;
if !(1..=12).contains(&month) || !(1..=31).contains(&day) {
return None;
}
Some((year.filter(|year| Some(*year) != omitted_year), month, day))
}
// easter sunday of a year in the gregorian calendar (the anonymous gregorian computus), as days since 1970
pub fn easter(year: i64) -> i64 {
let golden = year.rem_euclid(19);
let (century, rest) = (year.div_euclid(100), year.rem_euclid(100));
let (leap_centuries, century_rest) = (century / 4, century % 4);
let correction = (century + 8) / 25;
let moon = (century - correction + 1) / 3;
let epact = (19 * golden + century - leap_centuries - moon + 15) % 30;
let (leap_years, year_rest) = (rest / 4, rest % 4);
let weekday = (32 + 2 * century_rest + 2 * leap_years - epact - year_rest) % 7;
let shift = (golden + 11 * epact + 22 * weekday) / 451;
let month = (epact + weekday - 7 * shift + 114) / 31;
let day = (epact + weekday - 7 * shift + 114) % 31 + 1;
days_from_civil(year, month as u32, day as u32)
}
const STATES: [&str; 16] = ["BW", "BY", "BE", "BB", "HB", "HH", "HE", "MV", "NI", "NW", "RP", "SL", "SN", "ST", "SH", "TH"];
/*
* the public holidays (gesetzliche Feiertage) of a German state in a year, as (days since
* 1970, German name), in date order: the nationwide ones and the state's own. `state` is
* its code (BW, BY, BE, BB, HB, HH, HE, MV, NI, NW, RP, SL, SN, ST, SH, TH); empty gives
* none, an unknown code only the nationwide ones. school holidays are not public holidays.
* Mariä Himmelfahrt counts for all of BY although it's only official in its mostly catholic
* places, the majority; regional ones (Augsburg's Friedensfest, Fronleichnam in parts of
* SN and TH) are left out
*/
pub fn holidays(year: i64, state: &str) -> Vec<(i64, &'static str)> {
let state = state.trim().to_ascii_uppercase();
if state.is_empty() {
return Vec::new();
}
let known = STATES.contains(&state.as_str());
let here = |states: &[&str]| known && states.contains(&state.as_str());
let date = |month: u32, day: u32| days_from_civil(year, month, day);
let easter = easter(year);
let mut days: Vec<(i64, &'static str)> = vec![
(date(1, 1), "Neujahr"),
(easter - 2, "Karfreitag"),
(easter + 1, "Ostermontag"),
(date(5, 1), "Tag der Arbeit"),
(easter + 39, "Christi Himmelfahrt"),
(easter + 50, "Pfingstmontag"),
(date(12, 25), "1. Weihnachtstag"),
(date(12, 26), "2. Weihnachtstag"),
];
if year >= 1990 {
days.push((date(10, 3), "Tag der Deutschen Einheit"));
}
if here(&["BW", "BY", "ST"]) {
days.push((date(1, 6), "Heilige Drei Könige"));
}
if (here(&["BE"]) && year >= 2019) || (here(&["MV"]) && year >= 2023) {
days.push((date(3, 8), "Internationaler Frauentag"));
}
if here(&["BB", "HE"]) {
days.push((easter, "Ostersonntag"));
days.push((easter + 49, "Pfingstsonntag"));
}
if here(&["BE"]) && (year == 2020 || year == 2025) {
days.push((date(5, 8), "Tag der Befreiung"));
}
if here(&["BW", "BY", "HE", "NW", "RP", "SL"]) {
days.push((easter + 60, "Fronleichnam"));
}
if here(&["BY", "SL"]) {
days.push((date(8, 15), "Mariä Himmelfahrt"));
}
if here(&["TH"]) && year >= 2019 {
days.push((date(9, 20), "Weltkindertag"));
}
if year == 2017 || here(&["BB", "MV", "SN", "ST", "TH"]) || (here(&["HB", "HH", "NI", "SH"]) && year >= 2018) {
days.push((date(10, 31), "Reformationstag"));
}
if here(&["BW", "BY", "NW", "RP", "SL"]) {
days.push((date(11, 1), "Allerheiligen"));
}
// the wednesday before 23 november; everywhere until 1994
if here(&["SN"]) || year <= 1994 {
let before = date(11, 22);
days.push((before - (i64::from(weekday(before)) + 5) % 7, "Buß- und Bettag"));
}
days.sort_by_key(|(day, _)| *day);
days
}
#[cfg(test)]
mod tests {
use super::*;
@@ -2346,4 +2927,254 @@ Sink #80
assert_eq!(dav_time(1_792_146_607), "20261016T103007Z");
assert_eq!(dav_time(-1), "19691231T235959Z");
}
#[test]
fn reads_dav_discovery_and_reports() {
// icloud's principal, its calendar home on another host, and a carddav home
let principal = r#"<?xml version="1.0" encoding="UTF-8"?><multistatus xmlns="DAV:"><response><href>/</href><propstat><prop><current-user-principal><href>/123456789/principal/</href></current-user-principal></prop><status>HTTP/1.1 200 OK</status></propstat></response></multistatus>"#;
let home = r#"<multistatus xmlns="DAV:"><response><href>/123456789/principal/</href><propstat><prop><calendar-home-set xmlns="urn:ietf:params:xml:ns:caldav"><href xmlns="DAV:">https://p42-caldav.icloud.com:443/123456789/calendars/</href></calendar-home-set></prop><status>HTTP/1.1 200 OK</status></propstat></response></multistatus>"#;
let cards = r#"<d:multistatus xmlns:d="DAV:"><d:response><d:href>/123456789/principal/</d:href><d:propstat><d:prop><card:addressbook-home-set xmlns:card="urn:ietf:params:xml:ns:carddav"><d:href>https://p42-contacts.icloud.com:443/123456789/carddavhome/</d:href></card:addressbook-home-set></d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response></d:multistatus>"#;
assert_eq!(dav_href(principal, "current-user-principal").as_deref(), Some("/123456789/principal/"));
assert_eq!(dav_href(home, "calendar-home-set").as_deref(), Some("https://p42-caldav.icloud.com:443/123456789/calendars/"));
assert_eq!(dav_href(cards, "addressbook-home-set").as_deref(), Some("https://p42-contacts.icloud.com:443/123456789/carddavhome/"));
assert_eq!(dav_href(principal, "calendar-home-set"), None);
let books = r#"<multistatus xmlns="DAV:"><response><href>/123456789/carddavhome/</href><propstat><prop><resourcetype><collection/></resourcetype></prop><status>HTTP/1.1 200 OK</status></propstat></response><response><href>/123456789/carddavhome/card/</href><propstat><prop><resourcetype><collection/><addressbook xmlns="urn:ietf:params:xml:ns:carddav"/></resourcetype></prop><status>HTTP/1.1 200 OK</status></propstat></response></multistatus>"#;
assert_eq!(dav_addressbooks(books), ["/123456789/carddavhome/card/"]);
// a calendar shared read-only, the user's own with every right, one that doesn't say
let rights = r#"<multistatus xmlns="DAV:"><response><href>/1/calendars/shared/</href><propstat><prop><current-user-privilege-set><privilege><read/></privilege><privilege><read-current-user-privilege-set/></privilege></current-user-privilege-set></prop><status>HTTP/1.1 200 OK</status></propstat></response><response><href>/1/calendars/home/</href><propstat><prop><current-user-privilege-set><privilege><all/></privilege><privilege><read/></privilege><privilege><write/></privilege></current-user-privilege-set></prop><status>HTTP/1.1 200 OK</status></propstat></response><response><href>/1/calendars/work/</href><propstat><prop><current-user-privilege-set/></prop><status>HTTP/1.1 404 Not Found</status></propstat></response></multistatus>"#;
assert_eq!(dav_read_only(rights), ["/1/calendars/shared/"]);
// a calendar-query answer: icloud's cdata, entity-escaped data with &#13;, a 404 one left out
let report = "<?xml version='1.0' encoding='UTF-8'?><multistatus xmlns='DAV:'><response><href>/123456789/calendars/home/6B1C9E6A.ics</href><propstat><prop><getetag>\"mfe0k8mi\"</getetag><calendar-data xmlns='urn:ietf:params:xml:ns:caldav'><![CDATA[BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nUID:6B1C9E6A\r\nDTSTART:20261016T090000Z\r\nSUMMARY:A & B <c>\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n]]></calendar-data></prop><status>HTTP/1.1 200 OK</status></propstat></response>\
<response><href>/123456789/calendars/home/x2.ics</href><propstat><prop><getetag>&quot;abc&quot;</getetag><C:calendar-data xmlns:C='urn:ietf:params:xml:ns:caldav'>BEGIN:VCALENDAR&#13;\nSUMMARY:Tom &amp; Jerry&#13;\nEND:VCALENDAR&#13;\n</C:calendar-data></prop><status>HTTP/1.1 200 OK</status></propstat></response>\
<response><href>/123456789/calendars/home/gone.ics</href><propstat><prop><calendar-data/></prop><status>HTTP/1.1 404 Not Found</status></propstat></response></multistatus>";
let found = dav_resources(report, "calendar-data");
assert_eq!(found.len(), 2);
assert_eq!((found[0].href.as_str(), found[0].etag.as_str()), ("/123456789/calendars/home/6B1C9E6A.ics", "\"mfe0k8mi\""));
assert!(found[0].data.starts_with("BEGIN:VCALENDAR\r\n") && found[0].data.contains("SUMMARY:A & B <c>\r\n"));
assert_eq!((found[1].etag.as_str(), found[1].data.as_str()), ("\"abc\"", "BEGIN:VCALENDAR\r\nSUMMARY:Tom & Jerry\r\nEND:VCALENDAR"));
assert_eq!(curl_answer("<xml/>\nline\n207\n\"etag-1\""), ("<xml/>\nline", "207", "\"etag-1\""));
assert_eq!(curl_answer("\n000\n"), ("", "000", ""));
assert_eq!(curl_answer(""), ("", "", ""));
assert_eq!(curl_answer("ok\n204\n%header{etag}"), ("ok", "204", ""));
assert_eq!(curl_quoted("ann@icloud.com:ab\"c\\d"), "\"ann@icloud.com:ab\\\"c\\\\d\"");
assert_eq!(curl_quoted("BEGIN:VCALENDAR\r\n\tX"), "\"BEGIN:VCALENDAR\\r\\n\\tX\"");
}
#[test]
fn reads_offsets_and_typed_times() {
assert_eq!(["+0200\n", "-0930", "+0000", "0100", "+02", "bogus", ""].map(zone_offset), [Some(7_200), Some(-34_200), Some(0), Some(3_600), None, None, None]);
assert_eq!(
["9", "930", "0930", "9:30", "09.30", "9h30", "23:59", "0:00", "1:5", " 18:45 "].map(clock_minutes),
[Some(540), Some(570), Some(570), Some(570), Some(570), Some(570), Some(1_439), Some(0), Some(65), Some(1_125)]
);
assert_eq!(["24:00", "12:60", "", "ab", "12345", ":30", "9:", "9:300", "-1"].map(clock_minutes), [None; 9]);
}
#[test]
fn reads_series_and_instances() {
// icloud's answer to an expanded report: an instance of a series in utc, and an all-day event
let ics = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//CALENDARSERVER.ORG//NONSGML Version 1//EN\r\n\
BEGIN:VEVENT\r\nUID:series-1\r\nDTSTART:20261019T080000Z\r\nDTEND:20261019T083000Z\r\nRECURRENCE-ID:20261019T070000Z\r\nSUMMARY:Stand-up\r\nEND:VEVENT\r\n\
BEGIN:VEVENT\r\nUID:one-off\r\nDTSTART;VALUE=DATE:20261020\r\nDTEND;VALUE=DATE:20261021\r\nSUMMARY:Day off\r\nEND:VEVENT\r\n\
BEGIN:VEVENT\r\nUID:weekly\r\nDTSTART:20261021T100000Z\r\nRRULE:FREQ=WEEKLY\r\nSUMMARY:Unexpanded\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n";
let day = |month, day| days_from_civil(2026, month, day) * 86_400;
let found = ics_occurrences(ics, 7_200);
assert_eq!(found.iter().map(|occurrence| (occurrence.uid.as_str(), occurrence.recurring)).collect::<Vec<_>>(), [("series-1", true), ("one-off", false), ("weekly", true)]);
assert_eq!((found[0].event.start, found[0].event.end, found[0].event.all_day), (day(10, 19) + 8 * 3_600, day(10, 19) + 8 * 3_600 + 1_800, false));
assert_eq!((found[1].event.start, found[1].event.end, found[1].event.all_day), (day(10, 20) - 7_200, day(10, 21) - 7_200, true));
assert_eq!(ics_events(ics, 7_200), found.iter().map(|occurrence| occurrence.event.clone()).collect::<Vec<_>>());
}
#[test]
fn writes_icalendar() {
assert_eq!(ics_escape("a, b; c\\d\ne\r\nf"), "a\\, b\\; c\\\\d\\ne\\nf");
assert_eq!(ics_text(&ics_escape("Tom, Jerry; \\ and\nmore")), "Tom, Jerry; \\ and\nmore");
assert_eq!(ics_date(days_from_civil(2026, 10, 16)), "20261016");
// 100 letters: 75 on the first line, a space and the other 25 on the next
let long = "X".repeat(100);
assert_eq!(ics_fold(&long), format!("{}\r\n {}\r\n", "X".repeat(75), "X".repeat(25)));
assert_eq!(ics_fold("SHORT:yes"), "SHORT:yes\r\n");
// two-byte letters are never cut, and no line passes 75 octets
let wide = format!("SUMMARY:{}", "ä".repeat(80));
let folded = ics_fold(&wide);
assert!(folded.split("\r\n").all(|line| line.len() <= 75));
assert_eq!(folded.split("\r\n").next().map(str::len), Some(74));
assert_eq!(unfolded(&folded), [wide]);
let stamp = 1_792_022_400;
let start = days_from_civil(2026, 10, 16) * 86_400 + 9 * 3_600;
assert_eq!(
ics_new("ABC-1", stamp, "Lunch, with Ann; Bob ", "Café Einstein", When::Timed { start, end: start + 5_400 }),
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Magic Island//amane-config//EN\r\nCALSCALE:GREGORIAN\r\nBEGIN:VEVENT\r\nUID:ABC-1\r\nDTSTAMP:20261015T000000Z\r\nCREATED:20261015T000000Z\r\n\
LAST-MODIFIED:20261015T000000Z\r\nDTSTART:20261016T090000Z\r\nDTEND:20261016T103000Z\r\nSUMMARY:Lunch\\, with Ann\\; Bob\r\nLOCATION:Café Einstein\r\nSEQUENCE:0\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n"
);
// an all-day event over two days reads back; no location, no LOCATION line
let trip = ics_new("T-2", stamp, "Trip", " ", When::AllDay { first: days_from_civil(2026, 10, 16), days: 2 });
assert!(trip.contains("\r\nDTSTART;VALUE=DATE:20261016\r\nDTEND;VALUE=DATE:20261018\r\n") && !trip.contains("LOCATION"));
assert_eq!(
ics_occurrences(&trip, 0),
[Occurrence {
event: Event { start: days_from_civil(2026, 10, 16) * 86_400, end: days_from_civil(2026, 10, 18) * 86_400, all_day: true, summary: String::from("Trip"), location: String::new() },
uid: String::from("T-2"),
recurring: false
}]
);
}
#[test]
fn changes_icalendar_events() {
// an event as apple calendar stores it on icloud: a zone, a structured location, an alarm
let stored = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Apple Inc.//macOS 15.0//EN\r\nCALSCALE:GREGORIAN\r\nBEGIN:VTIMEZONE\r\nTZID:Europe/Berlin\r\nBEGIN:DAYLIGHT\r\nTZOFFSETFROM:+0100\r\n\
RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU\r\nDTSTART:19810329T020000\r\nTZNAME:CEST\r\nTZOFFSETTO:+0200\r\nEND:DAYLIGHT\r\nEND:VTIMEZONE\r\n\
BEGIN:VEVENT\r\nCREATED:20261001T080000Z\r\nDTEND;TZID=Europe/Berlin:20261016T120000\r\nDTSTAMP:20261001T080000Z\r\nDTSTART;TZID=Europe/Berlin:20261016T110000\r\n\
LAST-MODIFIED:20261001T080000Z\r\nLOCATION:Café Einstein\\nUnter den Linden 42\r\nSEQUENCE:2\r\nSUMMARY:Coffee\r\nUID:6B1C9E6A-1F7A-4C8E-9C51-0E3F2D8A7B11\r\n\
X-APPLE-STRUCTURED-LOCATION;VALUE=URI;X-ADDRESS=Unter den Linden 42;X-APPLE-RADIUS=70;X-TITLE=\"Café Einst\r\n ein\":geo:52.517,13.389\r\n\
BEGIN:VALARM\r\nACTION:DISPLAY\r\nDESCRIPTION:Reminder\r\nTRIGGER:-PT15M\r\nUID:ALARM-1\r\nEND:VALARM\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n";
let stamp = 1_792_022_400;
let start = days_from_civil(2026, 10, 16) * 86_400 + 11 * 3_600;
let change = Change { summary: String::from("Coffee with Ann"), location: String::from("Home"), when: Some(When::Timed { start, end: start + 3_600 }) };
let changed = ics_change(stored, stamp, &change).unwrap_or_default();
// the zone stays as it was; the event keeps its uid, creation and alarm
assert!(changed.starts_with("BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Apple Inc.//macOS 15.0//EN\r\nCALSCALE:GREGORIAN\r\nBEGIN:VTIMEZONE\r\nTZID:Europe/Berlin\r\n"));
assert!(changed.ends_with(
"BEGIN:VEVENT\r\nCREATED:20261001T080000Z\r\nUID:6B1C9E6A-1F7A-4C8E-9C51-0E3F2D8A7B11\r\nDTSTAMP:20261015T000000Z\r\nLAST-MODIFIED:20261015T000000Z\r\nSEQUENCE:3\r\n\
DTSTART:20261016T110000Z\r\nDTEND:20261016T120000Z\r\nSUMMARY:Coffee with Ann\r\nLOCATION:Home\r\nBEGIN:VALARM\r\nACTION:DISPLAY\r\nDESCRIPTION:Reminder\r\nTRIGGER:-PT15M\r\n\
UID:ALARM-1\r\nEND:VALARM\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n"
));
assert_eq!(
ics_events(&changed, 7_200),
[Event { start, end: start + 3_600, all_day: false, summary: String::from("Coffee with Ann"), location: String::from("Home") }]
);
// the same location keeps apple's structured one; no times given keeps them; an empty location drops it
let renamed = ics_change(stored, stamp, &Change { summary: String::from("Coffee"), location: String::from("Café Einstein\nUnter den Linden 42"), when: None }).unwrap_or_default();
assert!(renamed.contains("X-APPLE-STRUCTURED-LOCATION") && renamed.contains("DTSTART;TZID=Europe/Berlin:20261016T110000\r\n"));
let cleared = ics_change(stored, stamp, &Change { summary: String::from("Coffee"), location: String::new(), when: None }).unwrap_or_default();
assert!(!cleared.contains("LOCATION"));
// a series with a moved instance: both renamed, neither's days touched, each SEQUENCE up by one
let series = "BEGIN:VCALENDAR\nVERSION:2.0\nPRODID:-//Apple Inc.//iOS 18.0//EN\n\
BEGIN:VEVENT\nUID:series-1\nDTSTART;TZID=Europe/Berlin:20261005T090000\nDTEND;TZID=Europe/Berlin:20261005T093000\nRRULE:FREQ=WEEKLY;BYDAY=MO\nEXDATE;TZID=Europe/Berlin:20261012T090000\nSUMMARY:Stand-up\nDTSTAMP:20261001T080000Z\nEND:VEVENT\n\
BEGIN:VEVENT\nUID:series-1\nRECURRENCE-ID;TZID=Europe/Berlin:20261019T090000\nDTSTART;TZID=Europe/Berlin:20261019T100000\nDTEND;TZID=Europe/Berlin:20261019T103000\nSUMMARY:Stand-up\nSEQUENCE:1\nDTSTAMP:20261001T080000Z\nEND:VEVENT\n\
END:VCALENDAR\n";
let renamed = ics_change(series, stamp, &Change { summary: String::from("Daily, sync"), location: String::new(), when: None }).unwrap_or_default();
let read = ics_occurrences(&renamed, 7_200);
assert_eq!(read.iter().map(|occurrence| (occurrence.event.summary.as_str(), occurrence.recurring)).collect::<Vec<_>>(), [("Daily, sync", true), ("Daily, sync", true)]);
assert_eq!(read.iter().map(|occurrence| occurrence.event.start).collect::<Vec<_>>(), ics_occurrences(series, 7_200).iter().map(|occurrence| occurrence.event.start).collect::<Vec<_>>());
assert!(renamed.contains("RRULE:FREQ=WEEKLY;BYDAY=MO\r\n") && renamed.contains("EXDATE;TZID=Europe/Berlin:20261012T090000\r\n"));
assert_eq!(renamed.matches("SEQUENCE:1\r\n").count() + renamed.matches("SEQUENCE:2\r\n").count(), 2);
// times given to a series go to its first VEVENT only, never to a moved instance
let moved = ics_change(series, stamp, &Change { summary: String::from("x"), location: String::new(), when: Some(When::AllDay { first: days_from_civil(2026, 10, 6), days: 1 }) }).unwrap_or_default();
assert_eq!(moved.matches("DTSTART;VALUE=DATE:20261006").count(), 1);
assert!(moved.contains("DTSTART;TZID=Europe/Berlin:20261019T100000"));
assert_eq!(ics_change("BEGIN:VCALENDAR\r\nEND:VCALENDAR\r\n", stamp, &change), None);
}
#[test]
fn reads_birthdays_from_vcards() {
// icloud's vcard 3.0 with a folded name, apple's year-less date, vcard 4.0 forms, fallbacks
let cards = "BEGIN:VCARD\r\nVERSION:3.0\r\nPRODID:-//Apple Inc.//iOS 18.0//EN\r\nN:Müller;Anna;;;\r\nFN:Anna Mül\r\n ler\r\nBDAY;VALUE=date:1990-03-15\r\nEND:VCARD\r\n\
BEGIN:VCARD\r\nVERSION:3.0\r\nN:Smith;John;;;\r\nFN:Smith\\, John\r\nBDAY;X-APPLE-OMIT-YEAR=1604:1604-07-02\r\nEND:VCARD\r\n\
BEGIN:VCARD\nVERSION:4.0\nFN:Lena\nBDAY:--0704\nEND:VCARD\n\
BEGIN:VCARD\nVERSION:4.0\nFN:Max\nBDAY:--07-05\nEND:VCARD\n\
BEGIN:VCARD\nVERSION:3.0\nN:Doe;Jane;;;\nBDAY:19851224\nEND:VCARD\n\
BEGIN:VCARD\nVERSION:3.0\nORG:ACME GmbH;Sales\nBDAY:2001-10-16T00:00:00Z\nEND:VCARD\n\
BEGIN:VCARD\nVERSION:3.0\nFN:No Birthday\nTEL:+49 89 1234\nEND:VCARD\n\
BEGIN:VCARD\nVERSION:3.0\nFN:Broken\nBDAY:1990-13-40\nEND:VCARD\n\
BEGIN:VCARD\nVERSION:3.0\nFN:Leap\nbday;value=date:2000-02-29\nEND:VCARD\n";
let birthday = |name: &str, month, day, year| Birthday { name: String::from(name), month, day, year };
assert_eq!(
vcard_birthdays(cards),
[
birthday("Anna Müller", 3, 15, Some(1990)),
birthday("Smith, John", 7, 2, None),
birthday("Lena", 7, 4, None),
birthday("Max", 7, 5, None),
birthday("Jane Doe", 12, 24, Some(1985)),
birthday("ACME GmbH", 10, 16, Some(2001)),
birthday("Leap", 2, 29, Some(2000)),
]
);
assert!(vcard_birthdays("").is_empty());
}
#[test]
fn computes_german_holidays() {
let date = |year, month, day| days_from_civil(year, month, day);
// known easter sundays, the earliest and latest possible among them
for (year, month, day) in [(2024, 3, 31), (2025, 4, 20), (2026, 4, 5), (2027, 3, 28), (2000, 4, 23), (2008, 3, 23), (2011, 4, 24), (1818, 3, 22), (2038, 4, 25), (2285, 3, 22)] {
assert_eq!(easter(year), date(year, month, day), "easter {year}");
}
let bavaria = holidays(2026, "BY");
let named = |name: &str| bavaria.iter().find(|(_, holiday)| *holiday == name).map(|(day, _)| *day);
assert_eq!(bavaria.len(), 13);
assert_eq!(named("Karfreitag"), Some(date(2026, 4, 3)));
assert_eq!(named("Ostermontag"), Some(date(2026, 4, 6)));
assert_eq!(named("Christi Himmelfahrt"), Some(date(2026, 5, 14)));
assert_eq!(named("Pfingstmontag"), Some(date(2026, 5, 25)));
assert_eq!(named("Fronleichnam"), Some(date(2026, 6, 4)));
assert_eq!(named("Heilige Drei Könige"), Some(date(2026, 1, 6)));
assert_eq!(named("Mariä Himmelfahrt"), Some(date(2026, 8, 15)));
assert_eq!(named("Allerheiligen"), Some(date(2026, 11, 1)));
assert_eq!(named("Reformationstag"), None);
assert!(bavaria.windows(2).all(|pair| pair[0].0 < pair[1].0));
// berlin in 2025: women's day and the one-off day of liberation
let berlin: Vec<&str> = holidays(2025, "be").iter().map(|(_, name)| *name).collect();
assert_eq!(
berlin,
["Neujahr", "Internationaler Frauentag", "Karfreitag", "Ostermontag", "Tag der Arbeit", "Tag der Befreiung", "Christi Himmelfahrt", "Pfingstmontag", "Tag der Deutschen Einheit", "1. Weihnachtstag", "2. Weihnachtstag"]
);
// saxony's day of repentance is the wednesday before 23 november, 22 november itself in 2023
let repentance = |year| holidays(year, "SN").into_iter().find(|(_, name)| *name == "Buß- und Bettag").map(|(day, _)| day);
assert_eq!([2023, 2024, 2025, 2026].map(repentance), [Some(date(2023, 11, 22)), Some(date(2024, 11, 20)), Some(date(2025, 11, 19)), Some(date(2026, 11, 18))]);
assert_eq!(holidays(2026, "SN").iter().filter(|(_, name)| *name == "Reformationstag").count(), 1);
// brandenburg and hesse keep easter and whit sunday; thuringia children's day; the north the reformation since 2018
assert!(holidays(2026, "BB").contains(&(date(2026, 4, 5), "Ostersonntag")) && holidays(2026, "HE").contains(&(date(2026, 5, 24), "Pfingstsonntag")));
assert!(holidays(2026, "TH").contains(&(date(2026, 9, 20), "Weltkindertag")) && !holidays(2018, "TH").iter().any(|(_, name)| *name == "Weltkindertag"));
assert!(holidays(2018, "HH").contains(&(date(2018, 10, 31), "Reformationstag")) && !holidays(2016, "HH").iter().any(|(_, name)| *name == "Reformationstag"));
assert!(holidays(2017, "BY").contains(&(date(2017, 10, 31), "Reformationstag")));
// none without a state, the nationwide nine for an unknown code
assert!(holidays(2026, "").is_empty());
assert_eq!(holidays(2026, "XX").len(), 9);
}
}
+28 -6
View File
@@ -61,7 +61,7 @@ impl Pod {
// whether its card takes typing: then it has the keyboard and stays open when the pointer leaves
fn types(self) -> bool {
self == Self::Ai || (self == Self::Home && home::signing_in())
self == Self::Ai || (self == Self::Home && home::typing())
}
fn word(self) -> &'static str {
@@ -225,9 +225,15 @@ fn side(monitor: &Monitor, left: bool) -> LayerWindow {
let (card_w, card_h) = card.map_or((0.0, 0.0), |(pod, _)| pod.size());
// room for the pills to move away from the open island, or for the card
/*
* room for the pills to move away from the open island, or for this side's widest
* card. the width never follows the open card: a narrower window would move with
* its margin, and Hyprland applies size and position on different frames, so the
* pills jumped when a card closed
*/
let room = (island_card::width() - island_pill::width()) / 2.0;
let inner_w = (group_w + room).max(card_w);
let widest = pods.iter().map(|pod| pod.size().0).fold(0.0, f32::max);
let inner_w = (group_w + room).max(widest);
let window_h = top + h.max(card_h) + shadow;
// the group's edge nearest the island, in the window, and where the window starts on the monitor
@@ -261,19 +267,35 @@ fn side(monitor: &Monitor, left: bool) -> LayerWindow {
let (grow, pill_shown, reveal) = card.map_or((0.0, 1.0, 0.0), |(_, t)| phases(t));
/*
* the pills beside an open card are gone; a closing card shrinks back into its own
* pill's place, and they come back behind it only over the last `reveal_at` of its
* shrinking, so they don't pop up around a big card
*/
let reveal_at = layout::reveal_at();
let others = if grow <= 0.0 {
1.0
} else if reveal_at <= 0.0 {
0.0
} else {
let k = (1.0 - grow / reveal_at).clamp(0.0, 1.0);
k * k * (3.0 - 2.0 * k)
};
let mut layers: Vec<Box<dyn Widget>> = Vec::new();
let mut regions: Vec<InputArea> = Vec::new();
for pod in pods {
// the open one becomes the card; the others fade while it grows over them
if card.is_some_and(|(open, _)| open == pod) || grow >= 1.0 {
// the open one is the card, drawn last, over the others
if card.is_some_and(|(open, _)| open == pod) || others <= 0.0 {
continue;
}
let x = pill_x(pod);
let lit = state.hover.get(&pod).map_or(0.0, Animation::value);
let pill = pill(pod, lit, &colors, here);
let pill = if grow > 0.0 { pill.opacity(1.0 - grow) } else { pill };
let pill = if others < 1.0 { pill.opacity(others) } else { pill };
regions.push(InputArea { x: x as i32, y: (top + slide) as i32, width: h.ceil() as i32, height: h.ceil() as i32 });
layers.push(Box::new(pill.translate(x, top + slide)));
+240 -74
View File
@@ -4,21 +4,23 @@ use std::process::Command;
use std::thread;
use std::time::{Duration, Instant};
use amane::{Canvas, Color, Column, Path, Rectangle, Row, Service, Shape, SpaceBetween, Stack, Text, Weight, Widget};
use amane::{Blend, Canvas, Center, Color, Column, Full, Path, Rectangle, Row, Service, Shape, SpaceBetween, Stack, Start, Text, Weight, Widget};
use crate::config::appearance::text;
use crate::config::appearance::{overlays, text};
use crate::config::pills::{layout, monitor as settings};
use crate::glyph::Glyph;
use crate::glyph::{Glyph, glyph_shapes};
use crate::island::faded;
use crate::parse;
use crate::system::Gpu;
use crate::{pods, theme};
/*
* the system monitor card: cpu, gpu, memory, network, disk reads and writes as graphs
* over the last readings, and the hottest temperature sensors. everything comes from
* /proc and /sys on this Service's thread; no process list here, its button opens
* btm's in a terminal on a special workspace instead
* the system monitor card: cpu, the integrated gpu, the external gpu, memory, swap,
* network, disk reads and writes as graphs over the last readings; the external gpu's
* memory and the programs on it; the hottest temperature sensors in their colours.
* everything comes from /proc and /sys on this Service's thread (the external gpu from
* system::Gpu, which reads only while this card is open); no process list here, its
* button opens btm's in a terminal on a special workspace instead
*/
#[derive(Default)]
pub struct Stats {
@@ -27,15 +29,19 @@ pub struct Stats {
// the integrated gpu's clock as a share of its highest, and in words
gpu: Vec<f32>,
gpu_words: String,
// the external gpu's busy share
egpu: Vec<f32>,
ram: Vec<f32>,
ram_words: String,
swap: Vec<f32>,
swap_words: String,
// bytes a second
down: Vec<f32>,
up: Vec<f32>,
read: Vec<f32>,
write: Vec<f32>,
// (sensor, °C), hottest first
temps: Vec<(String, f32)>,
// (sensor, its icon, °C), hottest first
temps: Vec<(String, Glyph, f32)>,
}
struct Sample {
@@ -63,7 +69,9 @@ impl Service for Stats {
let busy = now.cpu.0.saturating_sub(before.cpu.0) as f32 / now.cpu.1.saturating_sub(before.cpu.1).max(1) as f32;
let memory = parse::memory(&read("/proc/meminfo")).unwrap_or_default();
let used = memory.total_kb.saturating_sub(memory.available_kb);
let swapped = memory.swap_total_kb.saturating_sub(memory.swap_free_kb);
let (clock, clock_words) = gpu_clock();
let egpu = Gpu::read().busy as f32 / 100.0;
let temps = temperatures();
{
@@ -72,14 +80,17 @@ impl Service for Stats {
push(&mut stats.cpu, busy, keep);
push(&mut stats.gpu, clock, keep);
push(&mut stats.egpu, egpu, keep);
push(&mut stats.ram, used as f32 / memory.total_kb.max(1) as f32, keep);
push(&mut stats.swap, swapped as f32 / memory.swap_total_kb.max(1) as f32, keep);
push(&mut stats.down, rate(now.net.0, before.net.0), keep);
push(&mut stats.up, rate(now.net.1, before.net.1), keep);
push(&mut stats.read, rate(now.disk.0, before.disk.0) * 512.0, keep);
push(&mut stats.write, rate(now.disk.1, before.disk.1) * 512.0, keep);
stats.gpu_words = clock_words;
stats.ram_words = format!("{:.1} of {:.1} GB", used as f32 / 1048576.0, memory.total_kb as f32 / 1048576.0);
stats.ram_words = format!("{} of {}", gigabytes(used), gigabytes(memory.total_kb));
stats.swap_words = if memory.swap_total_kb == 0 { String::from("no swap") } else { format!("{} of {}", gigabytes(swapped), gigabytes(memory.swap_total_kb)) };
stats.temps = temps;
}
@@ -88,6 +99,10 @@ impl Service for Stats {
}
}
fn gigabytes(kb: u64) -> String {
format!("{:.1} GB", kb as f32 / 1048576.0)
}
fn push(history: &mut Vec<f32>, value: f32, keep: usize) {
history.push(value);
@@ -142,8 +157,8 @@ fn gpu_clock() -> (f32, String) {
}
// every hwmon device's hottest sensor, named for what it is, hottest first
fn temperatures() -> Vec<(String, f32)> {
let mut found: Vec<(String, f32)> = Vec::new();
fn temperatures() -> Vec<(String, Glyph, f32)> {
let mut found: Vec<(String, Glyph, f32)> = Vec::new();
for entry in fs::read_dir("/sys/class/hwmon").into_iter().flatten().flatten() {
let device = entry.path();
@@ -161,28 +176,28 @@ fn temperatures() -> Vec<(String, f32)> {
let Some(celsius) = hottest else { continue };
let name = match kind.as_str() {
"coretemp" | "k10temp" | "zenpower" => "CPU",
"nvme" => "NVMe",
"amdgpu" | "nouveau" => "eGPU",
"xe" | "i915" => "GPU",
"acpitz" => "Board",
"thinkpad" => "ThinkPad",
"spd5118" | "jc42" => "Memory",
other if other.starts_with("iwlwifi") => "Wi-Fi",
other => other,
let (name, glyph) = match kind.as_str() {
"coretemp" | "k10temp" | "zenpower" => ("CPU", Glyph::Chip),
"nvme" => ("NVMe", Glyph::Drive),
"amdgpu" | "nouveau" => ("eGPU", Glyph::Gpu),
"xe" | "i915" => ("iGPU", Glyph::Gpu),
"acpitz" => ("Board", Glyph::Thermometer),
"thinkpad" => ("ThinkPad", Glyph::Laptop),
"spd5118" | "jc42" => ("Memory", Glyph::Memory),
other if other.starts_with("iwlwifi") => ("Wi-Fi", Glyph::Thermometer),
other => (other, Glyph::Thermometer),
};
found.push((name.to_string(), celsius));
found.push((name.to_string(), glyph, celsius));
}
found.sort_by(|a, b| b.1.total_cmp(&a.1));
found.sort_by(|a, b| b.2.total_cmp(&a.2));
found.truncate(settings::temperatures());
// a kind shown more than once is numbered, in the order shown
let names: Vec<String> = found.iter().map(|(name, _)| name.clone()).collect();
let names: Vec<String> = found.iter().map(|(name, ..)| name.clone()).collect();
for (index, (name, _)) in found.iter_mut().enumerate() {
for (index, (name, ..)) in found.iter_mut().enumerate() {
if names.iter().filter(|other| *other == name).count() > 1 {
let number = names[..index].iter().filter(|other| *other == name).count() + 1;
@@ -193,6 +208,19 @@ fn temperatures() -> Vec<(String, f32)> {
found
}
// a temperature's colour: cool below `warm`, then warm turning hot up to `hot`
fn temperature_color(celsius: f32) -> Color {
let (warm, hot) = (settings::warm(), settings::hot());
if celsius < warm {
settings::cool_color()
} else if celsius >= hot {
settings::hot_color()
} else {
Color::blend(settings::warm_color(), settings::hot_color(), (celsius - warm) / (hot - warm).max(0.1))
}
}
/*
* opens btm's process list in a terminal on a special workspace, or shows and hides that
* workspace when it runs already. the window is told apart by its class
@@ -222,19 +250,39 @@ fn label_height() -> f32 {
}
fn temp_row_height() -> f32 {
text::small() + 8.0
text::small() + 16.0
}
fn temp_rows() -> usize {
settings::temperatures().div_ceil(2)
}
// the card's height, from settings only, so the window's size doesn't follow the readings
pub fn height() -> f32 {
let graphs = 5.0 * (label_height() + settings::graph_height()) + 4.0 * 10.0;
fn program_row_height() -> f32 {
text::small() + 8.0
}
// header, graphs, rule, the temperatures' label and rows, with the column's four gaps
2.0 * layout::pad() + pods::header_height() + graphs + 1.0 + label_height() + temp_rows() as f32 * temp_row_height() + 4.0 * 10.0
// the external gpu's rows under its graph: its memory, then the programs on it (at least one line)
fn egpu_rows(gpu: &Gpu) -> usize {
1 + gpu.programs.len().clamp(1, settings::programs())
}
fn graph_count(gpu: &Gpu) -> usize {
if gpu.name.is_empty() { 6 } else { 7 }
}
/*
* the card's height, from settings and from whether there is an external gpu and how
* many programs use it, so the window's size doesn't follow the readings
*/
pub fn height() -> f32 {
let gpu = Gpu::read();
let count = graph_count(&gpu) as f32;
let graphs = count * (label_height() + settings::graph_height()) + (count - 1.0) * 10.0;
// header, graphs, the external gpu's rows, rule, the temperatures' label and rows, with the column's gaps
let (egpu, parts) = if gpu.name.is_empty() { (0.0, 5.0) } else { (egpu_rows(&gpu) as f32 * program_row_height(), 6.0) };
2.0 * layout::pad() + pods::header_height() + graphs + egpu + 1.0 + label_height() + temp_rows() as f32 * temp_row_height() + (parts - 1.0) * 10.0
}
// bytes a second in words
@@ -247,6 +295,15 @@ fn rate(bytes: f32) -> String {
}
}
// video memory in KiB, the way a person reads it
fn size(kib: u64) -> String {
match kib {
0..1024 => String::from("<1 MB"),
1024..1_048_576 => format!("{} MB", kib / 1024),
_ => format!("{:.1} GB", kib as f64 / 1_048_576.0),
}
}
// one graph's readings, the value at its top, its colour
type Series<'a> = (&'a [f32], f32, Color);
@@ -287,6 +344,119 @@ fn graph(history: &[f32], top: f32, origin: (f32, f32), size: (f32, f32), color:
vec![Box::new(area.line_to(last_x, y0 + height).close().fill(faded(color, 40))), Box::new(line.stroke(1.5, color))]
}
// a label on the left and its value on the right, one line
fn line_of(name: impl Into<String>, value: impl Into<String>, width: f32, height: f32, name_color: Color) -> Rectangle {
let theme = theme::now();
Rectangle::new().width(width).height(height).align_child(Start, Center).child(
Row::new(vec![
Box::new(Text::new(name).size(text::small()).color(name_color).elide()) as Box<dyn Widget>,
Box::new(Text::new(value).size(text::small()).color(theme.dim)),
])
.width(width)
.justify(SpaceBetween)
.align(Center),
)
}
// the external gpu's memory as a bar, and the programs on it, the most memory first
fn egpu_detail(gpu: &Gpu, width: f32) -> Column {
let theme = theme::now();
let row_h = program_row_height();
let share = if gpu.vram_total > 0 { gpu.vram_used as f32 / gpu.vram_total as f32 } else { 0.0 };
let words = format!("{} of {}", size(gpu.vram_used / 1024), size(gpu.vram_total / 1024));
let bar_w = width * 0.32;
let memory = Row::new(vec![
Box::new(Text::new("Video memory").size(text::small()).color(theme.text)) as Box<dyn Widget>,
Box::new(
Row::new(vec![
Box::new(Rectangle::new().width(bar_w).height(6.0).radius(Full).fill(overlays::track()).child(Rectangle::new().width((bar_w * share).max(6.0)).height(6.0).radius(Full).fill(theme.accent)))
as Box<dyn Widget>,
Box::new(Text::new(words).size(text::small()).color(theme.dim)),
])
.gap(10.0)
.align(Center),
),
])
.width(width)
.height(row_h)
.justify(SpaceBetween)
.align(Center);
let mut rows: Vec<Box<dyn Widget>> = vec![Box::new(memory)];
let most = settings::programs();
let shown = if gpu.programs.len() > most { most - 1 } else { most };
for program in gpu.programs.iter().take(shown) {
let amount = match program.busy {
Some(busy) => format!("{} · {busy}%", size(program.vram_kib)),
None => size(program.vram_kib),
};
rows.push(Box::new(line_of(program.name.as_str(), amount, width, row_h, theme.text)));
}
if gpu.programs.len() > most {
rows.push(Box::new(line_of(format!("and {} more", gpu.programs.len() - shown), "", width, row_h, theme.dim)));
}
if gpu.programs.is_empty() {
rows.push(Box::new(line_of("No programs on it", "", width, row_h, theme.dim)));
}
Column::new(rows)
}
// the temperatures in two columns: icon, name, value in its colour, a bar up to `hot`
fn temperature_grid(temps: &[(String, Glyph, f32)], width: f32) -> Stack {
let theme = theme::now();
let cell_w = width / 2.0 - 8.0;
let (row_h, line_h, icon) = (temp_row_height(), text::small() + 6.0, 14.0);
let bar_w = cell_w - icon - 8.0;
// every icon into one canvas behind the rows
let mut shapes: Vec<Box<dyn Shape>> = Vec::new();
let mut rows: Vec<Box<dyn Widget>> = Vec::new();
for (row, pair) in temps.chunks(2).enumerate() {
let mut cells: Vec<Box<dyn Widget>> = Vec::new();
for (column, (name, glyph, celsius)) in pair.iter().enumerate() {
let color = temperature_color(*celsius);
let share = (celsius / settings::hot().max(1.0)).clamp(0.0, 1.0);
shapes.extend(glyph_shapes(*glyph, icon, color, 0.0, (column as f32 * (cell_w + 16.0), row as f32 * row_h + (line_h - icon) / 2.0)));
cells.push(Box::new(
Rectangle::new().width(cell_w).height(row_h).padding(amane::Padding { top: 0.0, right: 0.0, bottom: 0.0, left: icon + 8.0 }).child(
Column::new(vec![
Box::new(
Row::new(vec![
Box::new(Text::new(name.as_str()).size(text::small()).color(theme.text)) as Box<dyn Widget>,
Box::new(Text::new(format!("{celsius:.0} °C")).size(text::small()).weight(Weight::Bold).color(color)),
])
.width(bar_w)
.height(line_h)
.justify(SpaceBetween)
.align(Center),
) as Box<dyn Widget>,
Box::new(Rectangle::new().width(bar_w).height(3.0).radius(Full).fill(overlays::track()).child(Rectangle::new().width((bar_w * share).max(3.0)).height(3.0).radius(Full).fill(color))),
])
.gap(2.0),
),
));
}
rows.push(Box::new(Row::new(cells).gap(16.0)));
}
let height = temp_rows() as f32 * row_h;
Stack::new(vec![Box::new(Canvas::new().width(width).height(height).shapes(shapes)) as Box<dyn Widget>, Box::new(Column::new(rows))]).width(width).height(height)
}
pub fn card(width: f32, height: f32) -> Rectangle {
let stats = Stats::read();
let gpu = Gpu::read();
@@ -300,23 +470,33 @@ pub fn card(width: f32, height: f32) -> Rectangle {
let net_top = peak(&stats.down, &stats.up);
let disk_top = peak(&stats.read, &stats.write);
let egpu = if gpu.name.is_empty() {
String::new()
} else {
let celsius = gpu.celsius.map_or(String::new(), |celsius| format!(" · {celsius} °C"));
format!(" · {} {}%{celsius}", gpu.name, gpu.busy)
};
// (name, value in words, graphs as (history, top, colour))
let blocks: Vec<(&str, String, Vec<Series>)> = vec![
("CPU", format!("{:.0} %", last(&stats.cpu) * 100.0), vec![(&stats.cpu, 1.0, theme.accent)]),
("GPU", format!("{}{egpu}", stats.gpu_words), vec![(&stats.gpu, 1.0, theme.accent)]),
("Memory", stats.ram_words.clone(), vec![(&stats.ram, 1.0, theme.accent)]),
("Network", format!("↓ {} ↑ {}", rate(last(&stats.down)), rate(last(&stats.up))), vec![(&stats.down, net_top, theme.accent), (&stats.up, net_top, theme.flame)]),
("Disk", format!("read {} write {}", rate(last(&stats.read)), rate(last(&stats.write))), vec![(&stats.read, disk_top, theme.accent), (&stats.write, disk_top, theme.flame)]),
let mut blocks: Vec<(String, String, Vec<Series>)> = vec![
(String::from("CPU"), format!("{:.0} %", last(&stats.cpu) * 100.0), vec![(&stats.cpu, 1.0, theme.accent)]),
(String::from("iGPU"), stats.gpu_words.clone(), vec![(&stats.gpu, 1.0, theme.accent)]),
];
if !gpu.name.is_empty() {
let mut facts = vec![format!("{}%", gpu.busy)];
if let Some(celsius) = gpu.celsius {
facts.push(format!("{celsius} °C"));
}
if let Some(watts) = gpu.watts {
facts.push(format!("{watts:.0} W"));
}
blocks.push((gpu.name.clone(), facts.join(" · "), vec![(&stats.egpu, 1.0, theme.flame)]));
}
blocks.extend([
(String::from("Memory"), stats.ram_words.clone(), vec![(stats.ram.as_slice(), 1.0, theme.accent)]),
(String::from("Swap"), stats.swap_words.clone(), vec![(stats.swap.as_slice(), 1.0, theme.flame)]),
(String::from("Network"), format!("↓ {} ↑ {}", rate(last(&stats.down)), rate(last(&stats.up))), vec![(stats.down.as_slice(), net_top, theme.accent), (stats.up.as_slice(), net_top, theme.flame)]),
(String::from("Disk"), format!("read {} write {}", rate(last(&stats.read)), rate(last(&stats.write))), vec![(stats.read.as_slice(), disk_top, theme.accent), (stats.write.as_slice(), disk_top, theme.flame)]),
]);
let (label_h, graph_h) = (label_height(), settings::graph_height());
// every graph into one canvas under the labels: each canvas costs the gpu on its own
@@ -342,41 +522,27 @@ pub fn card(width: f32, height: f32) -> Rectangle {
));
}
let graphs_h = 5.0 * (label_h + graph_h) + 4.0 * 10.0;
let count = graph_count(&gpu) as f32;
let graphs_h = count * (label_h + graph_h) + (count - 1.0) * 10.0;
let graphs = Stack::new(vec![Box::new(Canvas::new().width(width).height(graphs_h).shapes(shapes)) as Box<dyn Widget>, Box::new(Column::new(rows).gap(10.0))]).width(width).height(graphs_h);
// the temperatures in two columns
let mut temp_rows: Vec<Box<dyn Widget>> = Vec::new();
let mut parts: Vec<Box<dyn Widget>> = vec![Box::new(header), Box::new(graphs)];
for pair in stats.temps.chunks(2) {
let cell = |(name, celsius): &(String, f32)| -> Box<dyn Widget> {
let hot = if *celsius >= 85.0 { crate::config::appearance::overlays::low_battery() } else { theme.text };
Box::new(
Rectangle::new().width(width / 2.0 - 8.0).height(temp_row_height()).child(
Row::new(vec![Box::new(Text::new(name.as_str()).size(text::small()).color(theme.dim)) as Box<dyn Widget>, Box::new(Text::new(format!("{celsius:.0} °C")).size(text::small()).color(hot))])
.width(width / 2.0 - 8.0)
.justify(SpaceBetween),
),
)
};
temp_rows.push(Box::new(Row::new(pair.iter().map(cell).collect()).gap(16.0)));
if !gpu.name.is_empty() {
parts.push(Box::new(egpu_detail(&gpu, width)));
}
parts.push(Box::new(pods::rule(width)));
parts.push(Box::new(Rectangle::new().width(width).height(label_h).child(Text::new("Temperatures").size(text::small()).weight(Weight::Bold).color(theme.text))));
if stats.temps.is_empty() {
temp_rows.push(Box::new(Text::new(if stats.cpu.is_empty() { "Reading…" } else { "No temperature sensors" }).size(text::small()).color(theme.dim)));
let words = if stats.cpu.is_empty() { "Reading…" } else { "No temperature sensors" };
parts.push(Box::new(Rectangle::new().width(width).height(temp_rows() as f32 * temp_row_height()).child(Text::new(words).size(text::small()).color(theme.dim))));
} else {
parts.push(Box::new(temperature_grid(&stats.temps, width)));
}
Rectangle::new().width(width).height(height).child(
Column::new(vec![
Box::new(header) as Box<dyn Widget>,
Box::new(graphs),
Box::new(pods::rule(width)),
Box::new(Rectangle::new().width(width).height(label_h).child(Text::new("Temperatures").size(text::small()).weight(Weight::Bold).color(theme.text))),
Box::new(Column::new(temp_rows)),
])
.gap(10.0),
)
Rectangle::new().width(width).height(height).child(Column::new(parts).gap(10.0))
}
+2 -3
View File
@@ -14,7 +14,6 @@ use std::{fs, thread};
use amane::{Argument, Bluetooth, Bus, Service, Value};
use crate::control::Control;
use crate::parse;
use crate::config::control::{polling, services};
@@ -800,13 +799,13 @@ impl Service for Gpu {
Self::default()
}
// ponytail: scans every process's open files every 2 s, so only while the control centre is open
// ponytail: scans every process's open files every 2 s, so only while the system monitor's card is open
fn listen() {
let mut names: HashMap<String, String> = HashMap::new();
let mut engines: HashMap<u32, (u64, Instant)> = HashMap::new();
loop {
if !Control::read().expanded() {
if crate::pods::open_card() != Some(crate::pods::Pod::Monitor) {
thread::sleep(Duration::from_millis(polling::gpu_wait_ms()));
continue;
+33
View File
@@ -0,0 +1,33 @@
# Weather photos
The home card shows a photo of the weather now behind it. On first use the shell downloads the
one it needs from Wikimedia Commons (1280 px wide thumbnails) into `pills.home.photos`
(`~/.config/amane/weather` by default) as `<kind>-<day|night>.jpg`. It never replaces a file
that's already there, so any of them can be swapped for your own picture (a swapped file shows
after the shell restarts).
All of them are free to use under the licence given, with credit to their authors:
| File | Photo | Author | Licence |
|---|---|---|---|
| clear-day.jpg | [Zugspitze, Alpspitze und Jubiläumsgrat von Kinderburg, Allmannshausen, Starnberger See](https://commons.wikimedia.org/wiki/File:Zugspitze,_Alpspitze_und_Jubil%C3%A4umsgrat_von_Kinderburg,_Allmannshausen,_Starnberger_See.jpg) | TGoeller | CC0 1.0 |
| clear-night.jpg | [Starry night at Old Uppsala, Sweden](https://commons.wikimedia.org/wiki/File:Starry_night_at_Old_Uppsala,_Sweden.jpg) | Kevin Cho | CC BY-SA 4.0 |
| partly-cloudy-day.jpg | [Cumulus humilis Schönwald im Schwarzwald 20180810](https://commons.wikimedia.org/wiki/File:Cumulus_humilis_Sch%C3%B6nwald_im_Schwarzwald_20180810.jpg) | Uoaei1 | CC BY-SA 4.0 |
| partly-cloudy-night.jpg | [Full moon over Gullmarn fjord at Holma Marina 1](https://commons.wikimedia.org/wiki/File:Full_moon_over_Gullmarn_fjord_at_Holma_Marina_1.jpg) | W.carter | CC BY-SA 4.0 |
| cloudy-day.jpg | [Vragender (NL), Vragenderveen -- 2014 -- 3102](https://commons.wikimedia.org/wiki/File:Vragender_(NL),_Vragenderveen_--_2014_--_3102.jpg) | Dietmar Rabich | CC BY-SA 4.0 |
| cloudy-night.jpg | [Stora Bornö on a cloudy night 2](https://commons.wikimedia.org/wiki/File:Stora_Born%C3%B6_on_a_cloudy_night_2.jpg) | W.carter | CC BY-SA 4.0 |
| fog-day.jpg | [Dülmen, Umland -- 2014 -- 7056](https://commons.wikimedia.org/wiki/File:D%C3%BClmen,_Umland_--_2014_--_7056.jpg) | Dietmar Rabich | CC BY-SA 4.0 |
| fog-night.jpg | [Foggy Autumn Evening on Pawia Street, Warsaw](https://commons.wikimedia.org/wiki/File:Foggy_Autumn_Evening_on_Pawia_Street,_Warsaw.jpg) | Oleslawlama | CC BY-SA 4.0 |
| rain-day.jpg | [Rain drops on window 01 ies](https://commons.wikimedia.org/wiki/File:Rain_drops_on_window_01_ies.jpg) | Frank Vincentz | CC BY-SA 3.0 |
| rain-night.jpg | [City streets; Reflections on the street surface after rain](https://commons.wikimedia.org/wiki/File:City_streets;_Reflections_on_the_street_surface_after_rain.jpg) | Tarasna0922 | CC BY-SA 4.0 |
| snow-day.jpg | [Sninský kameň (v zime) 001](https://commons.wikimedia.org/wiki/File:Sninsk%C3%BD_kame%C5%88_(v_zime)_001.jpg) | Milan Bališin | CC BY-SA 4.0 |
| snow-night.jpg | [TwinLamp@snowyNight(byHideObara)](https://commons.wikimedia.org/wiki/File:TwinLamp@snowyNight(byHideObara).jpg) | Hide Obara | CC0 1.0 |
| storm-day.jpg | [2011-07 Unwetter Schwäbisch Gmünd](https://commons.wikimedia.org/wiki/File:2011-07_Unwetter_Schw%C3%A4bisch_Gm%C3%BCnd.jpg) | Kreuzschnabel | CC BY-SA 3.0 |
| storm-night.jpg | [Lightning, Friedrichshafen (OW1A0402)](https://commons.wikimedia.org/wiki/File:Lightning,_Friedrichshafen_(OW1A0402).jpg) | Matti Blume | CC BY-SA 4.0 |
Licences: [CC0 1.0](https://creativecommons.org/publicdomain/zero/1.0/),
[CC BY-SA 3.0](https://creativecommons.org/licenses/by-sa/3.0/),
[CC BY-SA 4.0](https://creativecommons.org/licenses/by-sa/4.0/). The shell shows the photos
scaled, cropped to the card and veiled in the card's colour; the files themselves are unchanged
Wikimedia thumbnails. Authors and licences were read from each file's Commons page
(`extmetadata`) on 2026-10-15.