clock_tui/
app.rs

1use chrono::DateTime;
2use chrono::Duration;
3use chrono::Local;
4use chrono::NaiveDate;
5use chrono::NaiveDateTime;
6use chrono::NaiveTime;
7use chrono::TimeZone;
8use chrono_tz::Tz;
9use clap::Subcommand;
10use crossterm::event::KeyCode;
11use ratatui::{
12    style::{Color, Style},
13    Frame,
14};
15use regex::Regex;
16
17use self::modes::Clock;
18use self::modes::Countdown;
19use self::modes::DurationFormat;
20use self::modes::Pause;
21use self::modes::Stopwatch;
22use self::modes::Timer;
23
24pub mod modes;
25
26#[derive(Debug, Subcommand)]
27pub enum Mode {
28    /// The clock mode displays the current time, the default mode.
29    Clock {
30        /// Custome timezone, for example "America/New_York", use local timezone if not specificed
31        #[clap(short = 'z', long, value_parser=parse_timezone)]
32        timezone: Option<Tz>,
33        /// Do not show date
34        #[clap(short = 'D', long, action)]
35        no_date: bool,
36        /// Do not show seconds
37        #[clap(short = 'S', long, action)]
38        no_seconds: bool,
39        /// Show milliseconds
40        #[clap(short, long, action)]
41        millis: bool,
42    },
43    /// The timer mode displays the remaining time until the timer is finished.
44    Timer {
45        /// Initial duration for timer, value can be 10s for 10 seconds, 1m for 1 minute, etc.
46        /// Also accept mulitple duration value and run the timers sequentially, eg. 25m 5m
47        #[clap(short, long="duration", value_parser = parse_duration, num_args = 1.., default_value = "5m")]
48        durations: Vec<Duration>,
49
50        /// Set the title for the timer, also accept mulitple titles for each durations correspondingly
51        #[clap(short, long = "title", num_args = 0..)]
52        titles: Vec<String>,
53
54        /// Restart the timer when timer is over
55        #[clap(long, short, action)]
56        repeat: bool,
57
58        /// Hide milliseconds
59        #[clap(long = "no-millis", short = 'M', action)]
60        no_millis: bool,
61
62        /// Start the timer paused
63        #[clap(long = "paused", short = 'P', action)]
64        paused: bool,
65
66        /// Auto quit when time is up
67        #[clap(long = "quit", short = 'Q', action)]
68        auto_quit: bool,
69
70        /// Command to run when the timer ends
71        #[clap(long, short, num_args = 1.., allow_hyphen_values = true)]
72        execute: Vec<String>,
73    },
74    /// The stopwatch mode displays the elapsed time since it was started.
75    Stopwatch,
76    /// The countdown timer mode shows the duration to a specific time
77    Countdown {
78        /// The target time to countdown to, eg. "2023-01-01", "20:00", "2022-12-25 20:00:00" or "2022-12-25T20:00:00-04:00"
79        #[clap(long, short, value_parser = parse_datetime)]
80        time: DateTime<Local>,
81
82        /// Title or description for countdown show in header
83        #[clap(long, short = 'T')]
84        title: Option<String>,
85
86        /// Continue to countdown after pass the target time
87        #[clap(long = "continue", short = 'c', action)]
88        continue_on_zero: bool,
89
90        /// Reverse the countdown, a.k.a. countup
91        #[clap(long, short, action)]
92        reverse: bool,
93
94        /// Show milliseconds
95        #[clap(short, long, action)]
96        millis: bool,
97    },
98}
99
100use crate::config::Config;
101
102#[derive(clap::Parser, Default)]
103#[clap(name = "tclock", about = "A clock app in terminal", long_about = None)]
104pub struct App {
105    #[clap(subcommand)]
106    pub mode: Option<Mode>,
107    /// Foreground color of the clock, possible values are:
108    ///     a) Any one of: Black, Red, Green, Yellow, Blue, Magenta, Cyan, Gray, DarkGray, LightRed, LightGreen, LightYellow, LightBlue, LightMagenta, LightCyan, White.
109    ///     b) Hexadecimal color code: #RRGGBB.
110    #[clap(short, long, value_parser = parse_color)]
111    pub color: Option<Color>,
112    /// Size of the clock, should be a positive integer (>=1).
113    #[clap(short, long, value_parser)]
114    pub size: Option<u16>,
115
116    #[clap(skip)]
117    clock: Option<Clock>,
118    #[clap(skip)]
119    timer: Option<Timer>,
120    #[clap(skip)]
121    stopwatch: Option<Stopwatch>,
122    #[clap(skip)]
123    countdown: Option<Countdown>,
124}
125
126impl App {
127    pub fn set_mode(&mut self, mode: Mode) {
128        self.mode = Some(mode);
129        self.init_app();
130    }
131
132    pub fn init_app(&mut self) {
133        // Load config
134        let config = Config::load();
135        let default_config = config.as_ref().map(|c| &c.default);
136
137        // default mode
138        if self.mode.is_none() {
139            self.mode = default_config.map(|c| match c.mode.as_str() {
140                "timer" => {
141                    let timer_config = config.as_ref().map(|c| &c.timer);
142                    Mode::Timer {
143                        durations: timer_config
144                            .map(|c| {
145                                c.durations
146                                    .iter()
147                                    .filter_map(|d| parse_duration(d).ok())
148                                    .collect()
149                            })
150                            .unwrap_or_else(|| vec![Duration::minutes(25), Duration::minutes(5)]),
151                        titles: timer_config.map(|c| c.titles.clone()).unwrap_or_default(),
152                        repeat: timer_config.map(|c| c.repeat).unwrap_or(false),
153                        no_millis: !timer_config.map(|c| c.show_millis).unwrap_or(true),
154                        paused: timer_config.map(|c| c.start_paused).unwrap_or(false),
155                        auto_quit: timer_config.map(|c| c.auto_quit).unwrap_or(false),
156                        execute: timer_config.map(|c| c.execute.clone()).unwrap_or_default(),
157                    }
158                }
159                "stopwatch" => Mode::Stopwatch,
160                "countdown" => {
161                    let countdown_config = config.as_ref().map(|c| &c.countdown);
162                    Mode::Countdown {
163                        time: countdown_config
164                            .and_then(|c| c.time.as_ref())
165                            .and_then(|t| parse_datetime(t).ok())
166                            .unwrap_or_else(|| Local::now()),
167                        title: countdown_config.map(|c| c.title.clone()).unwrap_or(None),
168                        continue_on_zero: countdown_config
169                            .map(|c| c.continue_on_zero)
170                            .unwrap_or(false),
171                        reverse: countdown_config.map(|c| c.reverse).unwrap_or(false),
172                        millis: countdown_config.map(|c| c.show_millis).unwrap_or(false),
173                    }
174                }
175                _ => {
176                    let clock_config = config.as_ref().map(|c| &c.clock);
177                    Mode::Clock {
178                        no_date: !clock_config.map(|c| c.show_date).unwrap_or(true),
179                        millis: clock_config.map(|c| c.show_millis).unwrap_or(false),
180                        no_seconds: !clock_config.map(|c| c.show_seconds).unwrap_or(true),
181                        timezone: clock_config.and_then(|c| c.timezone),
182                    }
183                }
184            });
185        }
186
187        // set default color and size
188        if self.color.is_none() {
189            self.color = default_config
190                .map(|c| parse_color(&c.color).unwrap_or(Color::Green))
191                .or(Some(Color::Green));
192        }
193        if self.size.is_none() {
194            self.size = default_config.map(|c| c.size).or(Some(1));
195        }
196
197        let style = Style::default().fg(self.color.unwrap_or(Color::Green));
198        let size = self.size.unwrap_or(1);
199
200        // initialize the clock mode
201        match self.mode.as_ref().unwrap_or(&Mode::Clock {
202            no_date: false,
203            millis: false,
204            no_seconds: false,
205            timezone: None,
206        }) {
207            Mode::Clock {
208                no_date,
209                no_seconds,
210                millis,
211                timezone,
212            } => {
213                let clock_config = config.as_ref().map(|c| &c.clock);
214                self.clock = Some(Clock {
215                    size,
216                    style,
217                    show_date: !no_date && clock_config.map(|c| c.show_date).unwrap_or(true),
218                    show_millis: *millis || clock_config.map(|c| c.show_millis).unwrap_or(false),
219                    show_secs: !no_seconds && clock_config.map(|c| c.show_seconds).unwrap_or(true),
220                    timezone: timezone.or_else(|| clock_config.and_then(|c| c.timezone)),
221                });
222            }
223            Mode::Timer {
224                durations,
225                titles,
226                repeat,
227                no_millis,
228                paused,
229                auto_quit,
230                execute,
231            } => {
232                let timer_config = config.as_ref().map(|c| &c.timer);
233                let format = if *no_millis {
234                    DurationFormat::HourMinSec
235                } else {
236                    DurationFormat::HourMinSecDeci
237                };
238                self.timer = Some(Timer::new(
239                    size,
240                    style,
241                    durations.to_owned(),
242                    titles.to_owned(),
243                    *repeat || timer_config.map(|c| c.repeat).unwrap_or(false),
244                    format,
245                    *paused || timer_config.map(|c| c.start_paused).unwrap_or(false),
246                    *auto_quit || timer_config.map(|c| c.auto_quit).unwrap_or(false),
247                    execute.to_owned(),
248                ));
249            }
250            Mode::Stopwatch => {
251                self.stopwatch = Some(Stopwatch::new(size, style));
252            }
253            Mode::Countdown {
254                time,
255                title,
256                continue_on_zero,
257                reverse,
258                millis,
259            } => {
260                let countdown_config = config.as_ref().map(|c| &c.countdown);
261                self.countdown = Some(Countdown {
262                    size,
263                    style,
264                    time: *time,
265                    title: title.to_owned(),
266                    continue_on_zero: *continue_on_zero
267                        || countdown_config
268                            .map(|c| c.continue_on_zero)
269                            .unwrap_or(false),
270                    reverse: *reverse || countdown_config.map(|c| c.reverse).unwrap_or(false),
271                    format: if *millis || countdown_config.map(|c| c.show_millis).unwrap_or(false) {
272                        DurationFormat::HourMinSecDeci
273                    } else {
274                        DurationFormat::HourMinSec
275                    },
276                })
277            }
278        }
279    }
280
281    pub fn ui(&self, f: &mut Frame) {
282        if let Some(ref w) = self.clock {
283            f.render_widget(w, f.size());
284        } else if let Some(ref w) = self.timer {
285            f.render_widget(w, f.size());
286        } else if let Some(ref w) = self.stopwatch {
287            f.render_widget(w, f.size());
288        } else if let Some(ref w) = self.countdown {
289            f.render_widget(w, f.size());
290        }
291    }
292
293    pub fn on_key(&mut self, key: KeyCode) {
294        if let Some(_w) = self.clock.as_mut() {
295        } else if let Some(w) = self.timer.as_mut() {
296            handle_key(w, key);
297        } else if let Some(w) = self.stopwatch.as_mut() {
298            handle_key(w, key);
299        }
300    }
301
302    pub fn is_ended(&self) -> bool {
303        if let Some(ref w) = self.timer {
304            return w.is_finished();
305        }
306        false
307    }
308
309    pub fn on_exit(&self) {
310        if let Some(ref w) = self.stopwatch {
311            println!("Stopwatch time: {}", w.get_display_time());
312        }
313    }
314}
315
316fn handle_key<T: Pause>(widget: &mut T, key: KeyCode) {
317    if let KeyCode::Char(' ') = key {
318        widget.toggle_paused()
319    }
320}
321
322fn parse_duration(s: &str) -> Result<Duration, String> {
323    let reg = Regex::new(r"^(\d+)([smhdSMHD])$").unwrap();
324    let cap = reg
325        .captures(s)
326        .ok_or_else(|| format!("{} is not a valid duration", s))?;
327
328    let num = cap.get(1).unwrap().as_str().parse::<i64>().unwrap();
329    let unit = cap.get(2).unwrap().as_str().to_lowercase();
330
331    match unit.as_str() {
332        "s" => Ok(Duration::seconds(num)),
333        "m" => Ok(Duration::minutes(num)),
334        "h" => Ok(Duration::hours(num)),
335        "d" => Ok(Duration::days(num)),
336        _ => Err(format!("Invalid duration: {}", s)),
337    }
338}
339
340fn parse_color(s: &str) -> Result<Color, String> {
341    let s = s.to_lowercase();
342    let reg = Regex::new(r"^#([0-9a-f]{6})$").unwrap();
343    match s.as_str() {
344        "black" => Ok(Color::Black),
345        "red" => Ok(Color::Red),
346        "green" => Ok(Color::Green),
347        "yellow" => Ok(Color::Yellow),
348        "blue" => Ok(Color::Blue),
349        "magenta" => Ok(Color::Magenta),
350        "cyan" => Ok(Color::Cyan),
351        "gray" => Ok(Color::Gray),
352        "darkgray" => Ok(Color::DarkGray),
353        "lightred" => Ok(Color::LightRed),
354        "lightgreen" => Ok(Color::LightGreen),
355        "lightyellow" => Ok(Color::LightYellow),
356        "lightblue" => Ok(Color::LightBlue),
357        "lightmagenta" => Ok(Color::LightMagenta),
358        "lightcyan" => Ok(Color::LightCyan),
359        "white" => Ok(Color::White),
360        s => {
361            let cap = reg
362                .captures(s)
363                .ok_or_else(|| format!("Invalid color: {}", s))?;
364            let hex = cap.get(1).unwrap().as_str();
365            let r = u8::from_str_radix(&hex[0..2], 16).unwrap();
366            let g = u8::from_str_radix(&hex[2..4], 16).unwrap();
367            let b = u8::from_str_radix(&hex[4..], 16).unwrap();
368            Ok(Color::Rgb(r, g, b))
369        }
370    }
371}
372
373fn parse_datetime(s: &str) -> Result<DateTime<Local>, String> {
374    let s = s.trim();
375    let today = Local::now().date_naive();
376
377    let time = NaiveTime::parse_from_str(s, "%H:%M");
378    if let Ok(time) = time {
379        let time = NaiveDateTime::new(today, time);
380        return Ok(Local.from_local_datetime(&time).unwrap());
381    }
382
383    let time = NaiveTime::parse_from_str(s, "%H:%M:%S");
384    if let Ok(time) = time {
385        let time = NaiveDateTime::new(today, time);
386        return Ok(Local.from_local_datetime(&time).unwrap());
387    }
388
389    let date = NaiveDate::parse_from_str(s, "%Y-%m-%d");
390    if let Ok(date) = date {
391        let time = NaiveDateTime::new(date, NaiveTime::from_hms_opt(0, 0, 0).unwrap());
392        return Ok(Local.from_local_datetime(&time).unwrap());
393    }
394
395    let date_time = NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S");
396    if let Ok(date_time) = date_time {
397        return Ok(Local.from_local_datetime(&date_time).unwrap());
398    }
399
400    let rfc_time = DateTime::parse_from_rfc3339(s);
401    if let Ok(rfc_time) = rfc_time {
402        return Ok(rfc_time.with_timezone(&Local));
403    }
404
405    Err("Invalid time format".to_string())
406}
407
408fn parse_timezone(s: &str) -> Result<Tz, String> {
409    s.parse()
410}