From 35442aa248c83e43b08fe80e86f938bde78c4221 Mon Sep 17 00:00:00 2001 From: Denys Papushaiev Date: Tue, 21 Apr 2026 18:08:36 +0200 Subject: [PATCH 1/3] Add ICS to Tempo --- src/config.rs | 30 +++ src/modules/tempo.rs | 585 ++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 580 insertions(+), 35 deletions(-) diff --git a/src/config.rs b/src/config.rs index db00fff89..d76c885c8 100644 --- a/src/config.rs +++ b/src/config.rs @@ -450,11 +450,39 @@ pub struct TempoModuleConfig { pub timezones: Vec, #[serde(default)] pub weather_location: Option, + #[serde(default)] + pub calendar_type: TempoCalendarType, + #[serde(default)] + pub calendars: Vec, pub weather_indicator: WeatherIndicator, #[serde(deserialize_with = "deserialize_locale")] pub locale: Locale, } +#[derive(Deserialize, Default, Clone, Debug, PartialEq, Eq)] +pub enum TempoCalendarType { + #[default] + Calendar, + Events, +} + +#[derive(Deserialize, Clone, Debug, PartialEq, Eq, Hash)] +#[serde(untagged)] +pub enum TempoCalendarSource { + Url { + #[serde(rename = "Url")] + url: String, + #[serde(rename = "Color")] + color: String, + }, + Path { + #[serde(rename = "Path")] + path: String, + #[serde(rename = "Color")] + color: String, + }, +} + #[derive(Deserialize, Default, Clone, Debug, PartialEq, Eq)] pub enum WeatherIndicator { #[default] @@ -492,6 +520,8 @@ impl Default for TempoModuleConfig { formats: vec![], timezones: vec![], weather_location: None, + calendar_type: TempoCalendarType::Calendar, + calendars: vec![], weather_indicator: WeatherIndicator::IconAndTemperature, locale: Locale::en_US, } diff --git a/src/modules/tempo.rs b/src/modules/tempo.rs index 52c62ac71..38cd46301 100644 --- a/src/modules/tempo.rs +++ b/src/modules/tempo.rs @@ -4,7 +4,10 @@ use crate::{ icons::{StaticIcon, icon_button}, styled_button, }, - config::{TempoModuleConfig, WeatherIndicator, WeatherLocation}, + config::{ + TempoCalendarSource, TempoCalendarType, TempoModuleConfig, WeatherIndicator, + WeatherLocation, + }, theme::AshellTheme, }; use chrono::{ @@ -12,8 +15,9 @@ use chrono::{ Weekday, }; use chrono_tz::Tz; +use hex_color::HexColor; use iced::{ - Background, Border, Degrees, Element, + Background, Border, Color, Degrees, Element, Length::{self, FillPortion}, Padding, Rotation, Subscription, Theme, alignment::{Horizontal, Vertical}, @@ -25,6 +29,8 @@ use iced::{ use itertools::izip; use log::{debug, warn}; use serde::{Deserialize, Deserializer}; +use std::collections::HashSet; +use std::str::FromStr; use std::time::Duration; #[derive(Debug, Clone)] @@ -33,6 +39,7 @@ pub enum Message { ChangeSelectDate(Option), UpdateWeather(Box), UpdateLocation(Location), + UpdateCalendarEvents(Vec), CycleFormat, CycleTimezone(TimezoneDirection), SetTimezone(usize), @@ -49,12 +56,21 @@ pub enum Action { None, } +#[derive(Debug, Clone)] +pub(crate) struct CalendarEvent { + title: String, + start: DateTime, + end: DateTime, + color: Option, +} + pub struct Tempo { config: TempoModuleConfig, date: DateTime, selected_date: Option, weather_data: Option, location: Option, + calendar_events: Vec, current_format_index: usize, current_timezone_index: usize, } @@ -67,6 +83,7 @@ impl Tempo { selected_date: None, weather_data: None, location: None, + calendar_events: vec![], current_format_index: 0, current_timezone_index: 0, } @@ -106,6 +123,11 @@ impl Tempo { Action::None } + Message::UpdateCalendarEvents(events) => { + self.calendar_events = events; + + Action::None + } Message::CycleFormat => { if !self.config.formats.is_empty() { self.current_format_index = @@ -157,6 +179,7 @@ impl Tempo { } self.config = new_config; + self.calendar_events.clear(); Action::None } } @@ -240,7 +263,7 @@ impl Tempo { pub fn menu_view<'a>(&'a self, theme: &'a AshellTheme) -> Element<'a, Message> { container( Row::with_capacity(2) - .push(self.calendar(theme)) + .push(self.calendar_panel(theme)) .push(self.weather(theme)) .spacing(theme.space.lg), ) @@ -248,6 +271,45 @@ impl Tempo { .into() } + fn calendar_panel<'a>(&'a self, theme: &'a AshellTheme) -> Element<'a, Message> { + let header = styled_button( + theme, + Element::from( + column!( + text( + self.date + .format_localized("%A", self.config.locale) + .to_string() + ) + .size(theme.font_size.sm), + text( + self.date + .format_localized("%d %B %Y", self.config.locale) + .to_string() + ) + .size(theme.font_size.md), + ) + .spacing(theme.space.xs), + ), + ) + .size(ButtonSize::Large) + .kind(ButtonKind::Outline) + .on_press_maybe(if self.selected_date.is_some() { + Some(Message::ChangeSelectDate(None)) + } else { + None + }) + .width(Length::Fill); + + let content = if self.config.calendar_type == TempoCalendarType::Calendar { + column!(header, self.calendar(theme), self.timezones(theme)) + } else { + column!(header, self.events_view(theme), self.timezones(theme)) + }; + + content.spacing(theme.space.lg).width(225).into() + } + fn naive_date(&'_ self, timezone_index: usize) -> NaiveDate { let utc_now = self.date.with_timezone(&Utc); @@ -403,36 +465,7 @@ impl Tempo { ] .spacing(theme.space.md); - let timezones = Column::with_children( - self.config - .timezones - .iter() - .enumerate() - .map(|(index, tz_name)| { - if self.current_timezone_index == index { - container( - text(format!("{}: {}", tz_name, self.time_str("%d %h %R", index))) - .wrapping(text::Wrapping::Word), - ) - .padding([theme.space.xxs, theme.space.sm]) - .width(Length::Fill) - .style(|theme: &Theme| container::Style { - text_color: Some(theme.palette().success), - ..Default::default() - }) - .into() - } else { - styled_button( - theme, - format!("{}: {}", tz_name, self.time_str("%d %h %R", index)), - ) - .width(Length::Fill) - .on_press(Message::SetTimezone(index)) - .into() - } - }) - .collect::>>(), - ); + let timezones = self.timezones(theme); column!( styled_button( @@ -471,6 +504,152 @@ impl Tempo { .into() } + fn timezones<'a>(&'a self, theme: &'a AshellTheme) -> Column<'a, Message> { + Column::with_children( + self.config + .timezones + .iter() + .enumerate() + .map(|(index, tz_name)| { + if self.current_timezone_index == index { + container( + text(format!("{}: {}", tz_name, self.time_str("%d %h %R", index))) + .wrapping(text::Wrapping::Word), + ) + .padding([theme.space.xxs, theme.space.sm]) + .width(Length::Fill) + .style(|theme: &Theme| container::Style { + text_color: Some(theme.palette().success), + ..Default::default() + }) + .into() + } else { + styled_button( + theme, + format!("{}: {}", tz_name, self.time_str("%d %h %R", index)), + ) + .width(Length::Fill) + .on_press(Message::SetTimezone(index)) + .into() + } + }) + .collect::>>(), + ) + } + + fn events_view<'a>(&'a self, theme: &'a AshellTheme) -> Element<'a, Message> { + let events = if self.config.calendars.is_empty() { + self.mock_events() + } else { + self.calendar_events.clone() + }; + let today_start = self + .date + .date_naive() + .and_hms_opt(0, 0, 0) + .unwrap_or_default(); + let yesterday_start = today_start - chrono::Duration::days(1); + let tomorrow_start = today_start + chrono::Duration::days(1); + let day_after_tomorrow_start = tomorrow_start + chrono::Duration::days(1); + let event_opacity = theme.opacity; + Column::with_children( + events + .into_iter() + .filter(|event| { + let start = event.start.naive_local(); + let end = event.end.naive_local(); + + start >= yesterday_start + && start < day_after_tomorrow_start + && end >= yesterday_start + && end < day_after_tomorrow_start + && start < tomorrow_start + && end >= today_start + }) + .map(|event| { + let card_opacity = if event.end.naive_local() < self.date.naive_local() { + event_opacity * 0.35 + } else { + event_opacity + }; + let background = event + .color + .as_deref() + .and_then(|color| HexColor::from_str(color).ok()) + .map(|color| Color::from_rgb8(color.r, color.g, color.b)) + .unwrap_or_else(|| { + theme.iced_theme.extended_palette().background.weak.color + }); + + container( + column!( + text(event.title).size(theme.font_size.sm), + text(format!( + "{} - {}", + event.start.format("%R"), + event.end.format("%R") + )) + .size(theme.font_size.xs), + ) + .spacing(theme.space.xxs), + ) + .padding(theme.space.sm) + .width(Length::Fill) + .style(move |_theme: &Theme| container::Style { + background: Background::Color(background.scale_alpha(card_opacity)).into(), + border: Border::default().rounded(theme.radius.sm), + ..Default::default() + }) + .into() + }) + .collect::>>(), + ) + .spacing(theme.space.xs) + .into() + } + + fn mock_events(&self) -> Vec { + let day = self.date.date_naive(); + vec![ + CalendarEvent { + title: "Team sync".to_string(), + start: Local + .from_local_datetime(&day.and_hms_opt(9, 30, 0).unwrap_or_default()) + .single() + .unwrap_or(self.date), + end: Local + .from_local_datetime(&day.and_hms_opt(10, 0, 0).unwrap_or_default()) + .single() + .unwrap_or(self.date), + color: None, + }, + CalendarEvent { + title: "Release prep".to_string(), + start: Local + .from_local_datetime(&day.and_hms_opt(13, 0, 0).unwrap_or_default()) + .single() + .unwrap_or(self.date), + end: Local + .from_local_datetime(&day.and_hms_opt(14, 0, 0).unwrap_or_default()) + .single() + .unwrap_or(self.date), + color: None, + }, + CalendarEvent { + title: "Design review".to_string(), + start: Local + .from_local_datetime(&day.and_hms_opt(15, 30, 0).unwrap_or_default()) + .single() + .unwrap_or(self.date), + end: Local + .from_local_datetime(&day.and_hms_opt(16, 15, 0).unwrap_or_default()) + .single() + .unwrap_or(self.date), + color: None, + }, + ] + } + fn weather<'a>(&'a self, theme: &'a AshellTheme) -> Option> { self.weather_data .as_ref() @@ -817,14 +996,350 @@ impl Tempo { }) }); + let calendars_sub = if self.config.calendars.is_empty() { + None + } else { + let calendars = self.config.calendars.clone(); + Some(Subscription::run_with(calendars, |calendars| { + let calendars = calendars.clone(); + channel(100, async move |mut output| { + loop { + let mut events = Vec::new(); + for calendar in &calendars { + match fetch_calendar_events(calendar).await { + Ok(mut items) => events.append(&mut items), + Err(e) => warn!("Failed to fetch calendar: {:?}", e), + } + } + + events.sort_by_key(|event| event.start); + let _ = output.send(Message::UpdateCalendarEvents(events)).await; + tokio::time::sleep(Duration::from_secs(60 * 15)).await; + } + }) + })) + }; + + let mut subscriptions = vec![time_sub]; if let Some(weather_sub) = weather_sub { - Subscription::batch(vec![time_sub, weather_sub]) + subscriptions.push(weather_sub); + } + if let Some(calendars_sub) = calendars_sub { + subscriptions.push(calendars_sub); + } + + if subscriptions.len() > 1 { + Subscription::batch(subscriptions) + } else { + subscriptions.into_iter().next().unwrap() + } + } +} + +async fn fetch_calendar_events(source: &TempoCalendarSource) -> anyhow::Result> { + let raw = match source { + TempoCalendarSource::Url { url, .. } => { + reqwest::Client::new().get(url).send().await?.text().await? + } + TempoCalendarSource::Path { path, .. } => { + let expanded = shellexpand::tilde(path); + std::fs::read_to_string(expanded.as_ref())? + } + }; + + let today_start = Local::now() + .date_naive() + .and_hms_opt(0, 0, 0) + .unwrap_or_default(); + let window_start = today_start - chrono::Duration::days(1); + let window_end = today_start + chrono::Duration::days(2); + + Ok(parse_ics_events(&raw, source) + .into_iter() + .filter(|event| { + let start = event.start.naive_local(); + let end = event.end.naive_local(); + start >= window_start && start < window_end && end >= window_start && end < window_end + }) + .collect()) +} + +fn parse_ics_events(raw: &str, source: &TempoCalendarSource) -> Vec { + let color = match source { + TempoCalendarSource::Url { color, .. } | TempoCalendarSource::Path { color, .. } => { + Some(color.clone()) + } + }; + + let unfolded = unfold_ics_lines(raw); + + let events: Vec = unfolded + .split("BEGIN:VEVENT") + .filter_map(|chunk| { + let section = chunk.split("END:VEVENT").next()?; + let title = get_ics_value(section, "SUMMARY")?; + let (start_value, start_tzid) = get_ics_value_and_tzid(section, "DTSTART")?; + let start = match parse_ics_datetime(&start_value, start_tzid.as_deref()) { + Ok(start) => start, + Err(e) => { + warn!("Skipping ICS event with invalid DTSTART: {e:?}"); + return None; + } + }; + let end = get_ics_value_and_tzid(section, "DTEND") + .and_then(|(value, tzid)| parse_ics_datetime(&value, tzid.as_deref()).ok()) + .unwrap_or_else(|| start + chrono::Duration::hours(1)); + + let event = RecurringEvent { + title, + start, + end, + color: color.clone(), + rrule: get_ics_value(section, "RRULE"), + }; + + Some(event.expand()) + }) + .flatten() + .collect(); + + let mut seen = HashSet::new(); + events + .into_iter() + .filter(|event| { + seen.insert(( + event.title.clone(), + event.start.naive_local(), + event.end.naive_local(), + event.color.clone(), + )) + }) + .collect() +} + +#[derive(Clone)] +struct RecurringEvent { + title: String, + start: DateTime, + end: DateTime, + color: Option, + rrule: Option, +} + +impl RecurringEvent { + fn expand(self) -> Vec { + let Some(rrule) = self.rrule else { + return vec![CalendarEvent { + title: self.title, + start: self.start, + end: self.end, + color: self.color, + }]; + }; + + let rule = RRule::parse(&rrule); + let duration = self.end - self.start; + let mut out = Vec::new(); + let mut current = self.start; + let mut emitted = 0usize; + let interval = rule.interval.max(1) as i64; + let count = rule.count.unwrap_or(usize::MAX); + let until = rule.until; + + while emitted < count { + if let Some(until) = until + && current > until + { + break; + } + + if rule.matches(current) { + out.push(CalendarEvent { + title: self.title.clone(), + start: current, + end: current + duration, + color: self.color.clone(), + }); + emitted += 1; + } + + current = match rule.freq { + Frequency::Daily => current + chrono::Duration::days(interval), + Frequency::Weekly => current + chrono::Duration::weeks(interval), + Frequency::Monthly => current + chrono::Duration::days(30 * interval), + Frequency::Yearly => current + chrono::Duration::days(365 * interval), + }; + + if out.len() > 500 { + break; + } + } + + if out.is_empty() { + vec![CalendarEvent { + title: self.title, + start: self.start, + end: self.end, + color: self.color, + }] } else { - time_sub + out } } } +#[derive(Clone, Copy)] +enum Frequency { + Daily, + Weekly, + Monthly, + Yearly, +} + +struct RRule { + freq: Frequency, + interval: u32, + count: Option, + until: Option>, + byday: Vec, +} + +impl RRule { + fn parse(raw: &str) -> Self { + let mut freq = Frequency::Weekly; + let mut interval = 1; + let mut count = None; + let mut until = None; + let mut byday = vec![]; + + for part in raw.split(';') { + if let Some(v) = part.strip_prefix("FREQ=") { + freq = match v { + "DAILY" => Frequency::Daily, + "WEEKLY" => Frequency::Weekly, + "MONTHLY" => Frequency::Monthly, + "YEARLY" => Frequency::Yearly, + _ => Frequency::Weekly, + }; + } else if let Some(v) = part.strip_prefix("INTERVAL=") { + interval = v.parse().unwrap_or(1); + } else if let Some(v) = part.strip_prefix("COUNT=") { + count = v.parse().ok(); + } else if let Some(v) = part.strip_prefix("UNTIL=") { + until = parse_ics_datetime(v, None).ok(); + } else if let Some(v) = part.strip_prefix("BYDAY=") { + byday = v.split(',').filter_map(parse_weekday).collect(); + } + } + + Self { + freq, + interval, + count, + until, + byday, + } + } + + fn matches(&self, dt: DateTime) -> bool { + if self.byday.is_empty() { + return true; + } + self.byday.contains(&dt.weekday()) + } +} + +fn parse_weekday(v: &str) -> Option { + match v { + "MO" => Some(Weekday::Mon), + "TU" => Some(Weekday::Tue), + "WE" => Some(Weekday::Wed), + "TH" => Some(Weekday::Thu), + "FR" => Some(Weekday::Fri), + "SA" => Some(Weekday::Sat), + "SU" => Some(Weekday::Sun), + _ => None, + } +} + +fn unfold_ics_lines(raw: &str) -> String { + let mut out = String::new(); + for line in raw.lines() { + if line.starts_with(' ') || line.starts_with('\t') { + out.push_str(line.trim_start()); + } else { + if !out.is_empty() { + out.push('\n'); + } + out.push_str(line); + } + } + out +} + +fn get_ics_value(section: &str, key: &str) -> Option { + section + .lines() + .find(|line| line.starts_with(key)) + .and_then(|line| { + line.split_once(':') + .map(|(_, value)| value.trim().to_string()) + }) +} + +fn get_ics_value_and_tzid(section: &str, key: &str) -> Option<(String, Option)> { + section.lines().find_map(|line| { + let (prop, value) = line.split_once(':')?; + if !prop.starts_with(key) { + return None; + } + + let tzid = prop + .split(';') + .find_map(|part| part.strip_prefix("TZID=").map(|v| v.to_string())); + + Some((value.trim().to_string(), tzid)) + }) +} + +fn parse_ics_datetime(value: &str, tzid: Option<&str>) -> anyhow::Result> { + let value = value.trim(); + + if let Some(utc_value) = value.strip_suffix('Z') + && let Ok(dt) = NaiveDateTime::parse_from_str(utc_value, "%Y%m%dT%H%M%S") + { + return Ok(Utc.from_utc_datetime(&dt).with_timezone(&Local)); + } + + if let Ok(dt) = NaiveDateTime::parse_from_str(value, "%Y%m%dT%H%M%S") { + if let Some(tzid) = tzid + && let Ok(tz) = tzid.parse::() + { + return Ok(tz + .from_local_datetime(&dt) + .single() + .unwrap_or_else(|| tz.from_utc_datetime(&dt)) + .with_timezone(&Local)); + } + + if let Some(local_dt) = Local.from_local_datetime(&dt).single() { + return Ok(local_dt); + } + return Ok(Local.from_utc_datetime(&dt)); + } + + if let Ok(date) = NaiveDate::parse_from_str(value, "%Y%m%d") { + return Ok(Local + .from_local_datetime(&date.and_hms_opt(0, 0, 0).unwrap_or_default()) + .single() + .unwrap_or_else(|| { + Local.from_utc_datetime(&date.and_hms_opt(0, 0, 0).unwrap_or_default()) + })); + } + + anyhow::bail!("unsupported ICS datetime: {value}") +} + async fn fetch_location(location: &WeatherLocation) -> anyhow::Result { let client = reqwest::Client::builder() .timeout(Duration::from_secs(20)) From be176168cfcca9ae1c08211ba8949f190d6ec0e1 Mon Sep 17 00:00:00 2001 From: Denys Papushaiev Date: Tue, 21 Apr 2026 18:23:42 +0200 Subject: [PATCH 2/3] ICS improvements --- src/modules/tempo.rs | 280 ++++++++++++++++++++++++++++++------------- 1 file changed, 194 insertions(+), 86 deletions(-) diff --git a/src/modules/tempo.rs b/src/modules/tempo.rs index 38cd46301..e12139e89 100644 --- a/src/modules/tempo.rs +++ b/src/modules/tempo.rs @@ -31,6 +31,7 @@ use log::{debug, warn}; use serde::{Deserialize, Deserializer}; use std::collections::HashSet; use std::str::FromStr; +use std::sync::{Arc, Mutex, OnceLock}; use std::time::Duration; #[derive(Debug, Clone)] @@ -64,6 +65,15 @@ pub(crate) struct CalendarEvent { color: Option, } +#[derive(Clone)] +struct CalendarCacheEntry { + events: Vec, + updated_at: std::time::Instant, +} + +static CALENDAR_CACHE: OnceLock>>> = + OnceLock::new(); + pub struct Tempo { config: TempoModuleConfig, date: DateTime, @@ -272,44 +282,112 @@ impl Tempo { } fn calendar_panel<'a>(&'a self, theme: &'a AshellTheme) -> Element<'a, Message> { - let header = styled_button( - theme, - Element::from( - column!( - text( - self.date - .format_localized("%A", self.config.locale) - .to_string() - ) - .size(theme.font_size.sm), - text( - self.date - .format_localized("%d %B %Y", self.config.locale) - .to_string() - ) - .size(theme.font_size.md), - ) - .spacing(theme.space.xs), - ), - ) - .size(ButtonSize::Large) - .kind(ButtonKind::Outline) - .on_press_maybe(if self.selected_date.is_some() { - Some(Message::ChangeSelectDate(None)) - } else { - None - }) - .width(Length::Fill); - let content = if self.config.calendar_type == TempoCalendarType::Calendar { - column!(header, self.calendar(theme), self.timezones(theme)) + column!( + self.calendar_header(theme, false), + self.calendar(theme), + self.timezones(theme) + ) } else { - column!(header, self.events_view(theme), self.timezones(theme)) + column!( + self.calendar_header(theme, true), + self.events_view(theme), + self.timezones(theme) + ) }; content.spacing(theme.space.lg).width(225).into() } + fn calendar_header<'a>( + &'a self, + theme: &'a AshellTheme, + events_mode: bool, + ) -> Element<'a, Message> { + let date = if events_mode { + self.selected_date + .unwrap_or_else(|| self.naive_date(self.current_timezone_index)) + } else { + self.date.date_naive() + }; + + let content = if events_mode { + column!( + text( + date.format_localized("%a, %d %b %Y", self.config.locale) + .to_string() + ) + .align_x(Horizontal::Center) + .wrapping(text::Wrapping::None) + .size(theme.font_size.sm), + ) + } else { + column!( + text(date.format_localized("%A", self.config.locale).to_string()) + .size(theme.font_size.sm), + text( + date.format_localized("%d %B %Y", self.config.locale) + .to_string() + ) + .size(theme.font_size.md), + ) + .spacing(theme.space.xs) + }; + + if events_mode { + Row::with_capacity(3) + .push( + container( + icon_button::(theme, StaticIcon::LeftChevron) + .kind(ButtonKind::Solid) + .on_press(Message::ChangeSelectDate(Some( + date - chrono::Duration::days(1), + ))), + ) + .width(Length::Shrink), + ) + .push( + container( + styled_button(theme, Element::from(content)) + .size(ButtonSize::Large) + .kind(ButtonKind::Outline) + .width(Length::Fixed(145.0)) + .on_press_maybe(if self.selected_date.is_some() { + Some(Message::ChangeSelectDate(None)) + } else { + None + }), + ) + .width(Length::Fill) + .center_x(Length::Fill), + ) + .push( + container( + icon_button::(theme, StaticIcon::RightChevron) + .kind(ButtonKind::Solid) + .on_press(Message::ChangeSelectDate(Some( + date + chrono::Duration::days(1), + ))), + ) + .width(Length::Shrink), + ) + .spacing(theme.space.xs) + .align_y(Vertical::Center) + .into() + } else { + styled_button(theme, Element::from(content)) + .size(ButtonSize::Large) + .kind(ButtonKind::Outline) + .on_press_maybe(if self.selected_date.is_some() { + Some(Message::ChangeSelectDate(None)) + } else { + None + }) + .width(Length::Fill) + .into() + } + } + fn naive_date(&'_ self, timezone_index: usize) -> NaiveDate { let utc_now = self.date.with_timezone(&Utc); @@ -413,34 +491,45 @@ impl Tempo { .map(|_| { let day = current; current = current.succ_opt().unwrap_or(current); + let event_count = self.events_on_day(day); styled_button( theme, Element::from( - text( - day.format_localized("%d", self.config.locale) - .to_string(), + column!( + text( + day.format_localized("%d", self.config.locale) + .to_string(), + ) + .align_x(Horizontal::Center) + .color_maybe({ + if day + == self + .naive_date(self.current_timezone_index) + { + Some(theme.iced_theme.palette().success) + } else if day == selected_date { + Some(theme.iced_theme.palette().primary) + } else if day.month0() != current_month { + Some( + theme + .iced_theme + .palette() + .text + .scale_alpha(0.2), + ) + } else { + None + } + }), + (event_count > 0).then(|| { + text("•") + .align_x(Horizontal::Center) + .size(theme.font_size.xs) + .color(theme.iced_theme.palette().primary) + }), ) - .align_x(Horizontal::Center) - .color_maybe({ - if day - == self.naive_date(self.current_timezone_index) - { - Some(theme.iced_theme.palette().success) - } else if day == selected_date { - Some(theme.iced_theme.palette().primary) - } else if day.month0() != current_month { - Some( - theme - .iced_theme - .palette() - .text - .scale_alpha(0.2), - ) - } else { - None - } - }), + .spacing(theme.space.xxs), ), ) .on_press_maybe( @@ -543,28 +632,18 @@ impl Tempo { } else { self.calendar_events.clone() }; - let today_start = self - .date - .date_naive() - .and_hms_opt(0, 0, 0) - .unwrap_or_default(); - let yesterday_start = today_start - chrono::Duration::days(1); + let selected_day = self + .selected_date + .unwrap_or_else(|| self.naive_date(self.current_timezone_index)); + let today_start = selected_day.and_hms_opt(0, 0, 0).unwrap_or_default(); let tomorrow_start = today_start + chrono::Duration::days(1); - let day_after_tomorrow_start = tomorrow_start + chrono::Duration::days(1); let event_opacity = theme.opacity; Column::with_children( events .into_iter() .filter(|event| { - let start = event.start.naive_local(); - let end = event.end.naive_local(); - - start >= yesterday_start - && start < day_after_tomorrow_start - && end >= yesterday_start - && end < day_after_tomorrow_start - && start < tomorrow_start - && end >= today_start + event.start.naive_local() < tomorrow_start + && event.end.naive_local() >= today_start }) .map(|event| { let card_opacity = if event.end.naive_local() < self.date.naive_local() { @@ -650,6 +729,17 @@ impl Tempo { ] } + fn events_on_day(&self, day: NaiveDate) -> usize { + self.calendar_events + .iter() + .filter(|event| { + let start = event.start.naive_local().date(); + let end = event.end.naive_local().date(); + start <= day && end >= day + }) + .count() + } + fn weather<'a>(&'a self, theme: &'a AshellTheme) -> Option> { self.weather_data .as_ref() @@ -1006,7 +1096,7 @@ impl Tempo { loop { let mut events = Vec::new(); for calendar in &calendars { - match fetch_calendar_events(calendar).await { + match fetch_calendar_events_cached(calendar).await { Ok(mut items) => events.append(&mut items), Err(e) => warn!("Failed to fetch calendar: {:?}", e), } @@ -1014,7 +1104,7 @@ impl Tempo { events.sort_by_key(|event| event.start); let _ = output.send(Message::UpdateCalendarEvents(events)).await; - tokio::time::sleep(Duration::from_secs(60 * 15)).await; + tokio::time::sleep(Duration::from_secs(60 * 10)).await; } }) })) @@ -1047,21 +1137,39 @@ async fn fetch_calendar_events(source: &TempoCalendarSource) -> anyhow::Result= window_start && start < window_end && end >= window_start && end < window_end - }) - .collect()) +async fn fetch_calendar_events_cached( + source: &TempoCalendarSource, +) -> anyhow::Result> { + let key = match source { + TempoCalendarSource::Url { url, .. } => format!("url:{url}"), + TempoCalendarSource::Path { path, .. } => format!("path:{path}"), + }; + + let cache = CALENDAR_CACHE + .get_or_init(|| Arc::new(Mutex::new(std::collections::HashMap::new()))) + .clone(); + + if let Some(entry) = cache.lock().ok().and_then(|m| m.get(&key).cloned()) + && entry.updated_at.elapsed() < Duration::from_secs(60 * 10) + { + return Ok(entry.events); + } + + let events = fetch_calendar_events(source).await?; + if let Ok(mut guard) = cache.lock() { + guard.insert( + key, + CalendarCacheEntry { + events: events.clone(), + updated_at: std::time::Instant::now(), + }, + ); + } + + Ok(events) } fn parse_ics_events(raw: &str, source: &TempoCalendarSource) -> Vec { From 69b597a07cfe65f69fd28be67d3180ec1b3164ef Mon Sep 17 00:00:00 2001 From: Denys Papushaiev Date: Tue, 21 Apr 2026 18:36:51 +0200 Subject: [PATCH 3/3] Feature Add ICS --- src/components/event_card.rs | 40 ++ src/components/mod.rs | 2 + src/modules/tempo.rs | 495 ++------------------ src/services/mod.rs | 1 + src/services/tempo_calendar.rs | 334 +++++++++++++ website/docs/configuration/modules/tempo.md | 16 +- 6 files changed, 420 insertions(+), 468 deletions(-) create mode 100644 src/components/event_card.rs create mode 100644 src/services/tempo_calendar.rs diff --git a/src/components/event_card.rs b/src/components/event_card.rs new file mode 100644 index 000000000..c96b23a00 --- /dev/null +++ b/src/components/event_card.rs @@ -0,0 +1,40 @@ +use hex_color::HexColor; +use iced::{ + Background, Border, Color, Element, Length, Theme, + widget::{column, container, text}, +}; +use std::str::FromStr; + +use crate::theme::AshellTheme; + +pub fn event_card<'a, Message: 'a>( + theme: &'a AshellTheme, + title: impl Into, + time_range: impl Into, + color: Option, + opacity: f32, + past: bool, +) -> Element<'a, Message> { + let background = color + .as_deref() + .and_then(|color| HexColor::from_str(color).ok()) + .map(|color| Color::from_rgb8(color.r, color.g, color.b)) + .unwrap_or_else(|| theme.iced_theme.extended_palette().background.weak.color); + let card_opacity = if past { opacity * 0.35 } else { opacity }; + + container( + column!( + text(title.into()).size(theme.font_size.sm), + text(time_range.into()).size(theme.font_size.xs), + ) + .spacing(theme.space.xxs), + ) + .padding(theme.space.sm) + .width(Length::Fill) + .style(move |_theme: &Theme| iced::widget::container::Style { + background: Background::Color(background.scale_alpha(card_opacity)).into(), + border: Border::default().rounded(theme.radius.sm), + ..Default::default() + }) + .into() +} diff --git a/src/components/mod.rs b/src/components/mod.rs index 1c1e182ce..0adfed962 100644 --- a/src/components/mod.rs +++ b/src/components/mod.rs @@ -1,5 +1,6 @@ pub mod button; mod centerbox; +mod event_card; mod format_indicator; pub mod icons; pub mod menu; @@ -14,6 +15,7 @@ mod sub_menu_wrapper; pub use button::*; pub use centerbox::*; +pub use event_card::*; pub use format_indicator::*; pub use menu::MenuSize; pub use menu_wrapper::*; diff --git a/src/modules/tempo.rs b/src/modules/tempo.rs index e12139e89..4f654260e 100644 --- a/src/modules/tempo.rs +++ b/src/modules/tempo.rs @@ -1,13 +1,11 @@ use crate::{ components::{ - ButtonKind, ButtonSize, MenuSize, + ButtonKind, ButtonSize, MenuSize, event_card, icons::{StaticIcon, icon_button}, styled_button, }, - config::{ - TempoCalendarSource, TempoCalendarType, TempoModuleConfig, WeatherIndicator, - WeatherLocation, - }, + config::{TempoCalendarType, TempoModuleConfig, WeatherIndicator, WeatherLocation}, + services::tempo_calendar::CalendarEvent, theme::AshellTheme, }; use chrono::{ @@ -15,9 +13,8 @@ use chrono::{ Weekday, }; use chrono_tz::Tz; -use hex_color::HexColor; use iced::{ - Background, Border, Color, Degrees, Element, + Background, Border, Degrees, Element, Length::{self, FillPortion}, Padding, Rotation, Subscription, Theme, alignment::{Horizontal, Vertical}, @@ -29,9 +26,6 @@ use iced::{ use itertools::izip; use log::{debug, warn}; use serde::{Deserialize, Deserializer}; -use std::collections::HashSet; -use std::str::FromStr; -use std::sync::{Arc, Mutex, OnceLock}; use std::time::Duration; #[derive(Debug, Clone)] @@ -57,23 +51,6 @@ pub enum Action { None, } -#[derive(Debug, Clone)] -pub(crate) struct CalendarEvent { - title: String, - start: DateTime, - end: DateTime, - color: Option, -} - -#[derive(Clone)] -struct CalendarCacheEntry { - events: Vec, - updated_at: std::time::Instant, -} - -static CALENDAR_CACHE: OnceLock>>> = - OnceLock::new(); - pub struct Tempo { config: TempoModuleConfig, date: DateTime, @@ -308,7 +285,8 @@ impl Tempo { self.selected_date .unwrap_or_else(|| self.naive_date(self.current_timezone_index)) } else { - self.date.date_naive() + self.selected_date + .unwrap_or_else(|| self.naive_date(self.current_timezone_index)) }; let content = if events_mode { @@ -434,6 +412,10 @@ impl Tempo { 6 }; + let header_date = self + .selected_date + .unwrap_or_else(|| self.naive_date(self.current_timezone_index)); + let calendar = column![ row![ icon_button::(theme, StaticIcon::LeftChevron) @@ -442,7 +424,7 @@ impl Tempo { selected_date.checked_sub_months(Months::new(1)), )), text( - selected_date + header_date .format_localized("%B", self.config.locale) .to_string() ) @@ -556,41 +538,10 @@ impl Tempo { let timezones = self.timezones(theme); - column!( - styled_button( - theme, - Element::from( - column!( - text( - self.date - .format_localized("%A", self.config.locale) - .to_string() - ) - .size(theme.font_size.sm), - text( - self.date - .format_localized("%d %B %Y", self.config.locale) - .to_string() - ) - .size(theme.font_size.md), - ) - .spacing(theme.space.xs), - ), - ) - .size(ButtonSize::Large) - .kind(ButtonKind::Outline) - .on_press_maybe(if self.selected_date.is_some() { - Some(Message::ChangeSelectDate(None)) - } else { - None - }) - .width(Length::Fill), - calendar, - timezones, - ) - .spacing(theme.space.lg) - .width(225) - .into() + column!(calendar, timezones) + .spacing(theme.space.lg) + .width(225) + .into() } fn timezones<'a>(&'a self, theme: &'a AshellTheme) -> Column<'a, Message> { @@ -627,11 +578,7 @@ impl Tempo { } fn events_view<'a>(&'a self, theme: &'a AshellTheme) -> Element<'a, Message> { - let events = if self.config.calendars.is_empty() { - self.mock_events() - } else { - self.calendar_events.clone() - }; + let events = self.calendar_events.clone(); let selected_day = self .selected_date .unwrap_or_else(|| self.naive_date(self.current_timezone_index)); @@ -646,40 +593,14 @@ impl Tempo { && event.end.naive_local() >= today_start }) .map(|event| { - let card_opacity = if event.end.naive_local() < self.date.naive_local() { - event_opacity * 0.35 - } else { - event_opacity - }; - let background = event - .color - .as_deref() - .and_then(|color| HexColor::from_str(color).ok()) - .map(|color| Color::from_rgb8(color.r, color.g, color.b)) - .unwrap_or_else(|| { - theme.iced_theme.extended_palette().background.weak.color - }); - - container( - column!( - text(event.title).size(theme.font_size.sm), - text(format!( - "{} - {}", - event.start.format("%R"), - event.end.format("%R") - )) - .size(theme.font_size.xs), - ) - .spacing(theme.space.xxs), + event_card( + theme, + event.title, + format!("{} - {}", event.start.format("%R"), event.end.format("%R")), + event.color, + event_opacity, + event.end.naive_local() < self.date.naive_local(), ) - .padding(theme.space.sm) - .width(Length::Fill) - .style(move |_theme: &Theme| container::Style { - background: Background::Color(background.scale_alpha(card_opacity)).into(), - border: Border::default().rounded(theme.radius.sm), - ..Default::default() - }) - .into() }) .collect::>>(), ) @@ -687,48 +608,6 @@ impl Tempo { .into() } - fn mock_events(&self) -> Vec { - let day = self.date.date_naive(); - vec![ - CalendarEvent { - title: "Team sync".to_string(), - start: Local - .from_local_datetime(&day.and_hms_opt(9, 30, 0).unwrap_or_default()) - .single() - .unwrap_or(self.date), - end: Local - .from_local_datetime(&day.and_hms_opt(10, 0, 0).unwrap_or_default()) - .single() - .unwrap_or(self.date), - color: None, - }, - CalendarEvent { - title: "Release prep".to_string(), - start: Local - .from_local_datetime(&day.and_hms_opt(13, 0, 0).unwrap_or_default()) - .single() - .unwrap_or(self.date), - end: Local - .from_local_datetime(&day.and_hms_opt(14, 0, 0).unwrap_or_default()) - .single() - .unwrap_or(self.date), - color: None, - }, - CalendarEvent { - title: "Design review".to_string(), - start: Local - .from_local_datetime(&day.and_hms_opt(15, 30, 0).unwrap_or_default()) - .single() - .unwrap_or(self.date), - end: Local - .from_local_datetime(&day.and_hms_opt(16, 15, 0).unwrap_or_default()) - .single() - .unwrap_or(self.date), - color: None, - }, - ] - } - fn events_on_day(&self, day: NaiveDate) -> usize { self.calendar_events .iter() @@ -1096,7 +975,11 @@ impl Tempo { loop { let mut events = Vec::new(); for calendar in &calendars { - match fetch_calendar_events_cached(calendar).await { + match crate::services::tempo_calendar::fetch_calendar_events_cached( + calendar, + ) + .await + { Ok(mut items) => events.append(&mut items), Err(e) => warn!("Failed to fetch calendar: {:?}", e), } @@ -1126,328 +1009,6 @@ impl Tempo { } } -async fn fetch_calendar_events(source: &TempoCalendarSource) -> anyhow::Result> { - let raw = match source { - TempoCalendarSource::Url { url, .. } => { - reqwest::Client::new().get(url).send().await?.text().await? - } - TempoCalendarSource::Path { path, .. } => { - let expanded = shellexpand::tilde(path); - std::fs::read_to_string(expanded.as_ref())? - } - }; - - Ok(parse_ics_events(&raw, source)) -} - -async fn fetch_calendar_events_cached( - source: &TempoCalendarSource, -) -> anyhow::Result> { - let key = match source { - TempoCalendarSource::Url { url, .. } => format!("url:{url}"), - TempoCalendarSource::Path { path, .. } => format!("path:{path}"), - }; - - let cache = CALENDAR_CACHE - .get_or_init(|| Arc::new(Mutex::new(std::collections::HashMap::new()))) - .clone(); - - if let Some(entry) = cache.lock().ok().and_then(|m| m.get(&key).cloned()) - && entry.updated_at.elapsed() < Duration::from_secs(60 * 10) - { - return Ok(entry.events); - } - - let events = fetch_calendar_events(source).await?; - if let Ok(mut guard) = cache.lock() { - guard.insert( - key, - CalendarCacheEntry { - events: events.clone(), - updated_at: std::time::Instant::now(), - }, - ); - } - - Ok(events) -} - -fn parse_ics_events(raw: &str, source: &TempoCalendarSource) -> Vec { - let color = match source { - TempoCalendarSource::Url { color, .. } | TempoCalendarSource::Path { color, .. } => { - Some(color.clone()) - } - }; - - let unfolded = unfold_ics_lines(raw); - - let events: Vec = unfolded - .split("BEGIN:VEVENT") - .filter_map(|chunk| { - let section = chunk.split("END:VEVENT").next()?; - let title = get_ics_value(section, "SUMMARY")?; - let (start_value, start_tzid) = get_ics_value_and_tzid(section, "DTSTART")?; - let start = match parse_ics_datetime(&start_value, start_tzid.as_deref()) { - Ok(start) => start, - Err(e) => { - warn!("Skipping ICS event with invalid DTSTART: {e:?}"); - return None; - } - }; - let end = get_ics_value_and_tzid(section, "DTEND") - .and_then(|(value, tzid)| parse_ics_datetime(&value, tzid.as_deref()).ok()) - .unwrap_or_else(|| start + chrono::Duration::hours(1)); - - let event = RecurringEvent { - title, - start, - end, - color: color.clone(), - rrule: get_ics_value(section, "RRULE"), - }; - - Some(event.expand()) - }) - .flatten() - .collect(); - - let mut seen = HashSet::new(); - events - .into_iter() - .filter(|event| { - seen.insert(( - event.title.clone(), - event.start.naive_local(), - event.end.naive_local(), - event.color.clone(), - )) - }) - .collect() -} - -#[derive(Clone)] -struct RecurringEvent { - title: String, - start: DateTime, - end: DateTime, - color: Option, - rrule: Option, -} - -impl RecurringEvent { - fn expand(self) -> Vec { - let Some(rrule) = self.rrule else { - return vec![CalendarEvent { - title: self.title, - start: self.start, - end: self.end, - color: self.color, - }]; - }; - - let rule = RRule::parse(&rrule); - let duration = self.end - self.start; - let mut out = Vec::new(); - let mut current = self.start; - let mut emitted = 0usize; - let interval = rule.interval.max(1) as i64; - let count = rule.count.unwrap_or(usize::MAX); - let until = rule.until; - - while emitted < count { - if let Some(until) = until - && current > until - { - break; - } - - if rule.matches(current) { - out.push(CalendarEvent { - title: self.title.clone(), - start: current, - end: current + duration, - color: self.color.clone(), - }); - emitted += 1; - } - - current = match rule.freq { - Frequency::Daily => current + chrono::Duration::days(interval), - Frequency::Weekly => current + chrono::Duration::weeks(interval), - Frequency::Monthly => current + chrono::Duration::days(30 * interval), - Frequency::Yearly => current + chrono::Duration::days(365 * interval), - }; - - if out.len() > 500 { - break; - } - } - - if out.is_empty() { - vec![CalendarEvent { - title: self.title, - start: self.start, - end: self.end, - color: self.color, - }] - } else { - out - } - } -} - -#[derive(Clone, Copy)] -enum Frequency { - Daily, - Weekly, - Monthly, - Yearly, -} - -struct RRule { - freq: Frequency, - interval: u32, - count: Option, - until: Option>, - byday: Vec, -} - -impl RRule { - fn parse(raw: &str) -> Self { - let mut freq = Frequency::Weekly; - let mut interval = 1; - let mut count = None; - let mut until = None; - let mut byday = vec![]; - - for part in raw.split(';') { - if let Some(v) = part.strip_prefix("FREQ=") { - freq = match v { - "DAILY" => Frequency::Daily, - "WEEKLY" => Frequency::Weekly, - "MONTHLY" => Frequency::Monthly, - "YEARLY" => Frequency::Yearly, - _ => Frequency::Weekly, - }; - } else if let Some(v) = part.strip_prefix("INTERVAL=") { - interval = v.parse().unwrap_or(1); - } else if let Some(v) = part.strip_prefix("COUNT=") { - count = v.parse().ok(); - } else if let Some(v) = part.strip_prefix("UNTIL=") { - until = parse_ics_datetime(v, None).ok(); - } else if let Some(v) = part.strip_prefix("BYDAY=") { - byday = v.split(',').filter_map(parse_weekday).collect(); - } - } - - Self { - freq, - interval, - count, - until, - byday, - } - } - - fn matches(&self, dt: DateTime) -> bool { - if self.byday.is_empty() { - return true; - } - self.byday.contains(&dt.weekday()) - } -} - -fn parse_weekday(v: &str) -> Option { - match v { - "MO" => Some(Weekday::Mon), - "TU" => Some(Weekday::Tue), - "WE" => Some(Weekday::Wed), - "TH" => Some(Weekday::Thu), - "FR" => Some(Weekday::Fri), - "SA" => Some(Weekday::Sat), - "SU" => Some(Weekday::Sun), - _ => None, - } -} - -fn unfold_ics_lines(raw: &str) -> String { - let mut out = String::new(); - for line in raw.lines() { - if line.starts_with(' ') || line.starts_with('\t') { - out.push_str(line.trim_start()); - } else { - if !out.is_empty() { - out.push('\n'); - } - out.push_str(line); - } - } - out -} - -fn get_ics_value(section: &str, key: &str) -> Option { - section - .lines() - .find(|line| line.starts_with(key)) - .and_then(|line| { - line.split_once(':') - .map(|(_, value)| value.trim().to_string()) - }) -} - -fn get_ics_value_and_tzid(section: &str, key: &str) -> Option<(String, Option)> { - section.lines().find_map(|line| { - let (prop, value) = line.split_once(':')?; - if !prop.starts_with(key) { - return None; - } - - let tzid = prop - .split(';') - .find_map(|part| part.strip_prefix("TZID=").map(|v| v.to_string())); - - Some((value.trim().to_string(), tzid)) - }) -} - -fn parse_ics_datetime(value: &str, tzid: Option<&str>) -> anyhow::Result> { - let value = value.trim(); - - if let Some(utc_value) = value.strip_suffix('Z') - && let Ok(dt) = NaiveDateTime::parse_from_str(utc_value, "%Y%m%dT%H%M%S") - { - return Ok(Utc.from_utc_datetime(&dt).with_timezone(&Local)); - } - - if let Ok(dt) = NaiveDateTime::parse_from_str(value, "%Y%m%dT%H%M%S") { - if let Some(tzid) = tzid - && let Ok(tz) = tzid.parse::() - { - return Ok(tz - .from_local_datetime(&dt) - .single() - .unwrap_or_else(|| tz.from_utc_datetime(&dt)) - .with_timezone(&Local)); - } - - if let Some(local_dt) = Local.from_local_datetime(&dt).single() { - return Ok(local_dt); - } - return Ok(Local.from_utc_datetime(&dt)); - } - - if let Ok(date) = NaiveDate::parse_from_str(value, "%Y%m%d") { - return Ok(Local - .from_local_datetime(&date.and_hms_opt(0, 0, 0).unwrap_or_default()) - .single() - .unwrap_or_else(|| { - Local.from_utc_datetime(&date.and_hms_opt(0, 0, 0).unwrap_or_default()) - })); - } - - anyhow::bail!("unsupported ICS datetime: {value}") -} - async fn fetch_location(location: &WeatherLocation) -> anyhow::Result { let client = reqwest::Client::builder() .timeout(Duration::from_secs(20)) diff --git a/src/services/mod.rs b/src/services/mod.rs index 997f176b7..d8ae799d4 100644 --- a/src/services/mod.rs +++ b/src/services/mod.rs @@ -10,6 +10,7 @@ pub mod mpris; pub mod network; pub mod notifications; pub mod privacy; +pub mod tempo_calendar; mod throttle; pub mod tray; pub mod upower; diff --git a/src/services/tempo_calendar.rs b/src/services/tempo_calendar.rs new file mode 100644 index 000000000..deee77e0f --- /dev/null +++ b/src/services/tempo_calendar.rs @@ -0,0 +1,334 @@ +use crate::config::TempoCalendarSource; +use chrono::{DateTime, Datelike, Local, NaiveDate, NaiveDateTime, TimeZone, Utc, Weekday}; +use chrono_tz::Tz; +use std::{ + collections::{HashMap, HashSet}, + sync::{Arc, Mutex, OnceLock}, + time::Duration, +}; + +#[derive(Debug, Clone)] +pub struct CalendarEvent { + pub title: String, + pub start: DateTime, + pub end: DateTime, + pub color: Option, +} + +#[derive(Clone)] +struct CalendarCacheEntry { + events: Vec, + updated_at: std::time::Instant, +} + +static CALENDAR_CACHE: OnceLock>>> = OnceLock::new(); + +pub async fn fetch_calendar_events_cached( + source: &TempoCalendarSource, +) -> anyhow::Result> { + let key = match source { + TempoCalendarSource::Url { url, .. } => format!("url:{url}"), + TempoCalendarSource::Path { path, .. } => format!("path:{path}"), + }; + + let cache = CALENDAR_CACHE + .get_or_init(|| Arc::new(Mutex::new(HashMap::new()))) + .clone(); + + if let Some(entry) = cache.lock().ok().and_then(|m| m.get(&key).cloned()) + && entry.updated_at.elapsed() < Duration::from_secs(600) + { + return Ok(entry.events); + } + + let events = fetch_calendar_events(source).await?; + if let Ok(mut guard) = cache.lock() { + guard.insert( + key, + CalendarCacheEntry { + events: events.clone(), + updated_at: std::time::Instant::now(), + }, + ); + } + + Ok(events) +} + +pub async fn fetch_calendar_events( + source: &TempoCalendarSource, +) -> anyhow::Result> { + let raw = match source { + TempoCalendarSource::Url { url, .. } => { + reqwest::Client::new().get(url).send().await?.text().await? + } + TempoCalendarSource::Path { path, .. } => { + std::fs::read_to_string(shellexpand::tilde(path).as_ref())? + } + }; + + Ok(parse_ics_events(&raw, source)) +} + +fn parse_ics_events(raw: &str, source: &TempoCalendarSource) -> Vec { + let color = match source { + TempoCalendarSource::Url { color, .. } | TempoCalendarSource::Path { color, .. } => { + Some(color.clone()) + } + }; + let unfolded = unfold_ics_lines(raw); + let events: Vec = unfolded + .split("BEGIN:VEVENT") + .filter_map(|chunk| { + let section = chunk.split("END:VEVENT").next()?; + let title = get_ics_value(section, "SUMMARY")?; + let (start_value, start_tzid) = get_ics_value_and_tzid(section, "DTSTART")?; + let start = parse_ics_datetime(&start_value, start_tzid.as_deref()).ok()?; + + let end = match get_ics_value_and_tzid(section, "DTEND") { + Some((value, _tzid)) if is_ics_date_only(&value) => { + parse_ics_date_end(&value).unwrap_or_else(|_| start + chrono::Duration::days(1)) + } + Some((value, tzid)) => parse_ics_datetime(&value, tzid.as_deref()) + .ok() + .unwrap_or_else(|| start + chrono::Duration::hours(1)), + None => start + chrono::Duration::hours(1), + }; + + let event = RecurringEvent { + title, + start, + end, + color: color.clone(), + rrule: get_ics_value(section, "RRULE"), + }; + Some(event.expand()) + }) + .flatten() + .collect(); + let mut seen = HashSet::new(); + events + .into_iter() + .filter(|event| { + seen.insert(( + event.title.clone(), + event.start.naive_local(), + event.end.naive_local(), + event.color.clone(), + )) + }) + .collect() +} + +#[derive(Clone)] +struct RecurringEvent { + title: String, + start: DateTime, + end: DateTime, + color: Option, + rrule: Option, +} + +impl RecurringEvent { + fn expand(self) -> Vec { + let Some(rrule) = self.rrule else { + return vec![CalendarEvent { + title: self.title, + start: self.start, + end: self.end, + color: self.color, + }]; + }; + let rule = RRule::parse(&rrule); + let duration = self.end - self.start; + let mut out = Vec::new(); + let mut current = self.start; + let mut emitted = 0usize; + let interval = rule.interval.max(1) as i64; + let count = rule.count.unwrap_or(usize::MAX); + while emitted < count { + if let Some(until) = rule.until + && current > until + { + break; + } + if rule.matches(current) { + out.push(CalendarEvent { + title: self.title.clone(), + start: current, + end: current + duration, + color: self.color.clone(), + }); + emitted += 1; + } + current = match rule.freq { + Frequency::Daily => current + chrono::Duration::days(interval), + Frequency::Weekly => current + chrono::Duration::weeks(interval), + Frequency::Monthly => current + chrono::Duration::days(30 * interval), + Frequency::Yearly => current + chrono::Duration::days(365 * interval), + }; + if out.len() > 500 { + break; + } + } + if out.is_empty() { + vec![CalendarEvent { + title: self.title, + start: self.start, + end: self.end, + color: self.color, + }] + } else { + out + } + } +} + +#[derive(Clone, Copy)] +enum Frequency { + Daily, + Weekly, + Monthly, + Yearly, +} +struct RRule { + freq: Frequency, + interval: u32, + count: Option, + until: Option>, + byday: Vec, +} +impl RRule { + fn parse(raw: &str) -> Self { + let mut freq = Frequency::Weekly; + let mut interval = 1; + let mut count = None; + let mut until = None; + let mut byday = vec![]; + for part in raw.split(';') { + if let Some(v) = part.strip_prefix("FREQ=") { + freq = match v { + "DAILY" => Frequency::Daily, + "WEEKLY" => Frequency::Weekly, + "MONTHLY" => Frequency::Monthly, + "YEARLY" => Frequency::Yearly, + _ => Frequency::Weekly, + }; + } else if let Some(v) = part.strip_prefix("INTERVAL=") { + interval = v.parse().unwrap_or(1); + } else if let Some(v) = part.strip_prefix("COUNT=") { + count = v.parse().ok(); + } else if let Some(v) = part.strip_prefix("UNTIL=") { + until = parse_ics_datetime(v, None).ok(); + } else if let Some(v) = part.strip_prefix("BYDAY=") { + byday = v.split(',').filter_map(parse_weekday).collect(); + } + } + Self { + freq, + interval, + count, + until, + byday, + } + } + fn matches(&self, dt: DateTime) -> bool { + self.byday.is_empty() || self.byday.contains(&dt.weekday()) + } +} +fn parse_weekday(v: &str) -> Option { + match v { + "MO" => Some(Weekday::Mon), + "TU" => Some(Weekday::Tue), + "WE" => Some(Weekday::Wed), + "TH" => Some(Weekday::Thu), + "FR" => Some(Weekday::Fri), + "SA" => Some(Weekday::Sat), + "SU" => Some(Weekday::Sun), + _ => None, + } +} +fn unfold_ics_lines(raw: &str) -> String { + let mut out = String::new(); + for line in raw.lines() { + if line.starts_with(' ') || line.starts_with('\t') { + out.push_str(line.trim_start()); + } else { + if !out.is_empty() { + out.push('\n'); + } + out.push_str(line); + } + } + out +} +fn get_ics_value(section: &str, key: &str) -> Option { + section + .lines() + .find(|line| line.starts_with(key)) + .and_then(|line| { + line.split_once(':') + .map(|(_, value)| value.trim().to_string()) + }) +} +fn get_ics_value_and_tzid(section: &str, key: &str) -> Option<(String, Option)> { + section.lines().find_map(|line| { + let (prop, value) = line.split_once(':')?; + if !prop.starts_with(key) { + return None; + } + let tzid = prop + .split(';') + .find_map(|part| part.strip_prefix("TZID=").map(|v| v.to_string())); + Some((value.trim().to_string(), tzid)) + }) +} +fn parse_ics_datetime(value: &str, tzid: Option<&str>) -> anyhow::Result> { + let value = value.trim(); + if let Some(utc_value) = value.strip_suffix('Z') + && let Ok(dt) = NaiveDateTime::parse_from_str(utc_value, "%Y%m%dT%H%M%S") + { + return Ok(Utc.from_utc_datetime(&dt).with_timezone(&Local)); + } + if let Ok(dt) = NaiveDateTime::parse_from_str(value, "%Y%m%dT%H%M%S") { + if let Some(tzid) = tzid + && let Ok(tz) = tzid.parse::() + { + return Ok(tz + .from_local_datetime(&dt) + .single() + .unwrap_or_else(|| tz.from_utc_datetime(&dt)) + .with_timezone(&Local)); + } + if let Some(local_dt) = Local.from_local_datetime(&dt).single() { + return Ok(local_dt); + } + return Ok(Local.from_utc_datetime(&dt)); + } + if let Ok(date) = NaiveDate::parse_from_str(value, "%Y%m%d") { + return Ok(Local + .from_local_datetime(&date.and_hms_opt(0, 0, 0).unwrap_or_default()) + .single() + .unwrap_or_else(|| { + Local.from_utc_datetime(&date.and_hms_opt(0, 0, 0).unwrap_or_default()) + })); + } + anyhow::bail!("unsupported ICS datetime: {value}") +} + +fn parse_ics_date_end(value: &str) -> anyhow::Result> { + let date = NaiveDate::parse_from_str(value.trim(), "%Y%m%d")?; + let next_day = date + .succ_opt() + .ok_or_else(|| anyhow::anyhow!("invalid ICS end date: {value}"))?; + Ok(Local + .from_local_datetime(&next_day.and_hms_opt(0, 0, 0).unwrap_or_default()) + .single() + .unwrap_or_else(|| { + Local.from_utc_datetime(&next_day.and_hms_opt(0, 0, 0).unwrap_or_default()) + })) +} + +fn is_ics_date_only(value: &str) -> bool { + value.trim().len() == 8 && value.chars().all(|c| c.is_ascii_digit()) +} diff --git a/website/docs/configuration/modules/tempo.md b/website/docs/configuration/modules/tempo.md index 076ea5bb2..500e8e6b7 100644 --- a/website/docs/configuration/modules/tempo.md +++ b/website/docs/configuration/modules/tempo.md @@ -13,7 +13,8 @@ forecast, and a seven-day outlook. - **Status bar** – current time (using your preferred `clock_format`) and, when weather data is available, an icon + temperature badge that match the current conditions. - **Menu** – a resizable panel containing: - - A calendar with month navigation and highlighted selections. + - A calendar with month navigation and highlighted selections, or an agenda-style event list when `calendar_type = + "Events"`. - Current city, timestamp, weather description, feels-like temperature, humidity, and wind information. - A horizontally scrollable hourly forecast. - A vertically stacked seven-day forecast with dominant wind direction and speeds. @@ -26,6 +27,7 @@ forecast, and a seven-day outlook. | `formats` | `array` | `[]` | Multiple datetime formats that can be cycled through by right-clicking the clock. When provided, clicking cycles through each format in sequence. | | `timezones` | `array` | `[]` | Timezone identifiers that can be cycled through by scrolling. Supports both IANA names (e.g., `"UTC"`, `"America/New_York"`) and fixed offsets (e.g., `"+00:00"`, `"-05:00"`). | | `weather_location` | `enum` | `None` | Determines which coordinates are queried when requesting weather data. `Current` geo-locates via IP using `ip-api.com`. Use the `City` variant to pin the module to a specific place. Use `Coordinates` to specify exact latitude and longitude. | +| `calendar_type` | `enum` | `Calendar` | Chooses the calendar pane layout. `Calendar` shows the month grid. `Events` shows a day selector plus matching events from configured calendars. | | `weather_indicator` | `enum` | `IconAndTemperature` | Determines what information about the weather is shown in the bar, valid options are `None`, `Icon`, and `IconAndTemperature`. | ### City-based weather @@ -55,6 +57,18 @@ clock_format = "%a %d %b %R" # weather_location left unspecified on purpose ``` +### Calendar type + +`calendar_type` controls what appears in the left side of the Tempo menu. + +- `Calendar` shows the month grid with day highlighting and month navigation. +- `Events` shows a selected-day header with left/right day navigation and a list of matching calendar events. + +```toml +[tempo] +calendar_type = "Events" +``` + ### Format Cycling The Tempo module supports multiple datetime formats that can be cycled through by right-clicking on the clock. When the