diff --git a/alarm_widg/Cargo.toml b/alarm_widg/Cargo.toml index 6036452..7fec542 100644 --- a/alarm_widg/Cargo.toml +++ b/alarm_widg/Cargo.toml @@ -5,6 +5,7 @@ edition = "2024" [dependencies] alarm_widg_config = {path = "../alarm_widg_config"} +chrono = "0.4.44" dirs = "6.0.0" i-slint-backend-winit = "1.15.1" slint = "1.15.1" diff --git a/alarm_widg/src/main.rs b/alarm_widg/src/main.rs index 22e945a..7227255 100644 --- a/alarm_widg/src/main.rs +++ b/alarm_widg/src/main.rs @@ -1,6 +1,78 @@ mod show_popup; -fn main() -> Result<(), Box> { - show_popup::show_popup()?; +use alarm_widg_config::Config; +use chrono::{Datelike, Local, Timelike}; +use show_popup::{PopupData, show_popup}; +use std::{collections::HashSet, thread, time::Duration}; + +use alarm_widg_config::Alarm; +use chrono::NaiveDate; + +fn alarm_matches_today(alarm: &Alarm, today: NaiveDate) -> bool { + if let Some(days) = &alarm.days { + let weekday = today.weekday().num_days_from_sunday() as u8; + return days.contains(&weekday); + } + + if let Some(dates) = &alarm.dates { + return dates.contains(&today); + } + + false +} + +fn trigger_alarm(alarm: &Alarm) -> Result<(), Box> { + println!("Triggering alarm: {}", alarm.title); + + let popup = PopupData { + title: alarm.title.clone(), + time: alarm.time.format("%I:%M%p").to_string(), + color: alarm.color.unwrap_or(0xff232323), + }; + + // UI must run on main thread in many backends. + // For now we block until closed. + show_popup(popup)?; + Ok(()) } + +fn run_scheduler(config: Config) -> Result<(), Box> { + println!("alarm_widg scheduler started"); + + // prevents duplicate triggers within same minute + let mut fired: HashSet<(String, i64)> = HashSet::new(); + + loop { + let now = Local::now(); + + for alarm in &config.alarms { + if !alarm.enabled { + continue; + } + + if !alarm_matches_today(alarm, now.date_naive()) { + continue; + } + + if alarm.time.hour() == now.hour() && alarm.time.minute() == now.minute() { + let key = (alarm.title.clone(), now.timestamp() / 60); + + if fired.insert(key) { + trigger_alarm(alarm)?; + } + } + } + + // keep memory bounded + fired.retain(|(_, minute)| now.timestamp() / 60 - *minute < 5); + + thread::sleep(Duration::from_secs(1)); + } +} + +fn main() -> Result<(), Box> { + let config = Config::load()?; + + run_scheduler(config) +} diff --git a/alarm_widg/src/show_popup.rs b/alarm_widg/src/show_popup.rs index 71fd3e2..adcd9d6 100644 --- a/alarm_widg/src/show_popup.rs +++ b/alarm_widg/src/show_popup.rs @@ -10,24 +10,27 @@ use i_slint_backend_winit::{ slint::include_modules!(); -fn set_ui_props(ui: &MainWindow) { - // Window placement - ui.window().set_position(LogicalPosition::new(200.0, 35.0)); +pub struct PopupData { + pub title: String, + pub time: String, + pub color: u32, +} - // Window size +fn set_ui_props(ui: &MainWindow, data: &PopupData) { + ui.window().set_position(LogicalPosition::new(200.0, 35.0)); ui.window().set_size(LogicalSize::new(300.0, 124.0)); - // Alarm properties - ui.set_alarm_name(SharedString::from("Sample alarm")); - ui.set_alarm_time(SharedString::from("11:45AM")); - ui.set_alarm_color(Color::from_argb_encoded(0xff232323)); + ui.set_alarm_name(SharedString::from(data.title.clone())); + ui.set_alarm_time(SharedString::from(data.time.clone())); + ui.set_alarm_color(Color::from_argb_encoded(data.color)); + ui.set_popup_width(300); ui.set_popup_height(124); ui.set_default_font(SharedString::from("IosevkaTermSlab Nerd Font Mono")); ui.set_alarm_icon(SharedString::from("🕓")); } -pub fn show_popup() -> Result<(), Box> { +pub fn show_popup(data: PopupData) -> Result<(), Box> { // Closure to configure X11 window attributes before Slint creates the window. let window_attrs = |attrs: WindowAttributes| { attrs @@ -49,7 +52,7 @@ pub fn show_popup() -> Result<(), Box> { let ui = MainWindow::new()?; // Send the alarm data to the UI as properties - set_ui_props(&ui); + set_ui_props(&ui, &data); // Run the UI ui.run()?; diff --git a/alarm_widg_config/Cargo.toml b/alarm_widg_config/Cargo.toml index df04132..4d06a23 100644 --- a/alarm_widg_config/Cargo.toml +++ b/alarm_widg_config/Cargo.toml @@ -4,6 +4,8 @@ version = "0.1.0" edition = "2024" [dependencies] +chrono = { version = "0.4.44", features = ["serde"] } dirs = "6.0.0" serde = { version = "1.0.228", features = ["derive"] } +thiserror = "2.0.18" toml = "1.0.3" diff --git a/alarm_widg_config/src/lib.rs b/alarm_widg_config/src/lib.rs index b93cf3f..0a691f0 100644 --- a/alarm_widg_config/src/lib.rs +++ b/alarm_widg_config/src/lib.rs @@ -1,14 +1,169 @@ -pub fn add(left: u64, right: u64) -> u64 { - left + right +use chrono::{NaiveDate, NaiveTime}; +use dirs::config_dir; +use serde::Deserialize; +use std::{fs, path::PathBuf}; +use thiserror::Error; + +const CONFIG_PATH: &str = "candywidgets/alarm-app/config.toml"; + +// Errors + +#[derive(Debug, Error)] +pub enum ConfigError { + #[error("config directory not found")] + NoConfigDir, + + #[error("config file not found: {0}")] + MissingFile(PathBuf), + + #[error("failed to read config: {0}")] + Io(#[from] std::io::Error), + + #[error("toml parse error: {0}")] + Toml(#[from] toml::de::Error), + + #[error("invalid alarm: {0}")] + Invalid(String), } -#[cfg(test)] -mod tests { - use super::*; +// Public API Types - #[test] - fn it_works() { - let result = add(2, 2); - assert_eq!(result, 4); +#[derive(Debug, Clone)] +pub struct Config { + pub alarms: Vec, +} + +#[derive(Debug, Clone)] +pub struct Alarm { + pub title: String, + pub time: NaiveTime, + pub days: Option>, + pub dates: Option>, + pub color: Option, + pub enabled: bool, +} + +// TOML Deserialization Layer +// (separate from public types intentionally) + +#[derive(Debug, Deserialize)] +struct RawConfig { + #[serde(default)] + alarm: Vec, +} + +#[derive(Debug, Deserialize)] +struct RawAlarm { + title: String, + time: String, + + days: Option>, + dates: Option>, + + color: Option, + + #[serde(default = "default_enabled")] + enabled: bool, +} + +fn default_enabled() -> bool { + true +} + +// Loader + +impl Config { + pub fn load() -> Result { + let path = config_path()?; + + if !path.exists() { + return Err(ConfigError::MissingFile(path)); + } + + let content = fs::read_to_string(&path)?; + let raw: RawConfig = toml::from_str(&content)?; + + let alarms = raw + .alarm + .into_iter() + .map(Alarm::from_raw) + .collect::, _>>()?; + + Ok(Self { alarms }) } } + +// Conversion + Validation + +impl Alarm { + fn from_raw(raw: RawAlarm) -> Result { + // validate schedule + if raw.days.is_none() && raw.dates.is_none() { + return Err(ConfigError::Invalid(format!( + "alarm '{}' must specify 'days' or 'dates'", + raw.title + ))); + } + + // parse time + let time = NaiveTime::parse_from_str(&raw.time, "%H:%M").map_err(|_| { + ConfigError::Invalid(format!("invalid time '{}' in '{}'", raw.time, raw.title)) + })?; + + // validate days + if let Some(ref days) = raw.days { + for d in days { + if *d > 6 { + return Err(ConfigError::Invalid(format!( + "day '{}' out of range in '{}'", + d, raw.title + ))); + } + } + } + + // parse dates + let dates = match raw.dates { + Some(list) => { + let mut out = Vec::new(); + for d in list { + let parsed = NaiveDate::parse_from_str(&d, "%Y-%m-%d").map_err(|_| { + ConfigError::Invalid(format!("invalid date '{}' in '{}'", d, raw.title)) + })?; + out.push(parsed); + } + Some(out) + } + None => None, + }; + + // parse color (ARGB hex) + let color = match raw.color { + Some(c) => Some(parse_argb(&c)?), + None => None, + }; + + Ok(Self { + title: raw.title, + time, + days: raw.days, + dates, + color, + enabled: raw.enabled, + }) + } +} + +// Helpers + +fn config_path() -> Result { + let base = config_dir().ok_or(ConfigError::NoConfigDir)?; + Ok(base.join(CONFIG_PATH)) +} + +fn parse_argb(input: &str) -> Result { + let cleaned = input.trim_start_matches('#'); + + u32::from_str_radix(cleaned, 16) + .map_err(|_| ConfigError::Invalid(format!("invalid ARGB color '{input}'"))) +}