From e1a98da9e776b17da39eb6384aca8700fe8bce0b Mon Sep 17 00:00:00 2001 From: Nick Mann Date: Fri, 25 Jul 2025 09:44:32 +0900 Subject: [PATCH 1/2] perf(tui-prompts): Add DateTime type --- Cargo.lock | 281 ++++++++++++++++++++ tui-prompts/Cargo.toml | 10 + tui-prompts/examples/utc_time.rs | 165 ++++++++++++ tui-prompts/src/datetime/datetime_prompt.rs | 105 ++++++++ tui-prompts/src/datetime/datetime_state.rs | 246 +++++++++++++++++ tui-prompts/src/datetime/mod.rs | 7 + tui-prompts/src/datetime/numeric_child.rs | 196 ++++++++++++++ tui-prompts/src/lib.rs | 11 +- tui-prompts/src/prompt.rs | 221 ++++++++++----- tui-prompts/src/text_prompt.rs | 10 +- tui-prompts/src/text_state.rs | 25 +- 11 files changed, 1207 insertions(+), 70 deletions(-) create mode 100644 tui-prompts/examples/utc_time.rs create mode 100644 tui-prompts/src/datetime/datetime_prompt.rs create mode 100644 tui-prompts/src/datetime/datetime_state.rs create mode 100644 tui-prompts/src/datetime/mod.rs create mode 100644 tui-prompts/src/datetime/numeric_child.rs diff --git a/Cargo.lock b/Cargo.lock index 0a1a7d3..e16d7a2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -32,6 +32,21 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" +[[package]] +name = "android-tzdata" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + [[package]] name = "anstream" version = "0.6.19" @@ -109,6 +124,12 @@ version = "2.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b8e56985ec62d17e9c1001dc89c88ecd7dc08e47eba5ec7c29c7b5eeecde967" +[[package]] +name = "bumpalo" +version = "3.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" + [[package]] name = "bytes" version = "1.10.1" @@ -130,12 +151,33 @@ dependencies = [ "rustversion", ] +[[package]] +name = "cc" +version = "1.2.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "deec109607ca693028562ed836a5f1c4b8bd77755c4e132fc5ce11b0b6211ae7" +dependencies = [ + "shlex", +] + [[package]] name = "cfg-if" version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9555578bc9e57714c812a1f84e4fc5b4d21fcb063490c624de019f7464c91268" +[[package]] +name = "chrono" +version = "0.4.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c469d952047f47f91b68d1cba3f10d63c11d73e4636f24f08daf0278abf01c4d" +dependencies = [ + "android-tzdata", + "iana-time-zone", + "num-traits", + "windows-link", +] + [[package]] name = "clap" version = "4.5.41" @@ -232,6 +274,12 @@ dependencies = [ "static_assertions", ] +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + [[package]] name = "crossterm" version = "0.28.1" @@ -557,6 +605,30 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "iana-time-zone" +version = "0.1.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c919e5debc312ad217002b8048a17b7d83f80703865bbfcfebb0458b0b27d8" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "ident_case" version = "1.0.1" @@ -639,6 +711,16 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" +[[package]] +name = "js-sys" +version = "0.3.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -725,6 +807,79 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + [[package]] name = "object" version = "0.36.7" @@ -1112,6 +1267,12 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + [[package]] name = "signal-hook" version = "0.3.18" @@ -1405,13 +1566,16 @@ dependencies = [ name = "tui-prompts" version = "0.5.0" dependencies = [ + "chrono", "clap", "color-eyre", "indoc", "itertools 0.14.0", + "num", "ratatui", "ratatui-macros", "rstest", + "strum 0.27.2", ] [[package]] @@ -1513,6 +1677,64 @@ dependencies = [ "wit-bindgen-rt", ] +[[package]] +name = "wasm-bindgen" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", +] + +[[package]] +name = "wasm-bindgen-backend" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6" +dependencies = [ + "bumpalo", + "log", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-backend", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d" +dependencies = [ + "unicode-ident", +] + [[package]] name = "winapi" version = "0.3.9" @@ -1535,6 +1757,65 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd9211b69f8dcdfa817bfd14bf1c97c9188afa36f4750130fcdf3f400eca9fa8" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-sys" version = "0.52.0" diff --git a/tui-prompts/Cargo.toml b/tui-prompts/Cargo.toml index 09875c4..8c8d824 100644 --- a/tui-prompts/Cargo.toml +++ b/tui-prompts/Cargo.toml @@ -14,12 +14,22 @@ rust-version.workspace = true # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] +chrono = { version = "0.4.41", optional = true, default-features = false, features = ["std", "clock"] } itertools.workspace = true +num = { version = "0.4.3", optional = true } ratatui = { workspace = true, features = ["crossterm"] } ratatui-macros.workspace = true +strum.workspace = true [dev-dependencies] clap.workspace = true color-eyre.workspace = true indoc.workspace = true rstest.workspace = true + +[features] +datetime = ["dep:chrono", "dep:num"] + +[[example]] +name = "utc_time" +required-features = [ "datetime" ] diff --git a/tui-prompts/examples/utc_time.rs b/tui-prompts/examples/utc_time.rs new file mode 100644 index 0000000..c6ccbe2 --- /dev/null +++ b/tui-prompts/examples/utc_time.rs @@ -0,0 +1,165 @@ +mod tui; + +use std::time::Duration; + +use chrono::{DateTime, Local, TimeZone}; +use clap::Parser; +use color_eyre::Result; +use ratatui::crossterm::event::{self, Event, KeyCode, KeyEvent, KeyModifiers}; +use ratatui::prelude::*; +use ratatui::widgets::*; +use tui::Tui; +use tui_prompts::prelude::*; + +#[derive(Parser)] +struct Cli { + #[arg(short, long)] + debug: bool, +} + +fn main() -> Result<()> { + let cli = Cli::parse(); + let mut app = App::new(cli); + app.run()?; + Ok(()) +} + +#[derive(Debug, Default, Clone, PartialEq, Eq, Hash)] +enum AppState { + #[default] + Running, + Quit, +} + +#[derive(Debug, Default, Clone, PartialEq, Eq, Hash)] +struct App<'a> { + today_state: DateTimeState<'a>, + app_state: AppState, +} + +impl<'a> App<'a> { + pub fn new(_cli: Cli) -> Self { + Self { + today_state: DateTimeState::new(), + app_state: AppState::Running, + } + } + + pub fn run(&mut self) -> Result<()> { + let mut tui = Tui::new()?; + *self.today_state.focus_state_mut() = FocusState::Focused; + while self.is_running() { + tui.draw(|frame| self.draw_ui(frame))?; + if event::poll(Duration::from_millis(16))? { + if let Event::Key(key_event) = event::read()? { + self.handle_key_event(key_event); + } + } + } + Ok(()) + } + + fn draw_ui(&mut self, frame: &mut Frame) { + let [_, intro_area, _, today_area, _, text_area, _, converted_area, _, info_area] = + Layout::new( + Direction::Vertical, + [ + Constraint::Min(1), + Constraint::Length(1), + Constraint::Min(1), + Constraint::Length(3), + Constraint::Min(1), + Constraint::Length(1), + Constraint::Min(1), + Constraint::Length(1), + Constraint::Min(1), + Constraint::Length(1), + ], + ) + .areas(frame.area()); + + match self.today_state.is_finished() { + false => { + frame.render_widget( + Line::from("Please enter a local time below for conversion."), + intro_area, + ); + + DateTimePrompt::new( + std::borrow::Cow::Borrowed("today"), + vec![ + NumericChildPrompt::new(std::borrow::Cow::Borrowed("year")), + NumericChildPrompt::new(std::borrow::Cow::Borrowed("month")), + NumericChildPrompt::new(std::borrow::Cow::Borrowed("day")), + NumericChildPrompt::new(std::borrow::Cow::Borrowed("hour")), + NumericChildPrompt::new(std::borrow::Cow::Borrowed("minute")), + ], + Span::raw(" "), + ) + .with_block( + Block::default() + .borders(Borders::ALL) + .title(" Local Time ") + .padding(Padding::horizontal(1)), + ) + .draw(frame, today_area, &mut self.today_state); + frame.render_widget( + Line::from(" Q: Exit | N: Current Time | Enter: Convert ").centered(), + info_area, + ); + } + true => { + frame.render_widget( + Line::from("At that local time, the time in UTC is:").centered(), + text_area, + ); + let naive = self.today_state.output_value().unwrap(); + let local_datetime: DateTime = Local.from_local_datetime(&naive).unwrap(); + frame.render_widget( + Line::from(format!( + "Time entered: {x}", + x = local_datetime.format("%a, %d %b %Y %H:%M").to_string() + )) + .centered(), + today_area, + ); + let utc_datetime = local_datetime.to_utc(); + frame.render_widget( + Line::from(utc_datetime.format("%a, %d %b %Y %H:%M").to_string()).centered(), + converted_area, + ); + frame.render_widget(Line::from(" Q: Exit ").centered(), info_area); + } + } + } + + fn quit(&mut self) { + self.app_state = AppState::Quit; + } + + fn is_running(&self) -> bool { + self.app_state == AppState::Running + } + + fn handle_key_event(&mut self, key_event: KeyEvent) { + match (key_event.code, key_event.modifiers) { + (KeyCode::Enter, _) => self.submit(), + (KeyCode::Char('q'), KeyModifiers::NONE) + | (KeyCode::Char('Q'), KeyModifiers::SHIFT) => self.quit(), + (KeyCode::Char('n'), KeyModifiers::NONE) + | (KeyCode::Char('N'), KeyModifiers::SHIFT) => self.today_state.time_now(), + _ => self.focus_handle_event(key_event), + } + } + + fn focus_handle_event(&mut self, key_event: KeyEvent) { + self.today_state.handle_key_event(key_event); + } + + fn submit(&mut self) { + self.today_state.complete(); + if self.today_state.is_finished() { + self.today_state.blur(); + } + } +} diff --git a/tui-prompts/src/datetime/datetime_prompt.rs b/tui-prompts/src/datetime/datetime_prompt.rs new file mode 100644 index 0000000..7be3678 --- /dev/null +++ b/tui-prompts/src/datetime/datetime_prompt.rs @@ -0,0 +1,105 @@ +use std::borrow::Cow; +use std::vec; + +use itertools::Itertools; +use ratatui::prelude::*; +use ratatui::widgets::{Block, StatefulWidget, Widget}; + +use crate::prelude::*; + +// TODO style the widget +// TODO style each element of the widget. +// TODO handle multi-line input. +// TODO handle scrolling. +// TODO handle vertical movement. +// TODO handle bracketed paste. + +/// A prompt widget that displays a message and a text input. +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub struct DateTimePrompt<'a> { + /// The message to display to the user before the input. + message: Cow<'a, str>, + /// The block to wrap the prompt in. + block: Option>, + spacer: Span<'a>, + child_prompts: Vec>, +} + +impl<'a> DateTimePrompt<'a> { + #[must_use] + pub const fn new( + message: Cow<'a, str>, + child_prompts: Vec>, + spacer: Span<'a>, + ) -> Self { + Self { + message, + block: None, + spacer, + child_prompts, + } + } + + #[must_use] + pub fn with_block(mut self, block: Block<'a>) -> Self { + self.block = Some(block); + self + } +} + +impl Prompt for DateTimePrompt<'_> { + /// Draws the prompt widget. + /// + /// This is in addition to the `Widget` trait implementation as we need the `Frame` to set the + /// cursor position. + fn draw(self, frame: &mut Frame, area: Rect, state: &mut Self::State) { + frame.render_stateful_widget(self, area, state); + if state.is_focused() { + frame.set_cursor_position(state.cursor()); + } + } +} + +impl<'a> StatefulWidget for DateTimePrompt<'a> { + type State = DateTimeState<'a>; + + fn render(mut self, mut area: Rect, buf: &mut Buffer, state: &mut Self::State) { + if state.element_count() != self.child_prompts.len() { + panic!("DateTimePrompt: number of NumericChildPrompts must equal number of fields"); + } + self.render_block(&mut area, buf); + let mut values: Vec = self + .child_prompts + .iter() + .zip(state.elements().iter()) + .flat_map(|(prompt, state)| { + vec![prompt.label_span(None), prompt.value_span(None, state)] + }) + .collect(); + values = Itertools::intersperse(values.into_iter(), self.spacer).collect(); + values.insert(0, state.status().symbol()); + values.insert(1, Span::raw(" ")); + + let cursor_index = 2 + (state.position() * 4) + 1; + + let length_to_cursor: usize = values[..(cursor_index)].iter().map(|x| x.width()).sum(); + let pos_in_prompt: usize = state.current_element().position() + 1; + + let line = Line::from(values); + line.render(area, buf); + *state.cursor_mut() = ( + length_to_cursor as u16 + pos_in_prompt as u16 + area.x + 1_u16, + area.y, + ); + } +} + +impl DateTimePrompt<'_> { + fn render_block(&mut self, area: &mut Rect, buf: &mut Buffer) { + if let Some(block) = self.block.take() { + let inner = block.inner(*area); + block.render(*area, buf); + *area = inner; + }; + } +} diff --git a/tui-prompts/src/datetime/datetime_state.rs b/tui-prompts/src/datetime/datetime_state.rs new file mode 100644 index 0000000..ee4c2da --- /dev/null +++ b/tui-prompts/src/datetime/datetime_state.rs @@ -0,0 +1,246 @@ +use chrono::NaiveDate; +use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use std::borrow::Cow; + +use crate::prelude::*; +use chrono::{DateTime, Days, Local, Months, NaiveDateTime, TimeDelta}; + +use strum::FromRepr; + +#[derive(Debug, Default, Clone, PartialEq, Eq, Hash)] +pub struct DateTimeState<'a> { + status: Status, + position: usize, + cursor: (u16, u16), + elements: Vec>, +} + +#[derive(FromRepr)] +enum DateTimePosition { + Year, + Month, + Day, + Hour, + Minute, +} + +impl<'a> DateTimeState<'a> { + #[must_use] + pub fn new() -> Self { + Self { + status: Status::Pending, + position: 0, + cursor: (0, 0), + elements: vec![ + NumericChildState::new(4, 'Y', Cow::from("")), + NumericChildState::new(2, 'M', Cow::from("")), + NumericChildState::new(2, 'D', Cow::from("")), + NumericChildState::new(2, 'H', Cow::from("")), + NumericChildState::new(2, 'M', Cow::from("")), + ], + } + } + + #[must_use] + pub const fn with_status(mut self, status: Status) -> Self { + self.status = status; + self + } + + #[must_use] + pub const fn is_finished(&self) -> bool { + self.status.is_finished() + } + + fn increment_element(&mut self, n: u32) { + self.increment_element_at_position(self.position(), n); + } + + fn decrement_element(&mut self, n: u32) { + self.decrement_element_at_position(self.position(), n); + } + + fn increment_element_at_position(&mut self, i: usize, n: u32) { + match self.output_value() { + Err(_) => (), + Ok(t) => match DateTimePosition::from_repr(i).expect("invalid position") { + DateTimePosition::Year => { + let n = Months::new(n * 12); + let new_time = t.checked_add_months(n).expect("year add failed"); + self.with_value(new_time); + } + DateTimePosition::Month => { + let n = Months::new(n); + let new_time = t.checked_add_months(n).expect("month add failed"); + self.with_value(new_time); + } + DateTimePosition::Day => { + let n = Days::new(n.into()); + let new_time = t.checked_add_days(n).expect("day add failed"); + self.with_value(new_time); + } + DateTimePosition::Hour => { + let n = TimeDelta::hours(n.into()); + let new_time = t.checked_add_signed(n).expect("hour add failed"); + self.with_value(new_time); + } + DateTimePosition::Minute => { + let n = TimeDelta::minutes(n.into()); + let new_time = t.checked_add_signed(n).expect("minute add failed"); + self.with_value(new_time); + } + }, + } + } + + fn decrement_element_at_position(&mut self, i: usize, n: u32) { + match self.output_value() { + Err(_) => (), + Ok(t) => match DateTimePosition::from_repr(i).expect("invalid position") { + DateTimePosition::Year => { + let n = Months::new(n * 12); + let new_time = t.checked_sub_months(n).expect("year sub failed"); + self.with_value(new_time); + } + DateTimePosition::Month => { + let n = Months::new(n); + let new_time = t.checked_sub_months(n).expect("month sub failed"); + self.with_value(new_time); + } + DateTimePosition::Day => { + let n = Days::new(n.into()); + let new_time = t.checked_sub_days(n).expect("day sub failed"); + self.with_value(new_time); + } + DateTimePosition::Hour => { + let n = TimeDelta::hours(n.into()); + let new_time = t.checked_sub_signed(n).expect("hour sub failed"); + self.with_value(new_time); + } + DateTimePosition::Minute => { + let n = TimeDelta::minutes(n.into()); + let new_time = t.checked_sub_signed(n).expect("minute sub failed"); + self.with_value(new_time); + } + }, + } + } + + pub fn handle_key_event(&mut self, key_event: KeyEvent) { + match (key_event.code, key_event.modifiers) { + (KeyCode::Up, KeyModifiers::CONTROL) => self.increment_element(5), + (KeyCode::Up, _) => self.increment_element(1), + (KeyCode::Down, KeyModifiers::CONTROL) => self.decrement_element(5), + (KeyCode::Down, _) => self.decrement_element(1), + (KeyCode::Left, KeyModifiers::CONTROL) | (KeyCode::BackTab, KeyModifiers::SHIFT) => { + self.jump_left() + } + (KeyCode::Left, _) | (KeyCode::Char('b'), KeyModifiers::CONTROL) => self.move_left(), + (KeyCode::Right, KeyModifiers::CONTROL) | (KeyCode::Tab, KeyModifiers::NONE) => { + self.jump_right() + } + (KeyCode::Right, _) | (KeyCode::Char('f'), KeyModifiers::CONTROL) => self.move_right(), + (KeyCode::Char(c), KeyModifiers::NONE | KeyModifiers::SHIFT) => self.push(c), + _ => self.current_element_mut().handle_key_event(key_event), + } + } + + pub fn time_now(&mut self) { + let local_datetime: DateTime = Local::now(); + self.with_value(local_datetime.naive_local()); + } +} + +impl CursorControl for DateTimeState<'_> { + fn cursor(&self) -> (u16, u16) { + self.cursor + } + + fn cursor_mut(&mut self) -> &mut (u16, u16) { + &mut self.cursor + } +} + +impl StateCommon for DateTimeState<'_> { + fn status(&self) -> Status { + self.status + } + + fn status_mut(&mut self) -> &mut Status { + &mut self.status + } + + fn focus_state_mut(&mut self) -> &mut FocusState { + &mut self.current_element_mut().focus + } + + fn focus_state(&self) -> FocusState { + self.current_element().focus + } + + fn position(&self) -> usize { + self.position + } + + fn position_mut(&mut self) -> &mut usize { + &mut self.position + } + + fn is_valid_value(&self) -> bool { + self.output_value().is_ok() + } + + fn final_position(&self) -> usize { + self.elements.len() - 1 + } +} + +impl<'a> CompoundState, NaiveDateTime> for DateTimeState<'a> { + fn with_value(&mut self, val: NaiveDateTime) { + // formats described in https://docs.rs/chrono/latest/chrono/format/strftime/index.html + let date_formats = ["%Y", "%m", "%d", "%H", "%M"]; + + self.elements_mut() + .iter_mut() + .zip(date_formats.iter()) + .for_each(|(el, dt)| { + *el.value_mut() = val.format(dt).to_string(); + *el.position_mut() = el.final_position(); + }); + } + + fn current_element(&self) -> &NumericChildState<'a> { + let i = self.position(); + &self.elements[i] + } + + fn current_element_mut(&mut self) -> &mut NumericChildState<'a> { + let i = self.position(); + &mut self.elements[i] + } + + fn elements(&self) -> Vec> { + self.elements.clone() + } + + fn elements_mut(&mut self) -> &mut Vec> { + &mut self.elements + } + + fn output_value_from_elements(&self, el: Vec>) -> Result { + if !self.elements.iter().all(|x| x.is_valid_value()) { + return Err(()); + } + match NaiveDate::from_ymd_opt( + el[0].as_numeric() as i32, + el[1].as_numeric(), + el[2].as_numeric(), + ) { + None => Err(()), + Some(x) => match x.and_hms_opt(el[3].as_numeric(), el[4].as_numeric(), 0) { + Some(x) => Ok(x), + None => Err(()), + }, + } + } +} diff --git a/tui-prompts/src/datetime/mod.rs b/tui-prompts/src/datetime/mod.rs new file mode 100644 index 0000000..9a890cd --- /dev/null +++ b/tui-prompts/src/datetime/mod.rs @@ -0,0 +1,7 @@ +mod datetime_prompt; +mod datetime_state; +mod numeric_child; + +pub use datetime_prompt::*; +pub use datetime_state::*; +pub use numeric_child::*; diff --git a/tui-prompts/src/datetime/numeric_child.rs b/tui-prompts/src/datetime/numeric_child.rs new file mode 100644 index 0000000..251fe29 --- /dev/null +++ b/tui-prompts/src/datetime/numeric_child.rs @@ -0,0 +1,196 @@ +use crate::prelude::*; +use ratatui::prelude::*; +use std::borrow::Cow; + +#[derive(Debug, Default, Clone, PartialEq, Eq, Hash)] +pub struct NumericChildState<'a> { + status: Status, + pub focus: FocusState, + position: usize, + default_char: char, + value: Cow<'a, str>, + max_width: usize, +} + +impl<'a> NumericChildState<'a> { + #[must_use] + pub const fn new(max_width: usize, default_char: char, value: Cow<'a, str>) -> Self { + Self { + status: Status::Pending, + focus: FocusState::Unfocused, + default_char, + position: 0, + value, + max_width, + } + } + + #[must_use] + pub const fn with_status(mut self, status: Status) -> Self { + self.status = status; + self + } + + #[must_use] + pub const fn with_focus(mut self, focus: FocusState) -> Self { + self.focus = focus; + self + } + + #[must_use] + pub fn with_value(mut self, value: impl Into>) -> Self { + self.value = value.into(); + self + } + + #[must_use] + pub const fn is_finished(&self) -> bool { + self.status.is_finished() + } + + pub fn as_numeric(&self) -> u32 { + self.value.parse::().unwrap() + } + + pub fn from_numeric(&mut self, n: impl num::Integer + ToString) { + self.value = n.to_string().into(); + } + + fn value_padded(&self) -> String { + format!("{:0width$}", &self.value, width = self.max_width) + } +} + +impl StateCommon for NumericChildState<'_> { + fn final_position(&self) -> usize { + self.value().chars().count() + } + + fn is_at_end(&self) -> bool { + self.position() == self.max_width + } + + fn status(&self) -> Status { + self.status + } + + fn status_mut(&mut self) -> &mut Status { + &mut self.status + } + + fn focus_state_mut(&mut self) -> &mut FocusState { + &mut self.focus + } + + fn focus_state(&self) -> FocusState { + self.focus + } + + fn position(&self) -> usize { + self.position + } + + fn position_mut(&mut self) -> &mut usize { + &mut self.position + } + + fn is_valid_value(&self) -> bool { + self.value.parse::().is_ok() + } +} + +impl TextualState for NumericChildState<'_> { + fn len(&self) -> usize { + self.value.len() + } + + fn value(&self) -> &str { + &self.value + } + + fn value_mut(&mut self) -> &mut String { + self.value.to_mut() + } + + fn is_valid_char(&self, c: char) -> bool { + c.is_ascii_digit() + } +} + +/// A prompt widget that displays a message and a text input. +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub struct NumericChildPrompt<'a> { + /// The message to display to the user before the input. + message: Cow<'a, str>, + render_style: NumericRenderStyle, +} + +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)] +pub enum NumericRenderStyle { + #[default] + Default, +} + +impl NumericRenderStyle { + #[must_use] + pub fn render(&self, state: &NumericChildState) -> Span { + match self { + Self::Default => { + match state.len() { + 0 => { + Span::raw(state.default_char.to_string().repeat(state.max_width)).style( + Style::new() + .bg(Color::Rgb(30, 30, 30)) + .fg(Color::Rgb(140, 140, 140)), + ) + }, + _ => { + Span::raw(state.value_padded().to_string()) + .style(Style::new().bg(Color::Rgb(30, 30, 30)).fg(Color::White)) + }, + } + } + } + } +} + +impl<'a> NumericChildPrompt<'a> { + #[must_use] + pub const fn new(message: Cow<'a, str>) -> Self { + Self { + message, + render_style: NumericRenderStyle::Default, + } + } + + #[must_use] + pub const fn with_render_style(mut self, render_style: NumericRenderStyle) -> Self { + self.render_style = render_style; + self + } + + pub fn label_span(&self, style: Option