Files
amane-config/src/home.rs
T

1853 lines
74 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<String>,
}
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<String> {
let path = expanded(&settings::avatar());
path.is_file().then(|| path.to_string_lossy().into_owned())
}
#[derive(Default)]
pub struct Forecast {
weather: Option<Weather>,
problem: Option<String>,
// the photo of the sky now, once its file is there
photo: Option<String>,
}
impl Service for Forecast {
fn new() -> Self {
Self::default()
}
fn listen() {
loop {
let url = format!(
"https://api.open-meteo.com/v1/forecast?latitude={}&longitude={}&current=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<String> {
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<Color>,
// 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<Color>,
writable: bool,
}
// the event form, beside the month
#[derive(Clone)]
struct Form {
// the event changed, none for a new one
editing: Option<Entry>,
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<String>,
}
#[derive(Default)]
pub struct Agenda {
// the events of `span` (local days, end excluded), soonest first
events: Vec<Entry>,
span: (i64, i64),
calendars: Vec<Calendar>,
birthdays: Vec<Birthday>,
synced: bool,
problem: Option<String>,
contacts_problem: Option<String>,
// 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<i64>,
form: Option<Form>,
}
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<String> {
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<String, String> {
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#"<d:propfind xmlns:d="DAV:"><d:prop><d:current-user-principal/></d:prop></d:propfind>"#;
// caldav discovery: the account's principal, its calendar home, the event calendars in it
fn calendars(server: &str, account: &str, password: &str) -> Result<Vec<Calendar>, 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#"<d:propfind xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav"><d:prop><c:calendar-home-set/></d:prop></d:propfind>"#)?;
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#"<d:propfind xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav" xmlns:a="http://apple.com/ns/ical/"><d:prop><d:displayname/><d:resourcetype/><a:calendar-color/><c:supported-calendar-component-set/><d:current-user-privilege-set/></d:prop></d:propfind>"#,
)?;
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<Calendar>) -> Result<(Vec<Calendar>, Vec<Entry>), 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#"<c:calendar-query xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav"><d:prop><d:getetag/><c:calendar-data><c:expand start="{from}" end="{to}"/></c:calendar-data></d:prop><c:filter><c:comp-filter name="VCALENDAR"><c:comp-filter name="VEVENT"><c:time-range start="{from}" end="{to}"/></c:comp-filter></c:comp-filter></c:filter></c:calendar-query>"#
);
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<i64> = 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<Entry> = 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<i64, i64> {
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<i64> = 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<Vec<Birthday>, 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#"<d:propfind xmlns:d="DAV:" xmlns:card="urn:ietf:params:xml:ns:carddav"><d:prop><card:addressbook-home-set/></d:prop></d:propfind>"#)?;
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#"<d:propfind xmlns:d="DAV:"><d:prop><d:resourcetype/></d:prop></d:propfind>"#)?;
let wanted = r#"<d:prop><d:getetag/><card:address-data><card:prop name="FN"/><card:prop name="N"/><card:prop name="ORG"/><card:prop name="BDAY"/></card:address-data></d:prop>"#;
let with_birthday = format!(r#"<card:addressbook-query xmlns:d="DAV:" xmlns:card="urn:ietf:params:xml:ns:carddav">{wanted}<card:filter><card:prop-filter name="BDAY"/></card:filter></card:addressbook-query>"#);
let every_card = format!(r#"<card:addressbook-query xmlns:d="DAV:" xmlns:card="urn:ietf:params:xml:ns:carddav">{wanted}<card:filter/></card:addressbook-query>"#);
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<i64, String> {
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<When, String> {
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::<Vec<_>>(), "", &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<String> = 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<i64>) {
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<Box<dyn Shape>> = 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<Box<dyn Widget>> = 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<Box<dyn Widget>> {
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<dyn Widget>, 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<dyn Shape>>) -> Box<dyn Widget> {
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<dyn Widget>,
Box::new(
Column::new(vec![
Box::new(Text::new(format!("{greeting}, {}", name())).size(text::heading()).weight(Weight::Bold).color(theme.text)) as Box<dyn Widget>,
Box::new(Text::new(date).size(text::small()).color(theme.dim)),
])
.gap(4.0),
),
])
.gap(settings::gap())
.align(Center),
) as Box<dyn Widget>,
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<dyn Shape>>) -> Box<dyn Widget> {
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<dyn Widget>, 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<Box<dyn Shape>>) -> 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<dyn Widget>,
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<dyn Widget>,
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<dyn Widget>,
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<dyn Widget>,
Box::new(
Column::new(vec![
Box::new(Text::new(format!("↑ {high:.0}° ↓ {low:.0}°")).size(text::small()).color(theme.text)) as Box<dyn Widget>,
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<Box<dyn Widget>> = 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<dyn Widget>,
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<dyn Widget>, 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<Item = &Birthday> {
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<dyn Shape>>) -> Box<dyn Widget> {
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<dyn Shape>>) -> Box<dyn Widget> {
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<Box<dyn Widget>> = 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<dyn Widget>,
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<dyn Widget>).collect(),
);
// what the six weeks hold
let holidays: Vec<i64> = holidays_between(start, end).into_iter().map(|(day, _)| day).collect();
let mut busy: HashMap<i64, Vec<Color>> = 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<Box<dyn Widget>> = vec![Box::new(header), Box::new(letters)];
for week in 0..6 {
let mut cells: Vec<Box<dyn Widget>> = 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("<Apple ID>", 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<dyn Shape>>) -> Box<dyn Widget> {
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<Box<dyn Widget>> = 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<Line> = 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<Box<dyn Widget>> = 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<dyn Widget> = 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<Box<dyn Widget>> = 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<dyn Widget>,
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<Color>, pressed: impl Fn() + 'static) -> Rectangle {
let theme = theme::now();
let size = text::small();
let mut parts: Vec<Box<dyn Widget>> = 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<Box<dyn Shape>>, 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<dyn Widget>, 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<dyn Shape>>) -> Box<dyn Widget> {
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<Box<dyn Widget>> = 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<Box<dyn Widget>> = 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<Box<dyn Widget>> = 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<Box<dyn Widget>> = Vec::new();
let mut row: Vec<Box<dyn Widget>> = 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<Box<dyn Widget>> = 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)
}