From e411d9ec3e921e812b7e8d2836c2ad0b60a0e6f8 Mon Sep 17 00:00:00 2001 From: Josh McKinney Date: Thu, 5 Dec 2024 14:12:40 -0800 Subject: [PATCH] docs: add input form example (#1551) Demonstrates how to manage the cursor and input focus with a simple form. Uses an enum to track the current input field and passes the input events to the active field. This is similar to the json tutorial on the website, but a bit simpler --- Cargo.lock | 11 ++ examples/apps/input-form/Cargo.toml | 16 ++ examples/apps/input-form/README.md | 27 +++ examples/apps/input-form/src/main.rs | 270 +++++++++++++++++++++++++++ 4 files changed, 324 insertions(+) create mode 100644 examples/apps/input-form/Cargo.toml create mode 100644 examples/apps/input-form/README.md create mode 100644 examples/apps/input-form/src/main.rs diff --git a/Cargo.lock b/Cargo.lock index 6bbb7a7f..7d3c3199 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1424,6 +1424,17 @@ version = "2.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b248f5224d1d606005e02c97f5aa4e88eeb230488bcc03bc9ca4d7991399f2b5" +[[package]] +name = "input-form" +version = "0.0.0" +dependencies = [ + "color-eyre", + "crossterm", + "ratatui", + "serde", + "serde_json", +] + [[package]] name = "instability" version = "0.3.3" diff --git a/examples/apps/input-form/Cargo.toml b/examples/apps/input-form/Cargo.toml new file mode 100644 index 00000000..28122c26 --- /dev/null +++ b/examples/apps/input-form/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "input-form" +publish = false +license.workspace = true +edition.workspace = true +rust-version.workspace = true + +[dependencies] +color-eyre.workspace = true +crossterm.workspace = true +ratatui.workspace = true +serde.workspace = true +serde_json.workspace = true + +[lints] +workspace = true diff --git a/examples/apps/input-form/README.md b/examples/apps/input-form/README.md new file mode 100644 index 00000000..11ae5182 --- /dev/null +++ b/examples/apps/input-form/README.md @@ -0,0 +1,27 @@ +# Input Form example + +This example demonstrates how to handle input across several form fields (2 strings and an number). +It uses an enum to track the focused field, and sends keyboard events to one which is current. + +Run this example with: + +```shell +cargo run -p input-form +``` + +This example does not handle things like cursor movement within the line (just keys and backspace). +Most apps would benefit from using the following crates for text input rather than directly using +strings: + +- [`tui-input`](https://crates.io/crates/tui-input) +- [`tui-prompts`](https://crates.io/crates/tui-prompts) +- [`tui-textarea`](https://crates.io/crates/tui-textarea) +- [`rat-salsa`](https://crates.io/crates/rat-salsa) + +Some more ideas for handling focus can be found in: + +- [`focusable`](https://crates.io/crates/focusable) (see also [Ratatui forum + post](https://forum.ratatui.rs/t/focusable-crate-manage-focus-state-for-your-widgets/73)) +- [`rat-focus`](https://crates.io/crates/rat-focus) +- A useful [`Bevy` discssion](https://github.com/bevyengine/bevy/discussions/15374) about focus + more generally. diff --git a/examples/apps/input-form/src/main.rs b/examples/apps/input-form/src/main.rs new file mode 100644 index 00000000..25082eef --- /dev/null +++ b/examples/apps/input-form/src/main.rs @@ -0,0 +1,270 @@ +//! A Ratatui example that demonstrates how to handle input form focus +//! +//! This example demonstrates how to handle cursor and input focus between multiple fields in a +//! form. You can navigate between fields using the Tab key. +//! +//! This does not handle cursor movement etc. This is just a simple example. In a real application, +//! consider using [`tui-input`], or [`tui-prompts`], or [`tui-textarea`]. +//! +//! This example runs with the Ratatui library code in the branch that you are currently reading. +//! See the [`latest`] branch for the code which works with the most recent Ratatui release. +//! +//! [`latest`]: https://github.com/ratatui/ratatui/tree/latest +//! [`tui-input`]: https://crates.io/crates/tui-input +//! [`tui-prompts`]: https://crates.io/crates/tui-prompts +//! [`tui-textarea`]: https://crates.io/crates/tui-textarea + +use color_eyre::Result; +use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind}; +use ratatui::{ + buffer::Buffer, + layout::{Constraint, Layout, Offset, Rect}, + style::Stylize, + text::Line, + widgets::Widget, + DefaultTerminal, Frame, +}; +use serde::Serialize; + +fn main() -> Result<()> { + color_eyre::install()?; + let terminal = ratatui::init(); + let result = App::default().run(terminal); + ratatui::restore(); + + // serialize the form to JSON if the user submitted it, otherwise print "Canceled" + match result { + Ok(Some(form)) => println!("{}", serde_json::to_string_pretty(&form)?), + Ok(None) => println!("Canceled"), + Err(err) => eprintln!("{err}"), + } + Ok(()) +} + +#[derive(Default)] +struct App { + state: AppState, + form: InputForm, +} + +#[derive(Default, PartialEq, Eq)] +enum AppState { + #[default] + Running, + Cancelled, + Submitted, +} + +impl App { + fn run(mut self, mut terminal: DefaultTerminal) -> Result> { + while self.state == AppState::Running { + terminal.draw(|frame| self.render(frame))?; + self.handle_events()?; + } + match self.state { + AppState::Cancelled => Ok(None), + AppState::Submitted => Ok(Some(self.form)), + AppState::Running => unreachable!(), + } + } + + fn render(&self, frame: &mut Frame) { + self.form.render(frame); + } + + fn handle_events(&mut self) -> Result<()> { + match event::read()? { + Event::Key(event) if event.kind == KeyEventKind::Press => match event.code { + KeyCode::Esc => self.state = AppState::Cancelled, + KeyCode::Enter => self.state = AppState::Submitted, + _ => self.form.on_key_press(event), + }, + _ => {} + } + Ok(()) + } +} + +#[derive(Serialize)] +struct InputForm { + #[serde(skip)] + focus: Focus, + first_name: StringField, + last_name: StringField, + age: AgeField, +} + +impl Default for InputForm { + fn default() -> Self { + Self { + focus: Focus::FirstName, + first_name: StringField::new("First Name"), + last_name: StringField::new("Last Name"), + age: AgeField::new("Age"), + } + } +} + +impl InputForm { + // Handle focus navigation or pass the event to the focused field. + fn on_key_press(&mut self, event: KeyEvent) { + match event.code { + KeyCode::Tab => self.focus = self.focus.next(), + _ => match self.focus { + Focus::FirstName => self.first_name.on_key_press(event), + Focus::LastName => self.last_name.on_key_press(event), + Focus::Age => self.age.on_key_press(event), + }, + } + } + + /// Render the form with the current focus. + /// + /// The cursor is placed at the end of the focused field. + fn render(&self, frame: &mut Frame) { + let [first_name_area, last_name_area, age_area] = + Layout::vertical(Constraint::from_lengths([1, 1, 1])).areas(frame.area()); + + frame.render_widget(&self.first_name, first_name_area); + frame.render_widget(&self.last_name, last_name_area); + frame.render_widget(&self.age, age_area); + + let cursor_position = match self.focus { + Focus::FirstName => first_name_area.offset(self.first_name.cursor_offset()), + Focus::LastName => last_name_area.offset(self.last_name.cursor_offset()), + Focus::Age => age_area.offset(self.age.cursor_offset()), + }; + frame.set_cursor_position(cursor_position); + } +} + +#[derive(Default, PartialEq, Eq)] +enum Focus { + #[default] + FirstName, + LastName, + Age, +} + +impl Focus { + // Round-robin focus order. + const fn next(&self) -> Self { + match self { + Self::FirstName => Self::LastName, + Self::LastName => Self::Age, + Self::Age => Self::FirstName, + } + } +} + +/// A new-type representing a string field with a label. +#[derive(Debug, Serialize)] +struct StringField { + #[serde(skip)] + label: &'static str, + value: String, +} + +impl StringField { + const fn new(label: &'static str) -> Self { + Self { + label, + value: String::new(), + } + } + + /// Handle input events for the string input. + fn on_key_press(&mut self, event: KeyEvent) { + match event.code { + KeyCode::Char(c) => self.value.push(c), + KeyCode::Backspace => { + self.value.pop(); + } + _ => {} + } + } + + fn cursor_offset(&self) -> Offset { + let x = (self.label.len() + self.value.len() + 2) as i32; + Offset::new(x, 0) + } +} + +impl Widget for &StringField { + fn render(self, area: Rect, buf: &mut Buffer) { + let constraints = [ + Constraint::Length(self.label.len() as u16 + 2), + Constraint::Fill(1), + ]; + let [label_area, value_area] = Layout::horizontal(constraints).areas(area); + let label = Line::from_iter([self.label, ": "]).bold(); + label.render(label_area, buf); + self.value.clone().render(value_area, buf); + } +} + +/// A new-type representing a person's age in years (0-130). +#[derive(Default, Clone, Copy, Serialize)] +struct AgeField { + #[serde(skip)] + label: &'static str, + value: u8, +} + +impl AgeField { + const MAX: u8 = 130; + + const fn new(label: &'static str) -> Self { + Self { label, value: 0 } + } + + /// Handle input events for the age input. + /// + /// Digits are accepted as input, with any input which would exceed the maximum age being + /// ignored. The up/down arrow keys and 'j'/'k' keys can be used to increment/decrement the + /// age. + fn on_key_press(&mut self, event: KeyEvent) { + match event.code { + KeyCode::Char(digit @ '0'..='9') => { + let value = self + .value + .saturating_mul(10) + .saturating_add(digit as u8 - b'0'); + if value <= Self::MAX { + self.value = value; + } + } + KeyCode::Backspace => self.value /= 10, + KeyCode::Up | KeyCode::Char('k') => self.increment(), + KeyCode::Down | KeyCode::Char('j') => self.decrement(), + _ => {} + }; + } + + fn increment(&mut self) { + self.value = self.value.saturating_add(1).min(Self::MAX); + } + + fn decrement(&mut self) { + self.value = self.value.saturating_sub(1); + } + + fn cursor_offset(&self) -> Offset { + let x = (self.label.len() + self.value.to_string().len() + 2) as i32; + Offset::new(x, 0) + } +} + +impl Widget for &AgeField { + fn render(self, area: Rect, buf: &mut Buffer) { + let constraints = [ + Constraint::Length(self.label.len() as u16 + 2), + Constraint::Fill(1), + ]; + let [label_area, value_area] = Layout::horizontal(constraints).areas(area); + let label = Line::from_iter([self.label, ": "]).bold(); + let value = self.value.to_string(); + label.render(label_area, buf); + value.render(value_area, buf); + } +}