diff --git a/crates/maestro-tui/src/app.rs b/crates/maestro-tui/src/app.rs index 25b438e..aa9574d 100644 --- a/crates/maestro-tui/src/app.rs +++ b/crates/maestro-tui/src/app.rs @@ -190,8 +190,8 @@ impl App { } SpawnInputResult::Submit => { effects.push(Effect::Spawn(CreateSessionRequest { - cwd: std::path::PathBuf::from(self.spawn.cwd.clone()), - command: self.spawn.command.clone(), + cwd: std::path::PathBuf::from(self.spawn.cwd.value().to_string()), + command: self.spawn.command.value().to_string(), env: Default::default(), metadata: Default::default(), worktree: None, @@ -267,8 +267,8 @@ impl App { } Action::OpenSpawnModal => { self.spawn = SpawnModal { - cwd: String::new(), - command: String::new(), + cwd: Default::default(), + command: Default::default(), focused_field: Default::default(), }; self.state.show_overlay(OverlayKind::Spawn); @@ -348,7 +348,10 @@ impl App { InputKey::Down => b"\x1b[B".to_vec(), InputKey::Right => b"\x1b[C".to_vec(), InputKey::Left => b"\x1b[D".to_vec(), - InputKey::Mouse { .. } => return None, + InputKey::Home => b"\x1b[H".to_vec(), + InputKey::End => b"\x1b[F".to_vec(), + InputKey::Delete => b"\x1b[3~".to_vec(), + InputKey::Paste(_) | InputKey::Mouse { .. } => return None, }; Some(Effect::Forward { id: sid, bytes }) } @@ -587,8 +590,8 @@ mod tests { fn submitting_the_spawn_modal_carries_the_typed_fields() { let mut app = make_app(); app.dispatch(Action::OpenSpawnModal); - app.spawn.cwd = "/srv/work".into(); - app.spawn.command = "claude".into(); + app.spawn.cwd = crate::text_input::TextInput::with_value("/srv/work"); + app.spawn.command = crate::text_input::TextInput::with_value("claude"); app.spawn.focused_field = crate::overlays::SpawnField::Submit; let (_, effects) = app.handle_overlay_input(&InputKey::Enter); diff --git a/crates/maestro-tui/src/input.rs b/crates/maestro-tui/src/input.rs index 4a2736d..34c4d4a 100644 --- a/crates/maestro-tui/src/input.rs +++ b/crates/maestro-tui/src/input.rs @@ -34,6 +34,12 @@ pub enum InputKey { Down, Left, Right, + Home, + End, + Delete, + /// Bracketed paste: crossterm delivers the whole payload as one event, so + /// text fields can insert it as a single operation. + Paste(String), Mouse { kind: MouseKind, region: Region, @@ -126,6 +132,12 @@ impl InputKey { InputKey::Down => "down".into(), InputKey::Left => "left".into(), InputKey::Right => "right".into(), + InputKey::Home => "home".into(), + InputKey::End => "end".into(), + InputKey::Delete => "delete".into(), + // Pastes are not bindable; the canonical form exists only to + // satisfy exhaustiveness and never round-trips through `parse`. + InputKey::Paste(_) => "paste".into(), InputKey::Mouse { kind, region, .. } => { let k = match kind { MouseKind::Click => "click", @@ -191,6 +203,9 @@ impl InputKey { "down" => return Ok(InputKey::Down), "left" => return Ok(InputKey::Left), "right" => return Ok(InputKey::Right), + "home" => return Ok(InputKey::Home), + "end" => return Ok(InputKey::End), + "delete" => return Ok(InputKey::Delete), _ => {} } // Modifier chain: ctrl-, alt-, shift- prefixes, then a single char. @@ -262,9 +277,13 @@ where KeyCode::Down => InputKey::Down, KeyCode::Left => InputKey::Left, KeyCode::Right => InputKey::Right, + KeyCode::Home => InputKey::Home, + KeyCode::End => InputKey::End, + KeyCode::Delete => InputKey::Delete, _ => return None, }) } + CtEvent::Paste(text) => Some(InputKey::Paste(text.clone())), CtEvent::Mouse(MouseEvent { kind, column, row, .. }) => { @@ -578,12 +597,25 @@ mod tests { "down", "left", "right", + "home", + "end", + "delete", ] { let k = InputKey::parse(s).unwrap(); assert_eq!(k.canonical(), s, "round-trip {s:?}"); } } + /// A bracketed paste must arrive as one key carrying the whole payload, + /// not a burst of per-character keys. + #[test] + fn canonicalise_paste_event_carries_the_whole_payload() { + use crossterm::event::Event as CtEvent; + let ev = CtEvent::Paste("/srv/work/maestro".into()); + let k = canonicalise(&ev, |_, _| Region::Unknown).unwrap(); + assert_eq!(k, InputKey::Paste("/srv/work/maestro".into())); + } + #[test] fn canonical_round_trips_mouse() { let s = "mouse:click(pane)"; 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/overlays.rs b/crates/maestro-tui/src/overlays.rs index 5cc5722..5bb49d2 100644 --- a/crates/maestro-tui/src/overlays.rs +++ b/crates/maestro-tui/src/overlays.rs @@ -20,8 +20,8 @@ pub enum SpawnField { #[derive(Debug, Default, Clone)] pub struct SpawnModal { - pub cwd: String, - pub command: String, + pub cwd: crate::text_input::TextInput, + pub command: crate::text_input::TextInput, pub focused_field: SpawnField, } @@ -64,27 +64,16 @@ impl SpawnModal { SpawnInputResult::Consumed } }, - InputKey::Char { c, .. } => { + // Everything else — characters, motion, editing, paste — goes to + // the focused field's text input. + other => { match self.focused_field { - SpawnField::Cwd => self.cwd.push(*c), - SpawnField::Command => self.command.push(*c), + SpawnField::Cwd => self.cwd.handle_key(other), + SpawnField::Command => self.command.handle_key(other), SpawnField::Submit => {} } SpawnInputResult::Consumed } - InputKey::Backspace => { - match self.focused_field { - SpawnField::Cwd => { - self.cwd.pop(); - } - SpawnField::Command => { - self.command.pop(); - } - _ => {} - } - SpawnInputResult::Consumed - } - _ => SpawnInputResult::Consumed, } } @@ -107,20 +96,40 @@ impl SpawnModal { Style::default() } }; - let field_row = |label: &str, value: &str, style: Style| { - Paragraph::new(Line::from(vec![ - ratatui::text::Span::raw(format!("{label:8} ")), - ratatui::text::Span::styled(value.to_string(), style), - ])) + + // Each text field renders through the shared `TextInput`, which + // handles horizontal scrolling and reports where the terminal cursor + // belongs. Label is fixed-width 8 chars + 1 space = column 9. + let label = + |text: &str| Paragraph::new(Line::from(ratatui::text::Span::raw(format!("{text:8} ")))); + f.render_widget(label("cwd:"), rows[0]); + f.render_widget(label("command:"), rows[1]); + let draw_field = |f: &mut Frame, + input: &crate::text_input::TextInput, + row: Rect, + focused: bool| + -> u16 { + let style = if focused { + Style::default().add_modifier(Modifier::REVERSED) + } else { + Style::default() + }; + let area = Rect { + x: row.x.saturating_add(9), + y: row.y, + width: row.width.saturating_sub(9), + height: 1, + }; + input.render(area, f.buffer_mut(), style) }; - f.render_widget( - field_row("cwd:", &self.cwd, highlight(SpawnField::Cwd)), - rows[0], - ); - f.render_widget( - field_row("command:", &self.command, highlight(SpawnField::Command)), + let cwd_col = draw_field(f, &self.cwd, rows[0], self.focused_field == SpawnField::Cwd); + let cmd_col = draw_field( + f, + &self.command, rows[1], + self.focused_field == SpawnField::Command, ); + f.render_widget( Paragraph::new(Line::from(vec![ratatui::text::Span::styled( "[ Spawn ]", @@ -129,20 +138,9 @@ impl SpawnModal { rows[2], ); - // Place the cursor at the end of the currently-focused text field so - // the user can see where their typing lands. Label is fixed-width - // 8 chars + 1 space = column 9 inside the row. - let cursor_col_for = |value: &str| -> u16 { - inner - .x - .saturating_add(9) - .saturating_add(value.chars().count() as u16) - }; match self.focused_field { - SpawnField::Cwd => f.set_cursor_position((cursor_col_for(&self.cwd), rows[0].y)), - SpawnField::Command => { - f.set_cursor_position((cursor_col_for(&self.command), rows[1].y)) - } + SpawnField::Cwd => f.set_cursor_position((cwd_col, rows[0].y)), + SpawnField::Command => f.set_cursor_position((cmd_col, rows[1].y)), SpawnField::Submit => { // The submit button is indicated by the REVERSED style; no // cursor placement. @@ -267,8 +265,8 @@ mod tests { #[test] fn spawn_modal_renders_field_labels() { let modal = SpawnModal { - cwd: "/x".into(), - command: "bash".into(), + cwd: crate::text_input::TextInput::with_value("/x"), + command: crate::text_input::TextInput::with_value("bash"), focused_field: SpawnField::Cwd, }; let rendered = render_to_string(80, 20, |f| modal.render(f, f.area())); @@ -338,4 +336,51 @@ mod tests { assert_eq!(modal.focused_field, SpawnField::Cwd); } } + + /// Editing keys must reach the focused field's text input, not a bare + /// String — this is what gives the operator cursor motion and word ops. + #[test] + fn editing_keys_reach_the_focused_text_input() { + let mut modal = SpawnModal::default(); + assert_eq!(modal.focused_field, SpawnField::Cwd); + for k in [ + crate::input::InputKey::char('/'), + crate::input::InputKey::char('t'), + crate::input::InputKey::char('m'), + crate::input::InputKey::char('p'), + ] { + modal.on_input(&k); + } + modal.on_input(&crate::input::InputKey::Left); + modal.on_input(&crate::input::InputKey::Backspace); + assert_eq!(modal.cwd.value(), "/tp"); + + // Tab to command, then a paste and ctrl-w land there, not in cwd. + modal.on_input(&crate::input::InputKey::Tab); + assert_eq!(modal.focused_field, SpawnField::Command); + modal.on_input(&crate::input::InputKey::Paste("/tmp/foo".into())); + assert_eq!(modal.command.value(), "/tmp/foo"); + modal.on_input(&crate::input::InputKey::ctrl_char('w')); + assert_eq!(modal.command.value(), ""); + assert_eq!(modal.cwd.value(), "/tp", "cwd untouched while unfocused"); + } + + /// The terminal cursor must sit right after the typed text in the focused + /// field (label is 8 chars + 1 space, modal is centered on screen). + #[test] + fn render_places_the_cursor_after_the_typed_text() { + use ratatui::backend::TestBackend; + use ratatui::Terminal; + + let modal = SpawnModal { + cwd: crate::text_input::TextInput::with_value("abc"), + ..SpawnModal::default() + }; + let backend = TestBackend::new(80, 20); + let mut terminal = Terminal::new(backend).unwrap(); + terminal.draw(|f| modal.render(f, f.area())).unwrap(); + // Modal: x = (80-60)/2 = 10; block border → inner.x = 11; label col 9. + let pos = terminal.get_cursor_position().unwrap(); + assert_eq!(pos, ratatui::layout::Position::new(11 + 9 + 3, 6)); + } } diff --git a/crates/maestro-tui/src/terminal.rs b/crates/maestro-tui/src/terminal.rs index eeb5fb5..e21df3f 100644 --- a/crates/maestro-tui/src/terminal.rs +++ b/crates/maestro-tui/src/terminal.rs @@ -5,7 +5,9 @@ use std::io; -use crossterm::event::{DisableMouseCapture, EnableMouseCapture}; +use crossterm::event::{ + DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture, +}; use crossterm::execute; use crossterm::terminal::{ disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen, @@ -26,7 +28,12 @@ impl TerminalGuard { pub fn enter() -> Result { enable_raw_mode()?; let mut out = io::stdout(); - execute!(out, EnterAlternateScreen, EnableMouseCapture)?; + execute!( + out, + EnterAlternateScreen, + EnableMouseCapture, + EnableBracketedPaste + )?; Ok(TerminalGuard) } @@ -34,7 +41,12 @@ impl TerminalGuard { /// call it without producing weird errors. fn restore() { let mut out = io::stdout(); - let _ = execute!(out, DisableMouseCapture, LeaveAlternateScreen); + let _ = execute!( + out, + DisableBracketedPaste, + DisableMouseCapture, + LeaveAlternateScreen + ); let _ = disable_raw_mode(); } } diff --git a/crates/maestro-tui/src/text_input.rs b/crates/maestro-tui/src/text_input.rs new file mode 100644 index 0000000..55a3a4a --- /dev/null +++ b/crates/maestro-tui/src/text_input.rs @@ -0,0 +1,462 @@ +//! 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. A `Cell` so `render` can + /// re-clamp it without `&mut self` — widgets are drawn through shared + /// references in this codebase. + scroll: std::cell::Cell, +} + +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.get() + } + + /// 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(&self, area: Rect, buf: &mut Buffer, style: Style) -> u16 { + let width = area.width as usize; + let len = self.value.chars().count(); + let scroll = self.scroll.get().min(len); + let scroll = if self.cursor < scroll { + self.cursor + } else if width > 0 && self.cursor.saturating_sub(scroll) >= width { + self.cursor + 1 - width + } else { + scroll + }; + self.scroll.set(scroll); + for (col, c) in self.value.chars().skip(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(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 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 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_offset()), + 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); + } +}