Load basic config
This commit is contained in:
parent
7cb750cfa7
commit
0fe4c45b31
@ -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"
|
||||
|
||||
@ -1,6 +1,78 @@
|
||||
mod show_popup;
|
||||
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
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<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(())
|
||||
}
|
||||
|
||||
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!();
|
||||
|
||||
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<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.
|
||||
let window_attrs = |attrs: WindowAttributes| {
|
||||
attrs
|
||||
@ -49,7 +52,7 @@ pub fn show_popup() -> Result<(), Box<dyn std::error::Error>> {
|
||||
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()?;
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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<Alarm>,
|
||||
}
|
||||
|
||||
#[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