use std::time::{Duration, Instant}; use clap::Parser; use eframe::{App, CreationContext}; use egui::{Color32, Direction, FontId, Layout, RichText, Ui}; use egui_extras::{Size, StripBuilder}; use crate::{cli::Cli, util::*}; const MIN_REPAINT: Duration = Duration::from_millis(200); // one frame before 200ms const MAX_REPAINT: Duration = Duration::from_millis(500); const DIGIT_FACTOR: f32 = 0.4; const TEXT_FACTOR: f32 = 0.2; #[derive(Debug, Clone, Copy)] pub enum CountDirection { Up, Down, } #[derive(Debug, Clone)] pub struct Timer { direction: CountDirection, duration: Duration, state: TimerState, tstart: Instant, // so we can blink alarm: Option, } #[derive(Debug, Clone, Copy)] enum TimerState { Unstarted, Paused(ChronoState), Running(ChronoState), Finished, } impl PartialEq for TimerState { fn eq(&self, other: &Self) -> bool { matches!( (self, other), (Self::Paused(_), Self::Paused(_)) | (Self::Running(_), Self::Running(_)) | (Self::Unstarted, Self::Unstarted) | (Self::Finished, Self::Finished) ) } } impl Eq for TimerState {} #[derive(Debug, Clone, Copy)] struct ChronoState { updated: Instant, remaining: Duration, } impl App for Timer { fn update(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) { ctx.request_repaint_after(MAX_REPAINT); let height = ctx.input().screen_rect().height(); egui::CentralPanel::default().show(ctx, |ui| { match self.state { TimerState::Unstarted => self.unstarted(ui, height), TimerState::Running(cs) => { let dur = Instant::now() - cs.updated; let dur = MIN_REPAINT.saturating_sub(dur); ctx.request_repaint_after(dur); self.running(ui, height); } TimerState::Paused(_) => self.paused(ui, height), TimerState::Finished => self.finished(ui, height), } // check for quit key if ui.input().key_pressed(egui::Key::Q) || ui.input().key_pressed(egui::Key::Escape) { frame.close(); } }); } } impl Timer { pub fn new(ctx: &CreationContext) -> Self { let cli = Cli::parse(); let predator = cli.predator; let seconds = cli.hours.unwrap_or(0) * 3600 + cli.minutes.unwrap_or(0) * 60 + cli.seconds.unwrap_or(0); let duration = Duration::from_secs(seconds); let direction = if cli.count_up { CountDirection::Up } else { CountDirection::Down }; if predator { let mut fonts = egui::FontDefinitions::default(); fonts.font_data.insert( "predator".to_owned(), egui::FontData::from_static(include_bytes!("../Predator.ttf")), ); fonts .families .entry(egui::FontFamily::Monospace) .or_default() .insert(0, "predator".to_owned()); ctx.egui_ctx.set_fonts(fonts); } ctx.egui_ctx.request_repaint_after(Duration::from_secs(1)); let mut timer = Timer { duration, direction, state: TimerState::Unstarted, tstart: Instant::now(), alarm: cli.alarm, }; if cli.running { let cs = ChronoState { remaining: duration, updated: Instant::now(), }; timer.state = TimerState::Running(cs); } timer } fn unstarted(&mut self, ui: &mut Ui, size: f32) { let tsize = size * 0.5; let start = RichText::new("START") .font(FontId::monospace(tsize)) .color(Color32::WHITE) .background_color(Color32::LIGHT_GREEN); StripBuilder::new(ui) .size(Size::remainder()) .cell_layout(Layout::centered_and_justified(egui::Direction::TopDown)) .horizontal(|mut strip| { strip.cell(|ui| { if ui.button(start).clicked() { let dur = self.duration; self.state = TimerState::Running(ChronoState { updated: Instant::now(), remaining: dur, }); } }); }); } fn running(&mut self, ui: &mut Ui, size: f32) { let tsize = size * TEXT_FACTOR; let text = RichText::new("PAUSE") .font(FontId::monospace(tsize)) .color(Color32::GOLD); let elapsed; let remaining; let mut is_paused = false; if let TimerState::Running(rs) = self.state { elapsed = Instant::now() - rs.updated; remaining = rs.remaining; } else { unreachable!() } let remaining = remaining.saturating_sub(elapsed); StripBuilder::new(ui) .size(Size::relative(0.3333)) .size(Size::remainder()) .cell_layout(Layout::centered_and_justified(Direction::LeftToRight)) .vertical(|mut strip| { // first the pause strip.cell(|ui| { if ui.button(text).clicked() { is_paused = true; } }); // if we're counting up, do the right thing let remaining = match self.direction { CountDirection::Down => remaining, CountDirection::Up => self.duration - remaining, }; // now the numbers let color = Color32::DARK_GRAY; let tsize = size * DIGIT_FACTOR; display_digits(&mut strip, remaining, color, tsize); }); if remaining.is_zero() { if let Some(alarm_file) = &self.alarm { let alarm_file = alarm_file.to_owned(); std::thread::spawn(move || alarm(alarm_file)); } self.state = TimerState::Finished; return; } let cs = ChronoState { remaining, updated: Instant::now(), }; if is_paused { // did we click the reset button? if self.state == TimerState::Unstarted { return; } self.state = TimerState::Paused(cs); } else { self.state = TimerState::Running(cs); } } fn paused(&mut self, ui: &mut Ui, vsize: f32) { let elapsed; let mut is_running = false; let tsize = vsize * TEXT_FACTOR; if let TimerState::Paused(cs) = self.state { elapsed = cs.remaining; } else { unreachable!() } let remaining = elapsed; StripBuilder::new(ui) .size(Size::relative(0.33333)) .size(Size::remainder()) .cell_layout(Layout::centered_and_justified(Direction::LeftToRight)) .vertical(|mut strip| { strip.strip(|pstrip| { pstrip .sizes(Size::remainder(), 2) .cell_layout(Layout::centered_and_justified(Direction::TopDown)) .horizontal(|mut pstrip| { pstrip.cell(|ui| { let resume = RichText::new("RESUME") .color(Color32::GREEN) .font(FontId::monospace(tsize)); if ui.button(resume).clicked() { is_running = true; } }); pstrip.cell(|ui| { let reset = RichText::new("RESET") .color(Color32::RED) .font(FontId::monospace(tsize)); if ui.button(reset).clicked() { self.state = TimerState::Unstarted; } }); }); }); let color = { let blink = (Instant::now() - self.tstart).as_secs() % 2 == 0; if blink { Color32::BLACK } else { Color32::DARK_GRAY } }; // if we're counting up, do the right thing let remaining = match self.direction { CountDirection::Down => remaining, CountDirection::Up => self.duration - remaining, }; display_digits(&mut strip, remaining, color, vsize * DIGIT_FACTOR); }); if is_running { let cs = ChronoState { remaining, updated: Instant::now(), }; self.state = TimerState::Running(cs); } } fn finished(&mut self, ui: &mut Ui, vsize: f32) { let remaining = match self.direction { CountDirection::Up => self.duration, CountDirection::Down => Duration::from_nanos(0), }; StripBuilder::new(ui) .size(Size::relative(0.33333)) .size(Size::remainder()) .cell_layout(Layout::centered_and_justified(Direction::LeftToRight)) .vertical(|mut strip| { strip.strip(|pstrip| { pstrip .size(Size::remainder()) .cell_layout(Layout::centered_and_justified(Direction::TopDown)) .horizontal(|mut pstrip| { pstrip.cell(|ui| { let tsize = vsize * 0.3; let reset = RichText::new("RESTART") .color(Color32::DARK_GREEN) .font(FontId::monospace(tsize)); if ui.button(reset).clicked() { self.state = TimerState::Unstarted; } }); }); }); let color = { let blink = (Instant::now() - self.tstart).as_secs() % 2 == 0; if blink { Color32::BLACK } else { Color32::DARK_GRAY } }; display_digits(&mut strip, remaining, color, vsize * DIGIT_FACTOR); }); } }