From d59a5bc81cbb316a7c82c954bee8848d49b866b4 Mon Sep 17 00:00:00 2001 From: server2 Date: Thu, 15 Oct 2026 23:25:54 +0200 Subject: [PATCH] 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 --- README.md | 8 +- src/ai.rs | 143 +++- src/config.rs | 49 +- src/control.rs | 103 +-- src/glyph.rs | 37 + src/home.rs | 1778 ++++++++++++++++++++++++++++++++++++-------- src/notices.rs | 116 ++- src/parse.rs | 861 ++++++++++++++++++++- src/pods.rs | 34 +- src/stats.rs | 314 ++++++-- src/system.rs | 5 +- weather/CREDITS.md | 33 + 12 files changed, 2935 insertions(+), 546 deletions(-) create mode 100644 weather/CREDITS.md diff --git a/README.md b/README.md index ad1651f..b4ab59c 100644 --- a/README.md +++ b/README.md @@ -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/-.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 ` (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 `, `processes`, `notices list|dismiss |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). diff --git a/src/ai.rs b/src/ai.rs index 50f577e..5f037a7 100644 --- a/src/ai.rs +++ b/src/ai.rs @@ -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>, } 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 = 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 )")) } 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> = Vec::new(); + // the model, as a chip that opens the list of the others + let mut buttons: Vec> = 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 = if bubbles.is_empty() { + let chat: Box = if let Some(models) = &ai.models { + let current = settings::model(); + let mut rows: Vec> = 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 diff --git a/src/config.rs b/src/config.rs index f9494c6..aecc6cc 100644 --- a/src/config.rs +++ b/src/config.rs @@ -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: -.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 " + 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" diff --git a/src/control.rs b/src/control.rs index 311ac2a..ddf5ef2 100644 --- a/src/control.rs +++ b/src/control.rs @@ -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, } 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 { - 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> = 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 diff --git a/src/glyph.rs b/src/glyph.rs index f457ef7..799bfb7 100644 --- a/src/glyph.rs +++ b/src/glyph.rs @@ -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) ], diff --git a/src/home.rs b/src/home.rs index 096e238..0010fd3 100644 --- a/src/home.rs +++ b/src/home.rs @@ -1,25 +1,29 @@ +use std::collections::HashMap; +use std::env; +use std::fs; use std::io::Write; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; +use std::sync::{Mutex, PoisonError}; use std::thread; use std::time::{Duration, SystemTime, UNIX_EPOCH}; -use std::env; -use amane::{Canvas, Center, Color, Column, Full, Image, Rectangle, Row, Service, Shape, SpaceBetween, Stack, Start, Text, TextInput, Weight, Widget}; +use amane::{Canvas, Center, Circle, Color, Column, Full, Image, Padding, Pointer, Rectangle, Row, Service, Shape, SpaceBetween, Stack, Start, Text, TextInput, Weight, Widget}; -use crate::config::appearance::{overlays, text}; +use crate::config::appearance::{font, overlays, text}; use crate::config::pills::{calendar as agenda_settings, home as settings, layout}; use crate::config; -use crate::glyph::{Glyph, glyph_shapes, icon}; -use crate::island::faded; -use crate::parse::{self, Event, Sky, Value, Weather}; +use crate::glyph::{Glyph, glyph_shapes}; +use crate::island::{CLEAR, faded}; +use crate::parse::{self, Birthday, Change, Sky, Weather, When}; use crate::{pods, theme}; /* - * the home card: who's here, a big clock and the date, the weather (open-meteo, no key), - * and the calendar (any caldav server, icloud's by default). the calendar password is an - * app-specific one in the keyring (secret-tool), handed to curl on stdin, never written - * to a file or a command line + * the home card: who's here, a big clock and the date, the weather (open-meteo, no key) over a + * photo of it, and a month of the calendar: the events of every caldav calendar (icloud's by + * default) to read, add, change and delete, the contacts' birthdays (carddav) and german public + * holidays worked out here. the app-specific password is read from the keyring (secret-tool) + * and goes to curl on stdin, never into a file, an argument or a log */ // local time, refreshed by the minute (by the second with seconds shown) @@ -51,7 +55,7 @@ impl Service for Clock { clock.avatar = avatar; } - let unix = SystemTime::now().duration_since(UNIX_EPOCH).map_or(0, |since| since.as_secs() as i64); + let unix = now_unix(); { let mut clock = Self::write(); @@ -67,29 +71,37 @@ impl Service for Clock { } } -// the local offset from utc in seconds, from `date +%z` (+0200) +fn now_unix() -> i64 { + SystemTime::now().duration_since(UNIX_EPOCH).map_or(0, |since| since.as_secs() as i64) +} + +// the local offset from utc in seconds now, from `date +%z` (+0200) fn utc_offset() -> i64 { - let printed = Command::new("date").arg("+%z").output().map(|done| String::from_utf8_lossy(&done.stdout).trim().to_string()).unwrap_or_default(); + let printed = Command::new("date").arg("+%z").output().map(|done| String::from_utf8_lossy(&done.stdout).into_owned()).unwrap_or_default(); - let sign = if printed.starts_with('-') { -1 } else { 1 }; - let digits = printed.trim_start_matches(['+', '-']); - let hours: i64 = digits.get(..2).and_then(|hours| hours.parse().ok()).unwrap_or(0); - let minutes: i64 = digits.get(2..4).and_then(|minutes| minutes.parse().ok()).unwrap_or(0); + parse::zone_offset(&printed).unwrap_or(0) +} - sign * (hours * 3600 + minutes * 60) +// today as days since 1970, in local time; on a service thread +fn local_today() -> i64 { + (now_unix() + utc_offset()).div_euclid(86_400) } fn home_dir() -> PathBuf { env::var_os("HOME").map(PathBuf::from).unwrap_or_default() } +// a path from the settings, ~/ meaning the home folder +fn expanded(path: &str) -> PathBuf { + match path.strip_prefix("~/") { + Some(rest) => home_dir().join(rest), + None => PathBuf::from(path), + } +} + // the avatar picture, if the file is there fn avatar_file() -> Option { - let wanted = settings::avatar(); - let path = match wanted.strip_prefix("~/") { - Some(rest) => home_dir().join(rest), - None => PathBuf::from(&wanted), - }; + let path = expanded(&settings::avatar()); path.is_file().then(|| path.to_string_lossy().into_owned()) } @@ -98,6 +110,8 @@ fn avatar_file() -> Option { pub struct Forecast { weather: Option, problem: Option, + // the photo of the sky now, once its file is there + photo: Option, } impl Service for Forecast { @@ -116,7 +130,7 @@ impl Service for Forecast { let printed = Command::new("curl").args(["-s", "-m", "15", &url]).output().map(|done| String::from_utf8_lossy(&done.stdout).into_owned()).unwrap_or_default(); - { + let sky = { let mut forecast = Self::write(); match parse::weather(&printed) { @@ -126,6 +140,15 @@ impl Service for Forecast { } None => forecast.problem = Some(String::from("The weather couldn't be fetched")), } + + forecast.weather.as_ref().map(|weather| (weather.code, weather.is_day)) + }; + + // the photo for the sky now, fetched first if its file is missing; after the weather shows + let photo = sky.and_then(|(code, day)| photo(code, day)); + + if Self::read().photo != photo { + Self::write().photo = photo; } thread::sleep(Duration::from_secs(settings::weather_minutes() * 60)); @@ -133,15 +156,172 @@ impl Service for Forecast { } } +const USER_AGENT: &str = "MagicIsland/1.0 (amane-config desktop shell)"; + +// a photo per kind of sky by day and by night, 1280 px wide, from wikimedia commons; their authors and licences are in weather/CREDITS.md +const PHOTOS: [(&str, &str); 14] = [ + ( + "clear-day", + "https://upload.wikimedia.org/wikipedia/commons/thumb/1/11/Zugspitze%2C_Alpspitze_und_Jubil%C3%A4umsgrat_von_Kinderburg%2C_Allmannshausen%2C_Starnberger_See.jpg/1280px-Zugspitze%2C_Alpspitze_und_Jubil%C3%A4umsgrat_von_Kinderburg%2C_Allmannshausen%2C_Starnberger_See.jpg", + ), + ("clear-night", "https://upload.wikimedia.org/wikipedia/commons/thumb/4/44/Starry_night_at_Old_Uppsala%2C_Sweden.jpg/1280px-Starry_night_at_Old_Uppsala%2C_Sweden.jpg"), + ( + "partly-cloudy-day", + "https://upload.wikimedia.org/wikipedia/commons/thumb/7/7b/Cumulus_humilis_Sch%C3%B6nwald_im_Schwarzwald_20180810.jpg/1280px-Cumulus_humilis_Sch%C3%B6nwald_im_Schwarzwald_20180810.jpg", + ), + ( + "partly-cloudy-night", + "https://upload.wikimedia.org/wikipedia/commons/thumb/b/bf/Full_moon_over_Gullmarn_fjord_at_Holma_Marina_1.jpg/1280px-Full_moon_over_Gullmarn_fjord_at_Holma_Marina_1.jpg", + ), + ( + "cloudy-day", + "https://upload.wikimedia.org/wikipedia/commons/thumb/0/01/Vragender_%28NL%29%2C_Vragenderveen_--_2014_--_3102.jpg/1280px-Vragender_%28NL%29%2C_Vragenderveen_--_2014_--_3102.jpg", + ), + ("cloudy-night", "https://upload.wikimedia.org/wikipedia/commons/thumb/5/5c/Stora_Born%C3%B6_on_a_cloudy_night_2.jpg/1280px-Stora_Born%C3%B6_on_a_cloudy_night_2.jpg"), + ("fog-day", "https://upload.wikimedia.org/wikipedia/commons/thumb/7/76/D%C3%BClmen%2C_Umland_--_2014_--_7056.jpg/1280px-D%C3%BClmen%2C_Umland_--_2014_--_7056.jpg"), + ( + "fog-night", + "https://upload.wikimedia.org/wikipedia/commons/thumb/0/04/Foggy_Autumn_Evening_on_Pawia_Street%2C_Warsaw.jpg/1280px-Foggy_Autumn_Evening_on_Pawia_Street%2C_Warsaw.jpg", + ), + ("rain-day", "https://upload.wikimedia.org/wikipedia/commons/thumb/1/1e/Rain_drops_on_window_01_ies.jpg/1280px-Rain_drops_on_window_01_ies.jpg"), + ( + "rain-night", + "https://upload.wikimedia.org/wikipedia/commons/thumb/c/c0/City_streets%3B_Reflections_on_the_street_surface_after_rain.jpg/1280px-City_streets%3B_Reflections_on_the_street_surface_after_rain.jpg", + ), + ("snow-day", "https://upload.wikimedia.org/wikipedia/commons/thumb/1/14/Sninsk%C3%BD_kame%C5%88_%28v_zime%29_001.jpg/1280px-Sninsk%C3%BD_kame%C5%88_%28v_zime%29_001.jpg"), + ("snow-night", "https://upload.wikimedia.org/wikipedia/commons/thumb/7/7c/TwinLamp%40snowyNight%28byHideObara%29.jpg/1280px-TwinLamp%40snowyNight%28byHideObara%29.jpg"), + ("storm-day", "https://upload.wikimedia.org/wikipedia/commons/thumb/1/16/2011-07_Unwetter_Schw%C3%A4bisch_Gm%C3%BCnd.jpg/1280px-2011-07_Unwetter_Schw%C3%A4bisch_Gm%C3%BCnd.jpg"), + ( + "storm-night", + "https://upload.wikimedia.org/wikipedia/commons/thumb/5/57/Lightning%2C_Friedrichshafen_%28OW1A0402%29.jpg/1280px-Lightning%2C_Friedrichshafen_%28OW1A0402%29.jpg", + ), +]; + +fn sky_word(sky: Sky) -> &'static str { + match sky { + Sky::Clear => "clear", + Sky::PartlyCloudy => "partly-cloudy", + Sky::Cloudy => "cloudy", + Sky::Fog => "fog", + Sky::Rain => "rain", + Sky::Snow => "snow", + Sky::Storm => "storm", + } +} + +// the photo file for a weather code by day or night, downloaded first when it's missing; on the forecast's thread +fn photo(code: u32, day: bool) -> Option { + if !settings::photo() { + return None; + } + + let kind = format!("{}-{}", sky_word(parse::sky(code)), if day { "day" } else { "night" }); + let folder = expanded(&settings::photos()); + let path = folder.join(format!("{kind}.jpg")); + + if !path.exists() + && let Some((_, url)) = PHOTOS.iter().find(|(name, _)| *name == kind) + { + fetch_photo(url, &folder, &path); + } + + path.is_file().then(|| path.to_string_lossy().into_owned()) +} + +// a photo downloaded beside its place, then linked into it only while nothing is there: a file the user put there is never replaced +fn fetch_photo(url: &str, folder: &Path, path: &Path) { + if fs::create_dir_all(folder).is_err() { + return; + } + + let part = folder.join(format!(".download-{}", std::process::id())); + + let fetched = Command::new("curl") + .args(["-s", "-f", "-L", "-m", "60", "-A", USER_AGENT, "-o"]) + .arg(&part) + .arg(url) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .is_ok_and(|status| status.success()); + + // the picture decoder only reads png and jpeg; an error page must not land there + if fetched && fs::read(&part).is_ok_and(|bytes| bytes.starts_with(&[0xFF, 0xD8, 0xFF])) { + // a link never replaces a file; where links can't be made, a rename while nothing is there + if fs::hard_link(&part, path).is_err() && !path.exists() { + let _ = fs::rename(&part, path); + } + } + + let _ = fs::remove_file(&part); +} + +// one event as the card shows it, and where it lives on the server +#[derive(Clone)] +struct Entry { + // local seconds (unix plus the offset at that moment); all-day ones run from local midnight + start: i64, + end: i64, + all_day: bool, + summary: String, + location: String, + color: Option, + // its calendar's and its own whole url, and the version the server gave + calendar: String, + href: String, + etag: String, + // part of a series: changes go to the whole series + recurring: bool, +} + +// an event calendar of the account +#[derive(Clone)] +struct Calendar { + url: String, + name: String, + color: Option, + writable: bool, +} + +// the event form, beside the month +#[derive(Clone)] +struct Form { + // the event changed, none for a new one + editing: Option, + title: String, + location: String, + day: i64, + all_day: bool, + // all day: how many days; at times: how many days after its start day it ends + span: i64, + start: String, + end: String, + calendar: String, + // a new event's title takes the keyboard until something is typed + fresh: bool, + // Delete was pressed once + confirm: bool, + saving: bool, + problem: Option, +} + #[derive(Default)] pub struct Agenda { - // upcoming events with their calendar's colour, soonest first - events: Vec<(Event, Option)>, + // the events of `span` (local days, end excluded), soonest first + events: Vec, + span: (i64, i64), + calendars: Vec, + birthdays: Vec, synced: bool, problem: Option, - // the sign-in form is open, and the apple id typed into it - form: bool, - account: String, + contacts_problem: Option, + // no account or no password in the keyring: the card says how to set them + missing: bool, + + // the month shown as (year, month), none for today's; the day picked, none for today + month: Option<(i64, u32)>, + selected: Option, + form: Option
, } impl Service for Agenda { @@ -149,18 +329,29 @@ impl Service for Agenda { Self::default() } + // everything every few minutes, at once when the account or the servers change fn listen() { loop { - sync(); + sync(Scope::Everything); - thread::sleep(Duration::from_secs(agenda_settings::sync_minutes() * 60)); + let synced_for = sync_settings(); + let mut waited = 0; + + while waited < agenda_settings::sync_minutes() * 60 && sync_settings() == synced_for { + thread::sleep(Duration::from_secs(1)); + waited += 1; + } } } } -// whether the card has a field to type into (then it keeps the keyboard and stays open) -pub fn signing_in() -> bool { - Agenda::read().form +fn sync_settings() -> (String, String, String, bool) { + (agenda_settings::account(), agenda_settings::server(), agenda_settings::contacts_server(), agenda_settings::birthdays()) +} + +// whether the card has a form to type into: then it keeps the keyboard and stays open +pub fn typing() -> bool { + Agenda::read().form.is_some() } const KEYRING: [&str; 2] = ["service", "amane-calendar"]; @@ -172,113 +363,172 @@ fn password(account: &str) -> Option { (done.status.success() && !password.is_empty()).then_some(password) } -fn sign_in(account: String, password: String) { - let account = account.trim().to_string(); - - if account.is_empty() || password.is_empty() { - Agenda::write().problem = Some(String::from("Type the Apple ID and an app-specific password")); - - return; - } - - TextInput::set_text("home-password", ""); - - thread::spawn(move || { - let stored = Command::new("secret-tool") - .args(["store", "--label=Magic Island calendar", KEYRING[0], KEYRING[1], "account", &account]) - .stdin(Stdio::piped()) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .spawn() - .and_then(|mut child| { - if let Some(mut input) = child.stdin.take() { - input.write_all(password.as_bytes())?; - } - - child.wait() - }) - .is_ok_and(|status| status.success()); - - if !stored { - Agenda::write().problem = Some(String::from("The keyring didn't take the password (secret-tool)")); - - return; - } - - let _ = config::set("pills.calendar.account", Value::Text(account)); - - Agenda::write().form = false; - - sync(); - }); +// what a sync fetches: everything with discovery and birthdays, the events again after a change, or the shown month if it isn't there yet +#[derive(Clone, Copy, PartialEq)] +enum Scope { + Everything, + Events, + Month, } -fn sign_out() { - let account = agenda_settings::account(); +// one sync at a time +static SYNCING: Mutex<()> = Mutex::new(()); - thread::spawn(move || { - let _ = Command::new("secret-tool").args(["clear", KEYRING[0], KEYRING[1], "account", &account]).output(); - let _ = config::reset("pills.calendar.account"); +// the largest offset from utc there is, so a utc window holds every local day asked for +const ZONE_SLACK: i64 = 14 * 3_600; +fn shifted((year, month): (i64, u32), by: i64) -> (i64, u32) { + let index = year * 12 + i64::from(month) - 1 + by; + + (index.div_euclid(12), (index.rem_euclid(12) + 1) as u32) +} + +fn month_start(month: (i64, u32), by: i64) -> i64 { + let (year, month) = shifted(month, by); + + parse::days_from_civil(year, month, 1) +} + +fn month_of(day: i64) -> (i64, u32) { + let (year, month, _) = parse::civil_from_days(day); + + (year, month) +} + +// the days a month's grid shows: six weeks from the monday before its first +fn grid_span(month: (i64, u32)) -> (i64, i64) { + let first = month_start(month, 0); + let start = first - i64::from(parse::weekday(first)); + + (start, start + 42) +} + +fn covers(span: (i64, i64), wanted: (i64, i64)) -> bool { + span.0 <= wanted.0 && span.1 >= wanted.1 +} + +// the calendars, the events from the month before the shown one to the one after, and with Everything the birthdays; on this thread +fn sync(scope: Scope) { + let _one = SYNCING.lock().unwrap_or_else(PoisonError::into_inner); + + let account = agenda_settings::account().trim().to_string(); + let password = if account.is_empty() { None } else { password(&account) }; + + let Some(password) = password else { let mut agenda = Agenda::write(); - agenda.events.clear(); + agenda.missing = true; agenda.synced = false; agenda.problem = None; - }); -} - -// the calendar's events from now on, on this thread -fn sync() { - let account = agenda_settings::account(); - - if account.is_empty() { - return; - } - - let Some(password) = password(&account) else { - Agenda::write().problem = Some(String::from("The calendar password isn't in the keyring: sign in again")); + agenda.contacts_problem = None; + agenda.events.clear(); + agenda.calendars.clear(); + agenda.birthdays.clear(); return; }; - let found = events(&agenda_settings::server(), &account, &password); + let (month, known, have, synced) = { + let agenda = Agenda::read(); + + (agenda.month, agenda.calendars.clone(), agenda.span, agenda.synced) + }; + + let month = month.unwrap_or_else(|| month_of(local_today())); + + if scope == Scope::Month && synced && covers(have, grid_span(month)) { + return; + } + + let span = (month_start(month, -1), month_start(month, 2)); + let known = if scope == Scope::Everything { Vec::new() } else { known }; + + let found = events(&agenda_settings::server(), &account, &password, span, known); + let birthdays = (scope == Scope::Everything && agenda_settings::birthdays()).then(|| birthdays(&agenda_settings::contacts_server(), &account, &password)); let mut agenda = Agenda::write(); + agenda.missing = false; + match found { - Ok(events) => { + Ok((calendars, events)) => { + agenda.calendars = calendars; agenda.events = events; + agenda.span = span; agenda.synced = true; agenda.problem = None; } Err(why) => agenda.problem = Some(why), } + + match birthdays { + Some(Ok(found)) => { + agenda.birthdays = found; + agenda.contacts_problem = None; + } + Some(Err(why)) => agenda.contacts_problem = Some(why), + None if !agenda_settings::birthdays() => agenda.birthdays.clear(), + None => {} + } } -// one webdav request with curl; the account and password go in on stdin as curl config -fn dav(method: &str, url: &str, depth: &str, body: &str, account: &str, password: &str) -> Result { - let mut child = Command::new("curl") - .args(["-s", "-m", "20", "-X", method, "-H", &format!("Depth: {depth}"), "-H", "Content-Type: application/xml; charset=utf-8", "--data-binary", body, "-w", "\n%{http_code}", "-K", "-", url]) - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::null()) - .spawn() - .map_err(|error| format!("curl: {error}"))?; +/* + * one webdav request with curl. the account, the password and any body go in on stdin as curl + * config (-K -), never as arguments, so no other program sees them; gives (status, body, etag) + */ +fn request(method: &str, url: &str, headers: &[String], body: &str, account: &str, password: &str) -> Result<(String, String, String), String> { + let mut command = Command::new("curl"); + + command.args(["-s", "-m", "30", "-X", method, "-w", "\n%{http_code}\n%header{etag}"]); + + for header in headers { + command.arg("-H").arg(header); + } + + let mut child = command.args(["-K", "-", url]).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::null()).spawn().map_err(|error| format!("curl: {error}"))?; if let Some(mut input) = child.stdin.take() { - let quote = |text: &str| text.replace('\\', "\\\\").replace('"', "\\\""); - let _ = writeln!(input, "user = \"{}:{}\"", quote(account), quote(password)); + let mut config = format!("user = {}\n", parse::curl_quoted(&format!("{account}:{password}"))); + + if !body.is_empty() { + config.push_str(&format!("data-binary = {}\n", parse::curl_quoted(body))); + } + + let _ = input.write_all(config.as_bytes()); } let done = child.wait_with_output().map_err(|error| format!("curl: {error}"))?; let printed = String::from_utf8_lossy(&done.stdout); - let (answer, status) = printed.rsplit_once('\n').unwrap_or(("", &printed)); + let (answer, status, etag) = parse::curl_answer(&printed); - match status.trim() { - "401" | "403" => Err(String::from("The calendar didn't accept the Apple ID or the app-specific password")), - code if code.starts_with('2') => Ok(answer.to_string()), - "000" => Err(String::from("The calendar server can't be reached")), + Ok((status.to_string(), answer.to_string(), etag.to_string())) +} + +// a webdav question (PROPFIND, REPORT) and its answer +fn dav(method: &str, url: &str, depth: &str, body: &str, account: &str, password: &str) -> Result { + let headers = [format!("Depth: {depth}"), String::from("Content-Type: application/xml; charset=utf-8")]; + let (status, answer, _) = request(method, url, &headers, body, account, password)?; + let host = url.split('/').nth(2).unwrap_or(url); + + match status.as_str() { + code if code.starts_with('2') => Ok(answer), + "401" => Err(format!("{host} didn't accept the Apple ID or the app-specific password")), + "000" | "" => Err(format!("{host} can't be reached")), + code => Err(format!("{host} answered {code}")), + } +} + +const CHANGED_ELSEWHERE: &str = "The event changed on another device meanwhile; it's synced again, try once more"; + +// what a change's answer means +fn changed(status: &str) -> Result<(), String> { + match status { + code if code.starts_with('2') => Ok(()), + "412" => Err(String::from(CHANGED_ELSEWHERE)), + "401" => Err(String::from("The calendar didn't accept the Apple ID or the app-specific password")), + "403" => Err(String::from("The calendar doesn't let you change this; it may be read-only")), + "404" => Err(String::from("The event isn't on the server anymore")), + "000" | "" => Err(String::from("The calendar server can't be reached")), code => Err(format!("The calendar server answered {code}")), } } @@ -294,53 +544,490 @@ fn join(base: &str, href: &str) -> String { format!("{}{href}", &base[..origin_end]) } -// caldav discovery (principal, calendar home, calendars), then every calendar's events in the coming days -fn events(server: &str, account: &str, password: &str) -> Result)>, String> { +const PRINCIPAL: &str = r#""#; + +// caldav discovery: the account's principal, its calendar home, the event calendars in it +fn calendars(server: &str, account: &str, password: &str) -> Result, String> { let ask = |method: &str, url: &str, depth: &str, body: &str| dav(method, url, depth, body, account, password); - let found = ask("PROPFIND", server, "0", r#""#)?; - let principal = parse::xml_blocks(&found, "current-user-principal").first().and_then(|block| parse::xml_texts(block, "href").into_iter().next()).ok_or("The calendar server named no account")?; - let principal = join(server, &principal); + let found = ask("PROPFIND", server, "0", PRINCIPAL)?; + let principal = join(server, &parse::dav_href(&found, "current-user-principal").ok_or("The calendar server named no account")?); let found = ask("PROPFIND", &principal, "0", r#""#)?; - let home = parse::xml_blocks(&found, "calendar-home-set").first().and_then(|block| parse::xml_texts(block, "href").into_iter().next()).ok_or("The calendar server named no calendars")?; - let home = join(&principal, &home); + let home = join(&principal, &parse::dav_href(&found, "calendar-home-set").ok_or("The calendar server named no calendars")?); let found = ask( "PROPFIND", &home, "1", - r#""#, + r#""#, )?; + let read_only = parse::dav_read_only(&found); + + Ok(parse::dav_calendars(&found) + .into_iter() + .map(|calendar| Calendar { + url: join(&home, &calendar.href), + writable: !read_only.contains(&calendar.href), + color: calendar.color.as_deref().and_then(config::color), + name: calendar.name, + }) + .collect()) +} + +/* + * every calendar's events between two local days, series expanded by the server (in utc), with + * their hrefs and etags. `known` calendars skip the discovery. times turn local with the offset + * in force at each moment, so an event across a change to or from summer time shows right + */ +fn events(server: &str, account: &str, password: &str, span: (i64, i64), known: Vec) -> Result<(Vec, Vec), String> { + let calendars = if known.is_empty() { calendars(server, account, password)? } else { known }; + let offset = utc_offset(); - let now = SystemTime::now().duration_since(UNIX_EPOCH).map_or(0, |since| since.as_secs() as i64); - let today = (now + offset).div_euclid(86400) * 86400 - offset; - let (from, to) = (parse::dav_time(today), parse::dav_time(today + agenda_settings::days() as i64 * 86400)); + let (from, to) = (parse::dav_time(span.0 * 86_400 - ZONE_SLACK), parse::dav_time(span.1 * 86_400 + ZONE_SLACK)); let query = format!( - r#""# + r#""# ); - let mut all: Vec<(Event, Option)> = Vec::new(); + let mut found = Vec::new(); + let mut failure = None; + let mut answered = 0; - for calendar in parse::dav_calendars(&found) { - let color = calendar.color.as_deref().and_then(config::color); - let Ok(answer) = ask("REPORT", &join(&home, &calendar.href), "1", &query) else { continue }; + for (index, calendar) in calendars.iter().enumerate() { + match dav("REPORT", &calendar.url, "1", &query, account, password) { + Ok(answer) => { + answered += 1; - for ics in parse::xml_texts(&answer, "calendar-data") { - all.extend(parse::ics_events(&ics, offset).into_iter().filter(|event| event.end > now).map(|event| (event, color))); + for resource in parse::dav_resources(&answer, "calendar-data") { + let href = join(&calendar.url, &resource.href); + + for occurrence in parse::ics_occurrences(&resource.data, offset) { + found.push((index, href.clone(), resource.etag.clone(), occurrence)); + } + } + } + Err(why) => { + failure.get_or_insert(why); + } } } - all.sort_by_key(|(event, _)| event.start); + if answered == 0 + && let Some(why) = failure + { + return Err(why); + } + + let instants: Vec = found.iter().filter(|(.., occurrence)| !occurrence.event.all_day).flat_map(|(.., occurrence)| [occurrence.event.start, occurrence.event.end]).collect(); + let offsets = offsets_at(&instants); + let local = |unix: i64| unix + offsets.get(&unix).copied().unwrap_or(offset); + + let mut entries: Vec = found + .into_iter() + .map(|(index, href, etag, occurrence)| { + let calendar = &calendars[index]; + let event = occurrence.event; + + // a date was read as local midnight at today's offset; adding it back gives the day itself + let (start, end) = if event.all_day { (event.start + offset, event.end + offset) } else { (local(event.start), local(event.end)) }; + + Entry { start, end, all_day: event.all_day, summary: event.summary, location: event.location, color: calendar.color, calendar: calendar.url.clone(), href, etag, recurring: occurrence.recurring } + }) + .filter(|entry| entry.end > span.0 * 86_400 && entry.start < span.1 * 86_400) + .collect(); + + entries.sort_by_key(|entry| (entry.start, entry.end)); + + Ok((calendars, entries)) +} + +// the local offset from utc at each moment, from one `date -f -` for all of them; empty when date can't say +fn offsets_at(instants: &[i64]) -> HashMap { + let mut unique = instants.to_vec(); + + unique.sort_unstable(); + unique.dedup(); + + if unique.is_empty() { + return HashMap::new(); + } + + let Ok(mut child) = Command::new("date").args(["-f", "-", "+%z"]).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::null()).spawn() else { + return HashMap::new(); + }; + + let asked: String = unique.iter().map(|unix| format!("@{unix}\n")).collect(); + + // written from its own thread, so a long list can't fill both pipes at once + if let Some(mut input) = child.stdin.take() { + thread::spawn(move || { + let _ = input.write_all(asked.as_bytes()); + }); + } + + let Ok(done) = child.wait_with_output() else { + return HashMap::new(); + }; + + let offsets: Vec = String::from_utf8_lossy(&done.stdout).lines().filter_map(parse::zone_offset).collect(); + + if offsets.len() != unique.len() { + return HashMap::new(); + } + + unique.into_iter().zip(offsets).collect() +} + +/* + * carddav: the principal, its address book home, the address books, and from each the cards + * that have a BDAY (only their names and birthdays asked for); a server that can't filter is + * asked for every card + */ +fn birthdays(server: &str, account: &str, password: &str) -> Result, String> { + let ask = |method: &str, url: &str, depth: &str, body: &str| dav(method, url, depth, body, account, password); + + let found = ask("PROPFIND", server, "0", PRINCIPAL)?; + let principal = join(server, &parse::dav_href(&found, "current-user-principal").ok_or("The contacts server named no account")?); + + let found = ask("PROPFIND", &principal, "0", r#""#)?; + let home = join(&principal, &parse::dav_href(&found, "addressbook-home-set").ok_or("The contacts server named no address books")?); + + let found = ask("PROPFIND", &home, "1", r#""#)?; + + let wanted = r#""#; + let with_birthday = format!(r#"{wanted}"#); + let every_card = format!(r#"{wanted}"#); + + let mut all = Vec::new(); + + for book in parse::dav_addressbooks(&found) { + let url = join(&home, &book); + let answer = ask("REPORT", &url, "1", &with_birthday).or_else(|_| ask("REPORT", &url, "1", &every_card))?; + + for card in parse::dav_resources(&answer, "address-data") { + all.extend(parse::vcard_birthdays(&card.data)); + } + } + + all.sort_by(|one, other| (one.month, one.day, &one.name).cmp(&(other.month, other.day, &other.name))); + all.dedup_by(|one, other| one.month == other.month && one.day == other.day && one.name == other.name); Ok(all) } +// a new event's uid, from the kernel's random uuids +fn new_uid() -> String { + let random = fs::read_to_string("/proc/sys/kernel/random/uuid").map(|uuid| uuid.trim().to_ascii_uppercase()).unwrap_or_default(); + + if random.is_empty() { + let nanos = SystemTime::now().duration_since(UNIX_EPOCH).map_or(0, |since| since.as_nanos()); + + return format!("{nanos:X}-{:X}-MAGIC-ISLAND", std::process::id()); + } + + random +} + +// a local date and time as unix seconds, asking `date` for the zone's rules on that day +fn local_unix(day: i64, minutes: u32) -> Result { + let (year, month, date) = parse::civil_from_days(day); + let words = format!("{year:04}-{month:02}-{date:02} {:02}:{:02}", minutes / 60, minutes % 60); + + let done = Command::new("date").args(["-d", &words, "+%s"]).output().map_err(|error| format!("date: {error}"))?; + + if !done.status.success() { + return Err(format!("{words} doesn't exist here: the clocks change then")); + } + + String::from_utf8_lossy(&done.stdout).trim().parse().map_err(|_| format!("date can't read {words}")) +} + +// when the form says the event happens, in utc for timed events +fn form_when(form: &Form) -> Result { + if form.all_day { + return Ok(When::AllDay { first: form.day, days: form.span.max(1) }); + } + + let typed = |text: &str| parse::clock_minutes(text).ok_or_else(|| String::from("Type the times like 9:30 or 14:00")); + let (from, to) = (typed(&form.start)?, typed(&form.end)?); + + let start = local_unix(form.day, from)?; + let mut end = local_unix(form.day + form.span.max(0), to)?; + + // an end before the start on the same day is after midnight + if end <= start && form.span <= 0 { + end = local_unix(form.day + 1, to)?; + } + + if end <= start { + return Err(String::from("The end comes before the start")); + } + + Ok(When::Timed { start, end }) +} + +fn with_slash(url: &str) -> String { + format!("{}/", url.trim_end_matches('/')) +} + +// the form's event written to the server: PUT a new resource, or GET, change and PUT the old one (moved: PUT there, DELETE here) +fn write_event(form: &Form) -> Result<(), String> { + let account = agenda_settings::account().trim().to_string(); + let password = password(&account).ok_or("The calendar password isn't in the keyring")?; + + let ics_header = String::from("Content-Type: text/calendar; charset=utf-8"); + let put = |url: &str, condition: String, ics: &str| -> Result<(), String> { + let headers = if condition.is_empty() { vec![ics_header.clone()] } else { vec![ics_header.clone(), condition] }; + + changed(&request("PUT", url, &headers, ics, &account, &password)?.0) + }; + + let stamp = now_unix(); + + let Some(entry) = &form.editing else { + let uid = new_uid(); + let ics = parse::ics_new(&uid, stamp, &form.title, &form.location, form_when(form)?); + + return put(&format!("{}{uid}.ics", with_slash(&form.calendar)), String::from("If-None-Match: *"), &ics); + }; + + let (status, stored, etag) = request("GET", &entry.href, &[], "", &account, &password)?; + + changed(&status)?; + + let etag = if etag.is_empty() { entry.etag.clone() } else { etag }; + let matching = |etag: &str| if etag.is_empty() { String::new() } else { format!("If-Match: {etag}") }; + + // a series keeps its days and times: they can only be changed in a full calendar app + let when = if entry.recurring { None } else { Some(form_when(form)?) }; + let change = Change { summary: form.title.clone(), location: form.location.clone(), when }; + let ics = parse::ics_change(&stored, stamp, &change).ok_or("The server's copy of the event holds no event")?; + + if form.calendar == entry.calendar { + return put(&entry.href, matching(&etag), &ics); + } + + // to another calendar: written there first, so nothing is lost when the delete fails + let name = entry.href.rsplit('/').find(|part| !part.is_empty()).unwrap_or("event.ics"); + + put(&format!("{}{name}", with_slash(&form.calendar)), String::from("If-None-Match: *"), &ics)?; + + changed(&request("DELETE", &entry.href, &[matching(&etag)].into_iter().filter(|header| !header.is_empty()).collect::>(), "", &account, &password)?.0) +} + +fn delete_event(entry: &Entry) -> Result<(), String> { + let account = agenda_settings::account().trim().to_string(); + let password = password(&account).ok_or("The calendar password isn't in the keyring")?; + let headers: Vec = if entry.etag.is_empty() { Vec::new() } else { vec![format!("If-Match: {}", entry.etag)] }; + + changed(&request("DELETE", &entry.href, &headers, "", &account, &password)?.0) +} + +// a change to the open form; any typing ends a new title's hold on the keyboard +fn edit_form(change: impl FnOnce(&mut Form)) { + if let Some(form) = Agenda::write().form.as_mut() { + form.fresh = false; + change(form); + } +} + +// after a change on the server, or a failed one, the events are fetched again +fn finish(result: Result<(), String>) { + match result { + Ok(()) => { + Agenda::write().form = None; + + sync(Scope::Events); + } + Err(why) => { + let conflict = why == CHANGED_ELSEWHERE; + + edit_form(|form| { + form.saving = false; + form.confirm = false; + form.problem = Some(why); + }); + + if conflict { + sync(Scope::Events); + } + } + } +} + +fn save() { + let Some(form) = Agenda::read().form.clone() else { return }; + + if form.saving { + return; + } + + let times = form.all_day || form.editing.as_ref().is_some_and(|entry| entry.recurring) || (parse::clock_minutes(&form.start).is_some() && parse::clock_minutes(&form.end).is_some()); + + let problem = if form.title.trim().is_empty() { + Some("Give the event a title") + } else if form.calendar.is_empty() { + Some("There's no calendar to write to yet") + } else if !times { + Some("Type the times like 9:30 or 14:00") + } else { + None + }; + + if let Some(problem) = problem { + edit_form(|form| form.problem = Some(String::from(problem))); + + return; + } + + edit_form(|form| { + form.saving = true; + form.problem = None; + }); + + thread::spawn(move || finish(write_event(&form))); +} + +fn remove() { + let Some(form) = Agenda::read().form.clone() else { return }; + let Some(entry) = form.editing.clone() else { return }; + + if form.saving { + return; + } + + if !form.confirm { + edit_form(|form| form.confirm = true); + + return; + } + + edit_form(|form| form.saving = true); + + thread::spawn(move || finish(delete_event(&entry))); +} + +fn hhmm(local: i64) -> String { + format!("{:02}:{:02}", local.rem_euclid(86_400) / 3_600, local.rem_euclid(3_600) / 60) +} + +// the form's fields filled for an event; the inputs keep their text under their ids +fn fill_inputs(form: &Form) { + TextInput::set_text("home-title", &form.title); + TextInput::set_text("home-location", &form.location); + TextInput::set_text("home-start", &form.start); + TextInput::set_text("home-end", &form.end); +} + +// a new event's times: from the next full hour, for the length the settings give +fn default_times() -> (String, String) { + let now = Clock::read().now; + let start = (now.div_euclid(3_600) + 1) * 3_600; + let end = start + agenda_settings::length_minutes() as i64 * 60; + + (hhmm(start), hhmm(end)) +} + +fn new_event(day: i64) { + let (start, end) = default_times(); + + let mut agenda = Agenda::write(); + let calendar = agenda.calendars.iter().find(|calendar| calendar.writable).map(|calendar| calendar.url.clone()).unwrap_or_default(); + + let form = Form { editing: None, title: String::new(), location: String::new(), day, all_day: false, span: 0, start, end, calendar, fresh: true, confirm: false, saving: false, problem: None }; + + fill_inputs(&form); + + agenda.selected = Some(day); + agenda.form = Some(form); +} + +fn edit_event(entry: Entry) { + let day = entry.start.div_euclid(86_400); + + let (span, start, end) = if entry.all_day { + let (start, end) = default_times(); + + ((entry.end - entry.start + 43_200).div_euclid(86_400).max(1), start, end) + } else { + (entry.end.div_euclid(86_400) - day, hhmm(entry.start), hhmm(entry.end)) + }; + + let form = Form { + title: entry.summary.clone(), + location: entry.location.clone(), + day, + all_day: entry.all_day, + span, + start, + end, + calendar: entry.calendar.clone(), + editing: Some(entry), + fresh: false, + confirm: false, + saving: false, + problem: None, + }; + + fill_inputs(&form); + + let mut agenda = Agenda::write(); + + agenda.selected = Some(day); + agenda.form = Some(form); +} + +fn toggle_all_day() { + edit_form(|form| { + form.all_day = !form.all_day; + form.span = if form.all_day { form.span + 1 } else { (form.span - 1).max(0) }; + }); +} + +// another month in the grid (`today` from the view), with the day picked in it; fetched when it isn't there yet +fn show_month(month: (i64, u32), today: i64, selected: Option) { + let resync = { + let mut agenda = Agenda::write(); + + agenda.month = if month == month_of(today) { None } else { Some(month) }; + agenda.selected = selected; + + agenda.synced && !covers(agenda.span, grid_span(month)) + }; + + if resync { + thread::spawn(|| sync(Scope::Month)); + } +} + +// a day clicked in the grid: picked, the form's day when one is open, and its month shown +fn pick(day: i64, shown: (i64, u32), today: i64) { + if month_of(day) != shown { + show_month(month_of(day), today, Some(day)); + } + + let mut agenda = Agenda::write(); + + agenda.selected = Some(day); + + if let Some(form) = agenda.form.as_mut() { + form.day = day; + } +} + const WEEKDAYS: [&str; 7] = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]; const MONTHS: [&str; 12] = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"]; +fn weekday_name(day: i64) -> &'static str { + WEEKDAYS[parse::weekday(day) as usize % 7] +} + +fn month_name(month: u32) -> &'static str { + MONTHS[(month as usize + 11) % 12] +} + fn sky_glyph(code: u32, day: bool) -> Glyph { match parse::sky(code) { Sky::Clear if day => Glyph::Sun, @@ -354,25 +1041,35 @@ fn sky_glyph(code: u32, day: bool) -> Glyph { } } -// the parts' heights, shared by the card and height() -const TOP_H: f32 = 48.0; -const WEATHER_H: f32 = 60.0; -const DAYS_H: f32 = 62.0; -const CELL: f32 = 24.0; +// the parts' sizes, shared by the card, height() and the canvas the card's icons are drawn in +fn button_size() -> f32 { + pods::header_height() - 4.0 +} fn clock_h() -> f32 { settings::clock_size() * 1.1 } -fn calendar_h() -> f32 { - let grid = text::tiny() + 8.0 + 6.0 * CELL; - let list = text::small() + 8.0 + agenda_settings::events() as f32 * (text::small() + 10.0); +fn weather_h() -> f32 { + settings::weather_height() + settings::gap() / 2.0 + settings::forecast_height() +} - grid.max(list) +fn middle_h() -> f32 { + clock_h().max(weather_h()) +} + +fn letters_h() -> f32 { + text::tiny() + 8.0 +} + +fn calendar_h() -> f32 { + pods::header_height() + letters_h() + 6.0 * agenda_settings::cell() } pub fn height() -> f32 { - 2.0 * layout::pad() + TOP_H + clock_h() + WEATHER_H + DAYS_H + 1.0 + calendar_h() + 5.0 * 12.0 + let gap = settings::gap(); + + 2.0 * layout::pad() + settings::avatar_size() + gap + middle_h() + gap + 1.0 + gap + calendar_h() } fn name() -> String { @@ -382,38 +1079,102 @@ fn name() -> String { letters.next().map(|first| first.to_uppercase().chain(letters).collect()).unwrap_or_default() } +/* + * the card: the greeting, the clock beside the weather, the month beside the picked day (or the + * event form). every icon is drawn into one canvas over it all, as each canvas has a large gpu + * cost of its own; the weather's photo lies under it all, veiled in the surface's colour + */ pub fn card(width: f32, height: f32) -> Rectangle { let clock = Clock::read(); let forecast = Forecast::read(); let agenda = Agenda::read(); + let gap = settings::gap(); + + let today = clock.now.div_euclid(86_400); + let mut shapes: Vec> = Vec::new(); + + let middle_y = settings::avatar_size() + gap; + let calendar_y = middle_y + middle_h() + gap + 1.0 + gap; + + let content = Column::new(vec![ + top_row(&clock, width, &mut shapes), + middle_row(&clock, &forecast, width, today, middle_y, &mut shapes), + Box::new(pods::rule(width)), + calendar(&agenda, width, today, calendar_y, &mut shapes), + ]) + .gap(gap); + + let mut layers: Vec> = Vec::new(); + + if let Some(photo) = backdrop(width, height, forecast.photo.as_deref()) { + layers.push(photo); + } + + layers.push(Box::new(content)); + + if !shapes.is_empty() { + layers.push(Box::new(Canvas::new().width(width).height(height).shapes(shapes))); + } + + Rectangle::new().width(width).height(height).child(Stack::new(layers).width(width).height(height)) +} + +// the weather's photo over the whole card just inside its rim, under a veil of the surface's colour +fn backdrop(width: f32, height: f32, photo: Option<&str>) -> Option> { + let shown = settings::photo_opacity().clamp(0.0, 1.0); + let path = photo.filter(|_| settings::photo() && shown > 0.0)?; + + let pad = layout::pad(); + let (w, h) = (width + 2.0 * pad - 2.0, height + 2.0 * pad - 2.0); + let radius = (layout::radius() - 1.0).max(0.0); + let veil = ((1.0 - shown) * 255.0).round() as u8; + + let picture = Rectangle::new().width(w).height(h).radius(radius).fill(Image::cover(path).thumbnail(w.ceil() as u32, h.ceil() as u32)).translate(1.0 - pad, 1.0 - pad); + let cover = Rectangle::new().width(w).height(h).radius(radius).fill(faded(theme::now().surface, veil)).translate(1.0 - pad, 1.0 - pad); + + Some(Box::new(Stack::new(vec![Box::new(picture) as Box, Box::new(cover)]).width(width).height(height))) +} + +// who's here: the avatar, a greeting, the date, and the cross that closes the card +fn top_row(clock: &Clock, width: f32, shapes: &mut Vec>) -> Box { let theme = theme::now(); + let size = settings::avatar_size(); - let days = clock.now.div_euclid(86400); - let (year, month, day) = parse::civil_from_days(days); + let avatar = match &clock.avatar { + Some(path) => Rectangle::new().width(size).height(size).radius(Full).fill(Image::cover(path.clone())), + None => { + let glyph = size * 0.54; - let weekday = parse::weekday(days) as usize; - let seconds_today = clock.now.rem_euclid(86400); - let (hour, minute, second) = (seconds_today / 3600, seconds_today % 3600 / 60, seconds_today % 60); + shapes.extend(glyph_shapes(Glyph::Person, glyph, theme.accent, 0.0, ((size - glyph) / 2.0, (size - glyph) / 2.0))); - // who's here: the avatar, a greeting, the date - let avatar: Box = match &clock.avatar { - Some(path) => Box::new(Rectangle::new().width(TOP_H).height(TOP_H).radius(Full).fill(Image::cover(path.clone()))), - None => Box::new(Rectangle::new().width(TOP_H).height(TOP_H).radius(Full).fill(faded(theme.accent, 60)).align_child(Center, Center).child(icon(Glyph::Person, 26.0, theme.accent))), + Rectangle::new().width(size).height(size).radius(Full).fill(faded(theme.accent, 60)) + } }; - let greeting = match hour { + let seconds_today = clock.now.rem_euclid(86_400); + + let greeting = match seconds_today / 3_600 { 5..12 => "Good morning", 12..18 => "Good afternoon", 18..23 => "Good evening", _ => "Good night", }; - let date = if clock.now == 0 { String::new() } else { format!("{}, {day} {} {year}", WEEKDAYS[weekday % 7], MONTHS[(month as usize + 11) % 12]) }; + let days = clock.now.div_euclid(86_400); + let (year, month, day) = parse::civil_from_days(days); + let date = if clock.now == 0 { String::new() } else { format!("{}, {day} {} {year}", weekday_name(days), month_name(month)) }; - let top = Row::new(vec![ + let button = button_size(); + let cross = button * 0.54; + + shapes.extend(glyph_shapes(Glyph::Cross, cross, theme.text, 0.0, (width - button + (button - cross) / 2.0, (size - cross) / 2.0))); + + let close = Rectangle::new().width(button).height(button).radius(Full).fill(overlays::chip()).cursor(Pointer).on_click(|_| pods::close()); + + let row = Row::new(vec![ Box::new( Row::new(vec![ - avatar, + Box::new(avatar) as Box, Box::new( Column::new(vec![ Box::new(Text::new(format!("{greeting}, {}", name())).size(text::heading()).weight(Weight::Bold).color(theme.text)) as Box, @@ -422,61 +1183,71 @@ pub fn card(width: f32, height: f32) -> Rectangle { .gap(4.0), ), ]) - .gap(12.0) + .gap(settings::gap()) .align(Center), ) as Box, - Box::new(pods::button(Glyph::Cross, pods::close)), + Box::new(close), ]) .width(width) .justify(SpaceBetween) .align(Center); - // the big clock + Box::new(Rectangle::new().width(width).height(size).child(row)) +} + +// the big clock, and the weather beside it; `y` is where the row starts in the card +fn middle_row(clock: &Clock, forecast: &Forecast, width: f32, today: i64, y: f32, shapes: &mut Vec>) -> Box { + let theme = theme::now(); + let gap = settings::gap(); + + let seconds_today = clock.now.rem_euclid(86_400); + let (hour, minute, second) = (seconds_today / 3_600, seconds_today % 3_600 / 60, seconds_today % 60); let time = if settings::seconds() { format!("{hour:02}:{minute:02}:{second:02}") } else { format!("{hour:02}:{minute:02}") }; - let big = Rectangle::new().width(width).height(clock_h()).align_child(Start, Center).child(Text::new(time).size(settings::clock_size()).weight(Weight::Bold).color(theme.text).tight()); - let (now_weather, days_row) = weather(&forecast, width, days); + let clock_w = (settings::clock_size() * font::letter_width() * time.chars().count() as f32).min(width / 2.0); + let weather_w = (width - clock_w - gap).max(0.0); + let height = middle_h(); - Rectangle::new().width(width).height(height).child( - Column::new(vec![ - Box::new(Rectangle::new().width(width).height(TOP_H).child(top)) as Box, - Box::new(big), - now_weather, - days_row, - Box::new(pods::rule(width)), - calendar(&agenda, width, (year, month), days, clock.offset), - ]) - .gap(12.0), + let big = Rectangle::new().width(clock_w).height(height).align_child(Start, Center).child(Text::new(time).size(settings::clock_size()).weight(Weight::Bold).color(theme.text).tight()); + let weather = weather(forecast, weather_w, today, (clock_w + gap, y + (height - weather_h()) / 2.0), shapes); + + Box::new( + Rectangle::new() + .width(width) + .height(height) + .child(Row::new(vec![Box::new(big) as Box, Box::new(Rectangle::new().width(weather_w).height(height).align_child(Start, Center).child(weather))]).gap(gap)), ) } -// the weather now, and the next days in one row (their icons in one canvas) -fn weather(forecast: &Forecast, width: f32, today: i64) -> (Box, Box) { +// the weather now over the coming days, at `origin` in the card +fn weather(forecast: &Forecast, width: f32, today: i64, origin: (f32, f32), shapes: &mut Vec>) -> Rectangle { let theme = theme::now(); + let (now_h, days_h, inner_gap) = (settings::weather_height(), settings::forecast_height(), settings::gap() / 2.0); let Some(weather) = &forecast.weather else { let words = forecast.problem.clone().unwrap_or_else(|| format!("Fetching the weather for {}…", settings::city())); - return ( - Box::new(Rectangle::new().width(width).height(WEATHER_H).align_child(Start, Center).child(Text::new(words).size(text::small()).color(theme.dim))), - Box::new(Rectangle::new().width(width).height(DAYS_H)), - ); + return Rectangle::new().width(width).height(weather_h()).align_child(Start, Center).child(Text::new(words).size(text::small()).color(theme.dim)); }; + let big = settings::weather_icon(); + + shapes.extend(glyph_shapes(sky_glyph(weather.code, weather.is_day), big, theme.accent, 0.0, (origin.0, origin.1 + (now_h - big) / 2.0))); + let (high, low, sunrise, sunset) = weather.days.first().map_or((0.0, 0.0, "", ""), |day| (day.max, day.min, day.sunrise.as_str(), day.sunset.as_str())); let now = Row::new(vec![ Box::new( Row::new(vec![ - Box::new(icon(sky_glyph(weather.code, weather.is_day), 44.0, theme.accent)) as Box, + Box::new(Rectangle::new().width(big).height(big)) as Box, Box::new( Column::new(vec![ Box::new( Row::new(vec![ - Box::new(Text::new(format!("{:.0}°", weather.temperature)).size(28.0).weight(Weight::Bold).color(theme.text)) as Box, + Box::new(Text::new(format!("{:.0}°", weather.temperature)).size(settings::temperature_size()).weight(Weight::Bold).color(theme.text)) as Box, Box::new(Text::new(format!("{} · feels {:.0}°", parse::weather_words(weather.code), weather.apparent)).size(text::small()).color(theme.text)), ]) - .gap(10.0) + .gap(inner_gap) .align(Center), ) as Box, Box::new(Text::new(format!("{} · wind {:.0} km/h · humidity {:.0}%", settings::city(), weather.wind, weather.humidity)).size(text::tiny()).color(theme.dim)), @@ -484,7 +1255,7 @@ fn weather(forecast: &Forecast, width: f32, today: i64) -> (Box, Box .gap(2.0), ), ]) - .gap(12.0) + .gap(settings::gap()) .align(Center), ) as Box, Box::new( @@ -499,24 +1270,22 @@ fn weather(forecast: &Forecast, width: f32, today: i64) -> (Box, Box .justify(SpaceBetween) .align(Center); - // the coming days, after today - let coming: Vec<_> = weather.days.iter().skip(1).take(4).collect(); + // the coming days after today, their icons in the card's canvas let column_w = width / 4.0; - let icon_size = 22.0; + let icon = settings::forecast_icon(); + let label_h = text::tiny() + 4.0; + let icon_y = origin.1 + now_h + inner_gap + label_h + 2.0 + 4.0; - let mut shapes: Vec> = Vec::new(); let mut columns: Vec> = Vec::new(); - for (index, day) in coming.iter().enumerate() { - let weekday = parse::weekday(today + 1 + index as i64) as usize; - - shapes.extend(glyph_shapes(sky_glyph(day.code, true), icon_size, theme.text, 0.0, (index as f32 * column_w + (column_w - icon_size) / 2.0, text::tiny() + 8.0))); + for (index, day) in weather.days.iter().skip(1).take(4).enumerate() { + shapes.extend(glyph_shapes(sky_glyph(day.code, true), icon, theme.text, 0.0, (origin.0 + index as f32 * column_w + (column_w - icon) / 2.0, icon_y))); columns.push(Box::new( - Rectangle::new().width(column_w).height(DAYS_H).child( + Rectangle::new().width(column_w).height(days_h).child( Column::new(vec![ - Box::new(Rectangle::new().width(column_w).height(text::tiny() + 4.0).align_child(Center, Center).child(Text::new(&WEEKDAYS[weekday % 7][..3]).size(text::tiny()).color(theme.dim))) as Box, - Box::new(Rectangle::new().width(column_w).height(icon_size + 8.0)), + Box::new(Rectangle::new().width(column_w).height(label_h).align_child(Center, Center).child(Text::new(&weekday_name(today + 1 + index as i64)[..3]).size(text::tiny()).color(theme.dim))) as Box, + Box::new(Rectangle::new().width(column_w).height(icon + 8.0)), Box::new(Rectangle::new().width(column_w).height(text::small() + 4.0).align_child(Center, Center).child(Text::new(format!("{:.0}° / {:.0}°", day.max, day.min)).size(text::small()).color(theme.text))), ]) .gap(2.0), @@ -524,155 +1293,560 @@ fn weather(forecast: &Forecast, width: f32, today: i64) -> (Box, Box )); } - let days = Stack::new(vec![Box::new(Canvas::new().width(width).height(DAYS_H).shapes(shapes)) as Box, Box::new(Row::new(columns))]).width(width).height(DAYS_H); - - (Box::new(Rectangle::new().width(width).height(WEATHER_H).child(now)), Box::new(days)) + Rectangle::new().width(width).height(weather_h()).child( + Column::new(vec![Box::new(Rectangle::new().width(width).height(now_h).align_child(Start, Center).child(now)) as Box, Box::new(Rectangle::new().width(width).height(days_h).child(Row::new(columns)))]) + .gap(inner_gap), + ) } -// this month's grid on the left, the coming events (or the sign-in) on the right; `offset` is local time's from utc -fn calendar(agenda: &Agenda, width: f32, (year, month): (i64, u32), today: i64, offset: i64) -> Box { +// the public holidays of the settings' state between two days +fn holidays_between(from: i64, to: i64) -> Vec<(i64, &'static str)> { + let state = agenda_settings::holidays(); + let (first, _, _) = parse::civil_from_days(from); + let (last, _, _) = parse::civil_from_days(to - 1); + + (first..=last).flat_map(|year| parse::holidays(year, &state)).filter(|(day, _)| (from..to).contains(day)).collect() +} + +// the birthdays on a day; a 29 february one shows on the 28th in other years +fn birthdays_on(birthdays: &[Birthday], day: i64) -> impl Iterator { + let (year, month, date) = parse::civil_from_days(day); + let leap = parse::days_from_civil(year, 3, 1) - parse::days_from_civil(year, 2, 28) == 2; + + birthdays.iter().filter(move |birthday| (birthday.month, birthday.day) == (month, date) || (!leap && (month, date) == (2, 28) && (birthday.month, birthday.day) == (2, 29))) +} + +// the local days an event touches, its end excluded +fn days_of(entry: &Entry) -> (i64, i64) { + let first = entry.start.div_euclid(86_400); + let last = if entry.end > entry.start { (entry.end - 1).div_euclid(86_400) } else { first }; + + (first, last) +} + +// the month beside the picked day's list or the form; `y` is where it starts in the card +fn calendar(agenda: &Agenda, width: f32, today: i64, y: f32, shapes: &mut Vec>) -> Box { + let grid_w = 7.0 * agenda_settings::cell(); + let list_gap = agenda_settings::list_gap(); + let list_w = (width - grid_w - list_gap).max(0.0); + + let selected = agenda.selected.unwrap_or(today); + let month = agenda.month.unwrap_or_else(|| month_of(today)); + + let grid = month_grid(agenda, month, today, selected, (0.0, y), shapes); + + let side = match &agenda.form { + Some(form) => event_form(agenda, form, list_w, (grid_w + list_gap, y), shapes), + None => day_list(agenda, selected, today, list_w, (grid_w + list_gap, y), shapes), + }; + + Box::new(Rectangle::new().width(width).height(calendar_h()).child(Row::new(vec![grid, side]).gap(list_gap))) +} + +/* + * the month: its name with ‹ Today ›, the weekdays, six weeks of days. today has the accent's + * disc, the picked day a ring, a public holiday its colour, a birthday a cake at the corner and + * events dots in their calendars' colours (all in the card's canvas) + */ +fn month_grid(agenda: &Agenda, (year, month): (i64, u32), today: i64, selected: i64, origin: (f32, f32), shapes: &mut Vec>) -> Box { let theme = theme::now(); - let grid_w = 7.0 * CELL; - let list_w = width - grid_w - 20.0; + let cell = agenda_settings::cell(); + let grid_w = 7.0 * cell; + let shown = (year, month); - // the month: weekday letters, then its days under the right ones; today in the accent, days with events dotted let first = parse::days_from_civil(year, month, 1); - let next = if month == 12 { parse::days_from_civil(year + 1, 1, 1) } else { parse::days_from_civil(year, month + 1, 1) }; - let lead = parse::weekday(first) as i64; + let next = month_start(shown, 1); + let (start, end) = grid_span(shown); - let busy: Vec = agenda.events.iter().map(|(event, _)| (event.start + offset).div_euclid(86400)).collect(); + let previous = shifted(shown, -1); + let following = shifted(shown, 1); + let first_of = move |month: (i64, u32)| if month == month_of(today) { None } else { Some(month_start(month, 0)) }; - let mut rows: Vec> = vec![Box::new(Row::new( - ["M", "T", "W", "T", "F", "S", "S"].iter().map(|letter| Box::new(Rectangle::new().width(CELL).height(text::tiny() + 8.0).align_child(Center, Center).child(Text::new(*letter).size(text::tiny()).color(theme.dim))) as Box).collect(), - ))]; + let mut nav: Vec> = vec![Box::new(pods::chip("‹", None, move || show_month(previous, today, first_of(previous))))]; + + if shown != month_of(today) || selected != today { + nav.push(Box::new(pods::chip("Today", None, move || show_month(month_of(today), today, None)))); + } + + nav.push(Box::new(pods::chip("›", None, move || show_month(following, today, first_of(following))))); + + let header = Rectangle::new().width(grid_w).height(pods::header_height()).child( + Row::new(vec![ + Box::new(Text::new(format!("{} {year}", month_name(month))).size(text::heading()).weight(Weight::Bold).color(theme.text)) as Box, + Box::new(Row::new(nav).gap(6.0).align(Center)), + ]) + .width(grid_w) + .justify(SpaceBetween) + .align(Center), + ); + + let letters = Row::new( + ["Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"].iter().map(|letters| Box::new(Rectangle::new().width(cell).height(letters_h()).align_child(Center, Center).child(Text::new(*letters).size(text::tiny()).color(theme.dim))) as Box).collect(), + ); + + // what the six weeks hold + let holidays: Vec = holidays_between(start, end).into_iter().map(|(day, _)| day).collect(); + let mut busy: HashMap> = HashMap::new(); + + for entry in &agenda.events { + let (first_day, last_day) = days_of(entry); + let color = entry.color.unwrap_or(theme.accent); + + for day in first_day.max(start)..=last_day.min(end - 1) { + let colors = busy.entry(day).or_default(); + + if !colors.contains(&color) { + colors.push(color); + } + } + } + + let (disc, ring, dot, dots, mark) = (cell * agenda_settings::disc(), agenda_settings::ring(), agenda_settings::dot(), agenda_settings::dots(), agenda_settings::mark()); + let (holiday_color, birthday_color) = (agenda_settings::holiday_color(), agenda_settings::birthday_color()); + let tint = (agenda_settings::holiday_tint().clamp(0.0, 1.0) * 255.0).round() as u8; + let cells_y = origin.1 + pods::header_height() + letters_h(); + + let mut rows: Vec> = vec![Box::new(header), Box::new(letters)]; for week in 0..6 { let mut cells: Vec> = Vec::new(); for weekday in 0..7 { - let date = first - lead + week * 7 + weekday; + let date = start + week * 7 + weekday; + let (x, y) = (origin.0 + weekday as f32 * cell, cells_y + week as f32 * cell); + let inside = date >= first && date < next; - let number = parse::civil_from_days(date).2; let is_today = date == today; - let dotted = inside && busy.contains(&date); + let holiday = holidays.contains(&date); - let color = if is_today { theme.on_accent } else if inside { theme.text } else { faded(theme.dim, 60) }; + // days of the months around are faded + let soft = |color: Color| if inside { color } else { faded(color, color.alpha() / 2) }; - let mut layers: Vec> = vec![Box::new(Text::new(number.to_string()).size(text::tiny()).weight(if is_today { Weight::Bold } else { Weight::Regular }).color(color))]; + let number = if is_today { + theme.on_accent + } else if holiday { + soft(holiday_color) + } else { + soft(theme.text) + }; - if dotted && !is_today { - layers.push(Box::new(Rectangle::new().width(3.0).height(3.0).radius(Full).fill(theme.accent).translate(0.0, 8.0))); + let fill = if is_today { + theme.accent + } else if holiday { + faded(holiday_color, tint) + } else { + CLEAR + }; + + let face = Rectangle::new() + .width(disc) + .height(disc) + .radius(Full) + .fill(fill) + .align_child(Center, Center) + .child(Text::new(parse::civil_from_days(date).2.to_string()).size(text::heading()).weight(if is_today || holiday { Weight::Bold } else { Weight::Regular }).color(number)); + + let face = if date == selected { face.border(ring, agenda_settings::selected_color()) } else { face }; + + cells.push(Box::new(Rectangle::new().width(cell).height(cell).align_child(Center, Center).cursor(Pointer).on_click(move |_| pick(date, shown, today)).child(face))); + + if let Some(colors) = busy.get(&date) { + let count = colors.len().min(dots); + let step = dot * 1.6; + let left = x + cell / 2.0 - (count as f32 - 1.0) * step / 2.0; + let dots_y = y + cell - (cell - disc) / 4.0; + + for (index, color) in colors.iter().take(count).enumerate() { + shapes.push(Box::new(Circle::new().center(left + index as f32 * step, dots_y).radius(dot / 2.0).fill(soft(*color)))); + } } - cells.push(Box::new( - Rectangle::new() - .width(CELL) - .height(CELL) - .radius(Full) - .fill(if is_today { theme.accent } else { amane::Color::rgba(0, 0, 0, 0) }) - .align_child(Center, Center) - .child(Stack::new(layers)), - )); + if birthdays_on(&agenda.birthdays, date).next().is_some() { + shapes.extend(glyph_shapes(Glyph::Cake, mark, soft(birthday_color), 0.0, (x + cell - mark - 2.0, y + 2.0))); + } } rows.push(Box::new(Row::new(cells))); } - let grid = Column::new(rows); + Box::new(Rectangle::new().width(grid_w).height(calendar_h()).child(Column::new(rows))) +} +// what the picked day's list shows, line by line +enum Line<'a> { + Holiday(&'static str), + Birthday(String), + Event(&'a Entry), + Note(String, Color), +} + +// when an event happens on a day of its, as the list says it +fn event_time(entry: &Entry, day: i64) -> String { + let (first, last) = days_of(entry); + + match (entry.all_day, first == day, last == day) { + (true, ..) => String::from("All day"), + (false, true, true) => format!("{} – {}", hhmm(entry.start), hhmm(entry.end)), + (false, true, false) => format!("from {}", hhmm(entry.start)), + (false, false, true) => format!("until {}", hhmm(entry.end)), + (false, false, false) => String::from("All day"), + } +} + +// the account and keyring set-up, as the setting's help says it +fn setup_words() -> String { + let help = config::option("pills.calendar.account").map_or("", |option| option.help); let account = agenda_settings::account(); - let list: Box = if agenda.form { - sign_in_form(agenda, list_w) - } else if account.is_empty() { - Box::new( - Column::new(vec![ - Box::new(Text::new("Calendar").size(text::small()).weight(Weight::Bold).color(theme.text)) as Box, - Box::new(Text::new("Sign in with your Apple ID and an app-specific password to see your iCloud calendar here.").size(text::small()).color(theme.dim).wrap()), - Box::new(pods::chip("Sign in to iCloud", None, || Agenda::write().form = true)), - ]) - .gap(8.0), - ) + if account.trim().is_empty() { + format!("Set pills.calendar.account in Settings. {help}") } else { - let mut lines: Vec> = vec![Box::new( - Row::new(vec![ - Box::new(Text::new("Coming up").size(text::small()).weight(Weight::Bold).color(theme.text)) as Box, - Box::new(pods::chip("Sign out", None, sign_out)), - ]) - .width(list_w) - .justify(SpaceBetween) - .align(Center), - )]; - - if let Some(problem) = &agenda.problem { - lines.push(Box::new(Text::new(problem.as_str()).size(text::small()).color(crate::config::appearance::overlays::low_battery()).wrap())); - } else if !agenda.synced { - lines.push(Box::new(Text::new("Syncing…").size(text::small()).color(theme.dim))); - } else if agenda.events.is_empty() { - lines.push(Box::new(Text::new(format!("Nothing in the next {} days", agenda_settings::days())).size(text::small()).color(theme.dim))); - } - - for (event, color) in agenda.events.iter().take(agenda_settings::events()) { - let start = event.start + offset; - let start_day = start.div_euclid(86400); - let clock = format!("{:02}:{:02}", start.rem_euclid(86400) / 3600, start.rem_euclid(3600) / 60); - - let when = match (start_day - today, event.all_day) { - (..=0, true) => String::from("Today"), - (..=0, false) => clock, - (1, true) => String::from("Tomorrow"), - (1, false) => format!("Tmrw {clock}"), - (_, true) => WEEKDAYS[parse::weekday(start_day) as usize % 7][..3].to_string(), - (_, false) => format!("{} {clock}", &WEEKDAYS[parse::weekday(start_day) as usize % 7][..3]), - }; - - lines.push(Box::new( - Row::new(vec![ - Box::new(Rectangle::new().width(6.0).height(6.0).radius(Full).fill(color.unwrap_or(theme.accent))) as Box, - Box::new(Rectangle::new().width(64.0).height(text::small() + 6.0).align_child(Start, Center).child(Text::new(when).size(text::small()).color(theme.dim))), - Box::new(Rectangle::new().width(list_w - 84.0).height(text::small() + 6.0).align_child(Start, Center).child(Text::new(event.summary.as_str()).size(text::small()).color(theme.text).elide())), - ]) - .gap(7.0) - .align(Center), - )); - } - - Box::new(Column::new(lines).gap(4.0)) - }; - - Box::new( - Rectangle::new() - .width(width) - .height(calendar_h()) - .child(Row::new(vec![Box::new(grid) as Box, Box::new(Rectangle::new().width(list_w).height(calendar_h()).child(Column::new(vec![list])))]).gap(20.0)), - ) + format!("The keyring has no password for {}. {}", account.trim(), help.replace("", account.trim())) + } } -fn sign_in_form(agenda: &Agenda, width: f32) -> Box { +/* + * the picked day: its holidays, birthdays and events (a click on one of a writable calendar + * opens it in the form), or how to set the calendar up; what doesn't fit is counted. `origin` + * places the flags and cakes in the card's canvas + */ +fn day_list(agenda: &Agenda, day: i64, today: i64, width: f32, origin: (f32, f32), shapes: &mut Vec>) -> Box { let theme = theme::now(); + let gap = agenda_settings::item_gap(); + let mark = agenda_settings::mark(); + let height = calendar_h(); + let (year, month, date) = parse::civil_from_days(day); - let field = |input: TextInput| Rectangle::new().width(width).height(32.0).radius(10.0).fill(overlays::chip()).padding(amane::Padding { top: 0.0, right: 10.0, bottom: 0.0, left: 10.0 }).align_child(Start, Center).child(input); + let title = format!("{}{}, {date} {}", if day == today { "Today · " } else { "" }, weekday_name(day), month_name(month)); + let can_add = !agenda.missing && agenda.calendars.iter().any(|calendar| calendar.writable); + let button = button_size(); - let account = TextInput::new("home-account").width(width - 20.0).size(text::small()).color(theme.text).placeholder("Apple ID (e-mail)").focused().on_change(|typed| Agenda::write().account = typed); + let mut header: Vec> = vec![Box::new(Text::new(title).size(text::heading()).weight(Weight::Bold).color(theme.text))]; - let password = TextInput::new("home-password").width(width - 20.0).size(text::small()).color(theme.text).placeholder("App-specific password, then Enter").password().on_submit(|typed| { - let account = Agenda::read().account.clone(); + if can_add { + let words = "New event"; + let at = (origin.0 + width - choice_width(words, button / 2.0 + 6.0), origin.1 + (pods::header_height() - button) / 2.0); - sign_in(account, typed); - }); - - let mut parts: Vec> = vec![ - Box::new(Text::new("iCloud calendar").size(text::small()).weight(Weight::Bold).color(theme.text)), - Box::new(field(account)), - Box::new(field(password)), - Box::new(Text::new("Make the password at account.apple.com › Sign-In and Security › App-Specific Passwords.").size(text::tiny()).color(theme.dim).wrap()), - ]; - - if let Some(problem) = &agenda.problem { - parts.push(Box::new(Text::new(problem.as_str()).size(text::tiny()).color(crate::config::appearance::overlays::low_battery()).wrap())); + header.push(Box::new(icon_chip(words, false, Glyph::Plus, at, shapes, move || new_event(day)))); } - parts.push(Box::new(pods::chip("Cancel", None, || Agenda::write().form = false))); + let mut lines: Vec = Vec::new(); - Box::new(Column::new(parts).gap(6.0)) + lines.extend(parse::holidays(year, &agenda_settings::holidays()).into_iter().filter(|(holiday, _)| *holiday == day).map(|(_, name)| Line::Holiday(name))); + + if !agenda.missing { + lines.extend(birthdays_on(&agenda.birthdays, day).map(|birthday| match birthday.year.map(|born| year - born).filter(|age| *age > 0) { + Some(age) => Line::Birthday(format!("{}'s birthday (turns {age})", birthday.name)), + None => Line::Birthday(format!("{}'s birthday", birthday.name)), + })); + } + + let mut events: Vec<&Entry> = agenda.events.iter().filter(|entry| (days_of(entry).0..=days_of(entry).1).contains(&day)).collect(); + + events.sort_by_key(|entry| (!entry.all_day, entry.start)); + + let warning = overlays::low_battery(); + + if agenda.missing { + lines.push(Line::Note(setup_words(), theme.dim)); + } else if let Some(problem) = &agenda.problem { + lines.push(Line::Note(problem.clone(), warning)); + } else if !agenda.synced { + lines.push(Line::Note(String::from("Syncing…"), theme.dim)); + } + + let quiet = events.is_empty() && lines.is_empty(); + + lines.extend(events.into_iter().map(Line::Event)); + + if quiet { + lines.push(Line::Note(String::from("Nothing planned"), theme.dim)); + } + + if let Some(problem) = agenda.contacts_problem.as_ref().filter(|_| !agenda.missing) { + lines.push(Line::Note(format!("Birthdays: {problem}"), theme.dim)); + } + + // laid out from the top while they fit, keeping room to count the rest + let row_h = text::small() + 12.0; + let event_h = text::small() + text::tiny() + 16.0; + let more_h = text::small() + 4.0; + let radius = layout::radius() / 2.0; + let time_w = text::small() * font::letter_width() * 14.0; + let total = lines.len(); + + let mut items: Vec> = vec![Box::new(Rectangle::new().width(width).height(pods::header_height()).child(Row::new(header).width(width).justify(SpaceBetween).align(Center)))]; + let mut used = pods::header_height(); + + for (index, line) in lines.into_iter().enumerate() { + let line_h = match &line { + Line::Holiday(_) | Line::Birthday(_) => row_h, + Line::Event(_) => event_h, + Line::Note(words, _) => Text::new(words.as_str()).size(text::small()).wrap().height_in(width), + }; + + let room = if index + 1 < total { height - more_h - gap } else { height }; + + if used + gap + line_h > room { + items.push(Box::new(Rectangle::new().width(width).height(more_h).align_child(Start, Center).child(Text::new(format!("and {} more", total - index)).size(text::small()).color(theme.dim)))); + + break; + } + + let y = origin.1 + used + gap; + + used += gap + line_h; + + let item: Box = match line { + Line::Holiday(name) => { + shapes.extend(glyph_shapes(Glyph::Flag, mark, agenda_settings::holiday_color(), 0.0, (origin.0, y + (row_h - mark) / 2.0))); + + Box::new( + Rectangle::new() + .width(width) + .height(row_h) + .padding(Padding { top: 0.0, right: 0.0, bottom: 0.0, left: mark + gap }) + .align_child(Start, Center) + .child(Text::new(format!("{name} · public holiday")).size(text::small()).color(agenda_settings::holiday_color()).elide()), + ) + } + Line::Birthday(words) => { + shapes.extend(glyph_shapes(Glyph::Cake, mark, agenda_settings::birthday_color(), 0.0, (origin.0, y + (row_h - mark) / 2.0))); + + Box::new(Rectangle::new().width(width).height(row_h).padding(Padding { top: 0.0, right: 0.0, bottom: 0.0, left: mark + gap }).align_child(Start, Center).child(Text::new(words).size(text::small()).color(theme.text).elide())) + } + Line::Note(words, color) => Box::new(wrapped(words, color, width)), + Line::Event(entry) => { + let writable = agenda.calendars.iter().any(|calendar| calendar.url == entry.calendar && calendar.writable); + + // a pencil at the right end of events that can be changed + let pencil = if writable { mark + gap } else { 0.0 }; + + if writable { + shapes.extend(glyph_shapes(Glyph::Pencil, mark, theme.dim, 0.0, (origin.0 + width - gap - mark, y + (event_h - mark) / 2.0))); + } + + let bar = Rectangle::new().width(4.0).height(event_h - 2.0 * gap).radius(Full).fill(entry.color.unwrap_or(theme.accent)); + let words_w = (width - 4.0 - time_w - 4.0 * gap - pencil).max(0.0); + let summary = if entry.summary.trim().is_empty() { String::from("Untitled") } else { entry.summary.clone() }; + + let mut words: Vec> = vec![Box::new(Text::new(summary).size(text::body()).weight(Weight::Bold).color(theme.text).elide())]; + + if !entry.location.trim().is_empty() { + words.push(Box::new(Text::new(entry.location.replace('\n', ", ")).size(text::tiny()).color(theme.dim).elide())); + } + + let row = Row::new(vec![ + Box::new(bar) as Box, + Box::new(Rectangle::new().width(time_w).height(event_h).align_child(Start, Center).child(Text::new(event_time(entry, day)).size(text::small()).color(theme.dim))), + Box::new(Rectangle::new().width(words_w).height(event_h).align_child(Start, Center).child(Column::new(words).gap(2.0))), + ]) + .gap(gap) + .align(Center); + + let block = Rectangle::new().width(width).height(event_h).radius(radius).fill(overlays::chip()).padding(Padding { top: 0.0, right: gap, bottom: 0.0, left: gap }).align_child(Start, Center).child(row); + + if writable { + let entry = entry.clone(); + + Box::new(block.cursor(Pointer).on_click(move |_| edit_event(entry.clone()))) + } else { + Box::new(block) + } + } + }; + + items.push(item); + } + + Box::new(Rectangle::new().width(width).height(height).child(Column::new(items).gap(gap))) +} + +// a chip's width for its words, with `lead` px before them (a colour dot or an icon) +fn choice_width(words: &str, lead: f32) -> f32 { + words.chars().count() as f32 * text::small() * font::letter_width() + 22.0 + lead +} + +fn dot_size() -> f32 { + text::small() * 0.7 +} + +// a chip that can be on (filled with the accent), with a colour dot before its words or not +fn choice(words: &str, on: bool, dot: Option, pressed: impl Fn() + 'static) -> Rectangle { + let theme = theme::now(); + let size = text::small(); + + let mut parts: Vec> = Vec::new(); + + if let Some(color) = dot { + parts.push(Box::new(Rectangle::new().width(dot_size()).height(dot_size()).radius(Full).fill(color))); + } + + parts.push(Box::new(Text::new(words).size(size).color(if on { theme.on_accent } else { theme.text }))); + + Rectangle::new() + .width(choice_width(words, if dot.is_some() { dot_size() + 6.0 } else { 0.0 })) + .height(button_size()) + .radius(Full) + .fill(if on { theme.accent } else { overlays::chip() }) + .align_child(Center, Center) + .cursor(Pointer) + .on_click(move |_| pressed()) + .child(Row::new(parts).gap(6.0).align(Center)) +} + +// a chip with an icon before its words, the icon drawn into the card's canvas; `at` is the chip's top left in the card +fn icon_chip(words: &str, on: bool, glyph: Glyph, at: (f32, f32), shapes: &mut Vec>, pressed: impl Fn() + 'static) -> Rectangle { + let theme = theme::now(); + let (button, side) = (button_size(), 11.0); + let icon = button / 2.0; + let color = if on { theme.on_accent } else { theme.text }; + + shapes.extend(glyph_shapes(glyph, icon, color, 0.0, (at.0 + side, at.1 + (button - icon) / 2.0))); + + Rectangle::new() + .width(choice_width(words, icon + 6.0)) + .height(button) + .radius(Full) + .fill(if on { theme.accent } else { overlays::chip() }) + .padding(Padding { top: 0.0, right: 0.0, bottom: 0.0, left: side }) + .align_child(Start, Center) + .cursor(Pointer) + .on_click(move |_| pressed()) + .child(Row::new(vec![Box::new(Rectangle::new().width(icon).height(icon)) as Box, Box::new(Text::new(words).size(text::small()).color(color))]).gap(6.0).align(Center)) +} + +/* + * the event form: title, day (picked in the month), all day or from and to, place, calendar, + * Save and Cancel, and Delete at the top (pressed twice). a series keeps its days and times: + * its title, place and calendar change for every time it happens, and Delete removes them all. + * `origin` places the bin's icon in the card's canvas + */ +fn event_form(agenda: &Agenda, form: &Form, width: f32, origin: (f32, f32), shapes: &mut Vec>) -> Box { + let theme = theme::now(); + let field_h = agenda_settings::field_height(); + let gap = agenda_settings::item_gap(); + let series = form.editing.as_ref().is_some_and(|entry| entry.recurring); + + let field = |input: TextInput, field_w: f32| { + Rectangle::new().width(field_w).height(field_h).radius(field_h / 3.0).fill(overlays::chip()).padding(Padding { top: 0.0, right: gap, bottom: 0.0, left: gap }).align_child(Start, Center).child(input) + }; + + let input = |id: &'static str, field_w: f32, hint: &str| TextInput::new(id).width((field_w - 2.0 * gap).max(0.0)).size(text::body()).color(theme.text).placeholder(hint).on_submit(|_| save()); + + let title = input("home-title", width, "Title").on_change(|typed| edit_form(move |form| form.title = typed)); + let title = if form.fresh { title.focused() } else { title }; + + let heading = match (&form.editing, series) { + (None, _) => "New event", + (Some(_), false) => "Change event", + (Some(_), true) => "Change repeating event", + }; + + let mut top: Vec> = vec![Box::new(Text::new(heading).size(text::heading()).weight(Weight::Bold).color(theme.text))]; + + if form.editing.is_some() { + let words = match (form.confirm, series) { + (false, _) => "Delete", + (true, false) => "Really delete?", + (true, true) => "Delete every time?", + }; + + let button = button_size(); + let at = (origin.0 + width - choice_width(words, button / 2.0 + 6.0), origin.1 + (pods::header_height() - button) / 2.0); + + top.push(Box::new(icon_chip(words, form.confirm, Glyph::Trash, at, shapes, remove))); + } + + let mut parts: Vec> = vec![ + Box::new(Rectangle::new().width(width).height(pods::header_height()).child(Row::new(top).width(width).justify(SpaceBetween).align(Center))), + Box::new(field(title, width)), + ]; + + if series { + let entry = form.editing.as_ref(); + let when = entry.map_or(String::new(), |entry| event_time(entry, entry.start.div_euclid(86_400))); + + parts.push(Box::new(wrapped( + format!("{when}, repeating. A new title, place or calendar goes to every time it happens and Delete removes them all; change its days and times in a full calendar app."), + theme.dim, + width, + ))); + } else { + let (year, month, date) = parse::civil_from_days(form.day); + + parts.push(Box::new(Text::new(format!("{} {date} {} {year} · pick another day in the month", weekday_name(form.day), month_name(month))).size(text::small()).color(theme.dim))); + + let mut when: Vec> = vec![Box::new(choice("All day", form.all_day, None, toggle_all_day))]; + + if form.all_day { + let days = form.span.max(1); + + when.push(Box::new(pods::chip("−", None, || edit_form(|form| form.span = (form.span - 1).max(1))))); + when.push(Box::new(Text::new(if days == 1 { String::from("1 day") } else { format!("{days} days") }).size(text::small()).color(theme.text))); + when.push(Box::new(pods::chip("+", None, || edit_form(|form| form.span += 1)))); + } else { + let time_w = text::body() * font::letter_width() * 5.0 + 2.0 * gap + gap; + + when.push(Box::new(Text::new("from").size(text::small()).color(theme.dim))); + when.push(Box::new(field(input("home-start", time_w, "9:00").on_change(|typed| edit_form(move |form| form.start = typed)), time_w))); + when.push(Box::new(Text::new("to").size(text::small()).color(theme.dim))); + when.push(Box::new(field(input("home-end", time_w, "10:00").on_change(|typed| edit_form(move |form| form.end = typed)), time_w))); + } + + parts.push(Box::new(Row::new(when).gap(gap).align(Center))); + } + + parts.push(Box::new(field(input("home-location", width, "Place (optional)").on_change(|typed| edit_form(move |form| form.location = typed)), width))); + + // the writable calendars as chips, in as many rows as they need + let mut rows: Vec> = Vec::new(); + let mut row: Vec> = Vec::new(); + let mut row_w = 0.0; + + for calendar in agenda.calendars.iter().filter(|calendar| calendar.writable) { + let chip_w = choice_width(&calendar.name, dot_size() + 6.0); + + if !row.is_empty() && row_w + gap + chip_w > width { + rows.push(Box::new(Row::new(std::mem::take(&mut row)).gap(gap))); + row_w = 0.0; + } + + let url = calendar.url.clone(); + + row_w += if row.is_empty() { chip_w } else { gap + chip_w }; + row.push(Box::new(choice(&calendar.name, calendar.url == form.calendar, Some(calendar.color.unwrap_or(theme.accent)), move || { + let url = url.clone(); + + edit_form(move |form| form.calendar = url); + }))); + } + + if !row.is_empty() { + rows.push(Box::new(Row::new(row).gap(gap))); + } + + if !rows.is_empty() { + parts.push(Box::new(Column::new(rows).gap(gap))); + } + + if let Some(problem) = &form.problem { + parts.push(Box::new(wrapped(problem.clone(), overlays::low_battery(), width))); + } + + let buttons: Vec> = vec![ + Box::new(choice(if form.saving { "Saving…" } else { "Save" }, true, None, save)), + Box::new(pods::chip("Cancel", None, || Agenda::write().form = None)), + ]; + + parts.push(Box::new(Row::new(buttons).gap(gap).align(Center))); + + Box::new(Rectangle::new().width(width).height(calendar_h()).child(Column::new(parts).gap(gap))) +} + +// small text wrapped to a width, in a box as tall as it comes out +fn wrapped(words: String, color: Color, width: f32) -> Rectangle { + let words = Text::new(words).size(text::small()).color(color).wrap(); + + Rectangle::new().width(width).height(words.height_in(width)).child(words) } diff --git a/src/notices.rs b/src/notices.rs index f9d3009..71c4678 100644 --- a/src/notices.rs +++ b/src/notices.rs @@ -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 { } 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)> = arrived.iter().map(|(id, _, _, icon, image)| (*id, picture(icon, image))).collect(); + let pictures: Vec<(u32, Option)> = 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 = 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 { 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> = vec![Box::new(pods::chip(if settings::enabled() { "Mute" } else { "Unmute" }, None, toggle_quiet))]; + let mut buttons: Vec> = 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::>(); + let mut undo: Vec> = 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 = 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 = 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, + 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, 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, body]).gap(10.0)) + let mut parts: Vec> = 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)) } diff --git a/src/parse.rs b/src/parse.rs index a1a9560..6d8335d 100644 --- a/src/parse.rs +++ b/src/parse.rs @@ -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 { + fn occurrence(self) -> Option { 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 { - // a line starting with a space or tab continues the one before - let mut lines: Vec = Vec::new(); + ics_occurrences(ics, offset_s).into_iter().map(|occurrence| occurrence.event).collect() +} - 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()), - } - } +// 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, +} - let mut events = Vec::new(); +// 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 { + let mut occurrences = Vec::new(); let mut draft: Option = 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 { ("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 { ("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 { + let mut lines: Vec = 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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, +} + +/* + * 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 { + 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 = 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> = 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 { + 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::().ok()).map_or(1, |number| number + 1); + + let mut kept: Vec = 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, +} + +/* + * 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 { + #[derive(Default)] + struct Card { + full: String, + parts: String, + org: String, + date: Option<(Option, u32, u32)>, + } + + let mut birthdays = Vec::new(); + let mut card: Option = 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 = 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::().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) -> Option<(Option, 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#"//123456789/principal/HTTP/1.1 200 OK"#; + let home = r#"/123456789/principal/https://p42-caldav.icloud.com:443/123456789/calendars/HTTP/1.1 200 OK"#; + let cards = r#"/123456789/principal/https://p42-contacts.icloud.com:443/123456789/carddavhome/HTTP/1.1 200 OK"#; + + 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#"/123456789/carddavhome/HTTP/1.1 200 OK/123456789/carddavhome/card/HTTP/1.1 200 OK"#; + + 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#"/1/calendars/shared/HTTP/1.1 200 OK/1/calendars/home/HTTP/1.1 200 OK/1/calendars/work/HTTP/1.1 404 Not Found"#; + + assert_eq!(dav_read_only(rights), ["/1/calendars/shared/"]); + + // a calendar-query answer: icloud's cdata, entity-escaped data with , a 404 one left out + let report = "/123456789/calendars/home/6B1C9E6A.ics\"mfe0k8mi\"\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n]]>HTTP/1.1 200 OK\ + /123456789/calendars/home/x2.ics"abc"BEGIN:VCALENDAR \nSUMMARY:Tom & Jerry \nEND:VCALENDAR \nHTTP/1.1 200 OK\ + /123456789/calendars/home/gone.icsHTTP/1.1 404 Not Found"; + + 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 \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("\nline\n207\n\"etag-1\""), ("\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::>(), [("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::>()); + } + + #[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::>(), [("Daily, sync", true), ("Daily, sync", true)]); + assert_eq!(read.iter().map(|occurrence| occurrence.event.start).collect::>(), ics_occurrences(series, 7_200).iter().map(|occurrence| occurrence.event.start).collect::>()); + 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); + } } diff --git a/src/pods.rs b/src/pods.rs index a2f0b76..a9edaf7 100644 --- a/src/pods.rs +++ b/src/pods.rs @@ -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> = Vec::new(); let mut regions: Vec = 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))); diff --git a/src/stats.rs b/src/stats.rs index b0eb95a..d1bf329 100644 --- a/src/stats.rs +++ b/src/stats.rs @@ -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, gpu_words: String, + // the external gpu's busy share + egpu: Vec, ram: Vec, ram_words: String, + swap: Vec, + swap_words: String, // bytes a second down: Vec, up: Vec, read: Vec, write: Vec, - // (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, 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 = found.iter().map(|(name, _)| name.clone()).collect(); + let names: Vec = 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, value: impl Into, 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, + 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, + 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, + 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> = 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> = Vec::new(); + let mut rows: Vec> = Vec::new(); + + for (row, pair) in temps.chunks(2).enumerate() { + let mut cells: Vec> = 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, + 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, + 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, 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)> = 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)> = 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, Box::new(Column::new(rows).gap(10.0))]).width(width).height(graphs_h); - // the temperatures in two columns - let mut temp_rows: Vec> = Vec::new(); + let mut parts: Vec> = vec![Box::new(header), Box::new(graphs)]; - for pair in stats.temps.chunks(2) { - let cell = |(name, celsius): &(String, f32)| -> Box { - 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, 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, - 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)) } diff --git a/src/system.rs b/src/system.rs index 24c2a65..d3572f5 100644 --- a/src/system.rs +++ b/src/system.rs @@ -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 = HashMap::new(); let mut engines: HashMap = 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; diff --git a/weather/CREDITS.md b/weather/CREDITS.md new file mode 100644 index 0000000..c8613cc --- /dev/null +++ b/weather/CREDITS.md @@ -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 `-.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.