From bbe184ab972804b922f0b31493e2005edab90299 Mon Sep 17 00:00:00 2001 From: server2 Date: Thu, 15 Oct 2026 12:52:55 +0200 Subject: [PATCH] Side pills: local AI chat, system monitor, home (clock, Munich weather, iCloud calendar), notifications daemon with popups and history --- README.md | 13 + src/ai.rs | 347 +++++++++++++++++++++ src/config.rs | 63 ++++ src/glyph.rs | 147 +++++++++ src/home.rs | 678 +++++++++++++++++++++++++++++++++++++++++ src/island.rs | 13 +- src/main.rs | 12 +- src/notices.rs | 443 +++++++++++++++++++++++++++ src/parse.rs | 807 +++++++++++++++++++++++++++++++++++++++++++++++++ src/pods.rs | 484 +++++++++++++++++++++++++++++ src/stats.rs | 382 +++++++++++++++++++++++ src/tray.rs | 2 +- 12 files changed, 3388 insertions(+), 3 deletions(-) create mode 100644 src/ai.rs create mode 100644 src/home.rs create mode 100644 src/notices.rs create mode 100644 src/pods.rs create mode 100644 src/stats.rs diff --git a/README.md b/README.md index af8ef2f..ad1651f 100644 --- a/README.md +++ b/README.md @@ -111,6 +111,19 @@ The Material You scheme type is Skwd-wall's setting (`matugen.schemeType`). Whether the open card takes the song cover's colours instead (`appearance.colors.cover_colors`), the fade time, and the colours used until there is a theme file are [settings](#settings). +## Side pills + +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. + +`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). + +Left out on purpose: phone stats (iOS shares nothing Linux can read without extra daemons). + ## Settings Every setting lives in `~/.config/amane/config.toml`: sizes, timings, text sizes, colours, poll intervals, equalizer presets and bands, the hotspot profile, the extra Sound output. The shell writes the file with every setting and its explanation the first time it starts (moving `cover_colors`, `transition_ms`, `hotspot` and `extra_output` over from an old `shell.conf`, which it no longer reads). Saving the file applies it at once; a line left out takes its default, and a line the shell can't use is listed at the top of the config window. The font and the cava bar count take effect on restart. diff --git a/src/ai.rs b/src/ai.rs new file mode 100644 index 0000000..50f577e --- /dev/null +++ b/src/ai.rs @@ -0,0 +1,347 @@ +use std::io::{BufRead, BufReader, Write}; +use std::process::{Child, Command, Stdio}; +use std::sync::{Mutex, PoisonError}; +use std::thread; +use std::time::{Duration, Instant}; + +use amane::{Center, Column, End, Full, Rectangle, Row, Scroll, Service, Stack, Start, Text, TextInput, Widget}; + +use crate::config::appearance::{overlays, text}; +use crate::config::pills::ai as settings; +use crate::glyph::Glyph; +use crate::island::faded; +use crate::parse::{self, Chunk}; +use crate::{pods, theme}; + +/* + * the AI card: a chat with a local model through ollama's http api (curl, streamed line + * by line), never a cloud service. ollama isn't kept running: the first question starts + * its openrc service when it doesn't answer (doas, the same fixed rc-service rule the + * control centre uses) + */ +#[derive(Clone, Copy, PartialEq)] +pub enum Who { + You, + Model, +} + +pub struct Ai { + pub said: Vec<(Who, String)>, + pub busy: bool, + // what's going on or what went wrong, under the chat + pub note: Option, + // how far the chat is scrolled back from its newest line, in px + back: f32, + // the question being typed, for the send button + typed: String, + // bumped by Stop and New chat, so an answer still streaming stops writing + round: u32, +} + +impl Service for Ai { + fn new() -> Self { + Self { said: Vec::new(), busy: false, note: None, back: 0.0, typed: String::new(), round: 0 } + } + + // changes only through input and the answering thread + fn listen() {} +} + +const INPUT: &str = "ai-input"; + +// the curl streaming the answer now, so Stop can end it +static ASKING: Mutex> = Mutex::new(None); + +// a question from outside (ipc), as if typed +pub fn ask(words: String) { + send(words); +} + +fn send(words: String) { + let words = words.trim().to_string(); + + if words.is_empty() || Ai::read().busy { + return; + } + + TextInput::set_text(INPUT, ""); + + let round = { + let mut ai = Ai::write(); + + ai.said.push((Who::You, words)); + ai.said.push((Who::Model, String::new())); + ai.busy = true; + ai.note = None; + ai.back = 0.0; + ai.typed.clear(); + ai.round + }; + + thread::spawn(move || { + if let Err(why) = answer(round) { + finish(round, Some(why)); + } + }); +} + +fn stop() { + if let Some(mut child) = ASKING.lock().unwrap_or_else(PoisonError::into_inner).take() { + let _ = child.kill(); + let _ = child.wait(); + } + + let mut ai = Ai::write(); + + ai.round += 1; + ai.busy = false; + + // an answer cut off before its first word leaves nothing to show + if ai.said.last().is_some_and(|(who, words)| *who == Who::Model && words.is_empty()) { + ai.said.pop(); + } +} + +fn new_chat() { + stop(); + + let mut ai = Ai::write(); + + ai.said.clear(); + ai.note = None; + ai.back = 0.0; +} + +// on the answering thread: start ollama if needed, then stream the reply into the 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, ""); + } + + let body = request(); + + let mut child = Command::new("curl") + .args(["-sN", "-X", "POST", "-H", "Content-Type: application/json", "--data-binary", "@-", &format!("{server}/api/chat")]) + .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 _ = input.write_all(body.as_bytes()); + } + + let output = child.stdout.take().ok_or("curl gave no output")?; + + *ASKING.lock().unwrap_or_else(PoisonError::into_inner) = Some(child); + + for line in BufReader::new(output).lines() { + let Ok(line) = line else { break }; + + match parse::ollama_chunk(&line) { + Some(Chunk::Text(piece)) => { + let mut ai = Ai::write(); + + if ai.round != round { + return Ok(()); + } + + if let Some((Who::Model, words)) = ai.said.last_mut() { + words.push_str(&piece); + } + } + Some(Chunk::Done) => break, + Some(Chunk::Failed(why)) => return Err(why), + None => {} + } + } + + if let Some(mut child) = ASKING.lock().unwrap_or_else(PoisonError::into_inner).take() { + let _ = child.wait(); + } + + let empty = Ai::read().said.last().is_some_and(|(who, words)| *who == Who::Model && words.is_empty()); + + finish(round, empty.then(|| String::from("The model gave no answer"))); + + Ok(()) +} + +// whether ollama answers at all, within a second +fn answers(server: &str) -> bool { + Command::new("curl") + .args(["-s", "-m", "1", "-o", "/dev/null", "-w", "%{http_code}", &format!("{server}/api/version")]) + .output() + .is_ok_and(|done| done.stdout == b"200") +} + +// the chat so far as ollama's /api/chat request +fn request() -> String { + let ai = Ai::read(); + + let mut messages = vec![format!("{{\"role\":\"system\",\"content\":{}}}", parse::json_string(&settings::system()))]; + + for (who, words) in &ai.said { + if words.is_empty() { + continue; + } + + let role = if *who == Who::You { "user" } else { "assistant" }; + + messages.push(format!("{{\"role\":\"{role}\",\"content\":{}}}", parse::json_string(words))); + } + + format!("{{\"model\":{},\"stream\":true,\"messages\":[{}]}}", parse::json_string(&settings::model()), messages.join(",")) +} + +fn note(round: u32, words: &str) { + let mut ai = Ai::write(); + + if ai.round == round { + ai.note = (!words.is_empty()).then(|| words.to_string()); + } +} + +fn finish(round: u32, problem: Option) { + let mut ai = Ai::write(); + + if ai.round != round { + return; + } + + ai.busy = false; + ai.note = problem; + + if ai.said.last().is_some_and(|(who, words)| *who == Who::Model && words.is_empty()) { + ai.said.pop(); + } +} + +fn scrolled(step: Scroll) { + let mut ai = Ai::write(); + + ai.back = (ai.back - step.y * 40.0).max(0.0); +} + +/* + * the card's inside: the header, the chat (newest at the bottom, the wheel scrolls back), + * a note line, the question field + */ +pub fn card(width: f32, height: f32) -> Rectangle { + let ai = Ai::read(); + let theme = theme::now(); + + let mut buttons: Vec> = Vec::new(); + + if ai.busy { + buttons.push(Box::new(pods::chip("Stop", None, stop))); + } else if !ai.said.is_empty() { + buttons.push(Box::new(pods::chip("New chat", None, new_chat))); + } + + let header = pods::header(&format!("AI · {}", settings::model()), buttons, width); + + let field_h = 40.0; + let note_h = text::small() + 8.0; + let chat_h = height - pods::header_height() - field_h - note_h - 3.0 * 8.0; + + // the bubbles, measured so the newest sit at the bottom + let bubble_w = width * 0.86; + let mut bubbles: Vec> = Vec::new(); + let mut total = 0.0; + + for (who, words) in &ai.said { + let shown = if words.is_empty() { String::from("…") } else { words.clone() }; + let words = Text::new(shown).size(text::body()).color(theme.text).wrap(); + let words_h = words.height_in(bubble_w - 24.0); + let mine = *who == Who::You; + + let bubble = Rectangle::new() + .width(bubble_w) + .height(words_h + 16.0) + .radius(14.0) + .fill(if mine { faded(theme.accent, 70) } else { overlays::chip() }) + .padding(8.0) + .child(Rectangle::new().width(bubble_w - 24.0).height(words_h).child(words)); + + total += words_h + 16.0 + 8.0; + // yours on the right, the model's on the left + let row = Rectangle::new().width(width).height(words_h + 16.0); + let row = if mine { row.align_child(End, Start) } else { row.align_child(Start, Start) }; + + bubbles.push(Box::new(row.child(bubble))); + } + + let chat: Box = 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 + let back = ai.back.min((total - chat_h).max(0.0)); + let column = Column::new(bubbles).gap(8.0); + + Box::new( + Rectangle::new() + .width(width) + .height(chat_h) + .clip() + .on_scroll(scrolled) + .child(Stack::new(vec![Box::new(Rectangle::new().width(width).height(total).translate(0.0, chat_h - total + back).child(column)) as Box]).width(width).height(chat_h)), + ) + }; + + let note = Text::new(ai.note.clone().unwrap_or_else(|| if ai.busy { String::from("Thinking…") } else { String::new() })).size(text::small()).color(theme.dim).elide(); + + let input = TextInput::new(INPUT).width(width - 2.0 * 14.0 - 34.0).size(text::body()).color(theme.text).placeholder(format!("Ask {}…", settings::model())).focused().on_change(|words| Ai::write().typed = words).on_submit(send); + + let field = Rectangle::new() + .width(width) + .height(field_h) + .radius(Full) + .fill(overlays::chip()) + .padding(amane::Padding { top: 0.0, right: 6.0, bottom: 0.0, left: 14.0 }) + .align_child(Start, Center) + .child( + Row::new(vec![ + Box::new(input) as Box, + Box::new(pods::button(Glyph::Send, || { + let typed = Ai::read().typed.clone(); + + send(typed); + })), + ]) + .gap(6.0) + .align(Center), + ); + + Rectangle::new().width(width).height(height).child( + Column::new(vec![ + Box::new(header) as Box, + chat, + Box::new(Rectangle::new().width(width).height(note_h).align_child(Start, Center).child(note)), + Box::new(field), + ]) + .gap(8.0), + ) +} diff --git a/src/config.rs b/src/config.rs index 777a5f4..f9494c6 100644 --- a/src/config.rs +++ b/src/config.rs @@ -497,6 +497,68 @@ settings! { media_check_ms: u64 = 300, 50..=5000; "How often the song is checked for a change, for the island's peek" } } + + pills { + layout { + gap: f32 = 10.0, 0..=200; "Space between the island's pill and the small pills beside it" + spacing: f32 = 8.0, 0..=100; "Space between the small pills on one side" + icon: f32 = 16.0, 6..=40; "Size of the small pills' icons" + 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" + } + ai { + width: f32 = 440.0, 280..=1200; "Width of the AI card" + height: f32 = 520.0, 200..=1200; "Height of the AI card" + server: String = String::from("http://localhost:11434"); "The Ollama server the AI card talks to" + model: String = String::from("gemma4:e4b"); "The Ollama model to chat with, with its tag (ollama list shows them)" + start_service: bool = true; "Start the local ollama service (doas rc-service ollama start) when it isn't answering" + system: String = String::from("You are a helpful assistant inside a Linux desktop shell. Answer briefly and plainly."); "What the model is told before every chat" + } + monitor { + width: f32 = 420.0, 280..=1200; "Width of the system monitor card" + interval_ms: u64 = 1000, 200..=10000; "How often the system monitor reads the system" + 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" + 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" + 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" + 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" + } + 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" + } + } + + notifications { + popups { + enabled: bool = true; "Show notifications as popups; off is do not disturb (they still go to the history)" + timeout_ms: u64 = 6000, 1000..=60000; "How long a normal notification's popup stays" + low_timeout_ms: u64 = 4000, 1000..=60000; "How long a low-urgency popup stays" + critical_stays: bool = true; "Critical notifications stay until dismissed" + width: f32 = 380.0, 240..=900; "Width of the popups" + 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" + } + history { + width: f32 = 420.0, 280..=1200; "Width of the notification card" + height: f32 = 560.0, 200..=1400; "Height of the notification card" + } + } } // what config.toml set, by path; a missing or bad setting takes its default @@ -864,6 +926,7 @@ pub fn modules() -> Vec<&'static str> { pub fn title(id: &str) -> String { match id { "control" => String::from("Control centre"), + "pills" => String::from("Side pills"), "bar" => String::from("Bar hiding"), "input-capture" => String::from("Input capture"), "opengl" => String::from("OpenGL"), diff --git a/src/glyph.rs b/src/glyph.rs index 4d8a916..f457ef7 100644 --- a/src/glyph.rs +++ b/src/glyph.rs @@ -32,6 +32,26 @@ pub enum Glyph { Ethernet, // a cog, for the config window Gear, + + // the side pills: a four-pointed spark for the AI, a pulse line for the system monitor, + // a house for home, a bell for notifications + Sparkle, + Pulse, + House, + Bell, + // a cross to close or delete, an arrow to send, a prompt for a terminal, a head and shoulders + Cross, + Send, + Terminal, + Person, + // the sky + Moon, + Cloud, + PartCloud, + Rain, + Snow, + Storm, + Fog, } pub fn icon(glyph: Glyph, size: f32, color: Color) -> Canvas { @@ -234,5 +254,132 @@ pub fn glyph_shapes(glyph: Glyph, size: f32, color: Color, weight: f32, origin: parts } + Glyph::Sparkle => shapes![ + Path::new() + .move_to(x(0.5), y(0.06)) + .quad_to(x(0.54), y(0.46), x(0.94), y(0.5)) + .quad_to(x(0.54), y(0.54), x(0.5), y(0.94)) + .quad_to(x(0.46), y(0.54), x(0.06), y(0.5)) + .quad_to(x(0.46), y(0.46), x(0.5), y(0.06)) + .close() + .fill(color) + ], + Glyph::Pulse => shapes![ + Path::new() + .move_to(x(0.04), y(0.54)) + .line_to(x(0.28), y(0.54)) + .line_to(x(0.38), y(0.24)) + .line_to(x(0.54), y(0.8)) + .line_to(x(0.66), y(0.42)) + .line_to(x(0.74), y(0.54)) + .line_to(x(0.96), y(0.54)) + .stroke(thin, color) + .cap(Cap::Round) + ], + Glyph::House => shapes![ + Path::new() + .move_to(x(0.1), y(0.48)) + .line_to(x(0.5), y(0.12)) + .line_to(x(0.9), y(0.48)) + .stroke(thin, color) + .cap(Cap::Round), + Path::new() + .move_to(x(0.22), y(0.4)) + .line_to(x(0.22), y(0.88)) + .line_to(x(0.42), y(0.88)) + .line_to(x(0.42), y(0.64)) + .line_to(x(0.58), y(0.64)) + .line_to(x(0.58), y(0.88)) + .line_to(x(0.78), y(0.88)) + .line_to(x(0.78), y(0.4)) + .stroke(thin, color), + ], + Glyph::Bell => shapes![ + Path::new() + .move_to(x(0.16), y(0.74)) + .quad_to(x(0.26), y(0.62), x(0.26), y(0.46)) + .quad_to(x(0.26), y(0.16), x(0.5), y(0.16)) + .quad_to(x(0.74), y(0.16), x(0.74), y(0.46)) + .quad_to(x(0.74), y(0.62), x(0.84), y(0.74)) + .close() + .fill(color), + Circle::new().center(x(0.5), y(0.84)).radius(0.08 * s).fill(color), + 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)], + 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) + ], + Glyph::Terminal => shapes![ + frame(0.06, 0.14, 0.94, 0.86).stroke(thin, color), + Path::new().move_to(x(0.22), y(0.36)).line_to(x(0.38), y(0.5)).line_to(x(0.22), y(0.64)).stroke(thin, color).cap(Cap::Round), + line(0.46, 0.66, 0.7, 0.66), + ], + Glyph::Person => shapes![ + Circle::new().center(x(0.5), y(0.34)).radius(0.18 * s).fill(color), + Path::new().move_to(x(0.14), y(0.92)).quad_to(x(0.18), y(0.58), x(0.5), y(0.58)).quad_to(x(0.82), y(0.58), x(0.86), y(0.92)).close().fill(color), + ], + Glyph::Moon => shapes![ + Path::new() + .move_to(x(0.62), y(0.1)) + .quad_to(x(0.12), y(0.16), x(0.16), y(0.56)) + .quad_to(x(0.3), y(0.94), x(0.86), y(0.72)) + .quad_to(x(0.4), y(0.64), x(0.62), y(0.1)) + .close() + .fill(color) + ], + Glyph::Cloud => shapes![cloud(&x, &y, 0.0, color)], + Glyph::PartCloud => { + let mut parts: Vec> = shapes![Circle::new().center(x(0.34), y(0.36)).radius(0.16 * s).fill(color)]; + + for ray in 0..6 { + let (sin, cos) = (ray as f32 * std::f32::consts::FRAC_PI_3).sin_cos(); + + parts.push(Box::new(line(0.34 + 0.23 * sin, 0.36 - 0.23 * cos, 0.34 + 0.31 * sin, 0.36 - 0.31 * cos))); + } + + parts.push(Box::new(cloud(&x, &y, 0.12, color))); + + parts + } + Glyph::Rain => { + let mut parts: Vec> = shapes![cloud(&x, &y, -0.14, color)]; + + for drop in [0.32, 0.5, 0.68] { + parts.push(Box::new(line(drop, 0.7, drop - 0.06, 0.88))); + } + + parts + } + Glyph::Snow => { + let mut parts: Vec> = shapes![cloud(&x, &y, -0.14, color)]; + + for flake in [0.32, 0.5, 0.68] { + parts.push(Box::new(Circle::new().center(x(flake), y(0.8)).radius(0.05 * s).fill(color))); + } + + parts + } + Glyph::Storm => shapes![ + cloud(&x, &y, -0.14, color), + Path::new().move_to(x(0.56), y(0.6)).line_to(x(0.42), y(0.78)).line_to(x(0.54), y(0.78)).line_to(x(0.46), y(0.96)).stroke(thin, color).cap(Cap::Round), + ], + Glyph::Fog => shapes![line(0.14, 0.34, 0.86, 0.34), line(0.22, 0.5, 0.78, 0.5), line(0.14, 0.66, 0.86, 0.66)], } } + +// a cloud, `drop` of the box lower (or higher, negative) than its middle +fn cloud(x: &impl Fn(f32) -> f32, y: &impl Fn(f32) -> f32, drop: f32, color: Color) -> Path { + let y = |k: f32| y(k + drop); + + Path::new() + .move_to(x(0.2), y(0.74)) + .quad_to(x(0.04), y(0.72), x(0.08), y(0.56)) + .quad_to(x(0.14), y(0.44), x(0.28), y(0.48)) + .quad_to(x(0.34), y(0.26), x(0.54), y(0.28)) + .quad_to(x(0.74), y(0.3), x(0.76), y(0.48)) + .quad_to(x(0.96), y(0.5), x(0.92), y(0.66)) + .quad_to(x(0.88), y(0.74), x(0.78), y(0.74)) + .close() + .fill(color) +} diff --git a/src/home.rs b/src/home.rs new file mode 100644 index 0000000..096e238 --- /dev/null +++ b/src/home.rs @@ -0,0 +1,678 @@ +use std::io::Write; +use std::path::PathBuf; +use std::process::{Command, Stdio}; +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 crate::config::appearance::{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::{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 + */ + +// local time, refreshed by the minute (by the second with seconds shown) +#[derive(Default)] +pub struct Clock { + // seconds since 1970 in local time, and the local offset from utc + now: i64, + offset: i64, + avatar: Option, +} + +impl Service for Clock { + fn new() -> Self { + Self::default() + } + + fn listen() { + let mut ticks = 0; + + loop { + // the offset (summer time) and the avatar file can change; look again each minute + if ticks % 60 == 0 { + let offset = utc_offset(); + let avatar = avatar_file(); + + let mut clock = Self::write(); + + clock.offset = offset; + clock.avatar = avatar; + } + + let unix = SystemTime::now().duration_since(UNIX_EPOCH).map_or(0, |since| since.as_secs() as i64); + + { + let mut clock = Self::write(); + + clock.now = unix + clock.offset; + } + + let wait = if settings::seconds() { 1 } else { 60 - (unix % 60) }; + + ticks += wait; + thread::sleep(Duration::from_secs(wait as u64)); + } + } +} + +// the local offset from utc in seconds, 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 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); + + sign * (hours * 3600 + minutes * 60) +} + +fn home_dir() -> PathBuf { + env::var_os("HOME").map(PathBuf::from).unwrap_or_default() +} + +// 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), + }; + + path.is_file().then(|| path.to_string_lossy().into_owned()) +} + +#[derive(Default)] +pub struct Forecast { + weather: Option, + problem: Option, +} + +impl Service for Forecast { + fn new() -> Self { + Self::default() + } + + fn listen() { + loop { + let url = format!( + "https://api.open-meteo.com/v1/forecast?latitude={}&longitude={}¤t=temperature_2m,apparent_temperature,relative_humidity_2m,weather_code,wind_speed_10m,is_day\ + &daily=weather_code,temperature_2m_max,temperature_2m_min,sunrise,sunset,precipitation_probability_max&timezone=auto&forecast_days=5", + settings::latitude(), + settings::longitude() + ); + + 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 mut forecast = Self::write(); + + match parse::weather(&printed) { + Some(weather) => { + forecast.weather = Some(weather); + forecast.problem = None; + } + None => forecast.problem = Some(String::from("The weather couldn't be fetched")), + } + } + + thread::sleep(Duration::from_secs(settings::weather_minutes() * 60)); + } + } +} + +#[derive(Default)] +pub struct Agenda { + // upcoming events with their calendar's colour, soonest first + events: Vec<(Event, Option)>, + synced: bool, + problem: Option, + // the sign-in form is open, and the apple id typed into it + form: bool, + account: String, +} + +impl Service for Agenda { + fn new() -> Self { + Self::default() + } + + fn listen() { + loop { + sync(); + + thread::sleep(Duration::from_secs(agenda_settings::sync_minutes() * 60)); + } + } +} + +// 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 +} + +const KEYRING: [&str; 2] = ["service", "amane-calendar"]; + +fn password(account: &str) -> Option { + let done = Command::new("secret-tool").args(["lookup", KEYRING[0], KEYRING[1], "account", account]).output().ok()?; + let password = String::from_utf8_lossy(&done.stdout).trim_end_matches('\n').to_string(); + + (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(); + }); +} + +fn sign_out() { + let account = agenda_settings::account(); + + thread::spawn(move || { + let _ = Command::new("secret-tool").args(["clear", KEYRING[0], KEYRING[1], "account", &account]).output(); + let _ = config::reset("pills.calendar.account"); + + let mut agenda = Agenda::write(); + + agenda.events.clear(); + 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")); + + return; + }; + + let found = events(&agenda_settings::server(), &account, &password); + + let mut agenda = Agenda::write(); + + match found { + Ok(events) => { + agenda.events = events; + agenda.synced = true; + agenda.problem = None; + } + Err(why) => agenda.problem = Some(why), + } +} + +// 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}"))?; + + if let Some(mut input) = child.stdin.take() { + let quote = |text: &str| text.replace('\\', "\\\\").replace('"', "\\\""); + let _ = writeln!(input, "user = \"{}:{}\"", quote(account), quote(password)); + } + + 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)); + + 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")), + code => Err(format!("The calendar server answered {code}")), + } +} + +// an href from an answer as a whole url, on the server it came from +fn join(base: &str, href: &str) -> String { + if href.starts_with("http") { + return href.to_string(); + } + + let origin_end = base.find("://").map_or(base.len(), |scheme| base[scheme + 3..].find('/').map_or(base.len(), |path| scheme + 3 + path)); + + 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> { + 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", &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 found = ask( + "PROPFIND", + &home, + "1", + r#""#, + )?; + + 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 query = format!( + r#""# + ); + + let mut all: Vec<(Event, Option)> = Vec::new(); + + 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 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))); + } + } + + all.sort_by_key(|(event, _)| event.start); + + Ok(all) +} + +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 sky_glyph(code: u32, day: bool) -> Glyph { + match parse::sky(code) { + Sky::Clear if day => Glyph::Sun, + Sky::Clear => Glyph::Moon, + Sky::PartlyCloudy => Glyph::PartCloud, + Sky::Cloudy => Glyph::Cloud, + Sky::Fog => Glyph::Fog, + Sky::Rain => Glyph::Rain, + Sky::Snow => Glyph::Snow, + Sky::Storm => Glyph::Storm, + } +} + +// 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; + +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); + + grid.max(list) +} + +pub fn height() -> f32 { + 2.0 * layout::pad() + TOP_H + clock_h() + WEATHER_H + DAYS_H + 1.0 + calendar_h() + 5.0 * 12.0 +} + +fn name() -> String { + let user = env::var("USER").unwrap_or_default(); + let mut letters = user.chars(); + + letters.next().map(|first| first.to_uppercase().chain(letters).collect()).unwrap_or_default() +} + +pub fn card(width: f32, height: f32) -> Rectangle { + let clock = Clock::read(); + let forecast = Forecast::read(); + let agenda = Agenda::read(); + let theme = theme::now(); + + let days = clock.now.div_euclid(86400); + let (year, month, day) = parse::civil_from_days(days); + + 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); + + // 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))), + }; + + let greeting = match hour { + 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 top = Row::new(vec![ + Box::new( + Row::new(vec![ + avatar, + Box::new( + Column::new(vec![ + Box::new(Text::new(format!("{greeting}, {}", name())).size(text::heading()).weight(Weight::Bold).color(theme.text)) as Box, + Box::new(Text::new(date).size(text::small()).color(theme.dim)), + ]) + .gap(4.0), + ), + ]) + .gap(12.0) + .align(Center), + ) as Box, + Box::new(pods::button(Glyph::Cross, pods::close)), + ]) + .width(width) + .justify(SpaceBetween) + .align(Center); + + // the big clock + 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); + + 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), + ) +} + +// 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) { + let theme = theme::now(); + + 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)), + ); + }; + + 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( + 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!("{} · feels {:.0}°", parse::weather_words(weather.code), weather.apparent)).size(text::small()).color(theme.text)), + ]) + .gap(10.0) + .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)), + ]) + .gap(2.0), + ), + ]) + .gap(12.0) + .align(Center), + ) as Box, + Box::new( + Column::new(vec![ + Box::new(Text::new(format!("↑ {high:.0}° ↓ {low:.0}°")).size(text::small()).color(theme.text)) as Box, + Box::new(Text::new(format!("rise {sunrise} · set {sunset}")).size(text::tiny()).color(theme.dim)), + ]) + .gap(4.0), + ), + ]) + .width(width) + .justify(SpaceBetween) + .align(Center); + + // the coming days, after today + let coming: Vec<_> = weather.days.iter().skip(1).take(4).collect(); + let column_w = width / 4.0; + let icon_size = 22.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))); + + columns.push(Box::new( + 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(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), + ), + )); + } + + 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)) +} + +// 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 { + let theme = theme::now(); + let grid_w = 7.0 * CELL; + let list_w = width - grid_w - 20.0; + + // 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 busy: Vec = agenda.events.iter().map(|(event, _)| (event.start + offset).div_euclid(86400)).collect(); + + 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(), + ))]; + + for week in 0..6 { + let mut cells: Vec> = Vec::new(); + + for weekday in 0..7 { + let date = first - lead + week * 7 + weekday; + 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 color = if is_today { theme.on_accent } else if inside { theme.text } else { faded(theme.dim, 60) }; + + 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))]; + + 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))); + } + + 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)), + )); + } + + rows.push(Box::new(Row::new(cells))); + } + + let grid = Column::new(rows); + + 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), + ) + } 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)), + ) +} + +fn sign_in_form(agenda: &Agenda, width: f32) -> Box { + let theme = theme::now(); + + 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 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 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(); + + 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())); + } + + parts.push(Box::new(pods::chip("Cancel", None, || Agenda::write().form = false))); + + Box::new(Column::new(parts).gap(6.0)) +} diff --git a/src/island.rs b/src/island.rs index 12d9ea3..d4d9376 100644 --- a/src/island.rs +++ b/src/island.rs @@ -394,6 +394,9 @@ fn open_on(island: &mut Island, monitor: Option) { island.expanded = true; island.open.to(1.0); + + // one card at a time: a side pill's card closes + crate::pods::close(); } // the open timeline, at rest on the pill @@ -408,7 +411,7 @@ pub fn focused_monitor() -> Option { workspaces.list().iter().find(|workspace| workspace.focused()).and_then(|workspace| workspace.output()).map(String::from) } -fn collapse() { +pub fn collapse() { let mut island = Island::write(); island.expanded = false; @@ -514,6 +517,14 @@ fn next_player() { Island::write().selected = next; } +// how wide this monitor's island is this frame, hover aside: the side pills keep beside it +pub fn width_on(monitor: &str) -> f32 { + let island = Island::read(); + let open = if island.opened_on.as_deref().is_none_or(|name| name == monitor) { island.open.value() } else { 0.0 }; + + mix(c::pill::width(), c::card::width(), phases(open).0) +} + pub fn mix(from: f32, to: f32, amount: f32) -> f32 { from + (to - from) * amount } diff --git a/src/main.rs b/src/main.rs index 97a0663..6b867e6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,16 +1,21 @@ +mod ai; mod art; mod config; mod control; mod edge; mod glyph; +mod home; mod hypr; mod island; mod map; +mod notices; mod parse; +mod pods; mod prompt; mod settings; mod sound; mod spectrum; +mod stats; mod surface; mod system; mod png; @@ -37,14 +42,19 @@ fn main() { .ipc("control", control::command) .ipc("tray", tray::command) .ipc("config", settings::command) + .ipc("pods", pods::command) .window_per_monitor(edge::spacer) .window_per_monitor(edge::view) .window_per_monitor(island::view) + // the small pills beside the island, and their cards + .window_per_monitor(pods::left) + .window_per_monitor(pods::right) .window_per_monitor(workspaces::view) .window_per_monitor(control::view) .window_per_monitor(control::menu_view) .window_per_monitor(tray::menu_view) - // last, so the popup lies over the control centre + // last, so the popups lie over the control centre + .window_per_monitor(notices::popups) .window_per_monitor(prompt::view) .run(); } diff --git a/src/notices.rs b/src/notices.rs new file mode 100644 index 0000000..f9d3009 --- /dev/null +++ b/src/notices.rs @@ -0,0 +1,443 @@ +use std::collections::HashMap; +use std::path::Path; +use std::thread; +use std::time::{Duration, Instant, SystemTime}; + +use amane::{ + Center, Column, Full, Horizontal, Image, LayerWindow, Layer, Margin, Monitor, Notification, Notifications, Pointer, Rectangle, Row, ScrollArea, Service, SpaceBetween, + Start, Text, Urgency, Vertical, Weight, Widget, Zone, +}; + +use crate::config::appearance::{overlays, text}; +use crate::config::control::pill as control_pill; +use crate::config::island::pill as island_pill; +use crate::config::notifications::popups as settings; +use crate::glyph::Glyph; +use crate::island::{self, faded}; +use crate::parse::{self, Value}; +use crate::{config, pods, surface, theme, tray}; + +/* + * notifications: amane's own server (org.freedesktop.Notifications, which dunst held + * before) keeps the list; this adds the popups, the unread count on the bell and the + * history card. a watcher reads the list four times a second (memory, not d-bus), finds + * each new one's picture once and times the popups out by urgency + */ +#[derive(Default)] +pub struct Notices { + // shown as popups, newest first, with when each (re)arrived + popups: Vec<(u32, Instant)>, + // each notification's arrival time as last seen, to tell new and replaced ones apart + known: HashMap, + // a picture file per notification: its image, else its icon found in the themes + pictures: HashMap, + unread: usize, + // the pointer over the popups: they don't time out meanwhile + hovered: bool, +} + +impl Service for Notices { + fn new() -> Self { + Self::default() + } + + fn listen() { + loop { + watch(); + + thread::sleep(Duration::from_millis(250)); + } + } +} + +pub fn unread() -> usize { + Notices::read().unread +} + +// the history was looked at +pub fn mark_read() { + let mut notices = Notices::write(); + + if notices.unread > 0 { + notices.unread = 0; + } +} + +fn timeout(urgency: Urgency) -> Option { + match urgency { + Urgency::Low => Some(Duration::from_millis(settings::low_timeout_ms())), + Urgency::Critical if settings::critical_stays() => None, + _ => Some(Duration::from_millis(settings::timeout_ms())), + } +} + +fn watch() { + let current: Vec<(u32, SystemTime, Urgency, String, String)> = + Notifications::read().list().iter().map(|notice| (notice.id(), notice.received(), notice.urgency(), notice.icon().to_string(), notice.image().to_string())).collect(); + + let (arrived, gone, expired) = { + let notices = Notices::read(); + + let arrived: Vec<_> = current.iter().filter(|(id, received, ..)| notices.known.get(id) != Some(received)).cloned().collect(); + let gone: Vec = notices.known.keys().filter(|id| !current.iter().any(|(known, ..)| known == *id)).copied().collect(); + + let expired: Vec = if notices.hovered { + Vec::new() + } else { + notices + .popups + .iter() + .filter(|(id, since)| current.iter().find(|(known, ..)| known == id).and_then(|(_, _, urgency, ..)| timeout(*urgency)).is_some_and(|stay| since.elapsed() >= stay)) + .map(|(id, _)| *id) + .collect() + }; + + (arrived, gone, expired) + }; + + if arrived.is_empty() && gone.is_empty() && expired.is_empty() { + return; + } + + // icon lookups walk the themes: here, not under the lock and never in a view + let pictures: Vec<(u32, Option)> = arrived.iter().map(|(id, _, _, icon, image)| (*id, picture(icon, image))).collect(); + + let looking = pods::open_card() == Some(pods::Pod::Notifications); + let popping = settings::enabled(); + + let mut notices = Notices::write(); + + for (id, received, ..) in &arrived { + let fresh = !notices.known.contains_key(id); + + notices.known.insert(*id, *received); + notices.popups.retain(|(shown, _)| shown != id); + + if popping { + notices.popups.insert(0, (*id, Instant::now())); + } + + if fresh && !looking { + notices.unread += 1; + } + } + + for (id, file) in pictures { + match file { + Some(file) => notices.pictures.insert(id, file), + None => notices.pictures.remove(&id), + }; + } + + for id in gone { + notices.known.remove(&id); + notices.pictures.remove(&id); + notices.popups.retain(|(shown, _)| *shown != id); + } + + notices.popups.retain(|(shown, _)| !expired.contains(shown)); + notices.unread = notices.unread.min(notices.known.len()); +} + +// a notification's picture as a file: its image (a path or file:// url), else its icon (a path or a theme name) +fn picture(icon: &str, image: &str) -> Option { + for wanted in [image, icon] { + let path = wanted.strip_prefix("file://").unwrap_or(wanted); + + if path.is_empty() { + continue; + } + + if path.starts_with('/') { + if Path::new(path).is_file() { + return Some(path.to_string()); + } + + continue; + } + + if let Some(file) = tray::icon_file(path, "") { + return Some(file.to_string_lossy().into_owned()); + } + } + + None +} + +fn hide(id: u32) { + Notices::write().popups.retain(|(shown, _)| *shown != id); +} + +fn hovering(inside: bool) { + let mut notices = Notices::write(); + + notices.hovered = inside; + + // leaving starts every popup's time anew + if !inside { + for (_, since) in &mut notices.popups { + *since = Instant::now(); + } + } +} + +fn ago(received: SystemTime) -> String { + let seconds = received.elapsed().map_or(0, |since| since.as_secs()); + + match seconds { + 0..60 => String::from("now"), + 60..3600 => format!("{} min", seconds / 60), + 3600..86400 => format!("{} h", seconds / 3600), + _ => format!("{} d", seconds / 86400), + } +} + +// the picture, or the app's first letter on a disc +fn picture_widget(file: Option<&String>, app: &str, size: f32) -> Box { + let theme = theme::now(); + + match file { + Some(file) => Box::new(Rectangle::new().width(size).height(size).radius(size * 0.22).fill(Image::contain(file.clone()))), + None => { + let letter: String = app.chars().next().map(|letter| letter.to_uppercase().collect()).unwrap_or_else(|| String::from("•")); + + Box::new(Rectangle::new().width(size).height(size).radius(Full).fill(faded(theme.accent, 70)).align_child(Center, Center).child(Text::new(letter).size(size * 0.42).weight(Weight::Bold).color(theme.accent))) + } + } +} + +// the buttons a notification asks for +fn actions(notice: &Notification) -> Option> { + if notice.actions().is_empty() { + return None; + } + + let id = notice.id(); + let chips: Vec> = notice + .actions() + .iter() + .map(|action| { + let key = action.key().to_string(); + + Box::new(pods::chip(action.label(), None, move || Notifications::invoke(id, &key))) as Box + }) + .collect(); + + Some(Box::new(Row::new(chips).gap(6.0))) +} + +/* + * one notification's face, for a popup and for the history: picture, app and time, + * summary, body; `lines` caps the body; `close` is the cross at its top right + */ +fn face(notice: &Notification, file: Option<&String>, width: f32, lines: usize, close: Box) -> (Rectangle, f32) { + let theme = theme::now(); + let picture_size = settings::icon(); + let text_w = width - picture_size - 12.0 - 30.0; + + let body = parse::plain_body(notice.body()); + let body = Text::new(body).size(text::small()).color(theme.dim).wrap().max_lines(lines); + let body_h = if notice.body().is_empty() { 0.0 } else { body.height_in(text_w) }; + + let line_h = text::tiny() + 6.0; + let summary_h = text::body() + 6.0; + let words_h = line_h + summary_h + body_h; + + let actions = actions(notice); + let actions_h = if actions.is_some() { 26.0 + 8.0 } else { 0.0 }; + + let height = words_h.max(picture_size) + actions_h; + + let app = if notice.app_name().is_empty() { String::from("Notification") } else { notice.app_name().to_string() }; + + let words = Column::new(vec![ + Box::new(Rectangle::new().width(text_w).height(line_h).align_child(Start, Center).child(Text::new(format!("{app} · {}", ago(notice.received()))).size(text::tiny()).color(theme.dim).elide())) as Box, + Box::new(Rectangle::new().width(text_w).height(summary_h).align_child(Start, Center).child(Text::new(notice.summary()).size(text::body()).weight(Weight::Bold).color(theme.text).elide())), + Box::new(Rectangle::new().width(text_w).height(body_h).child(body)), + ]); + + let top = Row::new(vec![ + Box::new(Row::new(vec![picture_widget(file, &app, picture_size), Box::new(words)]).gap(12.0)) as Box, + Box::new(pods::button(Glyph::Cross, close)), + ]) + .width(width) + .justify(SpaceBetween); + + let mut parts: Vec> = vec![Box::new(Rectangle::new().width(width).height(words_h.max(picture_size)).child(top))]; + + if let Some(actions) = actions { + parts.push(Box::new(Rectangle::new().width(width).height(26.0).padding(amane::Padding { top: 0.0, right: 0.0, bottom: 0.0, left: picture_size + 12.0 }).child(Column::new(vec![actions])))); + } + + (Rectangle::new().width(width).height(height).child(Column::new(parts).gap(8.0)), height) +} + +/* + * the popups, top right under the bar on the focused monitor, newest on top; a click + * runs the notification's default action (or just hides the popup), the cross hides + * it, both leave it in the history + */ +pub fn popups(monitor: &Monitor) -> LayerWindow { + theme::begin(); + + let notices = Notices::read(); + let list = Notifications::read(); + let shadow = island_pill::shadow(); + let width = settings::width(); + let pad = 14.0; + + // the right edge lines up with the control centre's pill above; the shadow there runs off the screen + let right = control_pill::right(); + + let window = |height: f32| { + LayerWindow::new() + .width(width + shadow + right) + .height(height) + .anchor_vertical(Vertical::Top) + .anchor_horizontal(Horizontal::Right) + .margin(Margin { top: (island_pill::top_gap() + island_pill::height() + 8.0 - shadow).max(0.0) as i32, right: 0, bottom: 0, left: 0 }) + .layer(Layer::Overlay) + .space(Zone::Ignore) + .namespace("magic-notifications") + }; + + let focused = island::focused_monitor().is_none_or(|name| name == monitor.name); + let shown: Vec<(&Notification, Instant)> = + notices.popups.iter().filter_map(|(id, since)| list.list().iter().find(|notice| notice.id() == *id).map(|notice| (notice, *since))).take(settings::count()).collect(); + + if !focused || shown.is_empty() { + return window(1.0).click_through().child(Rectangle::new().width(0.0).height(0.0)); + } + + let colors = theme::now().surfaces(); + let mut cards: Vec> = Vec::new(); + let mut total = 0.0; + let mut arriving = false; + + for (notice, since) in shown { + let id = notice.id(); + let (content, height) = face(notice, notices.pictures.get(&id), width - 2.0 * pad, 3, Box::new(move || hide(id))); + let height = height + 2.0 * pad; + + // slides in from the right over its first quarter second + let t = (since.elapsed().as_secs_f32() / 0.25).min(1.0); + arriving |= t < 1.0; + + let content = Rectangle::new().width(width).height(height).padding(pad).child(content); + let card = surface::shell(width, height, 18.0, &colors, if notice.urgency() == Urgency::Critical { 1.0 } else { 0.0 }, content); + let card = if notice.urgency() == Urgency::Critical { card.border(1.5, crate::config::appearance::overlays::low_battery()) } else { card }; + + let has_default = notice.has_default_action(); + + cards.push(Box::new(card.cursor(Pointer).on_click(move |_| if has_default { Notifications::click(id) } else { hide(id) }).translate((1.0 - t * t * (3.0 - 2.0 * t)) * 40.0, 0.0))); + + total += height + 10.0; + } + + if arriving { + amane::request_frame(); + } + + window(total + 2.0 * shadow).child( + Rectangle::new() + .width(width + shadow + right) + .height(total + 2.0 * shadow) + .padding(amane::Padding { top: shadow, right, bottom: shadow, left: shadow }) + .on_hover(hovering) + .child(Column::new(cards).gap(10.0)), + ) +} + +// `pods notices list|dismiss |clear`: the history without the mouse +pub fn command(words: &[&str]) -> String { + match words { + ["dismiss", id] => match id.parse() { + Ok(id) => { + Notifications::dismiss(id); + + String::from("ok") + } + Err(_) => String::from("dismiss takes a notification's number"), + }, + ["clear"] => { + Notifications::clear(); + + String::from("ok") + } + _ => { + let notifications = Notifications::read(); + let notices = Notices::read(); + let lines: Vec = notifications + .list() + .iter() + .map(|notice| { + let popup = if notices.popups.iter().any(|(id, _)| *id == notice.id()) { " popup" } else { "" }; + let picture = notices.pictures.get(¬ice.id()).map_or(String::from("-"), Clone::clone); + + format!("{} [{:?}{popup}] {}: {} | {} | picture {picture} | actions {}", notice.id(), notice.urgency(), notice.app_name(), notice.summary(), parse::plain_body(notice.body()), notice.actions().len()) + }) + .collect(); + + format!("running={} unread={}\n{}", notifications.running(), notices.unread, lines.join("\n")) + } + } +} + +// do not disturb: the popups off (or on again), kept in config.toml +fn toggle_quiet() { + let _ = config::set("notifications.popups.enabled", Value::Bool(!settings::enabled())); +} + +// the history card: every notification still open, newest first, each with its cross to delete it +pub fn card(width: f32, height: f32) -> Rectangle { + let notices = Notices::read(); + let list = Notifications::read(); + let theme = theme::now(); + + let mut buttons: Vec> = vec![Box::new(pods::chip(if settings::enabled() { "Mute" } else { "Unmute" }, None, toggle_quiet))]; + + if !list.list().is_empty() { + buttons.push(Box::new(pods::chip("Clear all", None, Notifications::clear))); + } + + let header = pods::header("Notifications", buttons, width); + let list_h = height - pods::header_height() - 10.0; + + let body: Box = if !list.running() { + Box::new( + Rectangle::new() + .width(width) + .height(list_h) + .align_child(Center, Center) + .child(Text::new("Another notification daemon (dunst?) holds the bus name: stop it and restart the shell").size(text::small()).color(theme.dim).wrap()), + ) + } else if list.list().is_empty() { + Box::new( + Rectangle::new().width(width).height(list_h).align_child(Center, Center).child( + Column::new(vec![ + Box::new(Rectangle::new().width(width).height(40.0).align_child(Center, Center).child(crate::glyph::icon(Glyph::Bell, 32.0, faded(theme.dim, 120)))) as Box, + Box::new(Rectangle::new().width(width).height(text::small() + 8.0).align_child(Center, Center).child(Text::new("No notifications").size(text::small()).color(theme.dim))), + ]) + .gap(8.0), + ), + ) + } else { + let item_w = width - 8.0; + let mut items: Vec> = Vec::new(); + + for notice in list.list().iter().rev() { + let id = notice.id(); + let (content, inner_h) = face(notice, notices.pictures.get(&id), item_w - 24.0, 4, Box::new(move || Notifications::dismiss(id))); + let has_default = notice.has_default_action(); + + let item = Rectangle::new().width(item_w).height(inner_h + 24.0).radius(16.0).fill(overlays::chip()).padding(12.0).child(content); + let item = if has_default { item.cursor(Pointer).on_click(move |_| Notifications::click(id)) } else { item }; + + items.push(Box::new(item)); + } + + Box::new(ScrollArea::new("notices-history", Column::new(items).gap(8.0)).width(width).height(list_h)) + }; + + Rectangle::new().width(width).height(height).child(Column::new(vec![Box::new(header) as Box, body]).gap(10.0)) +} diff --git a/src/parse.rs b/src/parse.rs index 0973cad..a1a9560 100644 --- a/src/parse.rs +++ b/src/parse.rs @@ -1205,6 +1205,626 @@ pub fn hypr_lua(kind: HyprKind, text: &str) -> Result { } } +// ---- side pills: ai, system monitor, weather, calendar, notifications ---- + +// text as a json string literal, quotes included, for request bodies +pub fn json_string(text: &str) -> String { + let mut out = String::with_capacity(text.len() + 2); + + out.push('"'); + + for letter in text.chars() { + match letter { + '"' => out.push_str("\\\""), + '\\' => out.push_str("\\\\"), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + _ if u32::from(letter) < 0x20 => out.push_str(&format!("\\u{:04x}", u32::from(letter))), + _ => out.push(letter), + } + } + + out.push('"'); + + out +} + +// what one line of ollama's streaming /api/chat answer says +#[derive(Debug, PartialEq)] +pub enum Chunk { + Text(String), + Done, + Failed(String), +} + +// one line of the stream: a piece of the reply, its end or an error; none for blank, non-json or empty pieces +pub fn ollama_chunk(line: &str) -> Option { + let answer = json(line).into_iter().next()?; + + if let Some(error) = answer.get("error").and_then(Value::text) { + return Some(Chunk::Failed(error.to_string())); + } + + if answer.get("done") == Some(&Value::Bool(true)) { + return Some(Chunk::Done); + } + + let content = answer.get("message")?.get("content")?.text()?; + + (!content.is_empty()).then(|| Chunk::Text(content.to_string())) +} + +// /proc/stat's all-cpu line as (busy, total) jiffies since boot; idle and iowait are the time not busy +pub fn cpu_jiffies(stat: &str) -> Option<(u64, u64)> { + let line = stat.lines().find_map(|line| line.strip_prefix("cpu "))?; + let numbers: Vec = line.split_whitespace().take(8).map_while(|word| word.parse().ok()).collect(); + + let idle = numbers.get(3)?.saturating_add(numbers.get(4).copied().unwrap_or(0)); + let total = numbers.iter().fold(0u64, |sum, number| sum.saturating_add(*number)); + + Some((total.saturating_sub(idle), total)) +} + +// memory and swap from /proc/meminfo, in kB +#[derive(Debug, Clone, Copy, PartialEq, Default)] +pub struct Memory { + pub total_kb: u64, + pub available_kb: u64, + pub swap_total_kb: u64, + pub swap_free_kb: u64, +} + +// /proc/meminfo as Memory; none without MemTotal or MemAvailable, swap 0 when missing +pub fn memory(meminfo: &str) -> Option { + let field = |name: &str| meminfo.lines().find_map(|line| line.strip_prefix(name)?.strip_prefix(':')?.split_whitespace().next()?.parse::().ok()); + + Some(Memory { total_kb: field("MemTotal")?, available_kb: field("MemAvailable")?, swap_total_kb: field("SwapTotal").unwrap_or(0), swap_free_kb: field("SwapFree").unwrap_or(0) }) +} + +// /proc/net/dev as (received, sent) bytes, summed over every interface but lo +pub fn network_bytes(dev: &str) -> (u64, u64) { + dev.lines().filter_map(|line| line.split_once(':')).filter(|(name, _)| name.trim() != "lo").fold((0, 0), |(received, sent), (_, counters)| { + let mut numbers = counters.split_whitespace().map(|number| number.parse::().unwrap_or(0)); + + (received.saturating_add(numbers.next().unwrap_or(0)), sent.saturating_add(numbers.nth(7).unwrap_or(0))) + }) +} + +// a /sys/block//stat line as (sectors read, sectors written), its 3rd and 7th numbers +pub fn block_sectors(stat: &str) -> Option<(u64, u64)> { + let mut fields = stat.split_whitespace(); + + Some((fields.nth(2)?.parse().ok()?, fields.nth(3)?.parse().ok()?)) +} + +// one day of open-meteo's forecast: date as "YYYY-MM-DD", sunrise and sunset as "HH:MM", rain chance in percent +#[derive(Debug, Clone, PartialEq)] +pub struct Day { + pub date: String, + pub code: u32, + pub max: f64, + pub min: f64, + pub sunrise: String, + pub sunset: String, + pub rain_chance: f64, +} + +// open-meteo's current conditions (°C, %, km/h, wmo code) and the days ahead +#[derive(Debug, Clone, PartialEq)] +pub struct Weather { + pub temperature: f64, + pub apparent: f64, + pub humidity: f64, + pub code: u32, + pub wind: f64, + pub is_day: bool, + pub days: Vec, +} + +// open-meteo's forecast json as Weather; none without its current object +pub fn weather(text: &str) -> Option { + let answer = json(text).into_iter().next()?; + let current = answer.get("current").filter(|current| matches!(current, Value::Table(_)))?; + let now = |key: &str| current.get(key).and_then(Value::number).unwrap_or(0.0); + + // the day at `at` in one of the daily lists + let daily = |key: &str, at: usize| match answer.get("daily")?.get(key)? { + Value::List(items) => items.get(at), + _ => None, + }; + let number = |key: &str, at: usize| daily(key, at).and_then(Value::number).unwrap_or(0.0); + let clock = |key: &str, at: usize| { + let time = daily(key, at).and_then(Value::text).and_then(|stamp| stamp.split_once('T')).map_or("", |(_, time)| time); + + time.get(..5).unwrap_or(time).to_string() + }; + + let days = (0..) + .map_while(|at| { + Some(Day { + date: daily("time", at)?.text()?.to_string(), + code: number("weather_code", at) as u32, + max: number("temperature_2m_max", at), + min: number("temperature_2m_min", at), + sunrise: clock("sunrise", at), + sunset: clock("sunset", at), + rain_chance: number("precipitation_probability_max", at), + }) + }) + .collect(); + + Some(Weather { + temperature: now("temperature_2m"), + apparent: now("apparent_temperature"), + humidity: now("relative_humidity_2m"), + code: now("weather_code") as u32, + wind: now("wind_speed_10m"), + is_day: now("is_day") > 0.0, + days, + }) +} + +// a wmo weather code as a short phrase +pub fn weather_words(code: u32) -> &'static str { + match code { + 0 => "Clear", + 1 => "Mainly clear", + 2 => "Partly cloudy", + 3 => "Overcast", + 45 | 48 => "Fog", + 51 | 53 | 55 => "Drizzle", + 56 | 57 => "Freezing drizzle", + 61 | 63 => "Rain", + 65 => "Heavy rain", + 66 | 67 => "Freezing rain", + 71 | 73 => "Snow", + 75 => "Heavy snow", + 77 => "Snow grains", + 80..=82 => "Showers", + 85 | 86 => "Snow showers", + 95 => "Thunderstorm", + 96 | 99 => "Thunderstorm with hail", + _ => "Unknown", + } +} + +// the kinds of sky a weather icon draws +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum Sky { + Clear, + PartlyCloudy, + Cloudy, + Fog, + Rain, + Snow, + Storm, +} + +// a wmo weather code as the sky to draw; unknown codes are cloudy +pub fn sky(code: u32) -> Sky { + match code { + 0 | 1 => Sky::Clear, + 2 => Sky::PartlyCloudy, + 45 | 48 => Sky::Fog, + 51 | 53 | 55 | 56 | 57 | 61 | 63 | 65 | 66 | 67 | 80..=82 => Sky::Rain, + 71 | 73 | 75 | 77 | 85 | 86 => Sky::Snow, + 95..=99 => Sky::Storm, + _ => Sky::Cloudy, + } +} + +// a notification body (freedesktop's markup subset) as plain text:
breaks the line, other tags go, entities are decoded +pub fn plain_body(body: &str) -> String { + unmarkup(body) +} + +// a markup tag: its name with any prefix, whether it closes or closes itself, and its length in bytes +struct Tag<'a> { + name: &'a str, + closing: bool, + empty: bool, + length: usize, +} + +// the tag at the start of the text; none when its '<' starts no tag, as in "a < b" +fn tag(text: &str) -> Option> { + let inside = text.strip_prefix('<')?; + let closing = inside.starts_with('/'); + let named = inside.strip_prefix('/').unwrap_or(inside); + let name = named.split(|letter: char| letter.is_whitespace() || letter == '/' || letter == '>').next()?; + + if !name.starts_with(|letter: char| letter.is_ascii_alphabetic() || letter == '_') { + return None; + } + + // a '>' in a quoted attribute doesn't end the tag + let mut quote = None; + + for (at, letter) in inside.char_indices() { + match (quote, letter) { + (None, '"' | '\'') => quote = Some(letter), + (Some(open), _) if open == letter => quote = None, + (None, '>') => return Some(Tag { name, closing, empty: inside.get(..at).is_some_and(|head| head.ends_with('/')), length: at + 2 }), + _ => {} + } + } + + None +} + +// the entity at the start of the text (& ' —) as its character and length; none for unknown ones +fn entity(text: &str) -> Option<(char, usize)> { + let (end, _) = text.char_indices().take(12).find(|(_, letter)| *letter == ';')?; + + let letter = match text.get(1..end)? { + "amp" => '&', + "lt" => '<', + "gt" => '>', + "quot" => '"', + "apos" => '\'', + name => { + let number = name.strip_prefix('#')?; + let code = match number.strip_prefix(['x', 'X']) { + Some(hex) => u32::from_str_radix(hex, 16).ok()?, + None => number.parse().ok()?, + }; + + char::from_u32(code)? + } + }; + + Some((letter, end + 1)) +} + +// markup as trimmed plain text:
is a line break, other tags and comments go, cdata stays as written, entities are decoded +fn unmarkup(markup: &str) -> String { + let mut plain = String::with_capacity(markup.len()); + let mut rest = markup; + + while let Some(at) = rest.find(['<', '&']) { + let (before, from) = rest.split_at(at); + + plain.push_str(before); + + let skip = if let Some(inner) = from.strip_prefix("").unwrap_or(inner.len()); + + plain.push_str(inner.get(..end).unwrap_or("")); + + Some("".len()) + } else if from.starts_with("").map_or(from.len(), |end| end + "-->".len())) + } else if from.starts_with('&') { + entity(from).map(|(letter, length)| { + plain.push(letter); + length + }) + } else { + tag(from).map(|tag| { + if tag.name.eq_ignore_ascii_case("br") { + plain.push('\n'); + } + tag.length + }) + }; + + // a lone '<' or '&' stays as it is + let length = skip.unwrap_or(1); + + if skip.is_none() { + plain.push_str(from.get(..1).unwrap_or("")); + } + + rest = from.get(length..).unwrap_or(""); + } + + plain.push_str(rest); + + plain.trim().to_string() +} + +// the raw inner xml of every element with this local name, whatever its namespace prefix; "" for a self-closing one +pub fn xml_blocks(xml: &str, name: &str) -> Vec { + let mut blocks = Vec::new(); + let mut open = None; + let mut at = 0; + + while let Some(found) = xml.get(at..).and_then(|rest| rest.find('<')) { + let start = at + found; + let rest = xml.get(start..).unwrap_or(""); + + // cdata and comments may hold any '<' + if let Some((_, end)) = [(""), ("")].into_iter().find(|(begin, _)| rest.starts_with(begin)) { + at = start + rest.find(end).map_or(rest.len(), |close| close + end.len()); + + continue; + } + + let Some(tag) = tag(rest) else { + at = start + 1; + + continue; + }; + + if tag.name.rsplit(':').next() == Some(name) { + match (tag.closing, open) { + (false, None) if tag.empty => blocks.push(String::new()), + (false, None) => open = Some(start + tag.length), + (true, Some(inner)) => { + blocks.push(xml.get(inner..start).unwrap_or("").to_string()); + open = None; + } + _ => {} + } + } + + at = start + tag.length; + } + + blocks +} + +// the text of every element with this local name: tags dropped, entities decoded, cdata unwrapped, trimmed +pub fn xml_texts(xml: &str, name: &str) -> Vec { + xml_blocks(xml, name).iter().map(|block| unmarkup(block)).collect() +} + +// one calendar collection on a caldav server; color as "#RRGGBB" +#[derive(Debug, Clone, PartialEq)] +pub struct Calendar { + pub href: String, + pub name: String, + pub color: Option, +} + +/* + * the event calendars in a depth 1 propfind answer: responses whose resourcetype holds a + * calendar and whose component set, if listed, has VEVENT; the calendar home itself and + * reminders lists (VTODO only) are left out. a calendar without a name is named after its path + */ +pub fn dav_calendars(xml: &str) -> Vec { + xml_blocks(xml, "response") + .iter() + .filter_map(|response| { + // only the properties the server found, not the ones in a 404 propstat + let found: String = xml_blocks(response, "propstat").into_iter().filter(|propstat| xml_texts(propstat, "status").iter().all(|status| status.contains(" 200"))).collect(); + + let calendar = xml_blocks(&found, "resourcetype").iter().any(|kinds| !xml_blocks(kinds, "calendar").is_empty()); + let events = xml_blocks(&found, "supported-calendar-component-set").iter().all(|set| set.contains("VEVENT")); + + if !calendar || !events { + return None; + } + + let href = xml_texts(response, "href").into_iter().next()?; + let name = xml_texts(&found, "displayname").into_iter().find(|name| !name.is_empty()); + let name = name.unwrap_or_else(|| href.trim_end_matches('/').rsplit('/').next().unwrap_or("").to_string()); + + // apple's "#FF2968FF" carries an alpha pair + let color = xml_texts(&found, "calendar-color").into_iter().find_map(|color| { + let digits = color.strip_prefix('#').filter(|digits| matches!(digits.len(), 6 | 8) && digits.chars().all(|digit| digit.is_ascii_hexdigit()))?; + + Some(format!("#{}", digits.get(..6)?)) + }); + + Some(Calendar { href, name, color }) + }) + .collect() +} + +// one calendar event, start and end as unix seconds; an all-day event runs from local midnight +#[derive(Debug, Clone, PartialEq)] +pub struct Event { + pub start: i64, + pub end: i64, + pub all_day: bool, + pub summary: String, + pub location: String, +} + +// a VEVENT while its lines are read +#[derive(Default)] +struct Draft { + start: Option<(i64, bool)>, + end: Option, + duration: Option, + summary: String, + location: String, + cancelled: 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 { + 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 }) + } +} + +/* + * 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 + */ +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(); + + 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()), + } + } + + let mut events = 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 { + let Some((head, value)) = content_line(line) else { + continue; + }; + + let key = head.split(';').next().unwrap_or(head).to_ascii_uppercase(); + + match (key.as_str(), draft.as_mut()) { + ("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)), + (_, Some(_)) if nested > 0 => {} + ("SUMMARY", Some(event)) => event.summary = ics_text(value), + ("LOCATION", Some(event)) => event.location = ics_text(value), + ("STATUS", Some(event)) => event.cancelled = value.trim().eq_ignore_ascii_case("CANCELLED"), + ("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), + _ => {} + } + } + + events +} + +// an ics line as (name with its parameters, value), split at the first ':' outside quotes +fn content_line(line: &str) -> Option<(&str, &str)> { + let mut quoted = false; + + let colon = line.find(|letter: char| { + if letter == '"' { + quoted = !quoted; + } + + letter == ':' && !quoted + })?; + + Some((line.get(..colon)?, line.get(colon + 1..)?)) +} + +// an ics DATE (20261016, a whole day) or DATE-TIME (20261016T090000, with Z for utc) as unix seconds and whether it's a whole day +fn ics_time(value: &str, offset_s: i64) -> Option<(i64, bool)> { + let value = value.trim(); + let number = |from: usize, to: usize| value.get(from..to)?.parse::().ok(); + + let month = number(4, 6).filter(|month| (1..=12).contains(month))?; + let day = number(6, 8).filter(|day| (1..=31).contains(day))?; + let midnight = days_from_civil(i64::from(number(0, 4)?), month, day) * 86_400; + + if value.len() == 8 { + return Some((midnight - offset_s, true)); + } + + value.get(8..9).filter(|letter| letter.eq_ignore_ascii_case("T"))?; + + let time = i64::from(number(9, 11)?) * 3_600 + i64::from(number(11, 13)?) * 60 + i64::from(number(13, 15).unwrap_or(0)); + let local = if value.ends_with(['Z', 'z']) { 0 } else { offset_s }; + + Some((midnight + time - local, false)) +} + +// an ics duration (PT1H30M, P1D, P1W, -PT15M) in seconds +fn ics_duration(value: &str) -> Option { + let value = value.trim(); + let (sign, rest) = match value.strip_prefix('-') { + Some(rest) => (-1, rest), + None => (1, value.strip_prefix('+').unwrap_or(value)), + }; + + let mut total: i64 = 0; + let mut number: i64 = 0; + + for letter in rest.strip_prefix('P')?.chars() { + let unit = match letter { + 'W' => 604_800, + 'D' => 86_400, + 'H' => 3_600, + 'M' => 60, + 'S' => 1, + 'T' => continue, + _ => { + number = number.checked_mul(10)?.checked_add(i64::from(letter.to_digit(10)?))?; + + continue; + } + }; + + total = number.checked_mul(unit)?.checked_add(total)?; + number = 0; + } + + Some(sign * total) +} + +// an ics text value without its escapes: \, \; \n \N and \\ +fn ics_text(value: &str) -> String { + let mut text = String::with_capacity(value.len()); + let mut letters = value.chars(); + + while let Some(letter) = letters.next() { + if letter != '\\' { + text.push(letter); + + continue; + } + + match letters.next() { + Some('n' | 'N') => text.push('\n'), + Some(other) => text.push(other), + None => text.push('\\'), + } + } + + text +} + +// days since 1970-01-01 for a date in the proleptic gregorian calendar (howard hinnant's days_from_civil) +pub fn days_from_civil(year: i64, month: u32, day: u32) -> i64 { + let (month, day) = (i64::from(month), i64::from(day)); + let year = if month <= 2 { year - 1 } else { year }; + + // 400-year eras, each 146097 days; march is the first month so leap days come last + let era = year.div_euclid(400); + let year_of_era = year - era * 400; + let day_of_year = (153 * ((month + 9) % 12) + 2) / 5 + day - 1; + let day_of_era = year_of_era * 365 + year_of_era / 4 - year_of_era / 100 + day_of_year; + + era * 146_097 + day_of_era - 719_468 +} + +// days since 1970-01-01 as (year, month, day) +pub fn civil_from_days(days: i64) -> (i64, u32, u32) { + let days = days + 719_468; + let era = days.div_euclid(146_097); + let day_of_era = days - era * 146_097; + let year_of_era = (day_of_era - day_of_era / 1_460 + day_of_era / 36_524 - day_of_era / 146_096) / 365; + let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100); + let march_month = (5 * day_of_year + 2) / 153; + let day = day_of_year - (153 * march_month + 2) / 5 + 1; + let month = if march_month < 10 { march_month + 3 } else { march_month - 9 }; + + (year_of_era + era * 400 + i64::from(month <= 2), month as u32, day as u32) +} + +// days since 1970-01-01 as a weekday, 0 monday to 6 sunday +pub fn weekday(days: i64) -> u32 { + // 1970-01-01 was a thursday + ((days.rem_euclid(7) + 3) % 7) as u32 +} + +// unix seconds as a utc time for caldav's time-range queries, like "20261015T000000Z" +pub fn dav_time(unix: i64) -> String { + let (year, month, day) = civil_from_days(unix.div_euclid(86_400)); + let seconds = unix.rem_euclid(86_400); + + format!("{year:04}{month:02}{day:02}T{:02}{:02}{:02}Z", seconds / 3_600, seconds / 60 % 60, seconds % 60) +} + #[cfg(test)] mod tests { use super::*; @@ -1539,4 +2159,191 @@ Sink #80 assert_eq!(hypr_lua(HyprKind::Float, "3").unwrap(), "3.0"); assert_eq!(hypr_lua(HyprKind::Text, "say \"hi\"").unwrap(), "\"say \\\"hi\\\"\""); } + + #[test] + fn writes_json_strings() { + let text = "say \"hi\" \\ to\n\tthem\r\u{1} — ok"; + + assert_eq!(json_string(text), "\"say \\\"hi\\\" \\\\ to\\n\\tthem\\r\\u0001 — ok\""); + assert_eq!(json(&json_string(text)), [Value::Text(String::from(text))]); + assert_eq!(json_string(""), "\"\""); + } + + #[test] + fn reads_ollama_streams() { + let stream = r#"{"model":"gemma4","created_at":"2026-10-15T10:12:01.123Z","message":{"role":"assistant","content":"Hi"},"done":false} +{"model":"gemma4","created_at":"2026-10-15T10:12:01.180Z","message":{"role":"assistant","content":" there, \"friend\"\n"},"done":false} +{"model":"gemma4","created_at":"2026-10-15T10:12:01.230Z","message":{"role":"assistant","content":""},"done_reason":"stop","done":true,"total_duration":1234567890,"eval_count":12} +"#; + + let chunks: Vec = stream.lines().filter_map(ollama_chunk).collect(); + + assert_eq!(chunks, [Chunk::Text(String::from("Hi")), Chunk::Text(String::from(" there, \"friend\"\n")), Chunk::Done]); + assert_eq!(ollama_chunk(r#"{"error":"model 'x' not found"}"#), Some(Chunk::Failed(String::from("model 'x' not found")))); + assert_eq!(ollama_chunk(""), None); + assert_eq!(ollama_chunk("Internal Server Error"), None); + assert_eq!(ollama_chunk(r#"{"model":"gemma4","message":{"role":"assistant","content":""},"done":false}"#), None); + } + + #[test] + fn reads_proc_and_sys() { + let stat = "cpu 10132153 290696 3084719 46828483 16683 0 25195 0 175628 0\ncpu0 1393280 32966 572056 13343292 6130 0 17875 0 23933 0\ncpu1 1335736 37133 425613 13347651 2152 0 3051 0 23498 0\nintr 1462898 0 9 0\nctxt 2633617\nbtime 1792022400\nprocesses 10423\n"; + + // busy is user + nice + system + irq + softirq + steal; guest columns are already in user + assert_eq!(cpu_jiffies(stat), Some((13_532_763, 60_377_929))); + assert_eq!(cpu_jiffies("cpu0 1 2 3 4\n"), None); + + let meminfo = "MemTotal: 32718416 kB\nMemFree: 2034560 kB\nMemAvailable: 20513204 kB\nBuffers: 612340 kB\nCached: 17012344 kB\nSwapCached: 128 kB\nActive: 9216000 kB\nSwapTotal: 8388604 kB\nSwapFree: 8126460 kB\nDirty: 420 kB\n"; + + assert_eq!(memory(meminfo), Some(Memory { total_kb: 32_718_416, available_kb: 20_513_204, swap_total_kb: 8_388_604, swap_free_kb: 8_126_460 })); + assert_eq!(memory("MemTotal: 1000 kB\nMemAvailable: 600 kB\n"), Some(Memory { total_kb: 1000, available_kb: 600, ..Memory::default() })); + assert_eq!(memory("MemTotal: 1000 kB\nMemFree: 600 kB\n"), None); + + let dev = "Inter-| Receive | Transmit + face |bytes packets errs drop fifo frame compressed multicast|bytes packets errs drop fifo colls carrier compressed + lo: 2776770 11307 0 0 0 0 0 0 2776770 11307 0 0 0 0 0 0 + wlan0: 1215645 2751 0 0 0 0 0 0 176304 1576 0 0 0 0 0 0 + eth0:4294967295123 3500000 0 0 0 0 0 1200 98765432 600000 0 0 0 0 0 0 +"; + + assert_eq!(network_bytes(dev), (1_215_645 + 4_294_967_295_123, 176_304 + 98_765_432)); + assert_eq!(network_bytes(""), (0, 0)); + + let sda = " 6431 2209 451390 2563 9036 9837 627608 22412 0 14812 25000 0 0 0 0 389 123\n"; + + assert_eq!(block_sectors(sda), Some((451_390, 627_608))); + assert_eq!(block_sectors("1 2 3"), None); + } + + #[test] + fn reads_open_meteo() { + let answer = r#"{"latitude":52.52,"longitude":13.419998,"generationtime_ms":0.0581741333,"utc_offset_seconds":7200,"timezone":"Europe/Berlin","timezone_abbreviation":"GMT+2","elevation":38.0, +"current_units":{"time":"iso8601","interval":"seconds","temperature_2m":"°C","apparent_temperature":"°C","relative_humidity_2m":"%","weather_code":"wmo code","wind_speed_10m":"km/h","is_day":""}, +"current":{"time":"2026-10-15T12:15","interval":900,"temperature_2m":14.2,"apparent_temperature":12.9,"relative_humidity_2m":71,"weather_code":3,"wind_speed_10m":9.4,"is_day":1}, +"daily_units":{"time":"iso8601","weather_code":"wmo code","temperature_2m_max":"°C","temperature_2m_min":"°C","sunrise":"iso8601","sunset":"iso8601","precipitation_probability_max":"%"}, +"daily":{"time":["2026-10-15","2026-10-16"],"weather_code":[3,61],"temperature_2m_max":[15.1,12],"temperature_2m_min":[8.4,7.9],"sunrise":["2026-10-15T07:34","2026-10-16T07:36"],"sunset":["2026-10-15T18:31","2026-10-16T18:29"],"precipitation_probability_max":[20,null]}}"#; + + let expected = Weather { + temperature: 14.2, + apparent: 12.9, + humidity: 71.0, + code: 3, + wind: 9.4, + is_day: true, + days: vec![ + Day { date: String::from("2026-10-15"), code: 3, max: 15.1, min: 8.4, sunrise: String::from("07:34"), sunset: String::from("18:31"), rain_chance: 20.0 }, + Day { date: String::from("2026-10-16"), code: 61, max: 12.0, min: 7.9, sunrise: String::from("07:36"), sunset: String::from("18:29"), rain_chance: 0.0 }, + ], + }; + + assert_eq!(weather(answer), Some(expected)); + assert_eq!(weather(r#"{"error":true,"reason":"Latitude must be in range of -90 to 90°. Given: 91.0."}"#), None); + assert_eq!(weather(""), None); + assert_eq!(weather(r#"{"current":{"temperature_2m":-3,"is_day":0}}"#).map(|now| (now.temperature, now.is_day, now.days.len())), Some((-3.0, false, 0))); + + assert_eq!( + [0, 2, 3, 48, 53, 57, 63, 65, 67, 73, 75, 77, 81, 86, 95, 99, 42].map(weather_words), + ["Clear", "Partly cloudy", "Overcast", "Fog", "Drizzle", "Freezing drizzle", "Rain", "Heavy rain", "Freezing rain", "Snow", "Heavy snow", "Snow grains", "Showers", "Snow showers", "Thunderstorm", "Thunderstorm with hail", "Unknown"] + ); + assert_eq!( + [1, 2, 3, 45, 55, 66, 82, 77, 85, 96, 42].map(sky), + [Sky::Clear, Sky::PartlyCloudy, Sky::Cloudy, Sky::Fog, Sky::Rain, Sky::Rain, Sky::Rain, Sky::Snow, Sky::Snow, Sky::Storm, Sky::Cloudy] + ); + } + + #[test] + fn reads_notification_markup() { + let body = r#" Ann & Bob sent a link
3 < 4 > 2, it's "fine" 'ok'
— — &bogus; AT&T a < b
xend "#; + + assert_eq!(plain_body(body), "Ann & Bob sent a link\n3 < 4 > 2, it's \"fine\" 'ok'\n— — &bogus; AT&T a < b\nend"); + assert_eq!(plain_body("x <3"), "x <3"); + assert_eq!(plain_body("<b>literal</b>"), "literal"); + } + + const PROPFIND: &str = r##" +/123456789/calendars/CalendarsHTTP/1.1 200 OKHTTP/1.1 404 Not Found/123456789/calendars/home/Home & Family#FF2968FFHTTP/1.1 200 OK/123456789/calendars/tasks/Reminders#1BADF8FFHTTP/1.1 200 OK"##; + + #[test] + fn reads_caldav_xml() { + let mixed = r#"/a//b/#fff"#; + + assert_eq!(xml_blocks(mixed, "response"), ["/a/", "/b/"]); + assert_eq!(xml_blocks(mixed, "href"), ["/a/", "/b/", ""]); + assert!(xml_blocks(mixed, "calendar").is_empty()); + assert_eq!( + xml_texts(r#" & more]]> Tom & Jerry's"#, "displayname"), + ["Work & more", "Tom & Jerry's"] + ); + + // the home collection and the reminders list are left out + assert_eq!( + dav_calendars(PROPFIND), + [Calendar { href: String::from("/123456789/calendars/home/"), name: String::from("Home & Family"), color: Some(String::from("#FF2968")) }] + ); + + // nextcloud's prefixes, an empty name and no colour or component set + let nextcloud = r#"/remote.php/dav/calendars/ann/work/HTTP/1.1 200 OK"#; + + assert_eq!(dav_calendars(nextcloud), [Calendar { href: String::from("/remote.php/dav/calendars/ann/work/"), name: String::from("work"), color: None }]); + } + + #[test] + fn reads_icalendar_events() { + let ics = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Apple Inc.//iCloud 2.0//EN\r\nBEGIN:VTIMEZONE\r\nTZID:Europe/Berlin\r\nBEGIN:DAYLIGHT\r\nDTSTART:19810329T020000\r\nTZOFFSETTO:+0200\r\nEND:DAYLIGHT\r\nEND:VTIMEZONE\r\n\ + BEGIN:VEVENT\r\nUID:a1\r\nDTSTART;VALUE=DATE:20261016\r\nDTEND;VALUE=DATE:20261017\r\nSUMMARY:Mum's birthday\r\nRRULE:FREQ=YEARLY\r\nEND:VEVENT\r\n\ + BEGIN:VEVENT\r\nUID:a2\r\nDTSTART:20261016T090000Z\r\nDURATION:PT1H30M\r\nSUMMARY:Stand-up\\, team A\\; B\r\nLOCATION:Room 4\\nFloor 2 \\\\ east\r\nEND:VEVENT\r\n\ + BEGIN:VEVENT\r\nUID:a3\r\nDTSTART;TZID=Europe/Berlin:20261016T110000\r\nDTEND;TZID=Europe/Berlin:20261016T120000\r\nSUMMARY:A very long meeting title that the serv\r\n er folded onto a second line\r\nLOCATION:Caf\r\n\té Einstein\r\n\ + BEGIN:VALARM\r\nTRIGGER:-PT15M\r\nACTION:DISPLAY\r\nSUMMARY:Alarm\r\nDTSTART:19760401T005545Z\r\nEND:VALARM\r\nEND:VEVENT\r\n\ + BEGIN:VEVENT\r\nUID:a4\r\nDTSTART:20261017T100000Z\r\nSUMMARY:Called off\r\nSTATUS:CANCELLED\r\nEND:VEVENT\r\n\ + BEGIN:VEVENT\nUID:a5\nDTSTART:20261018T080000\nSUMMARY:Floating\\, LF only\nEND:VEVENT\nEND:VCALENDAR\r\n"; + + // CEST, two hours east of utc; 2026-10-16T00:00Z is 1792108800 + let event = |start, end, all_day, summary: &str, location: &str| Event { start, end, all_day, summary: String::from(summary), location: String::from(location) }; + + assert_eq!( + ics_events(ics, 7_200), + [ + event(1_792_101_600, 1_792_188_000, true, "Mum's birthday", ""), + event(1_792_141_200, 1_792_146_600, false, "Stand-up, team A; B", "Room 4\nFloor 2 \\ east"), + event(1_792_141_200, 1_792_144_800, false, "A very long meeting title that the server folded onto a second line", "Café Einstein"), + event(1_792_303_200, 1_792_306_800, false, "Floating, LF only", ""), + ] + ); + + // no end: an all-day event lasts a day; an event without a start is dropped + let short = "BEGIN:VEVENT\nDTSTART;VALUE=DATE:20261020\nSUMMARY:Holiday\nEND:VEVENT\nBEGIN:VEVENT\nSUMMARY:No start\nEND:VEVENT\n"; + + assert_eq!(ics_events(short, 0), [event(1_792_454_400, 1_792_540_800, true, "Holiday", "")]); + assert!(ics_events("", 0).is_empty()); + } + + #[test] + fn counts_days() { + assert_eq!(days_from_civil(1970, 1, 1), 0); + assert_eq!(days_from_civil(2024, 2, 29), 19_782); + assert_eq!(days_from_civil(2026, 10, 15) * 86_400, 1_792_022_400); + assert_eq!(civil_from_days(0), (1970, 1, 1)); + assert_eq!(civil_from_days(19_782), (2024, 2, 29)); + assert_eq!(civil_from_days(19_783), (2024, 3, 1)); + assert_eq!(civil_from_days(-1), (1969, 12, 31)); + + // every day from 1422 to 2517 reads back, one day after the other + let mut previous = civil_from_days(-200_001); + + for days in -200_000..200_000 { + let (year, month, day) = civil_from_days(days); + + assert_eq!(days_from_civil(year, month, day), days); + assert!((year, month, day) > previous && (1..=12).contains(&month) && (1..=31).contains(&day)); + + previous = (year, month, day); + } + + // thursday 1970-01-01, thursday 2024-02-29, thursday 2026-10-15, sunday 2026-10-18 + assert_eq!([0, -1, 19_782, days_from_civil(2026, 10, 15), days_from_civil(2026, 10, 18)].map(weekday), [3, 2, 3, 3, 6]); + + assert_eq!(dav_time(1_792_022_400), "20261015T000000Z"); + assert_eq!(dav_time(1_792_146_607), "20261016T103007Z"); + assert_eq!(dav_time(-1), "19691231T235959Z"); + } } diff --git a/src/pods.rs b/src/pods.rs new file mode 100644 index 0000000..a2f0b76 --- /dev/null +++ b/src/pods.rs @@ -0,0 +1,484 @@ +use std::collections::HashMap; +use std::thread; +use std::time::Duration; + +use amane::{ + Animation, Blend, Center, Color, Easing, Full, Horizontal, InputArea, Key, Keyboard, Layer, LayerWindow, Margin, Monitor, Parent, Pointer, Rectangle, Row, + Service, SpaceBetween, Stack, Start, Text, Vertical, Weight, Widget, Zone, +}; + +use crate::config::appearance::{motion, overlays, text}; +use crate::config::island::{card as island_card, pill as island_pill}; +use crate::config::notifications::history; +use crate::config::pills::{self as settings, layout}; +use crate::edge::{self, Edge}; +use crate::glyph::{Glyph, icon}; +use crate::island::{self, CLEAR, mix, phases, veiled}; +use crate::{ai, home, notices, stats, surface, theme}; + +/* + * four small one-icon pills beside the island, each opening into a card the way the + * island does: left of it the AI chat and the system monitor, right of it home and + * notifications. one window per side and monitor (magic-pods-left|right) placed beside + * the island's pill; while the island opens they move out of its way. one card at a + * time across the shell: opening one collapses the island and the other way round + */ +#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)] +pub enum Pod { + Ai, + Monitor, + Home, + Notifications, +} + +// left of the island, outermost first; right of it, innermost first +const LEFT: [Pod; 2] = [Pod::Ai, Pod::Monitor]; +const RIGHT: [Pod; 2] = [Pod::Home, Pod::Notifications]; + +impl Pod { + fn left(self) -> bool { + matches!(self, Self::Ai | Self::Monitor) + } + + fn glyph(self) -> Glyph { + match self { + Self::Ai => Glyph::Sparkle, + Self::Monitor => Glyph::Pulse, + Self::Home => Glyph::House, + Self::Notifications => Glyph::Bell, + } + } + + // the open card's size + fn size(self) -> (f32, f32) { + match self { + Self::Ai => (settings::ai::width(), settings::ai::height()), + Self::Monitor => (settings::monitor::width(), stats::height()), + Self::Home => (settings::home::width(), home::height()), + Self::Notifications => (history::width(), history::height()), + } + } + + // 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()) + } + + fn word(self) -> &'static str { + match self { + Self::Ai => "ai", + Self::Monitor => "monitor", + Self::Home => "home", + Self::Notifications => "notifications", + } + } + + fn from_word(word: &str) -> Option { + [Self::Ai, Self::Monitor, Self::Home, Self::Notifications].into_iter().find(|pod| pod.word() == word) + } +} + +pub struct Pods { + // the card open or closing, and the monitor it's on; `shown` whether it's opening or open + card: Option<(Pod, String)>, + shown: bool, + timeline: Animation, + + hover: HashMap, + + // the pointer over the card, and a count every arrival and departure bumps, so a late close does nothing + inside: bool, + moves: u32, +} + +impl Service for Pods { + fn new() -> Self { + Self { card: None, shown: false, timeline: timeline(), hover: HashMap::new(), inside: false, moves: 0 } + } + + // changes only through input + fn listen() {} +} + +fn timeline() -> Animation { + Animation::new(0.0).duration(Duration::from_millis(layout::open_ms())).easing(Easing::Linear) +} + +pub fn open(pod: Pod, monitor: &str) { + island::collapse(); + + let mut pods = Pods::write(); + + // another card, or this one on another monitor: it grows from its own pill + if pods.card.as_ref().is_none_or(|(open, on)| *open != pod || on != monitor) { + pods.card = Some((pod, monitor.to_string())); + pods.timeline = timeline(); + } + + pods.shown = true; + pods.inside = true; + pods.timeline.to(1.0); + + drop(pods); + + if pod == Pod::Notifications { + notices::mark_read(); + } +} + +pub fn close() { + let mut pods = Pods::write(); + + if pods.shown { + pods.shown = false; + pods.timeline.to(0.0); + } +} + +fn toggle(pod: Pod, monitor: &str) { + let open_here = { + let pods = Pods::read(); + + pods.shown && pods.card.as_ref().is_some_and(|(open, on)| *open == pod && on == monitor) + }; + + if open_here { close() } else { open(pod, monitor) } +} + +// the open card's pod, if any +pub fn open_card() -> Option { + let pods = Pods::read(); + + pods.card.as_ref().filter(|_| pods.shown).map(|(pod, _)| *pod) +} + +fn hovering(pod: Pod) -> impl Fn(bool) + 'static { + move |inside| { + edge::over_bar(inside); + + Pods::write() + .hover + .entry(pod) + .or_insert_with(|| Animation::new(0.0).duration(Duration::from_millis(motion::hover_ms()))) + .to(if inside { 1.0 } else { 0.0 }); + } +} + +// the pointer on or off a card; leaving one that takes no typing closes it a moment later +fn over_card(inside: bool) { + edge::over_bar(inside); + + let moves = { + let mut pods = Pods::write(); + + pods.inside = inside; + pods.moves += 1; + pods.moves + }; + + if inside { + return; + } + + thread::spawn(move || { + thread::sleep(Duration::from_millis(400)); + + let leave = { + let pods = Pods::read(); + + pods.moves == moves && !pods.inside && pods.card.as_ref().is_some_and(|(pod, _)| !pod.types()) + }; + + if leave { + close(); + } + }); +} + +pub fn left(monitor: &Monitor) -> LayerWindow { + side(monitor, true) +} + +pub fn right(monitor: &Monitor) -> LayerWindow { + side(monitor, false) +} + +fn side(monitor: &Monitor, left: bool) -> LayerWindow { + theme::begin(); + + let here = monitor.name.as_str(); + let pods = if left { LEFT } else { RIGHT }; + let colors = theme::now().surfaces(); + + let state = Pods::read(); + + // this side's card on this monitor, while it's at all open + let card = state.card.as_ref().filter(|(pod, on)| pod.left() == left && on == here).map(|(pod, _)| (*pod, state.timeline.value())).filter(|(_, t)| *t > 0.0); + + // with the bar on a fullscreen monitor, unless its card is open; out of the island's way while it opens + let slide = if card.is_some() { 0.0 } else { edge::hidden_by(Edge::read().shown(here), 0.0, edge::hide_h()) }; + let shift = (island::width_on(here) - island_pill::width()) / 2.0; + + let (h, top, shadow, spacing) = (island_pill::height(), island_pill::top_gap(), island_pill::shadow(), layout::spacing()); + let count = pods.len() as f32; + let group_w = count * h + (count - 1.0) * spacing; + + 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 + let room = (island_card::width() - island_pill::width()) / 2.0; + let inner_w = (group_w + room).max(card_w); + 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 + let near = if left { shadow + inner_w } else { shadow }; + let middle = monitor.width as f32 / 2.0; + let beside = island_pill::width() / 2.0 + layout::gap(); + let start = if left { middle - beside - near } else { middle + beside - near }; + + let window = LayerWindow::new() + .width(inner_w + 2.0 * shadow) + .height(window_h) + .anchor_vertical(Vertical::Top) + .anchor_horizontal(Horizontal::Left) + .margin(Margin { top: 0, right: 0, bottom: 0, left: start.round() as i32 }) + .layer(Layer::Overlay) + .space(Zone::Ignore) + .namespace(if left { "magic-pods-left" } else { "magic-pods-right" }); + + // empty and click-through rather than hidden: see the island's view for why + if slide <= -edge::hide_h() { + return window.click_through().child(Rectangle::new().width(0.0).height(0.0)); + } + + // a pill's left edge in the window; `k` counts from the island outwards + let pill_x = |pod: Pod| { + let at = pods.iter().position(|other| *other == pod).unwrap_or(0) as f32; + let k = if left { count - 1.0 - at } else { at }; + + if left { near - shift - (k + 1.0) * h - k * spacing } else { near + shift + k * (h + spacing) } + }; + + let (grow, pill_shown, reveal) = card.map_or((0.0, 1.0, 0.0), |(_, t)| phases(t)); + + 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 { + 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 }; + + 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))); + } + + let typing = card.is_some_and(|(pod, _)| pod.types() && state.shown); + + if let Some((pod, _)) = card { + // from the pill to the card, its edge nearest the island staying put + let from_x = pill_x(pod); + let to_x = if left { near - shift - card_w } else { near + shift }; + + let width = mix(h, card_w, grow); + let height = mix(h, card_h, grow); + let x = mix(from_x, to_x, grow); + let radius = mix(h / 2.0, layout::radius(), grow); + let lit = state.hover.get(&pod).map_or(0.0, Animation::value); + + let face: Rectangle = if reveal > 0.0 { + veiled(content(pod, card_w, card_h), width, height, radius, reveal, &colors) + } else { + // the pill's icon fading as the shape grows + let tint = Color::blend(CLEAR, theme::now().text, pill_shown); + + Rectangle::new().width(h).height(h).align_child(Center, Center).child(icon(pod.glyph(), layout::icon(), tint)) + }; + + let face = Rectangle::new().width(width).height(height).align_child(Start, Start).child(face); + let shape = surface::shell(width, height, radius, &colors, lit * pill_shown, face).on_hover(over_card).translate(x, top + slide); + + regions.push(InputArea { x: x as i32, y: (top + slide) as i32, width: width.ceil() as i32, height: height.ceil() as i32 }); + layers.push(Box::new(shape)); + } + + let window = window.input_region(regions); + + let window = if typing { + window.keyboard(Keyboard::OnDemand).on_key(|key| { + if key == Key::Escape { + close(); + } + }) + } else { + window + }; + + window.child(Rectangle::new().width(Parent).height(Parent).child(Stack::new(layers).width(Parent).height(Parent))) +} + +// a small pill: the surface with one icon; the bell carries the unread count +fn pill(pod: Pod, lit: f32, colors: &[Color; 3], monitor: &str) -> Rectangle { + let h = island_pill::height(); + let theme = theme::now(); + + let glyph = icon(pod.glyph(), layout::icon() * (1.0 + 0.08 * lit), Color::blend(theme.dim, theme.text, 0.6 + 0.4 * lit)); + let mut inside: Vec> = vec![Box::new(Rectangle::new().width(h).height(h).align_child(Center, Center).child(glyph))]; + + let unread = if pod == Pod::Notifications { notices::unread() } else { 0 }; + + if unread > 0 { + let badge = text::tiny() + 6.0; + let words = if unread > 9 { String::from("9+") } else { unread.to_string() }; + + inside.push(Box::new( + Rectangle::new() + .width(badge) + .height(badge) + .radius(Full) + .fill(theme.accent) + .align_child(Center, Center) + .translate(h - badge - 3.0, 3.0) + .child(Text::new(words).size(text::tiny() - 1.0).weight(Weight::Bold).color(theme.on_accent)), + )); + } + + let content = Rectangle::new().width(h).height(h).child(Stack::new(inside).width(h).height(h)); + let on = monitor.to_string(); + + surface::shell(h, h, h / 2.0, colors, lit, content).cursor(Pointer).on_hover(hovering(pod)).on_click(move |_| toggle(pod, &on)) +} + +fn content(pod: Pod, width: f32, height: f32) -> Rectangle { + let pad = layout::pad(); + let (inner_w, inner_h) = (width - 2.0 * pad, height - 2.0 * pad); + + let body = match pod { + Pod::Ai => ai::card(inner_w, inner_h), + Pod::Monitor => stats::card(inner_w, inner_h), + Pod::Home => home::card(inner_w, inner_h), + Pod::Notifications => notices::card(inner_w, inner_h), + }; + + Rectangle::new().width(width).height(height).padding(pad).child(body) +} + +/* + * a card's first row: its title, the card's own buttons, then a cross that closes it. + * the cards share it so they read as one family + */ +pub fn header(title: &str, buttons: Vec>, width: f32) -> Rectangle { + let theme = theme::now(); + + let mut right: Vec> = buttons; + right.push(Box::new(button(Glyph::Cross, close))); + + Rectangle::new().width(width).height(header_height()).child( + Row::new(vec![ + Box::new(Text::new(title).size(text::heading()).weight(Weight::Bold).color(theme.text)) as Box, + Box::new(Row::new(right).gap(6.0).align(Center)), + ]) + .width(width) + .justify(SpaceBetween) + .align(Center), + ) +} + +pub fn header_height() -> f32 { + 30.0 +} + +// a small round icon button +pub fn button(glyph: Glyph, pressed: impl Fn() + 'static) -> Rectangle { + let theme = theme::now(); + + Rectangle::new() + .width(26.0) + .height(26.0) + .radius(Full) + .fill(overlays::chip()) + .align_child(Center, Center) + .cursor(Pointer) + .on_click(move |_| pressed()) + .child(icon(glyph, 14.0, theme.text)) +} + +// a chip with words on it, for actions like "Clear all" or "Processes" +pub fn chip(words: &str, glyph: Option, pressed: impl Fn() + 'static) -> Rectangle { + let theme = theme::now(); + let letters = words.chars().count() as f32; + let width = letters * text::small() * crate::config::appearance::font::letter_width() + if glyph.is_some() { 40.0 } else { 22.0 }; + + let mut parts: Vec> = Vec::new(); + + if let Some(glyph) = glyph { + parts.push(Box::new(icon(glyph, 13.0, theme.text))); + } + + parts.push(Box::new(Text::new(words).size(text::small()).color(theme.text))); + + Rectangle::new() + .width(width) + .height(26.0) + .radius(Full) + .fill(overlays::chip()) + .align_child(Center, Center) + .cursor(Pointer) + .on_click(move |_| pressed()) + .child(Row::new(parts).gap(6.0).align(Center)) +} + +// `amane ipc call pods open|toggle ai|monitor|home|notifications [monitor]`, `close`, `status`; the focused monitor by default +pub fn command(arguments: &[String]) -> String { + let words: Vec<&str> = arguments.iter().map(String::as_str).collect(); + let focused = island::focused_monitor().unwrap_or_default(); + + match words.as_slice() { + [verb @ ("open" | "toggle"), name, rest @ ..] => match Pod::from_word(name) { + Some(pod) => { + let on = rest.first().map_or(focused, |monitor| monitor.to_string()); + + if *verb == "open" { open(pod, &on) } else { toggle(pod, &on) } + + String::from("ok") + } + None => String::from("pods: ai, monitor, home or notifications"), + }, + ["close"] => { + close(); + + String::from("ok") + } + ["ask", question @ ..] if !question.is_empty() => { + ai::ask(question.join(" ")); + + String::from("ok") + } + ["notices", rest @ ..] => notices::command(rest), + ["processes"] => { + stats::processes(); + + String::from("ok") + } + ["status"] | [] => { + let pods = Pods::read(); + let card = pods.card.as_ref().filter(|_| pods.shown).map_or("none", |(pod, _)| pod.word()); + let on = pods.card.as_ref().map_or("", |(_, on)| on.as_str()); + + format!("card={card} monitor={on} timeline={:.2} unread={}", pods.timeline.value(), notices::unread()) + } + _ => String::from("usage: pods open|toggle [monitor] | close | ask | notices list|dismiss |clear | status"), + } +} + +// a thin divider across a card +pub fn rule(width: f32) -> Rectangle { + Rectangle::new().width(width).height(1.0).fill(overlays::track()) +} + diff --git a/src/stats.rs b/src/stats.rs new file mode 100644 index 0000000..b0eb95a --- /dev/null +++ b/src/stats.rs @@ -0,0 +1,382 @@ +use std::fs; +use std::path::PathBuf; +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 crate::config::appearance::text; +use crate::config::pills::{layout, monitor as settings}; +use crate::glyph::Glyph; +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 + */ +#[derive(Default)] +pub struct Stats { + // 0 to 1 + cpu: Vec, + // the integrated gpu's clock as a share of its highest, and in words + gpu: Vec, + gpu_words: String, + ram: Vec, + ram_words: String, + // bytes a second + down: Vec, + up: Vec, + read: Vec, + write: Vec, + // (sensor, °C), hottest first + temps: Vec<(String, f32)>, +} + +struct Sample { + at: Instant, + cpu: (u64, u64), + net: (u64, u64), + disk: (u64, u64), +} + +impl Service for Stats { + fn new() -> Self { + Self::default() + } + + fn listen() { + let mut before = sample(); + + loop { + thread::sleep(Duration::from_millis(settings::interval_ms())); + + let now = sample(); + let seconds = now.at.duration_since(before.at).as_secs_f32().max(0.001); + let rate = |new: u64, old: u64| new.saturating_sub(old) as f32 / seconds; + + 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 (clock, clock_words) = gpu_clock(); + let temps = temperatures(); + + { + let mut stats = Self::write(); + let keep = settings::history(); + + push(&mut stats.cpu, busy, keep); + push(&mut stats.gpu, clock, keep); + push(&mut stats.ram, used as f32 / memory.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.temps = temps; + } + + before = now; + } + } +} + +fn push(history: &mut Vec, value: f32, keep: usize) { + history.push(value); + + let extra = history.len().saturating_sub(keep); + + history.drain(..extra); +} + +fn read(path: impl Into) -> String { + fs::read_to_string(path.into()).unwrap_or_default() +} + +fn sample() -> Sample { + Sample { at: Instant::now(), cpu: parse::cpu_jiffies(&read("/proc/stat")).unwrap_or((0, 0)), net: parse::network_bytes(&read("/proc/net/dev")), disk: disk_sectors() } +} + +// sectors read and written by the whole physical disks: ones with a device behind them, not loop, ram or zram +fn disk_sectors() -> (u64, u64) { + let mut sum = (0, 0); + + for entry in fs::read_dir("/sys/block").into_iter().flatten().flatten() { + let name = entry.file_name().to_string_lossy().into_owned(); + + if ["loop", "ram", "zram"].iter().any(|virtual_disk| name.starts_with(virtual_disk)) || !entry.path().join("device").exists() { + continue; + } + + if let Some((reads, writes)) = parse::block_sectors(&read(entry.path().join("stat"))) { + sum.0 += reads; + sum.1 += writes; + } + } + + sum +} + +// the first gpu that tells its clock (xe or i915): its share of the highest clock, and the clock in words +fn gpu_clock() -> (f32, String) { + for entry in fs::read_dir("/sys/class/drm").into_iter().flatten().flatten() { + let card = entry.path(); + + for (now, top) in [("device/tile0/gt0/freq0/act_freq", "device/tile0/gt0/freq0/max_freq"), ("gt_act_freq_mhz", "gt_max_freq_mhz")] { + let mhz = |file: &str| read(card.join(file)).trim().parse::().ok(); + + if let (Some(now), Some(top)) = (mhz(now), mhz(top)) { + return ((now / top.max(1.0)).clamp(0.0, 1.0), format!("{now:.0} of {top:.0} MHz")); + } + } + } + + (0.0, String::from("no clock to read")) +} + +// 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(); + + for entry in fs::read_dir("/sys/class/hwmon").into_iter().flatten().flatten() { + let device = entry.path(); + let kind = read(device.join("name")).trim().to_string(); + + let hottest = fs::read_dir(&device) + .into_iter() + .flatten() + .flatten() + .filter(|file| file.file_name().to_string_lossy().starts_with("temp") && file.file_name().to_string_lossy().ends_with("_input")) + .filter_map(|file| read(file.path()).trim().parse::().ok()) + .map(|milli| milli / 1000.0) + .filter(|celsius| *celsius > 0.0 && *celsius < 150.0) + .fold(None, |hottest: Option, celsius| Some(hottest.map_or(celsius, |hot| hot.max(celsius)))); + + 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, + }; + + found.push((name.to_string(), celsius)); + } + + found.sort_by(|a, b| b.1.total_cmp(&a.1)); + 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(); + + 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; + + *name = format!("{name} {number}"); + } + } + + found +} + +/* + * 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 + */ +pub fn processes() { + pods::close(); + + let (terminal, command, space) = (settings::terminal(), settings::processes(), settings::workspace()); + + thread::spawn(move || { + let class = format!("amane.{space}"); + let running = Command::new("pgrep").args(["-f", "--", &format!("--class={class}")]).output().is_ok_and(|done| done.status.success()); + + if !running { + let line = format!("{terminal} --class={class} -e {command}"); + let lua = format!("hl.exec_cmd({}, {{ workspace = {} }})", parse::quoted(&line), parse::quoted(&format!("special:{space} silent"))); + + let _ = Command::new("hyprctl").args(["eval", &lua]).output(); + } + + let _ = Command::new("hyprctl").args(["dispatch", &format!("hl.dsp.workspace.toggle_special({})", parse::quoted(&space))]).output(); + }); +} + +fn label_height() -> f32 { + text::small() + 8.0 +} + +fn temp_row_height() -> f32 { + text::small() + 8.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; + + // 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 +} + +// bytes a second in words +fn rate(bytes: f32) -> String { + match bytes { + b if b >= 1e9 => format!("{:.1} GB/s", b / 1e9), + b if b >= 1e6 => format!("{:.1} MB/s", b / 1e6), + b if b >= 1e3 => format!("{:.0} kB/s", b / 1e3), + b => format!("{b:.0} B/s"), + } +} + +// one graph's readings, the value at its top, its colour +type Series<'a> = (&'a [f32], f32, Color); + +// a graph's line and the faint area under it, scaled so `top` reaches the top +fn graph(history: &[f32], top: f32, origin: (f32, f32), size: (f32, f32), color: Color) -> Vec> { + let (x0, y0) = origin; + let (width, height) = size; + let keep = settings::history().max(2) as f32; + + if history.len() < 2 { + return Vec::new(); + } + + // newest on the right; older readings fill in leftwards + let point = |index: usize, value: f32| { + let age = (history.len() - 1 - index) as f32; + + (x0 + width - age / (keep - 1.0) * width, y0 + height - (value / top.max(f32::EPSILON)).clamp(0.0, 1.0) * height) + }; + + let mut line = Path::new(); + let mut area = Path::new(); + + for (index, value) in history.iter().enumerate() { + let (x, y) = point(index, *value); + + if index == 0 { + line = line.move_to(x, y); + area = area.move_to(x, y0 + height).line_to(x, y); + } else { + line = line.line_to(x, y); + area = area.line_to(x, y); + } + } + + let (last_x, _) = point(history.len() - 1, 0.0); + + vec![Box::new(area.line_to(last_x, y0 + height).close().fill(faded(color, 40))), Box::new(line.stroke(1.5, color))] +} + +pub fn card(width: f32, height: f32) -> Rectangle { + let stats = Stats::read(); + let gpu = Gpu::read(); + let theme = theme::now(); + + let header = pods::header("System", vec![Box::new(pods::chip("Processes", Some(Glyph::Terminal), processes))], width); + + let last = |history: &[f32]| history.last().copied().unwrap_or(0.0); + let peak = |a: &[f32], b: &[f32]| a.iter().chain(b).copied().fold(1024.0_f32, f32::max); + + 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 (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 + let mut shapes: Vec> = Vec::new(); + let mut rows: Vec> = Vec::new(); + + for (index, (name, words, series)) in blocks.into_iter().enumerate() { + let top = index as f32 * (label_h + graph_h + 10.0); + + for (history, peak, color) in series { + shapes.extend(graph(history, peak, (0.0, top + label_h), (width, graph_h), color)); + } + + rows.push(Box::new( + Rectangle::new().width(width).height(label_h + graph_h).child( + Row::new(vec![ + Box::new(Text::new(name).size(text::small()).weight(Weight::Bold).color(theme.text)) as Box, + Box::new(Text::new(words).size(text::small()).color(theme.dim)), + ]) + .width(width) + .justify(SpaceBetween), + ), + )); + } + + let graphs_h = 5.0 * (label_h + graph_h) + 4.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(); + + 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 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))); + } + + 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), + ) +} diff --git a/src/tray.rs b/src/tray.rs index f146016..d2a70a6 100644 --- a/src/tray.rs +++ b/src/tray.rs @@ -306,7 +306,7 @@ fn forget(name: &str) { * (gtk's choice, hicolor, Adwaita), then /usr/share/pixmaps. themes are walked once * per name, the answer kept */ -fn icon_file(name: &str, theme_path: &str) -> Option { +pub fn icon_file(name: &str, theme_path: &str) -> Option { static FOUND: Mutex>>> = Mutex::new(None); if name.is_empty() {