mod context;
mod mode;
mod modes;
mod render;
mod settings;
mod system_metrics;
mod weather;
use std::time::{Duration, Instant};
use clap::Parser;
use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use ratatui::{
DefaultTerminal, Frame,
layout::Alignment,
style::{Color, Style},
text::{Line, Span},
widgets::{Block, Borders, Clear, Paragraph},
};
use sigye_config::Config;
use sigye_core::{BackgroundStyle, ColorTheme, DisplayMode};
use sigye_fonts::FontRegistry;
use context::RenderContext;
use mode::Mode;
use modes::clock::ClockMode;
use modes::pomodoro::PomodoroMode;
use modes::stopwatch::StopwatchMode;
use modes::timer::TimerMode;
use modes::world_clock::WorldClockMode;
use settings::SettingsDialog;
use sigye_background::BackgroundState;
use system_metrics::SystemMonitor;
use weather::WeatherMonitor;
#[derive(Parser)]
#[command(name = "sigye", version, about)]
struct Cli {
#[arg(long)]
screensaver: bool,
#[arg(long)]
demo: bool,
#[arg(long)]
font: Option<String>,
#[arg(long)]
theme: Option<String>,
#[arg(long = "bg", alias = "background")]
background: Option<String>,
#[arg(long)]
mode: Option<String>,
#[arg(long = "tz", value_name = "LABEL=TIMEZONE")]
timezones: Vec<String>,
#[arg(long)]
on_complete: Option<String>,
#[arg(long)]
start: bool,
#[arg(long)]
pomo: bool,
#[arg(long)]
sw: bool,
#[arg(long = "timer", value_name = "MINS")]
timer_mins: Option<u32>,
#[arg(long)]
once: bool,
#[arg(long, default_value = "human")]
format: String,
}
fn main() -> color_eyre::Result<()> {
color_eyre::install()?;
let cli = Cli::parse();
if cli.once {
let now = chrono::Local::now();
let output = match cli.format.as_str() {
"unix" | "timestamp" | "epoch" => now.timestamp().to_string(),
"iso" | "iso8601" => now.format("%Y-%m-%dT%H:%M:%S%:z").to_string(),
"hex" => {
let h = now.format("%H").to_string().parse::<u32>().unwrap_or(0);
let m = now.format("%M").to_string().parse::<u32>().unwrap_or(0);
let s = now.format("%S").to_string().parse::<u32>().unwrap_or(0);
format!("{h:02X}:{m:02X}:{s:02X}")
}
_ => {
now.format("%Y-%m-%d %H:%M:%S").to_string()
}
};
println!("{output}");
return Ok(());
}
let terminal = ratatui::init();
let result = App::new_with_cli(cli).run(terminal);
ratatui::restore();
result
}
pub struct App {
running: bool,
ctx: RenderContext,
modes: Vec<Box<dyn Mode>>,
active_mode_index: usize,
settings_dialog: SettingsDialog,
background_state: BackgroundState,
system_monitor: Option<SystemMonitor>,
weather_monitor: Option<WeatherMonitor>,
show_help: bool,
demo_mode: bool,
demo_theme_cycle: Instant,
demo_bg_cycle: Instant,
demo_font_cycle: Instant,
}
impl App {
pub fn new() -> Self {
let config = Config::load();
let mut font_registry = FontRegistry::new();
font_registry.load_custom_fonts(&Config::fonts_dir());
let available_fonts: Vec<String> = font_registry
.list_fonts()
.into_iter()
.map(String::from)
.collect();
let settings_dialog = SettingsDialog::new(available_fonts);
let system_monitor = if config.background_style.is_reactive() {
let monitor = SystemMonitor::new();
monitor.start();
Some(monitor)
} else {
None
};
let weather_monitor = if config.background_style.requires_weather() {
let monitor = WeatherMonitor::new(config.weather_location.clone());
monitor.start();
Some(monitor)
} else {
None
};
let on_complete_command = config.on_complete.clone();
let ctx = RenderContext {
time_format: config.time_format,
color_theme: config.color_theme,
animation_style: config.animation_style,
animation_speed: config.animation_speed,
colon_blink: config.colon_blink,
show_seconds: config.show_seconds,
background_style: config.background_style,
current_font: config.font_name.clone(),
font_registry,
config,
animation_start: Instant::now(),
flash_intensity: 0.0,
flash_start: None,
screensaver_mode: false,
on_complete_command,
desktop_notifications: false,
sunrise_sunset: None,
};
let modes: Vec<Box<dyn Mode>> = vec![
Box::new(ClockMode::new()),
Box::new(PomodoroMode::new(
ctx.config.pomodoro_work_mins,
ctx.config.pomodoro_sessions_completed,
ctx.config.pomodoro_total_focus_mins,
)),
Box::new(TimerMode::new(ctx.config.timer_duration_mins)),
Box::new(StopwatchMode::new()),
Box::new(WorldClockMode::new(&ctx.config.world_clock_zones)),
];
Self {
running: false,
ctx,
modes,
active_mode_index: 0,
settings_dialog,
background_state: BackgroundState::new(),
system_monitor,
weather_monitor,
show_help: false,
demo_mode: false,
demo_theme_cycle: Instant::now(),
demo_bg_cycle: Instant::now(),
demo_font_cycle: Instant::now(),
}
}
fn new_with_cli(cli: Cli) -> Self {
let mut app = Self::new();
if let Some(font_name) = cli.font {
let fonts = app.ctx.font_registry.list_fonts();
if let Some(matched) = fonts.iter().find(|f| f.eq_ignore_ascii_case(&font_name)) {
app.ctx.current_font = matched.to_string();
} else {
app.ctx.current_font = font_name;
}
}
if let Some(theme_str) = cli.theme
&& let Some(theme) = parse_color_theme(&theme_str)
{
app.ctx.color_theme = theme;
}
if let Some(bg_str) = cli.background
&& let Some(bg) = parse_background_style(&bg_str)
{
app.ctx.background_style = bg;
app.update_background_monitors();
}
if let Some(mode_str) = cli.mode
&& let Some(mode) = parse_display_mode(&mode_str)
{
if let Some(idx) = app.modes.iter().position(|m| m.display_mode() == mode) {
app.active_mode_index = idx;
}
}
if !cli.timezones.is_empty() {
app.ctx.config.world_clock_zones = cli.timezones;
for m in app.modes.iter_mut() {
if let Some(wc) = m.as_any_mut().downcast_mut::<WorldClockMode>() {
wc.update_entries(&app.ctx.config.world_clock_zones);
break;
}
}
}
if cli.on_complete.is_some() {
app.ctx.on_complete_command = cli.on_complete;
}
app.ctx.screensaver_mode = cli.screensaver;
app.ctx.desktop_notifications = app.ctx.config.desktop_notifications;
app.demo_mode = cli.demo;
if app.ctx.screensaver_mode && app.ctx.background_style == BackgroundStyle::None {
app.ctx.background_style = BackgroundStyle::Aurora;
app.update_background_monitors();
}
if cli.pomo
&& let Some(idx) = app
.modes
.iter()
.position(|m| m.display_mode() == DisplayMode::Pomodoro)
{
app.active_mode_index = idx;
}
if cli.sw
&& let Some(idx) = app
.modes
.iter()
.position(|m| m.display_mode() == DisplayMode::Stopwatch)
{
app.active_mode_index = idx;
}
if let Some(mins) = cli.timer_mins
&& let Some(idx) = app
.modes
.iter()
.position(|m| m.display_mode() == DisplayMode::Timer)
{
app.active_mode_index = idx;
if let Some(tm) = app.modes[idx].as_any_mut().downcast_mut::<TimerMode>() {
tm.sync_duration(mins);
}
}
let auto_start = cli.start || cli.pomo || cli.sw || cli.timer_mins.is_some();
if auto_start {
let idx = app.active_mode_index;
let mode = &mut app.modes[idx];
if let Some(pm) = mode.as_any_mut().downcast_mut::<PomodoroMode>() {
pm.toggle();
} else if let Some(tm) = mode.as_any_mut().downcast_mut::<TimerMode>() {
tm.toggle();
} else if let Some(sw) = mode.as_any_mut().downcast_mut::<StopwatchMode>() {
sw.toggle();
}
}
app
}
pub fn run(mut self, mut terminal: DefaultTerminal) -> color_eyre::Result<()> {
self.running = true;
while self.running {
terminal.draw(|frame| self.render(frame))?;
self.handle_crossterm_events()?;
}
Ok(())
}
fn render(&mut self, frame: &mut Frame) {
if self.demo_mode {
if self.demo_theme_cycle.elapsed() >= Duration::from_secs(5) {
self.ctx.color_theme = self.ctx.color_theme.next();
self.demo_theme_cycle = Instant::now();
}
if self.demo_bg_cycle.elapsed() >= Duration::from_secs(15) {
self.ctx.background_style = self.ctx.background_style.next();
self.update_background_monitors();
self.demo_bg_cycle = Instant::now();
}
if self.demo_font_cycle.elapsed() >= Duration::from_secs(30) {
let fonts = self.ctx.font_registry.list_fonts();
let current_idx = fonts
.iter()
.position(|f| f == &self.ctx.current_font)
.unwrap_or(0);
let next_idx = (current_idx + 1) % fonts.len();
self.ctx.current_font = fonts[next_idx].to_string();
self.demo_font_cycle = Instant::now();
}
}
let elapsed_ms = self.ctx.elapsed_ms();
let metrics = self.system_monitor.as_ref().map(|m| m.get_metrics());
let effective_background = if self.ctx.background_style == BackgroundStyle::Weather {
self.weather_monitor
.as_ref()
.map(|m| m.get_background())
.unwrap_or(BackgroundStyle::Starfield)
} else {
self.ctx.background_style
};
self.background_state.render(
frame,
effective_background,
elapsed_ms,
self.ctx.animation_speed,
metrics.as_ref(),
);
self.ctx.sunrise_sunset = self
.weather_monitor
.as_ref()
.and_then(|m| m.get_sunrise_sunset());
let mode = &mut self.modes[self.active_mode_index];
mode.update(&mut self.ctx);
mode.render(frame, &self.ctx);
let color = self.ctx.color();
let area = frame.area();
self.settings_dialog.render(frame, area, color);
self.render_help_overlay(frame);
}
fn handle_crossterm_events(&mut self) -> color_eyre::Result<()> {
if event::poll(Duration::from_millis(100))? {
match event::read()? {
Event::Key(key) if key.kind == KeyEventKind::Press => self.on_key_event(key),
Event::Mouse(_) => {}
Event::Resize(_, _) => {}
_ => {}
}
}
Ok(())
}
fn on_key_event(&mut self, key: KeyEvent) {
if self.settings_dialog.visible {
self.handle_settings_key(key);
return;
}
if key.code == KeyCode::Char('?') {
self.show_help = !self.show_help;
return;
}
if self.show_help {
self.show_help = false;
return;
}
if self.modes[self.active_mode_index].handle_key(key, &mut self.ctx) {
return;
}
match (key.modifiers, key.code) {
(_, KeyCode::Esc | KeyCode::Char('q'))
| (KeyModifiers::CONTROL, KeyCode::Char('c') | KeyCode::Char('C')) => self.quit(),
(_, KeyCode::Char('m')) => self.toggle_display_mode(),
(_, KeyCode::Char('t')) => {
self.ctx.time_format = self.ctx.time_format.toggle();
}
(_, KeyCode::Char('c')) => {
self.ctx.color_theme = self.ctx.color_theme.next();
}
(_, KeyCode::Char('a')) => {
self.ctx.animation_style = self.ctx.animation_style.next();
}
(_, KeyCode::Char('b')) => {
self.ctx.background_style = self.ctx.background_style.next();
self.update_background_monitors();
}
(_, KeyCode::Char('s')) => self.open_settings(),
_ => {}
}
}
fn handle_settings_key(&mut self, key: KeyEvent) {
match key.code {
KeyCode::Esc => {
self.cancel_settings();
}
KeyCode::Enter => {
self.save_settings();
}
KeyCode::Up | KeyCode::Char('k') => {
self.settings_dialog.prev_field();
}
KeyCode::Down | KeyCode::Char('j') => {
self.settings_dialog.next_field();
}
KeyCode::Left | KeyCode::Char('h') => {
self.settings_dialog.prev_value();
self.apply_preview();
}
KeyCode::Right | KeyCode::Char('l') => {
self.settings_dialog.next_value();
self.apply_preview();
}
_ => {}
}
}
fn apply_preview(&mut self) {
self.ctx.current_font = self.settings_dialog.selected_font().to_string();
self.ctx.color_theme = self.settings_dialog.color_theme;
self.ctx.time_format = self.settings_dialog.time_format;
self.ctx.animation_style = self.settings_dialog.animation_style;
self.ctx.animation_speed = self.settings_dialog.animation_speed;
self.ctx.colon_blink = self.settings_dialog.colon_blink;
self.ctx.show_seconds = self.settings_dialog.show_seconds;
self.ctx.background_style = self.settings_dialog.background_style;
self.ctx.config.pomodoro_work_mins = self.settings_dialog.pomodoro_work_mins;
self.ctx.config.pomodoro_break_mins = self.settings_dialog.pomodoro_break_mins;
self.ctx.config.pomodoro_long_break_mins = self.settings_dialog.pomodoro_long_break_mins;
self.ctx.config.pomodoro_sound = self.settings_dialog.pomodoro_sound;
self.ctx.config.desktop_notifications = self.settings_dialog.desktop_notifications;
let new_timer_mins = self.settings_dialog.timer_duration_mins;
self.ctx.config.timer_duration_mins = new_timer_mins;
for m in self.modes.iter_mut() {
if let Some(tm) = m.as_any_mut().downcast_mut::<TimerMode>() {
tm.sync_duration(new_timer_mins);
break;
}
}
self.update_background_monitors();
}
fn open_settings(&mut self) {
self.settings_dialog.open(
&self.ctx.current_font,
self.ctx.color_theme,
self.ctx.time_format,
self.ctx.animation_style,
self.ctx.animation_speed,
self.ctx.colon_blink,
self.ctx.show_seconds,
self.ctx.background_style,
self.ctx.config.pomodoro_work_mins,
self.ctx.config.pomodoro_break_mins,
self.ctx.config.pomodoro_long_break_mins,
self.ctx.config.pomodoro_sound,
self.ctx.config.desktop_notifications,
self.ctx.config.timer_duration_mins,
);
}
fn save_settings(&mut self) {
self.ctx.config.font_name = self.ctx.current_font.clone();
self.ctx.config.color_theme = self.ctx.color_theme;
self.ctx.config.time_format = self.ctx.time_format;
self.ctx.config.animation_style = self.ctx.animation_style;
self.ctx.config.animation_speed = self.ctx.animation_speed;
self.ctx.config.colon_blink = self.ctx.colon_blink;
self.ctx.config.show_seconds = self.ctx.show_seconds;
self.ctx.config.background_style = self.ctx.background_style;
self.ctx.config.desktop_notifications = self.settings_dialog.desktop_notifications;
self.ctx.config.timer_duration_mins = self.settings_dialog.timer_duration_mins;
if let Err(e) = self.ctx.config.save() {
eprintln!("Warning: Failed to save config: {e}");
}
self.settings_dialog.close();
}
fn cancel_settings(&mut self) {
self.ctx.current_font = self.settings_dialog.original_font().to_string();
self.ctx.color_theme = self.settings_dialog.original_color_theme();
self.ctx.time_format = self.settings_dialog.original_time_format();
self.ctx.animation_style = self.settings_dialog.original_animation_style();
self.ctx.animation_speed = self.settings_dialog.original_animation_speed();
self.ctx.colon_blink = self.settings_dialog.original_colon_blink();
self.ctx.show_seconds = self.settings_dialog.original_show_seconds();
self.ctx.background_style = self.settings_dialog.original_background_style();
self.ctx.config.pomodoro_work_mins = self.settings_dialog.original_pomodoro_work_mins();
self.ctx.config.pomodoro_break_mins = self.settings_dialog.original_pomodoro_break_mins();
self.ctx.config.pomodoro_long_break_mins =
self.settings_dialog.original_pomodoro_long_break_mins();
self.ctx.config.pomodoro_sound = self.settings_dialog.original_pomodoro_sound();
self.ctx.config.desktop_notifications =
self.settings_dialog.original_desktop_notifications();
let orig_timer_mins = self.settings_dialog.original_timer_duration_mins();
self.ctx.config.timer_duration_mins = orig_timer_mins;
for m in self.modes.iter_mut() {
if let Some(tm) = m.as_any_mut().downcast_mut::<TimerMode>() {
tm.sync_duration(orig_timer_mins);
break;
}
}
self.update_background_monitors();
self.settings_dialog.close();
}
fn update_background_monitors(&mut self) {
if self.ctx.background_style.is_reactive() && self.system_monitor.is_none() {
let monitor = SystemMonitor::new();
monitor.start();
self.system_monitor = Some(monitor);
} else if !self.ctx.background_style.is_reactive() && self.system_monitor.is_some() {
self.system_monitor = None;
}
if self.ctx.background_style.requires_weather() && self.weather_monitor.is_none() {
let monitor = WeatherMonitor::new(self.ctx.config.weather_location.clone());
monitor.start();
self.weather_monitor = Some(monitor);
} else if !self.ctx.background_style.requires_weather() && self.weather_monitor.is_some() {
self.weather_monitor = None;
}
}
fn quit(&mut self) {
self.running = false;
}
fn toggle_display_mode(&mut self) {
self.active_mode_index = (self.active_mode_index + 1) % self.modes.len();
}
fn render_help_overlay(&self, frame: &mut Frame) {
if !self.show_help {
return;
}
let area = frame.area();
let width = 56u16.min(area.width.saturating_sub(4));
let height = 28u16.min(area.height.saturating_sub(2));
let x = (area.width.saturating_sub(width)) / 2;
let y = (area.height.saturating_sub(height)) / 2;
let overlay_area = ratatui::layout::Rect::new(x, y, width, height);
frame.render_widget(Clear, overlay_area);
let accent = self.ctx.color();
let fg = Color::White;
let dim = Color::Gray;
let key_line = |key: &'static str, desc: &'static str| -> Line<'static> {
Line::from(vec![
Span::styled(key, Style::default().fg(fg).bold()),
Span::styled(desc, Style::default().fg(dim)),
])
};
let help_lines = vec![
Line::from(Span::styled(
"Keyboard Shortcuts",
Style::default().fg(accent).bold(),
))
.centered(),
Line::from(""),
Line::from(Span::styled(" Global", Style::default().fg(accent).bold())),
key_line(" q / Esc ", "Quit"),
key_line(" m ", "Cycle mode"),
key_line(" t ", "Toggle 12/24 hour"),
key_line(" c ", "Cycle color theme"),
key_line(" a ", "Cycle animation style"),
key_line(" b ", "Cycle background"),
key_line(" s ", "Open settings"),
key_line(" ? ", "Toggle this help"),
Line::from(""),
Line::from(Span::styled(
" Pomodoro",
Style::default().fg(accent).bold(),
)),
key_line(" Space ", "Start / Pause"),
key_line(" r ", "Reset current phase"),
key_line(" n ", "Skip to next phase"),
Line::from(""),
Line::from(Span::styled(" Timer", Style::default().fg(accent).bold())),
key_line(" Space ", "Start / Pause"),
key_line(" r ", "Reset"),
key_line(" + / - ", "Adjust duration"),
Line::from(""),
Line::from(Span::styled(
" Stopwatch",
Style::default().fg(accent).bold(),
)),
key_line(" Space ", "Start / Pause"),
key_line(" r ", "Reset"),
key_line(" l ", "Lap"),
Line::from(""),
Line::from(Span::styled(
"Press any key to close",
Style::default().fg(dim),
))
.centered(),
];
let help_widget = Paragraph::new(help_lines).block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(accent))
.title(" Help ")
.title_alignment(Alignment::Center)
.style(Style::default().fg(fg).bg(Color::Black)),
);
frame.render_widget(help_widget, overlay_area);
}
}
impl Default for App {
fn default() -> Self {
Self::new()
}
}
fn parse_color_theme(s: &str) -> Option<ColorTheme> {
let lower = s.to_lowercase();
match lower.as_str() {
"cyan" => Some(ColorTheme::Cyan),
"green" => Some(ColorTheme::Green),
"white" => Some(ColorTheme::White),
"magenta" => Some(ColorTheme::Magenta),
"yellow" => Some(ColorTheme::Yellow),
"red" => Some(ColorTheme::Red),
"blue" => Some(ColorTheme::Blue),
"rainbow" => Some(ColorTheme::Rainbow),
"rainbowvertical" | "rainbow-vertical" => Some(ColorTheme::RainbowVertical),
"gradientwarm" | "gradient-warm" | "warm" => Some(ColorTheme::GradientWarm),
"gradientcool" | "gradient-cool" | "cool" => Some(ColorTheme::GradientCool),
"gradientocean" | "gradient-ocean" | "ocean" => Some(ColorTheme::GradientOcean),
"gradientneon" | "gradient-neon" | "neon" => Some(ColorTheme::GradientNeon),
"gradientfire" | "gradient-fire" | "fire" => Some(ColorTheme::GradientFire),
"gradientfrost" | "gradient-frost" | "frost" => Some(ColorTheme::GradientFrost),
"gradientaurora" | "gradient-aurora" | "aurora" => Some(ColorTheme::GradientAurora),
"gradientwinter" | "gradient-winter" | "winter" => Some(ColorTheme::GradientWinter),
"gradientsakura" | "gradient-sakura" | "sakura" => Some(ColorTheme::GradientSakura),
_ => None,
}
}
fn parse_background_style(s: &str) -> Option<BackgroundStyle> {
let lower = s.to_lowercase();
match lower.as_str() {
"none" => Some(BackgroundStyle::None),
"starfield" | "stars" => Some(BackgroundStyle::Starfield),
"matrix" | "matrixrain" | "matrix-rain" => Some(BackgroundStyle::MatrixRain),
"gradient" | "gradientwave" | "gradient-wave" => Some(BackgroundStyle::GradientWave),
"snowfall" | "snow" => Some(BackgroundStyle::Snowfall),
"frost" => Some(BackgroundStyle::Frost),
"aurora" => Some(BackgroundStyle::Aurora),
"sunny" | "sun" => Some(BackgroundStyle::Sunny),
"rainy" | "rain" => Some(BackgroundStyle::Rainy),
"stormy" | "storm" => Some(BackgroundStyle::Stormy),
"windy" | "wind" => Some(BackgroundStyle::Windy),
"cloudy" | "clouds" => Some(BackgroundStyle::Cloudy),
"foggy" | "fog" => Some(BackgroundStyle::Foggy),
"weather" => Some(BackgroundStyle::Weather),
"dawn" | "twilightdawn" | "twilight-dawn" => Some(BackgroundStyle::TwilightDawn),
"dusk" | "twilightdusk" | "twilight-dusk" => Some(BackgroundStyle::TwilightDusk),
"cherryblossom" | "cherry-blossom" | "sakura" => Some(BackgroundStyle::CherryBlossom),
"systempulse" | "system-pulse" | "pulse" => Some(BackgroundStyle::SystemPulse),
"resourcewave" | "resource-wave" | "resource" => Some(BackgroundStyle::ResourceWave),
"dataflow" | "data-flow" => Some(BackgroundStyle::DataFlow),
"heatmap" | "heat-map" | "heat" => Some(BackgroundStyle::HeatMap),
_ => None,
}
}
fn parse_display_mode(s: &str) -> Option<DisplayMode> {
let lower = s.to_lowercase();
match lower.as_str() {
"clock" => Some(DisplayMode::Clock),
"pomodoro" => Some(DisplayMode::Pomodoro),
"timer" => Some(DisplayMode::Timer),
"stopwatch" => Some(DisplayMode::Stopwatch),
"worldclock" | "world-clock" | "world" => Some(DisplayMode::WorldClock),
_ => None,
}
}