Load basic config
This commit is contained in:
parent
7cb750cfa7
commit
0fe4c45b31
@ -5,6 +5,7 @@ edition = "2024"
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
alarm_widg_config = {path = "../alarm_widg_config"}
|
alarm_widg_config = {path = "../alarm_widg_config"}
|
||||||
|
chrono = "0.4.44"
|
||||||
dirs = "6.0.0"
|
dirs = "6.0.0"
|
||||||
i-slint-backend-winit = "1.15.1"
|
i-slint-backend-winit = "1.15.1"
|
||||||
slint = "1.15.1"
|
slint = "1.15.1"
|
||||||
|
|||||||
@ -1,6 +1,78 @@
|
|||||||
mod show_popup;
|
mod show_popup;
|
||||||
|
|
||||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
use alarm_widg_config::Config;
|
||||||
show_popup::show_popup()?;
|
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<dyn std::error::Error>> {
|
||||||
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn run_scheduler(config: Config) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
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<dyn std::error::Error>> {
|
||||||
|
let config = Config::load()?;
|
||||||
|
|
||||||
|
run_scheduler(config)
|
||||||
|
}
|
||||||
|
|||||||
@ -10,24 +10,27 @@ use i_slint_backend_winit::{
|
|||||||
|
|
||||||
slint::include_modules!();
|
slint::include_modules!();
|
||||||
|
|
||||||
fn set_ui_props(ui: &MainWindow) {
|
pub struct PopupData {
|
||||||
// Window placement
|
pub title: String,
|
||||||
ui.window().set_position(LogicalPosition::new(200.0, 35.0));
|
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));
|
ui.window().set_size(LogicalSize::new(300.0, 124.0));
|
||||||
|
|
||||||
// Alarm properties
|
ui.set_alarm_name(SharedString::from(data.title.clone()));
|
||||||
ui.set_alarm_name(SharedString::from("Sample alarm"));
|
ui.set_alarm_time(SharedString::from(data.time.clone()));
|
||||||
ui.set_alarm_time(SharedString::from("11:45AM"));
|
ui.set_alarm_color(Color::from_argb_encoded(data.color));
|
||||||
ui.set_alarm_color(Color::from_argb_encoded(0xff232323));
|
|
||||||
ui.set_popup_width(300);
|
ui.set_popup_width(300);
|
||||||
ui.set_popup_height(124);
|
ui.set_popup_height(124);
|
||||||
ui.set_default_font(SharedString::from("IosevkaTermSlab Nerd Font Mono"));
|
ui.set_default_font(SharedString::from("IosevkaTermSlab Nerd Font Mono"));
|
||||||
ui.set_alarm_icon(SharedString::from("🕓"));
|
ui.set_alarm_icon(SharedString::from("🕓"));
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn show_popup() -> Result<(), Box<dyn std::error::Error>> {
|
pub fn show_popup(data: PopupData) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
// Closure to configure X11 window attributes before Slint creates the window.
|
// Closure to configure X11 window attributes before Slint creates the window.
|
||||||
let window_attrs = |attrs: WindowAttributes| {
|
let window_attrs = |attrs: WindowAttributes| {
|
||||||
attrs
|
attrs
|
||||||
@ -49,7 +52,7 @@ pub fn show_popup() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
let ui = MainWindow::new()?;
|
let ui = MainWindow::new()?;
|
||||||
|
|
||||||
// Send the alarm data to the UI as properties
|
// Send the alarm data to the UI as properties
|
||||||
set_ui_props(&ui);
|
set_ui_props(&ui, &data);
|
||||||
|
|
||||||
// Run the UI
|
// Run the UI
|
||||||
ui.run()?;
|
ui.run()?;
|
||||||
|
|||||||
@ -4,6 +4,8 @@ version = "0.1.0"
|
|||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
chrono = { version = "0.4.44", features = ["serde"] }
|
||||||
dirs = "6.0.0"
|
dirs = "6.0.0"
|
||||||
serde = { version = "1.0.228", features = ["derive"] }
|
serde = { version = "1.0.228", features = ["derive"] }
|
||||||
|
thiserror = "2.0.18"
|
||||||
toml = "1.0.3"
|
toml = "1.0.3"
|
||||||
|
|||||||
@ -1,14 +1,169 @@
|
|||||||
pub fn add(left: u64, right: u64) -> u64 {
|
use chrono::{NaiveDate, NaiveTime};
|
||||||
left + right
|
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)]
|
// Public API Types
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
#[derive(Debug, Clone)]
|
||||||
fn it_works() {
|
pub struct Config {
|
||||||
let result = add(2, 2);
|
pub alarms: Vec<Alarm>,
|
||||||
assert_eq!(result, 4);
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Alarm {
|
||||||
|
pub title: String,
|
||||||
|
pub time: NaiveTime,
|
||||||
|
pub days: Option<Vec<u8>>,
|
||||||
|
pub dates: Option<Vec<NaiveDate>>,
|
||||||
|
pub color: Option<u32>,
|
||||||
|
pub enabled: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
// TOML Deserialization Layer
|
||||||
|
// (separate from public types intentionally)
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct RawConfig {
|
||||||
|
#[serde(default)]
|
||||||
|
alarm: Vec<RawAlarm>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct RawAlarm {
|
||||||
|
title: String,
|
||||||
|
time: String,
|
||||||
|
|
||||||
|
days: Option<Vec<u8>>,
|
||||||
|
dates: Option<Vec<String>>,
|
||||||
|
|
||||||
|
color: Option<String>,
|
||||||
|
|
||||||
|
#[serde(default = "default_enabled")]
|
||||||
|
enabled: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_enabled() -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Loader
|
||||||
|
|
||||||
|
impl Config {
|
||||||
|
pub fn load() -> Result<Self, ConfigError> {
|
||||||
|
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::<Result<Vec<_>, _>>()?;
|
||||||
|
|
||||||
|
Ok(Self { alarms })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Conversion + Validation
|
||||||
|
|
||||||
|
impl Alarm {
|
||||||
|
fn from_raw(raw: RawAlarm) -> Result<Self, ConfigError> {
|
||||||
|
// 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<PathBuf, ConfigError> {
|
||||||
|
let base = config_dir().ok_or(ConfigError::NoConfigDir)?;
|
||||||
|
Ok(base.join(CONFIG_PATH))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_argb(input: &str) -> Result<u32, ConfigError> {
|
||||||
|
let cleaned = input.trim_start_matches('#');
|
||||||
|
|
||||||
|
u32::from_str_radix(cleaned, 16)
|
||||||
|
.map_err(|_| ConfigError::Invalid(format!("invalid ARGB color '{input}'")))
|
||||||
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user