split code into sensible modules
This commit is contained in:
parent
d6990ac8f7
commit
3a9607c4c9
5 changed files with 338 additions and 241 deletions
280
src/main.rs
280
src/main.rs
|
|
@ -1,24 +1,24 @@
|
||||||
//! A simple CLI app for displaying rich 24 hour time information for multiple
|
//! A simple CLI app for displaying rich 24 hour time information for multiple
|
||||||
//! people and/or time zones.
|
//! locations.
|
||||||
|
|
||||||
mod config;
|
mod config;
|
||||||
|
mod message;
|
||||||
|
mod model;
|
||||||
|
mod update;
|
||||||
|
mod view;
|
||||||
|
|
||||||
use std::{ffi::OsString, io, time::Duration};
|
use std::{ffi::OsString, io, time::Duration};
|
||||||
|
|
||||||
use chrono::{NaiveTime, TimeDelta, Timelike, Utc, offset::LocalResult};
|
|
||||||
use chrono_tz::Tz;
|
|
||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
|
use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers};
|
||||||
use ratatui::{
|
use ratatui::DefaultTerminal;
|
||||||
DefaultTerminal,
|
|
||||||
prelude::*,
|
|
||||||
symbols::border,
|
|
||||||
widgets::{Block, BorderType::Rounded, Cell, Padding, Row, Table, TableState},
|
|
||||||
};
|
|
||||||
use ratatui_textarea::{Input, TextArea};
|
|
||||||
use unidecode::unidecode;
|
|
||||||
|
|
||||||
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
|
/// A simple CLI app for displaying rich 24 hour time information for multiple
|
||||||
/// people and/or time zones.
|
/// people and/or time zones.
|
||||||
|
|
@ -40,70 +40,42 @@ fn main() -> color_eyre::Result<()> {
|
||||||
/// TUI render loop.
|
/// TUI render loop.
|
||||||
fn run(terminal: &mut DefaultTerminal, config: Config) -> color_eyre::Result<()> {
|
fn run(terminal: &mut DefaultTerminal, config: Config) -> color_eyre::Result<()> {
|
||||||
let mut model = Model::from_config(config);
|
let mut model = Model::from_config(config);
|
||||||
|
|
||||||
while model.running_state != RunningState::Done {
|
while model.running_state != RunningState::Done {
|
||||||
terminal.draw(|f| view(&mut model, f))?;
|
model.update_loop(Some(Message::TimeProgressed))?;
|
||||||
|
terminal.draw(|frame| view(&mut model, frame))?;
|
||||||
// Handle events and map to a Message
|
|
||||||
let mut current_msg = handle_event(&model)?;
|
|
||||||
|
|
||||||
// Process updates as long as they return a non-None message
|
// Process updates as long as they return a non-None message
|
||||||
while current_msg.is_some() {
|
model.update_loop(handle_event(&model)?)?;
|
||||||
current_msg = update(&mut model, current_msg.unwrap());
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// App-wide Elm model.
|
/// Momentarily block until a crossterm event is detected or the internal
|
||||||
#[derive(Debug)]
|
/// timeout expires.
|
||||||
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),
|
|
||||||
}
|
|
||||||
|
|
||||||
fn handle_event(model: &Model) -> io::Result<Option<Message>> {
|
fn handle_event(model: &Model) -> io::Result<Option<Message>> {
|
||||||
Ok(if event::poll(Duration::from_secs(5))? {
|
Ok(if event::poll(Duration::from_secs(5))? {
|
||||||
match event::read()? {
|
match event::read()? {
|
||||||
Event::Key(key_event) if key_event.kind == KeyEventKind::Press => {
|
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,
|
_ => None,
|
||||||
}
|
}
|
||||||
|
|
@ -113,177 +85,3 @@ fn handle_event(model: &Model) -> io::Result<Option<Message>> {
|
||||||
None
|
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
10
src/message.rs
Normal 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
101
src/model.rs
Normal 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
69
src/update.rs
Normal 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
119
src/view.rs
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue