1853 lines
74 KiB
Rust
1853 lines
74 KiB
Rust
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={}¤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<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)
|
||
}
|