split code into sensible modules

This commit is contained in:
Brent Schroeter 2026-06-08 05:53:41 +00:00
parent d6990ac8f7
commit 3a9607c4c9
5 changed files with 338 additions and 241 deletions

View file

@ -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<Option<Message>> {
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<Option<Message>> {
None
})
}
fn handle_key_event(model: &Model, key_event: KeyEvent) -> Option<Message> {
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<Message> {
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<String> = 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::<Vec<&str>>(),
)
.cyan()
.bold();
let col_widths: Vec<Constraint> = visible_locations
.iter()
.map(|location| Constraint::Length(clamp_u16(location.name.len(), 5, 32)))
.collect();
let mut rows: Vec<Row> = 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<T>(value: T, min: u16, max: u16) -> u16
where
T: Into<usize>,
{
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")
}
}

10
src/message.rs Normal file
View file

@ -0,0 +1,10 @@
use ratatui_textarea::Input;
/// Elm message type.
pub(crate) enum Message {
Quit,
FilterOpened,
FilterCancelled,
FilterUpdated(Input),
TimeProgressed,
}

101
src/model.rs Normal file
View file

@ -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<Vec<CellData>>,
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<Message>) -> 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<Location> {
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<String> {
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,
}

69
src/update.rs Normal file
View file

@ -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<Option<Message>> {
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<Vec<Vec<CellData>>> {
let mut rows: Vec<Vec<CellData>> = 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)
}

119
src/view.rs Normal file
View file

@ -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(), "<Esc>".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::<Vec<&str>>(),
)
.cyan()
.bold();
let col_widths: Vec<Constraint> = 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::<Vec<Cell>>(),
)
})
.collect::<Vec<Row>>(),
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<T>(value: T, min: u16, max: u16) -> u16
where
T: Into<usize>,
{
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")
}
}