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/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..4f654260e 100644 --- a/src/modules/tempo.rs +++ b/src/modules/tempo.rs @@ -1,10 +1,11 @@ use crate::{ components::{ - ButtonKind, ButtonSize, MenuSize, + ButtonKind, ButtonSize, MenuSize, event_card, icons::{StaticIcon, icon_button}, styled_button, }, - config::{TempoModuleConfig, WeatherIndicator, WeatherLocation}, + config::{TempoCalendarType, TempoModuleConfig, WeatherIndicator, WeatherLocation}, + services::tempo_calendar::CalendarEvent, theme::AshellTheme, }; use chrono::{ @@ -33,6 +34,7 @@ pub enum Message { ChangeSelectDate(Option), UpdateWeather(Box), UpdateLocation(Location), + UpdateCalendarEvents(Vec), CycleFormat, CycleTimezone(TimezoneDirection), SetTimezone(usize), @@ -55,6 +57,7 @@ pub struct Tempo { selected_date: Option, weather_data: Option, location: Option, + calendar_events: Vec, current_format_index: usize, current_timezone_index: usize, } @@ -67,6 +70,7 @@ impl Tempo { selected_date: None, weather_data: None, location: None, + calendar_events: vec![], current_format_index: 0, current_timezone_index: 0, } @@ -106,6 +110,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 +166,7 @@ impl Tempo { } self.config = new_config; + self.calendar_events.clear(); Action::None } } @@ -240,7 +250,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 +258,114 @@ impl Tempo { .into() } + fn calendar_panel<'a>(&'a self, theme: &'a AshellTheme) -> Element<'a, Message> { + let content = if self.config.calendar_type == TempoCalendarType::Calendar { + column!( + self.calendar_header(theme, false), + self.calendar(theme), + self.timezones(theme) + ) + } else { + 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.selected_date + .unwrap_or_else(|| self.naive_date(self.current_timezone_index)) + }; + + 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); @@ -294,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) @@ -302,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() ) @@ -351,34 +473,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( @@ -403,7 +536,16 @@ impl Tempo { ] .spacing(theme.space.md); - let timezones = Column::with_children( + let timezones = self.timezones(theme); + + column!(calendar, timezones) + .spacing(theme.space.lg) + .width(225) + .into() + } + + fn timezones<'a>(&'a self, theme: &'a AshellTheme) -> Column<'a, Message> { + Column::with_children( self.config .timezones .iter() @@ -432,45 +574,51 @@ impl Tempo { } }) .collect::>>(), - ); - - 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), + ) + } + + fn events_view<'a>(&'a self, theme: &'a AshellTheme) -> Element<'a, Message> { + let events = self.calendar_events.clone(); + 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 event_opacity = theme.opacity; + Column::with_children( + events + .into_iter() + .filter(|event| { + event.start.naive_local() < tomorrow_start + && event.end.naive_local() >= today_start + }) + .map(|event| { + 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(), ) - .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, + }) + .collect::>>(), ) - .spacing(theme.space.lg) - .width(225) + .spacing(theme.space.xs) .into() } + 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() @@ -817,10 +965,46 @@ 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 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), + } + } + + events.sort_by_key(|event| event.start); + let _ = output.send(Message::UpdateCalendarEvents(events)).await; + tokio::time::sleep(Duration::from_secs(60 * 10)).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 { - time_sub + subscriptions.into_iter().next().unwrap() } } } 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