Side pills: local AI chat, system monitor, home (clock, Munich weather, iCloud calendar), notifications daemon with popups and history

This commit is contained in:
server2
2026-10-15 12:52:55 +02:00
parent 6399844731
commit bbe184ab97
12 changed files with 3388 additions and 3 deletions
+678
View File
@@ -0,0 +1,678 @@
use std::io::Write;
use std::path::PathBuf;
use std::process::{Command, Stdio};
use std::thread;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use std::env;
use amane::{Canvas, Center, Color, Column, Full, Image, Rectangle, Row, Service, Shape, SpaceBetween, Stack, Start, Text, TextInput, Weight, Widget};
use crate::config::appearance::{overlays, text};
use crate::config::pills::{calendar as agenda_settings, home as settings, layout};
use crate::config;
use crate::glyph::{Glyph, glyph_shapes, icon};
use crate::island::faded;
use crate::parse::{self, Event, Sky, Value, Weather};
use crate::{pods, theme};
/*
* the home card: who's here, a big clock and the date, the weather (open-meteo, no key),
* and the calendar (any caldav server, icloud's by default). the calendar password is an
* app-specific one in the keyring (secret-tool), handed to curl on stdin, never written
* to a file or a command line
*/
// local time, refreshed by the minute (by the second with seconds shown)
#[derive(Default)]
pub struct Clock {
// seconds since 1970 in local time, and the local offset from utc
now: i64,
offset: i64,
avatar: Option<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 = SystemTime::now().duration_since(UNIX_EPOCH).map_or(0, |since| since.as_secs() as i64);
{
let mut clock = Self::write();
clock.now = unix + clock.offset;
}
let wait = if settings::seconds() { 1 } else { 60 - (unix % 60) };
ticks += wait;
thread::sleep(Duration::from_secs(wait as u64));
}
}
}
// the local offset from utc in seconds, from `date +%z` (+0200)
fn utc_offset() -> i64 {
let printed = Command::new("date").arg("+%z").output().map(|done| String::from_utf8_lossy(&done.stdout).trim().to_string()).unwrap_or_default();
let sign = if printed.starts_with('-') { -1 } else { 1 };
let digits = printed.trim_start_matches(['+', '-']);
let hours: i64 = digits.get(..2).and_then(|hours| hours.parse().ok()).unwrap_or(0);
let minutes: i64 = digits.get(2..4).and_then(|minutes| minutes.parse().ok()).unwrap_or(0);
sign * (hours * 3600 + minutes * 60)
}
fn home_dir() -> PathBuf {
env::var_os("HOME").map(PathBuf::from).unwrap_or_default()
}
// the avatar picture, if the file is there
fn avatar_file() -> Option<String> {
let wanted = settings::avatar();
let path = match wanted.strip_prefix("~/") {
Some(rest) => home_dir().join(rest),
None => PathBuf::from(&wanted),
};
path.is_file().then(|| path.to_string_lossy().into_owned())
}
#[derive(Default)]
pub struct Forecast {
weather: Option<Weather>,
problem: 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 mut forecast = Self::write();
match parse::weather(&printed) {
Some(weather) => {
forecast.weather = Some(weather);
forecast.problem = None;
}
None => forecast.problem = Some(String::from("The weather couldn't be fetched")),
}
}
thread::sleep(Duration::from_secs(settings::weather_minutes() * 60));
}
}
}
#[derive(Default)]
pub struct Agenda {
// upcoming events with their calendar's colour, soonest first
events: Vec<(Event, Option<Color>)>,
synced: bool,
problem: Option<String>,
// the sign-in form is open, and the apple id typed into it
form: bool,
account: String,
}
impl Service for Agenda {
fn new() -> Self {
Self::default()
}
fn listen() {
loop {
sync();
thread::sleep(Duration::from_secs(agenda_settings::sync_minutes() * 60));
}
}
}
// whether the card has a field to type into (then it keeps the keyboard and stays open)
pub fn signing_in() -> bool {
Agenda::read().form
}
const KEYRING: [&str; 2] = ["service", "amane-calendar"];
fn password(account: &str) -> Option<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)
}
fn sign_in(account: String, password: String) {
let account = account.trim().to_string();
if account.is_empty() || password.is_empty() {
Agenda::write().problem = Some(String::from("Type the Apple ID and an app-specific password"));
return;
}
TextInput::set_text("home-password", "");
thread::spawn(move || {
let stored = Command::new("secret-tool")
.args(["store", "--label=Magic Island calendar", KEYRING[0], KEYRING[1], "account", &account])
.stdin(Stdio::piped())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.and_then(|mut child| {
if let Some(mut input) = child.stdin.take() {
input.write_all(password.as_bytes())?;
}
child.wait()
})
.is_ok_and(|status| status.success());
if !stored {
Agenda::write().problem = Some(String::from("The keyring didn't take the password (secret-tool)"));
return;
}
let _ = config::set("pills.calendar.account", Value::Text(account));
Agenda::write().form = false;
sync();
});
}
fn sign_out() {
let account = agenda_settings::account();
thread::spawn(move || {
let _ = Command::new("secret-tool").args(["clear", KEYRING[0], KEYRING[1], "account", &account]).output();
let _ = config::reset("pills.calendar.account");
let mut agenda = Agenda::write();
agenda.events.clear();
agenda.synced = false;
agenda.problem = None;
});
}
// the calendar's events from now on, on this thread
fn sync() {
let account = agenda_settings::account();
if account.is_empty() {
return;
}
let Some(password) = password(&account) else {
Agenda::write().problem = Some(String::from("The calendar password isn't in the keyring: sign in again"));
return;
};
let found = events(&agenda_settings::server(), &account, &password);
let mut agenda = Agenda::write();
match found {
Ok(events) => {
agenda.events = events;
agenda.synced = true;
agenda.problem = None;
}
Err(why) => agenda.problem = Some(why),
}
}
// one webdav request with curl; the account and password go in on stdin as curl config
fn dav(method: &str, url: &str, depth: &str, body: &str, account: &str, password: &str) -> Result<String, String> {
let mut child = Command::new("curl")
.args(["-s", "-m", "20", "-X", method, "-H", &format!("Depth: {depth}"), "-H", "Content-Type: application/xml; charset=utf-8", "--data-binary", body, "-w", "\n%{http_code}", "-K", "-", url])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.map_err(|error| format!("curl: {error}"))?;
if let Some(mut input) = child.stdin.take() {
let quote = |text: &str| text.replace('\\', "\\\\").replace('"', "\\\"");
let _ = writeln!(input, "user = \"{}:{}\"", quote(account), quote(password));
}
let done = child.wait_with_output().map_err(|error| format!("curl: {error}"))?;
let printed = String::from_utf8_lossy(&done.stdout);
let (answer, status) = printed.rsplit_once('\n').unwrap_or(("", &printed));
match status.trim() {
"401" | "403" => Err(String::from("The calendar didn't accept the Apple ID or the app-specific password")),
code if code.starts_with('2') => Ok(answer.to_string()),
"000" => Err(String::from("The calendar server can't be reached")),
code => Err(format!("The calendar server answered {code}")),
}
}
// an href from an answer as a whole url, on the server it came from
fn join(base: &str, href: &str) -> String {
if href.starts_with("http") {
return href.to_string();
}
let origin_end = base.find("://").map_or(base.len(), |scheme| base[scheme + 3..].find('/').map_or(base.len(), |path| scheme + 3 + path));
format!("{}{href}", &base[..origin_end])
}
// caldav discovery (principal, calendar home, calendars), then every calendar's events in the coming days
fn events(server: &str, account: &str, password: &str) -> Result<Vec<(Event, Option<Color>)>, String> {
let ask = |method: &str, url: &str, depth: &str, body: &str| dav(method, url, depth, body, account, password);
let found = ask("PROPFIND", server, "0", r#"<d:propfind xmlns:d="DAV:"><d:prop><d:current-user-principal/></d:prop></d:propfind>"#)?;
let principal = parse::xml_blocks(&found, "current-user-principal").first().and_then(|block| parse::xml_texts(block, "href").into_iter().next()).ok_or("The calendar server named no account")?;
let principal = join(server, &principal);
let found = ask("PROPFIND", &principal, "0", r#"<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 = parse::xml_blocks(&found, "calendar-home-set").first().and_then(|block| parse::xml_texts(block, "href").into_iter().next()).ok_or("The calendar server named no calendars")?;
let home = join(&principal, &home);
let found = ask(
"PROPFIND",
&home,
"1",
r#"<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:prop></d:propfind>"#,
)?;
let offset = utc_offset();
let now = SystemTime::now().duration_since(UNIX_EPOCH).map_or(0, |since| since.as_secs() as i64);
let today = (now + offset).div_euclid(86400) * 86400 - offset;
let (from, to) = (parse::dav_time(today), parse::dav_time(today + agenda_settings::days() as i64 * 86400));
let query = format!(
r#"<c:calendar-query xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav"><d:prop><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 all: Vec<(Event, Option<Color>)> = Vec::new();
for calendar in parse::dav_calendars(&found) {
let color = calendar.color.as_deref().and_then(config::color);
let Ok(answer) = ask("REPORT", &join(&home, &calendar.href), "1", &query) else { continue };
for ics in parse::xml_texts(&answer, "calendar-data") {
all.extend(parse::ics_events(&ics, offset).into_iter().filter(|event| event.end > now).map(|event| (event, color)));
}
}
all.sort_by_key(|(event, _)| event.start);
Ok(all)
}
const WEEKDAYS: [&str; 7] = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"];
const MONTHS: [&str; 12] = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"];
fn sky_glyph(code: u32, day: bool) -> Glyph {
match parse::sky(code) {
Sky::Clear if day => Glyph::Sun,
Sky::Clear => Glyph::Moon,
Sky::PartlyCloudy => Glyph::PartCloud,
Sky::Cloudy => Glyph::Cloud,
Sky::Fog => Glyph::Fog,
Sky::Rain => Glyph::Rain,
Sky::Snow => Glyph::Snow,
Sky::Storm => Glyph::Storm,
}
}
// the parts' heights, shared by the card and height()
const TOP_H: f32 = 48.0;
const WEATHER_H: f32 = 60.0;
const DAYS_H: f32 = 62.0;
const CELL: f32 = 24.0;
fn clock_h() -> f32 {
settings::clock_size() * 1.1
}
fn calendar_h() -> f32 {
let grid = text::tiny() + 8.0 + 6.0 * CELL;
let list = text::small() + 8.0 + agenda_settings::events() as f32 * (text::small() + 10.0);
grid.max(list)
}
pub fn height() -> f32 {
2.0 * layout::pad() + TOP_H + clock_h() + WEATHER_H + DAYS_H + 1.0 + calendar_h() + 5.0 * 12.0
}
fn name() -> String {
let user = env::var("USER").unwrap_or_default();
let mut letters = user.chars();
letters.next().map(|first| first.to_uppercase().chain(letters).collect()).unwrap_or_default()
}
pub fn card(width: f32, height: f32) -> Rectangle {
let clock = Clock::read();
let forecast = Forecast::read();
let agenda = Agenda::read();
let theme = theme::now();
let days = clock.now.div_euclid(86400);
let (year, month, day) = parse::civil_from_days(days);
let weekday = parse::weekday(days) as usize;
let seconds_today = clock.now.rem_euclid(86400);
let (hour, minute, second) = (seconds_today / 3600, seconds_today % 3600 / 60, seconds_today % 60);
// who's here: the avatar, a greeting, the date
let avatar: Box<dyn Widget> = match &clock.avatar {
Some(path) => Box::new(Rectangle::new().width(TOP_H).height(TOP_H).radius(Full).fill(Image::cover(path.clone()))),
None => Box::new(Rectangle::new().width(TOP_H).height(TOP_H).radius(Full).fill(faded(theme.accent, 60)).align_child(Center, Center).child(icon(Glyph::Person, 26.0, theme.accent))),
};
let greeting = match hour {
5..12 => "Good morning",
12..18 => "Good afternoon",
18..23 => "Good evening",
_ => "Good night",
};
let date = if clock.now == 0 { String::new() } else { format!("{}, {day} {} {year}", WEEKDAYS[weekday % 7], MONTHS[(month as usize + 11) % 12]) };
let top = Row::new(vec![
Box::new(
Row::new(vec![
avatar,
Box::new(
Column::new(vec![
Box::new(Text::new(format!("{greeting}, {}", name())).size(text::heading()).weight(Weight::Bold).color(theme.text)) as Box<dyn Widget>,
Box::new(Text::new(date).size(text::small()).color(theme.dim)),
])
.gap(4.0),
),
])
.gap(12.0)
.align(Center),
) as Box<dyn Widget>,
Box::new(pods::button(Glyph::Cross, pods::close)),
])
.width(width)
.justify(SpaceBetween)
.align(Center);
// the big clock
let time = if settings::seconds() { format!("{hour:02}:{minute:02}:{second:02}") } else { format!("{hour:02}:{minute:02}") };
let big = Rectangle::new().width(width).height(clock_h()).align_child(Start, Center).child(Text::new(time).size(settings::clock_size()).weight(Weight::Bold).color(theme.text).tight());
let (now_weather, days_row) = weather(&forecast, width, days);
Rectangle::new().width(width).height(height).child(
Column::new(vec![
Box::new(Rectangle::new().width(width).height(TOP_H).child(top)) as Box<dyn Widget>,
Box::new(big),
now_weather,
days_row,
Box::new(pods::rule(width)),
calendar(&agenda, width, (year, month), days, clock.offset),
])
.gap(12.0),
)
}
// the weather now, and the next days in one row (their icons in one canvas)
fn weather(forecast: &Forecast, width: f32, today: i64) -> (Box<dyn Widget>, Box<dyn Widget>) {
let theme = theme::now();
let Some(weather) = &forecast.weather else {
let words = forecast.problem.clone().unwrap_or_else(|| format!("Fetching the weather for {}…", settings::city()));
return (
Box::new(Rectangle::new().width(width).height(WEATHER_H).align_child(Start, Center).child(Text::new(words).size(text::small()).color(theme.dim))),
Box::new(Rectangle::new().width(width).height(DAYS_H)),
);
};
let (high, low, sunrise, sunset) = weather.days.first().map_or((0.0, 0.0, "", ""), |day| (day.max, day.min, day.sunrise.as_str(), day.sunset.as_str()));
let now = Row::new(vec![
Box::new(
Row::new(vec![
Box::new(icon(sky_glyph(weather.code, weather.is_day), 44.0, theme.accent)) as Box<dyn Widget>,
Box::new(
Column::new(vec![
Box::new(
Row::new(vec![
Box::new(Text::new(format!("{:.0}°", weather.temperature)).size(28.0).weight(Weight::Bold).color(theme.text)) as Box<dyn Widget>,
Box::new(Text::new(format!("{} · feels {:.0}°", parse::weather_words(weather.code), weather.apparent)).size(text::small()).color(theme.text)),
])
.gap(10.0)
.align(Center),
) as Box<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(12.0)
.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
let coming: Vec<_> = weather.days.iter().skip(1).take(4).collect();
let column_w = width / 4.0;
let icon_size = 22.0;
let mut shapes: Vec<Box<dyn Shape>> = Vec::new();
let mut columns: Vec<Box<dyn Widget>> = Vec::new();
for (index, day) in coming.iter().enumerate() {
let weekday = parse::weekday(today + 1 + index as i64) as usize;
shapes.extend(glyph_shapes(sky_glyph(day.code, true), icon_size, theme.text, 0.0, (index as f32 * column_w + (column_w - icon_size) / 2.0, text::tiny() + 8.0)));
columns.push(Box::new(
Rectangle::new().width(column_w).height(DAYS_H).child(
Column::new(vec![
Box::new(Rectangle::new().width(column_w).height(text::tiny() + 4.0).align_child(Center, Center).child(Text::new(&WEEKDAYS[weekday % 7][..3]).size(text::tiny()).color(theme.dim))) as Box<dyn Widget>,
Box::new(Rectangle::new().width(column_w).height(icon_size + 8.0)),
Box::new(Rectangle::new().width(column_w).height(text::small() + 4.0).align_child(Center, Center).child(Text::new(format!("{:.0}° / {:.0}°", day.max, day.min)).size(text::small()).color(theme.text))),
])
.gap(2.0),
),
));
}
let days = Stack::new(vec![Box::new(Canvas::new().width(width).height(DAYS_H).shapes(shapes)) as Box<dyn Widget>, Box::new(Row::new(columns))]).width(width).height(DAYS_H);
(Box::new(Rectangle::new().width(width).height(WEATHER_H).child(now)), Box::new(days))
}
// this month's grid on the left, the coming events (or the sign-in) on the right; `offset` is local time's from utc
fn calendar(agenda: &Agenda, width: f32, (year, month): (i64, u32), today: i64, offset: i64) -> Box<dyn Widget> {
let theme = theme::now();
let grid_w = 7.0 * CELL;
let list_w = width - grid_w - 20.0;
// the month: weekday letters, then its days under the right ones; today in the accent, days with events dotted
let first = parse::days_from_civil(year, month, 1);
let next = if month == 12 { parse::days_from_civil(year + 1, 1, 1) } else { parse::days_from_civil(year, month + 1, 1) };
let lead = parse::weekday(first) as i64;
let busy: Vec<i64> = agenda.events.iter().map(|(event, _)| (event.start + offset).div_euclid(86400)).collect();
let mut rows: Vec<Box<dyn Widget>> = vec![Box::new(Row::new(
["M", "T", "W", "T", "F", "S", "S"].iter().map(|letter| Box::new(Rectangle::new().width(CELL).height(text::tiny() + 8.0).align_child(Center, Center).child(Text::new(*letter).size(text::tiny()).color(theme.dim))) as Box<dyn Widget>).collect(),
))];
for week in 0..6 {
let mut cells: Vec<Box<dyn Widget>> = Vec::new();
for weekday in 0..7 {
let date = first - lead + week * 7 + weekday;
let inside = date >= first && date < next;
let number = parse::civil_from_days(date).2;
let is_today = date == today;
let dotted = inside && busy.contains(&date);
let color = if is_today { theme.on_accent } else if inside { theme.text } else { faded(theme.dim, 60) };
let mut layers: Vec<Box<dyn Widget>> = vec![Box::new(Text::new(number.to_string()).size(text::tiny()).weight(if is_today { Weight::Bold } else { Weight::Regular }).color(color))];
if dotted && !is_today {
layers.push(Box::new(Rectangle::new().width(3.0).height(3.0).radius(Full).fill(theme.accent).translate(0.0, 8.0)));
}
cells.push(Box::new(
Rectangle::new()
.width(CELL)
.height(CELL)
.radius(Full)
.fill(if is_today { theme.accent } else { amane::Color::rgba(0, 0, 0, 0) })
.align_child(Center, Center)
.child(Stack::new(layers)),
));
}
rows.push(Box::new(Row::new(cells)));
}
let grid = Column::new(rows);
let account = agenda_settings::account();
let list: Box<dyn Widget> = if agenda.form {
sign_in_form(agenda, list_w)
} else if account.is_empty() {
Box::new(
Column::new(vec![
Box::new(Text::new("Calendar").size(text::small()).weight(Weight::Bold).color(theme.text)) as Box<dyn Widget>,
Box::new(Text::new("Sign in with your Apple ID and an app-specific password to see your iCloud calendar here.").size(text::small()).color(theme.dim).wrap()),
Box::new(pods::chip("Sign in to iCloud", None, || Agenda::write().form = true)),
])
.gap(8.0),
)
} else {
let mut lines: Vec<Box<dyn Widget>> = vec![Box::new(
Row::new(vec![
Box::new(Text::new("Coming up").size(text::small()).weight(Weight::Bold).color(theme.text)) as Box<dyn Widget>,
Box::new(pods::chip("Sign out", None, sign_out)),
])
.width(list_w)
.justify(SpaceBetween)
.align(Center),
)];
if let Some(problem) = &agenda.problem {
lines.push(Box::new(Text::new(problem.as_str()).size(text::small()).color(crate::config::appearance::overlays::low_battery()).wrap()));
} else if !agenda.synced {
lines.push(Box::new(Text::new("Syncing…").size(text::small()).color(theme.dim)));
} else if agenda.events.is_empty() {
lines.push(Box::new(Text::new(format!("Nothing in the next {} days", agenda_settings::days())).size(text::small()).color(theme.dim)));
}
for (event, color) in agenda.events.iter().take(agenda_settings::events()) {
let start = event.start + offset;
let start_day = start.div_euclid(86400);
let clock = format!("{:02}:{:02}", start.rem_euclid(86400) / 3600, start.rem_euclid(3600) / 60);
let when = match (start_day - today, event.all_day) {
(..=0, true) => String::from("Today"),
(..=0, false) => clock,
(1, true) => String::from("Tomorrow"),
(1, false) => format!("Tmrw {clock}"),
(_, true) => WEEKDAYS[parse::weekday(start_day) as usize % 7][..3].to_string(),
(_, false) => format!("{} {clock}", &WEEKDAYS[parse::weekday(start_day) as usize % 7][..3]),
};
lines.push(Box::new(
Row::new(vec![
Box::new(Rectangle::new().width(6.0).height(6.0).radius(Full).fill(color.unwrap_or(theme.accent))) as Box<dyn Widget>,
Box::new(Rectangle::new().width(64.0).height(text::small() + 6.0).align_child(Start, Center).child(Text::new(when).size(text::small()).color(theme.dim))),
Box::new(Rectangle::new().width(list_w - 84.0).height(text::small() + 6.0).align_child(Start, Center).child(Text::new(event.summary.as_str()).size(text::small()).color(theme.text).elide())),
])
.gap(7.0)
.align(Center),
));
}
Box::new(Column::new(lines).gap(4.0))
};
Box::new(
Rectangle::new()
.width(width)
.height(calendar_h())
.child(Row::new(vec![Box::new(grid) as Box<dyn Widget>, Box::new(Rectangle::new().width(list_w).height(calendar_h()).child(Column::new(vec![list])))]).gap(20.0)),
)
}
fn sign_in_form(agenda: &Agenda, width: f32) -> Box<dyn Widget> {
let theme = theme::now();
let field = |input: TextInput| Rectangle::new().width(width).height(32.0).radius(10.0).fill(overlays::chip()).padding(amane::Padding { top: 0.0, right: 10.0, bottom: 0.0, left: 10.0 }).align_child(Start, Center).child(input);
let account = TextInput::new("home-account").width(width - 20.0).size(text::small()).color(theme.text).placeholder("Apple ID (e-mail)").focused().on_change(|typed| Agenda::write().account = typed);
let password = TextInput::new("home-password").width(width - 20.0).size(text::small()).color(theme.text).placeholder("App-specific password, then Enter").password().on_submit(|typed| {
let account = Agenda::read().account.clone();
sign_in(account, typed);
});
let mut parts: Vec<Box<dyn Widget>> = vec![
Box::new(Text::new("iCloud calendar").size(text::small()).weight(Weight::Bold).color(theme.text)),
Box::new(field(account)),
Box::new(field(password)),
Box::new(Text::new("Make the password at account.apple.com › Sign-In and Security › App-Specific Passwords.").size(text::tiny()).color(theme.dim).wrap()),
];
if let Some(problem) = &agenda.problem {
parts.push(Box::new(Text::new(problem.as_str()).size(text::tiny()).color(crate::config::appearance::overlays::low_battery()).wrap()));
}
parts.push(Box::new(pods::chip("Cancel", None, || Agenda::write().form = false)));
Box::new(Column::new(parts).gap(6.0))
}