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