diff --git a/crates/maestro-tui/src/lib.rs b/crates/maestro-tui/src/lib.rs index bc9ed0c..b76bd84 100644 --- a/crates/maestro-tui/src/lib.rs +++ b/crates/maestro-tui/src/lib.rs @@ -18,6 +18,7 @@ pub mod sidebar; pub mod state; pub mod tabs; pub mod terminal; +pub mod text_input; use std::path::PathBuf; use std::time::Duration; diff --git a/crates/maestro-tui/src/text_input.rs b/crates/maestro-tui/src/text_input.rs new file mode 100644 index 0000000..402b7ce --- /dev/null +++ b/crates/maestro-tui/src/text_input.rs @@ -0,0 +1,457 @@ +//! Single-line text-input primitive shared by every TUI form field. +//! +//! Owns a `String`, a character-indexed cursor, and horizontal scroll state. +//! All editing goes through [`TextInput::handle_key`] so callers feed the same +//! canonical [`InputKey`] stream the rest of the app uses; bracketed paste +//! arrives as one [`InputKey::Paste`] and is inserted as a single operation. +//! +//! This is the one piece of the TUI form layer that unit-tests without a +//! terminal: drive it with `InputKey` sequences and inspect value/cursor. + +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::style::Style; + +use crate::input::InputKey; + +#[derive(Debug, Default, Clone)] +pub struct TextInput { + value: String, + /// Cursor position as a char offset into `value`. + cursor: usize, + /// Char offset of the leftmost visible cell. + scroll: usize, +} + +impl TextInput { + pub fn with_value(value: impl Into) -> Self { + let mut input = Self { + value: value.into(), + ..Self::default() + }; + input.cursor = input.value.chars().count(); + input + } + + pub fn value(&self) -> &str { + &self.value + } + + pub fn cursor(&self) -> usize { + self.cursor + } + + /// Char offset of the leftmost visible cell; only meaningful after + /// [`TextInput::render`] has clamped it to the field width. + pub fn scroll_offset(&self) -> usize { + self.scroll + } + + /// Insert `text` at the cursor as one operation. Used both for typed + /// characters and for whole bracketed-paste payloads. + pub fn insert(&mut self, text: &str) { + let at = self.byte_offset(self.cursor); + self.value.insert_str(at, text); + self.cursor += text.chars().count(); + } + + pub fn handle_key(&mut self, key: &InputKey) { + match key { + InputKey::Char { + c, + ctrl: false, + alt: false, + .. + } => self.insert(&c.to_string()), + InputKey::Char { + c: 'w', ctrl: true, .. + } => self.delete_word_backwards(), + InputKey::Char { + c: 'k', ctrl: true, .. + } => self.kill_to_end(), + // Emacs-style word motion; ctrl-arrow reaches us as a plain + // Left/Right today, so these are the dependable binding. + InputKey::Char { + c: 'b', ctrl: true, .. + } => self.cursor = self.word_left(), + InputKey::Char { + c: 'f', ctrl: true, .. + } => self.cursor = self.word_right(), + InputKey::Left => { + self.cursor = self.cursor.saturating_sub(1); + } + InputKey::Right => { + if self.cursor < self.value.chars().count() { + self.cursor += 1; + } + } + InputKey::Home => self.cursor = 0, + InputKey::End => self.cursor = self.value.chars().count(), + InputKey::Backspace => { + if self.cursor > 0 { + let at = self.byte_offset(self.cursor - 1); + let end = self.byte_offset(self.cursor); + self.value.replace_range(at..end, ""); + self.cursor -= 1; + } + } + InputKey::Delete => { + if self.cursor < self.value.chars().count() { + let at = self.byte_offset(self.cursor); + let end = self.byte_offset(self.cursor + 1); + self.value.replace_range(at..end, ""); + } + } + InputKey::Paste(text) => self.insert(text), + _ => {} + } + } + + /// Draw into `area` (expected one row tall), scrolling horizontally so the + /// cursor stays visible, and return the screen column where the caller + /// should place the terminal cursor. + pub fn render(&mut self, area: Rect, buf: &mut Buffer, style: Style) -> u16 { + let width = area.width as usize; + let len = self.value.chars().count(); + self.scroll = self.scroll.min(len); + if self.cursor < self.scroll { + self.scroll = self.cursor; + } else if width > 0 && self.cursor.saturating_sub(self.scroll) >= width { + self.scroll = self.cursor + 1 - width; + } + for (col, c) in self.value.chars().skip(self.scroll).take(width).enumerate() { + buf[(area.x + col as u16, area.y)] + .set_symbol(&c.to_string()) + .set_style(style); + } + area.x + self.cursor.saturating_sub(self.scroll) as u16 + } + + fn byte_offset(&self, char_index: usize) -> usize { + self.value + .char_indices() + .nth(char_index) + .map(|(b, _)| b) + .unwrap_or(self.value.len()) + } + + fn is_word_char(c: char) -> bool { + c.is_alphanumeric() || c == '_' + } + + /// Start of the word before the cursor, skipping any separators first — + /// emacs `backward-word`. + fn word_left(&self) -> usize { + let chars: Vec = self.value.chars().collect(); + let mut i = self.cursor; + while i > 0 && !Self::is_word_char(chars[i - 1]) { + i -= 1; + } + while i > 0 && Self::is_word_char(chars[i - 1]) { + i -= 1; + } + i + } + + /// Start of the word after the cursor, skipping the rest of the current + /// word first — emacs `forward-word`. + fn word_right(&self) -> usize { + let chars: Vec = self.value.chars().collect(); + let len = chars.len(); + let mut i = self.cursor; + while i < len && !Self::is_word_char(chars[i]) { + i += 1; + } + while i < len && Self::is_word_char(chars[i]) { + i += 1; + } + i + } + + fn kill_to_end(&mut self) { + let at = self.byte_offset(self.cursor); + self.value.truncate(at); + } + + /// Shell-style ctrl-w: kill back to the previous whitespace, taking any + /// trailing whitespace run with it — so `cd /tmp/foo` loses the whole + /// path segment in one keystroke. + fn delete_word_backwards(&mut self) { + let chars: Vec = self.value.chars().collect(); + let mut i = self.cursor; + while i > 0 && chars[i - 1].is_whitespace() { + i -= 1; + } + while i > 0 && !chars[i - 1].is_whitespace() { + i -= 1; + } + let at = self.byte_offset(i); + let end = self.byte_offset(self.cursor); + self.value.replace_range(at..end, ""); + self.cursor = i; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + use InputKey::{Backspace, Delete, End, Home, Left, Right}; + + fn typed(s: &str) -> Vec { + s.chars().map(InputKey::char).collect() + } + + fn fed(keys: &[InputKey]) -> TextInput { + let mut input = TextInput::default(); + for k in keys { + input.handle_key(k); + } + input + } + + fn ctrl(c: char) -> InputKey { + InputKey::ctrl_char(c) + } + + #[test] + fn typing_lands_in_order() { + let input = fed(&typed("hello")); + assert_eq!(input.value(), "hello"); + assert_eq!(input.cursor(), 5); + } + + #[test] + fn backspace_removes_the_last_typed_char() { + let mut input = fed(&typed("hello")); + input.handle_key(&Backspace); + assert_eq!(input.value(), "hell"); + assert_eq!(input.cursor(), 4); + } + + #[test] + fn left_then_insert_fixes_a_middle_typo() { + let mut input = fed(&typed("helo")); + input.handle_key(&Left); + input.handle_key(&Left); + input.handle_key(&InputKey::char('l')); + assert_eq!(input.value(), "hello"); + assert_eq!(input.cursor(), 3); + } + + #[test] + fn motion_keys_stop_at_the_edges() { + let mut input = fed(&typed("abc")); + for _ in 0..5 { + input.handle_key(&Left); + } + assert_eq!(input.cursor(), 0); + input.handle_key(&Left); + assert_eq!(input.cursor(), 0); + for _ in 0..5 { + input.handle_key(&Right); + } + assert_eq!(input.cursor(), 3); + } + + #[test] + fn home_and_end_jump_to_the_ends() { + let mut input = fed(&typed("abcdef")); + input.handle_key(&Home); + assert_eq!(input.cursor(), 0); + input.handle_key(&InputKey::char('x')); + assert_eq!(input.value(), "xabcdef"); + input.handle_key(&End); + input.handle_key(&InputKey::char('!')); + assert_eq!(input.value(), "xabcdef!"); + } + + /// Cursor starts after the final `/`; word-left lands on word starts, + /// never inside a separator run. + #[test] + fn ctrl_b_walks_left_one_word_at_a_time() { + let mut input = TextInput::with_value("/tmp/foo bar-baz/"); + assert_eq!(input.cursor(), 17); + for expected in [13, 9, 5, 1, 0, 0] { + input.handle_key(&ctrl('b')); + assert_eq!(input.cursor(), expected); + } + } + + #[test] + fn ctrl_f_walks_right_one_word_at_a_time() { + let mut input = TextInput::with_value("/tmp/foo bar"); + input.handle_key(&Home); + for expected in [4, 8, 12, 12] { + input.handle_key(&ctrl('f')); + assert_eq!(input.cursor(), expected); + } + } + + #[test] + fn backspace_deletes_before_the_cursor() { + let mut input = fed(&typed("abcdef")); + input.handle_key(&Left); + input.handle_key(&Left); + input.handle_key(&Left); + input.handle_key(&Backspace); + assert_eq!(input.value(), "abdef"); + assert_eq!(input.cursor(), 2); + } + + #[test] + fn backspace_on_empty_input_is_a_noop() { + let mut input = TextInput::default(); + input.handle_key(&Backspace); + assert_eq!(input.value(), ""); + assert_eq!(input.cursor(), 0); + } + + #[test] + fn delete_removes_the_char_under_the_cursor() { + let mut input = fed(&typed("abcdef")); + input.handle_key(&Home); + input.handle_key(&Delete); + assert_eq!(input.value(), "bcdef"); + assert_eq!(input.cursor(), 0); + } + + #[test] + fn delete_past_the_end_is_a_noop() { + let mut input = TextInput::with_value("abc"); + input.handle_key(&End); + input.handle_key(&Delete); + assert_eq!(input.value(), "abc"); + } + + /// `cd /tmp/foo` with ctrl-w leaves `cd ` — the whole path segment is one + /// word to a shell (readline kills back to whitespace, spaces included). + #[test] + fn ctrl_w_deletes_the_word_behind_the_cursor() { + let mut input = fed(&[typed("cd ").as_slice(), typed("/tmp/foo").as_slice()].concat()); + input.handle_key(&ctrl('w')); + assert_eq!(input.value(), "cd "); + assert_eq!(input.cursor(), 3); + } + + #[test] + fn ctrl_w_stops_before_the_separator_run() { + let mut input = TextInput::with_value("a b"); + input.cursor = 5; + input.handle_key(&ctrl('w')); + assert_eq!(input.value(), "a "); + assert_eq!(input.cursor(), 4); + } + + #[test] + fn ctrl_k_kills_everything_after_the_cursor() { + let mut input = fed(&typed("spawn --debug extra")); + for _ in 0..14 { + input.handle_key(&Left); + } + input.handle_key(&ctrl('k')); + assert_eq!(input.value(), "spawn"); + assert_eq!(input.cursor(), 5); + } + + /// A paste arrives as one event carrying the whole payload and must land + /// at the cursor as a single insertion, not per-character. + #[test] + fn paste_inserts_whole_payload_at_the_cursor() { + let mut input = fed(&typed("cd ")); + input.handle_key(&InputKey::Paste("/srv/work".into())); + assert_eq!(input.value(), "cd /srv/work"); + assert_eq!(input.cursor(), 12); + // What follows still behaves like ordinary typed text. + input.handle_key(&InputKey::char('/')); + assert_eq!(input.value(), "cd /srv/work/"); + } + + #[test] + fn paste_between_existing_text_keeps_both_halves() { + let mut input = TextInput::with_value("ac"); + input.handle_key(&Left); + input.handle_key(&InputKey::Paste("b×".into())); + assert_eq!(input.value(), "ab×c"); + assert_eq!(input.cursor(), 3, "cursor counts chars, not bytes"); + } + + #[test] + fn rendering_an_empty_field_points_at_its_first_column() { + let mut input = TextInput::default(); + let area = Rect::new(10, 5, 20, 1); + let mut buf = Buffer::empty(area); + let col = input.render(area, &mut buf, Style::default()); + assert_eq!(col, 10); + } + + #[test] + fn rendering_reports_the_visible_cursor_column() { + let mut input = TextInput::with_value("hello"); + input.handle_key(&Left); + input.handle_key(&Left); + let area = Rect::new(10, 5, 20, 1); + let mut buf = Buffer::empty(area); + let col = input.render(area, &mut buf, Style::default()); + assert_eq!(col, 13); + for (i, expected) in ["h", "e", "l", "l", "o"].iter().enumerate() { + assert_eq!(buf[(10 + i as u16, 5)].symbol(), *expected); + } + } + + #[test] + fn rendering_a_value_wider_than_the_field_shows_its_tail() { + let value = "0123456789abcdefghij"; + let mut input = TextInput::with_value(value); + let area = Rect::new(0, 0, 10, 1); + let mut buf = Buffer::empty(area); + let col = input.render(area, &mut buf, Style::default()); + assert_eq!(col, 9, "cursor sits on the last visible column"); + for (i, expected) in value.chars().skip(input.scroll_offset()).enumerate() { + assert_eq!( + buf[(i as u16, 0)].symbol(), + expected.to_string(), + "cell {i}" + ); + } + } + + /// Moving left into the hidden region must scroll the window back so the + /// cursor never disappears off the left edge. + #[test] + fn moving_left_into_the_hidden_region_scrolls_back() { + let value = "0123456789abcdefghij"; + let mut input = TextInput::with_value(value); + let area = Rect::new(0, 0, 10, 1); + let mut buf = Buffer::empty(area); + input.render(area, &mut buf, Style::default()); + assert_eq!(input.cursor(), 20); + + input.handle_key(&Home); + for _ in 0..5 { + input.handle_key(&Right); + } + let mut buf = Buffer::empty(area); + let col = input.render(area, &mut buf, Style::default()); + assert_eq!(input.cursor(), 5); + assert_eq!( + input.value().chars().nth(input.scroll), + Some('5'), + "window scrolled back to the cursor" + ); + assert_eq!(col, 0); + assert_eq!(buf[(0, 0)].symbol(), "5"); + } + + #[test] + fn shrinking_the_value_clamps_scroll_and_cursor() { + let mut input = TextInput::with_value("0123456789abcdefghij"); + input.handle_key(&Home); + input.handle_key(&ctrl('k')); + let area = Rect::new(0, 0, 10, 1); + let mut buf = Buffer::empty(area); + let col = input.render(area, &mut buf, Style::default()); + assert_eq!(input.value(), ""); + assert_eq!(col, 0); + } +}