diff --git a/src/main.rs b/src/main.rs index d989b81..7ee8bd1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,24 +1,24 @@ //! A simple CLI app for displaying rich 24 hour time information for multiple -//! people and/or time zones. +//! locations. mod config; +mod message; +mod model; +mod update; +mod view; use std::{ffi::OsString, io, time::Duration}; -use chrono::{NaiveTime, TimeDelta, Timelike, Utc, offset::LocalResult}; -use chrono_tz::Tz; use clap::Parser; -use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; -use ratatui::{ - DefaultTerminal, - prelude::*, - symbols::border, - widgets::{Block, BorderType::Rounded, Cell, Padding, Row, Table, TableState}, -}; -use ratatui_textarea::{Input, TextArea}; -use unidecode::unidecode; +use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers}; +use ratatui::DefaultTerminal; -use crate::config::{Config, Location}; +use crate::{ + config::Config, + message::Message, + model::{FocusState, Model, RunningState}, + view::view, +}; /// A simple CLI app for displaying rich 24 hour time information for multiple /// people and/or time zones. @@ -40,70 +40,42 @@ fn main() -> color_eyre::Result<()> { /// TUI render loop. fn run(terminal: &mut DefaultTerminal, config: Config) -> color_eyre::Result<()> { let mut model = Model::from_config(config); - while model.running_state != RunningState::Done { - terminal.draw(|f| view(&mut model, f))?; - - // Handle events and map to a Message - let mut current_msg = handle_event(&model)?; - + model.update_loop(Some(Message::TimeProgressed))?; + terminal.draw(|frame| view(&mut model, frame))?; // Process updates as long as they return a non-None message - while current_msg.is_some() { - current_msg = update(&mut model, current_msg.unwrap()); - } + model.update_loop(handle_event(&model)?)?; } - Ok(()) } -/// App-wide Elm model. -#[derive(Debug)] -struct Model<'a> { - config: Config, - table_state: TableState, - running_state: RunningState, - focus_state: FocusState, - filter_textarea: TextArea<'a>, -} - -impl<'a> Model<'a> { - fn from_config(config: Config) -> Self { - Self { - config, - table_state: Default::default(), - running_state: Default::default(), - focus_state: Default::default(), - filter_textarea: Default::default(), - } - } -} - -#[derive(Debug, Default, Eq, PartialEq)] -enum RunningState { - #[default] - Running, - Done, -} - -#[derive(Debug, Default, Eq, PartialEq)] -enum FocusState { - #[default] - Table, - Filter, -} - -enum Message { - Quit, - FilterOpened, - FilterCancelled, - FilterUpdated(Input), -} - +/// Momentarily block until a crossterm event is detected or the internal +/// timeout expires. fn handle_event(model: &Model) -> io::Result> { Ok(if event::poll(Duration::from_secs(5))? { match event::read()? { Event::Key(key_event) if key_event.kind == KeyEventKind::Press => { - handle_key_event(model, key_event) + match (&model.focus_state, key_event.code) { + (_, KeyCode::Char('c')) + if key_event.modifiers.contains(KeyModifiers::CONTROL) => + { + Some(Message::Quit) + } + (FocusState::Table, KeyCode::Char('q')) => Some(Message::Quit), + (FocusState::Table, KeyCode::Char('/')) => Some(Message::FilterOpened), + (FocusState::Filter, KeyCode::Esc) => Some(Message::FilterCancelled), + // Ignore input that creates newlines: START + (FocusState::Filter, KeyCode::Enter) => None, + (FocusState::Filter, KeyCode::Char('m')) + if key_event.modifiers.contains(KeyModifiers::CONTROL) + && !key_event.modifiers.contains(KeyModifiers::ALT) => + { + None + } + // Ignore input that creates newlines: END + (FocusState::Filter, _) => Some(Message::FilterUpdated(key_event.into())), + _ => None, + } } _ => None, } @@ -113,177 +85,3 @@ fn handle_event(model: &Model) -> io::Result> { None }) } - -fn handle_key_event(model: &Model, key_event: KeyEvent) -> Option { - match (&model.focus_state, key_event.code) { - (_, KeyCode::Char('c')) if key_event.modifiers.contains(KeyModifiers::CONTROL) => { - Some(Message::Quit) - } - (FocusState::Table, KeyCode::Char('q')) => Some(Message::Quit), - (FocusState::Table, KeyCode::Char('/')) => Some(Message::FilterOpened), - (FocusState::Filter, KeyCode::Esc) | (FocusState::Filter, KeyCode::Enter) => { - Some(Message::FilterCancelled) - } - // Ignore input that creates newlines. - (FocusState::Filter, KeyCode::Char('m')) - if key_event.modifiers.contains(KeyModifiers::CONTROL) - && !key_event.modifiers.contains(KeyModifiers::ALT) => - { - None - } - (FocusState::Filter, _) => Some(Message::FilterUpdated(key_event.into())), - _ => None, - } -} - -/// Elm update function. -fn update(model: &mut Model, msg: Message) -> Option { - match msg { - Message::Quit => model.running_state = RunningState::Done, - Message::FilterOpened => model.focus_state = FocusState::Filter, - Message::FilterCancelled => { - model.focus_state = FocusState::Table; - model.filter_textarea.clear(); - } - Message::FilterUpdated(input) => { - model.filter_textarea.input(input); - } - } - None -} - -/// Elm render function. -fn view(model: &mut Model, frame: &mut Frame) { - let title = Line::from(" Team Time Zones ".bold()); - let instructions = Line::from(vec![ - " Filter ".into(), - "/".blue().bold(), - " Quit ".into(), - "Q ".blue().bold(), - ]); - let block = Block::bordered() - .title(title.centered().fg(Color::Reset)) - .title_bottom(instructions.centered().fg(Color::Reset)) - .border_set(border::PLAIN) - .border_style(Style::new().fg(Color::DarkGray)) - .border_type(Rounded) - .padding(Padding::horizontal(2)); - - let visible_locations: Vec<&Location> = if model.focus_state == FocusState::Filter { - let filter_terms: Vec = model - .filter_textarea - .clone() - .into_lines() - .into_iter() - .next() - .unwrap_or_default() - .split('|') - .map(|value| value.trim().to_owned()) - .collect(); - model - .config - .locations - .iter() - .enumerate() - .filter(|(i, location)| { - *i == 0 - || filter_terms.iter().any(|filter_term| { - unidecode(&location.name.to_ascii_lowercase()) - .contains(&unidecode(&filter_term.to_ascii_lowercase())) - }) - }) - .map(|(_, value)| value) - .collect() - } else { - model.config.locations.iter().collect() - }; - - let header = Row::new( - visible_locations - .iter() - .map(|location| location.name.as_str()) - .collect::>(), - ) - .cyan() - .bold(); - let col_widths: Vec = visible_locations - .iter() - .map(|location| Constraint::Length(clamp_u16(location.name.len(), 5, 32))) - .collect(); - - let mut rows: Vec = Vec::with_capacity(24); - let home_tz = visible_locations - .first() - .expect("should always be at least one location loaded and visible") - .tz; - let now = Utc::now(); - let mut home_datetime = match now - .with_timezone(&home_tz) - .with_time(NaiveTime::from_hms_opt(0, 0, 0).expect("00:00:00 is a valid time")) - { - LocalResult::Single(datetime) | LocalResult::Ambiguous(datetime, _) => datetime, - LocalResult::None => { - // TODO: display error - return; - } - }; - for _ in 0..24 { - let is_current_time = now.with_timezone(&Tz::UTC) - home_datetime > TimeDelta::zero() - && now.with_timezone(&Tz::UTC) - home_datetime < TimeDelta::minutes(60); - rows.push( - Row::new(visible_locations.iter().map(|Location { tz, .. }| { - let col_datetime = home_datetime.with_timezone(tz); - let is_daylight = col_datetime.hour() >= 9 && col_datetime.hour() <= 16; - Cell::new(col_datetime.format("%H:%M").to_string()).style( - if is_current_time || is_daylight { - Style::new() - } else { - Style::new().fg(Color::DarkGray) - }, - ) - })) - .style(if is_current_time { - Style::new().red() - } else { - Style::new() - }), - ); - home_datetime += TimeDelta::hours(1); - } - - let table = Table::new(rows, col_widths) - .header(header) - .block(block) - .column_spacing(4); - - let layout = Layout::vertical([ - Constraint::Fill(1), - Constraint::Length(if model.focus_state == FocusState::Filter { - 1 - } else { - 0 - }), - ]); - let [table_area, filter_area] = layout.areas(frame.area()); - if model.focus_state == FocusState::Filter { - frame.render_widget(&model.filter_textarea, filter_area); - } - frame.render_stateful_widget(table, table_area, &mut model.table_state); -} - -/// Clip a [`usize`]-like value to between two [`u16`]s (inclusive). -fn clamp_u16(value: T, min: u16, max: u16) -> u16 -where - T: Into, -{ - let value: usize = value.into(); - if value <= min.into() { - min - } else if value >= max.into() { - max - } else { - value - .try_into() - .expect("value is known to be unsigned and less than some valid u16") - } -} diff --git a/src/message.rs b/src/message.rs new file mode 100644 index 0000000..020fcf1 --- /dev/null +++ b/src/message.rs @@ -0,0 +1,10 @@ +use ratatui_textarea::Input; + +/// Elm message type. +pub(crate) enum Message { + Quit, + FilterOpened, + FilterCancelled, + FilterUpdated(Input), + TimeProgressed, +} diff --git a/src/model.rs b/src/model.rs new file mode 100644 index 0000000..f13d5a3 --- /dev/null +++ b/src/model.rs @@ -0,0 +1,101 @@ +use chrono::NaiveDateTime; +use color_eyre::Result; +use ratatui::widgets::TableState; +use ratatui_textarea::TextArea; +use unidecode::unidecode; + +use crate::{ + config::{Config, Location}, + message::Message, + update::update, +}; + +/// App-wide Elm model. +#[derive(Debug)] +pub(crate) struct Model<'a> { + pub(crate) config: Config, + pub(crate) table_state: TableState, + pub(crate) table_rows: Vec>, + pub(crate) running_state: RunningState, + pub(crate) focus_state: FocusState, + pub(crate) filter_textarea: TextArea<'a>, +} + +impl<'a> Model<'a> { + /// Create new model from parsed runtime config. + pub(crate) fn from_config(config: Config) -> Self { + Self { + config, + table_state: Default::default(), + table_rows: Default::default(), + running_state: Default::default(), + focus_state: Default::default(), + filter_textarea: Default::default(), + } + } + + /// Run Elm updates until they produce no further recursive messages. + pub(crate) fn update_loop(&mut self, msg: Option) -> Result<()> { + let mut maybe_msg = msg; + while let Some(msg) = maybe_msg { + maybe_msg = update(self, msg)?; + } + Ok(()) + } + + /// Compute list of visible locations based on filter value. + pub(crate) fn get_visible_locations(&self) -> Vec { + if self.focus_state == FocusState::Filter { + let filter_terms = self.get_filter_terms(); + self.config + .locations + .iter() + .enumerate() + .filter(|(i, location)| { + *i == 0 + || filter_terms.iter().any(|filter_term| { + unidecode(&location.name.to_ascii_lowercase()) + .contains(&unidecode(&filter_term.to_ascii_lowercase())) + }) + }) + .map(|(_, value)| value.clone()) + .collect() + } else { + self.config.locations.to_vec() + } + } + + fn get_filter_terms(&self) -> Vec { + self.filter_textarea + .clone() + .into_lines() + .into_iter() + .next() + .unwrap_or_default() + .split('|') + .map(|value| value.trim().to_owned()) + .collect() + } +} + +#[derive(Debug, Default, Eq, PartialEq)] +pub(crate) enum RunningState { + #[default] + Running, + Done, +} + +#[derive(Debug, Default, Eq, PartialEq)] +pub(crate) enum FocusState { + #[default] + Table, + Filter, +} + +/// Computed data required to render a timetable cell. +#[derive(Debug)] +pub(crate) struct CellData { + pub(crate) is_daylight: bool, + pub(crate) is_current_time: bool, + pub(crate) datetime: NaiveDateTime, +} diff --git a/src/update.rs b/src/update.rs new file mode 100644 index 0000000..2fde706 --- /dev/null +++ b/src/update.rs @@ -0,0 +1,69 @@ +use chrono::{NaiveTime, TimeDelta, Timelike as _, Utc, offset::LocalResult}; +use chrono_tz::Tz; +use color_eyre::{Result, eyre::eyre}; + +use crate::{ + config::Location, + message::Message, + model::{CellData, FocusState, Model, RunningState}, +}; + +/// Elm update function. +pub(crate) fn update(model: &mut Model, msg: Message) -> Result> { + match msg { + Message::Quit => model.running_state = RunningState::Done, + Message::FilterOpened => model.focus_state = FocusState::Filter, + Message::FilterCancelled => { + model.focus_state = FocusState::Table; + model.filter_textarea.clear(); + } + Message::FilterUpdated(input) => { + model.filter_textarea.input(input); + } + Message::TimeProgressed => { + model.table_rows = compute_rows(&model.get_visible_locations())?; + } + } + Ok(None) +} + +/// Compute timetable data based on the model state and current system time. +fn compute_rows(visible_locations: &[Location]) -> Result>> { + let mut rows: Vec> = Vec::with_capacity(24); + let home_tz = visible_locations + .first() + .expect("should always be at least one location loaded and visible") + .tz; + let now = Utc::now(); + let mut home_datetime = match now + .with_timezone(&home_tz) + .with_time(NaiveTime::from_hms_opt(0, 0, 0).expect("00:00:00 is a valid time")) + { + LocalResult::Single(datetime) | LocalResult::Ambiguous(datetime, _) => datetime, + LocalResult::None => { + return Err(eyre!( + "Unable to compute: home time zone has no midnight. Date math is hard!" + )); + } + }; + for _ in 0..24 { + let is_current_time = now.with_timezone(&Tz::UTC) - home_datetime > TimeDelta::zero() + && now.with_timezone(&Tz::UTC) - home_datetime < TimeDelta::minutes(60); + + rows.push( + visible_locations + .iter() + .map(|Location { tz, .. }| { + let cell_datetime = home_datetime.with_timezone(tz).naive_local(); + CellData { + is_current_time, + is_daylight: cell_datetime.hour() >= 9 && cell_datetime.hour() <= 16, + datetime: cell_datetime, + } + }) + .collect(), + ); + home_datetime += TimeDelta::hours(1); + } + Ok(rows) +} diff --git a/src/view.rs b/src/view.rs new file mode 100644 index 0000000..fe0acdb --- /dev/null +++ b/src/view.rs @@ -0,0 +1,119 @@ +use ratatui::widgets::BorderType::Rounded; +use ratatui::widgets::{Cell, Padding, Row, Table}; +use ratatui::{prelude::*, symbols::border, widgets::Block}; + +use crate::model::{CellData, FocusState, Model}; + +/// Elm render function. +pub(crate) fn view(model: &mut Model, frame: &mut Frame) { + let title = Line::from(" Team Time Zones ".bold()); + let instructions = Line::from(match model.focus_state { + FocusState::Table => vec![ + " Filter ".into(), + "/".blue().bold(), + " Quit ".into(), + "Q ".blue().bold(), + ], + FocusState::Filter => vec![" Reset ".into(), "".blue().bold(), " ".into()], + }); + let main_block = Block::bordered() + .title(title.centered().fg(Color::Reset)) + .title_bottom(instructions.centered().fg(Color::Reset)) + .border_set(border::PLAIN) + .border_style(Style::new().fg(Color::DarkGray)) + .border_type(Rounded); + frame.render_widget(&main_block, frame.area()); + + let visible_locations = model.get_visible_locations(); + let header = Row::new( + visible_locations + .iter() + .map(|location| location.name.as_str()) + .collect::>(), + ) + .cyan() + .bold(); + let col_widths: Vec = visible_locations + .iter() + .map(|location| Constraint::Length(clamp_u16(location.name.len(), 5, 32))) + .collect(); + + // TODO: Figure out a good way to refactor this into a distinct view + // function without offending the borrow checker. + let table = Table::new( + model + .table_rows + .iter() + .map(|row| { + Row::new( + row.iter() + .map( + |&CellData { + is_current_time, + is_daylight, + datetime, + }| { + Cell::new(datetime.format("%H:%M").to_string()).style( + if is_current_time { + Style::new().red() + } else if !is_daylight { + Style::new().fg(Color::DarkGray) + } else { + Style::new() + }, + ) + }, + ) + .collect::>(), + ) + }) + .collect::>(), + col_widths, + ) + .header(header) + .column_spacing(4) + .block(Block::new().padding(Padding::horizontal(2))); + + let [table_area, filter_area] = Layout::vertical([ + Constraint::Fill(1), + Constraint::Length(if model.focus_state == FocusState::Filter { + 1 + } else { + 0 + }), + ]) + .areas(main_block.inner(frame.area())); + if model.focus_state == FocusState::Filter { + const FILTER_LABEL_TEXT: &str = "Filter: "; + let [filter_label_area, filter_input_area] = Layout::horizontal([ + Constraint::Length( + FILTER_LABEL_TEXT + .len() + .try_into() + .expect("filter label is of static, reasonable length"), + ), + Constraint::Fill(1), + ]) + .areas(filter_area); + frame.render_widget(FILTER_LABEL_TEXT.dim(), filter_label_area); + frame.render_widget(&model.filter_textarea, filter_input_area); + } + frame.render_stateful_widget(table, table_area, &mut model.table_state); +} + +/// Clip a [`usize`]-like value to between two [`u16`]s (inclusive). +fn clamp_u16(value: T, min: u16, max: u16) -> u16 +where + T: Into, +{ + let value: usize = value.into(); + if value <= min.into() { + min + } else if value >= max.into() { + max + } else { + value + .try_into() + .expect("value is known to be unsigned and less than some valid u16") + } +}