This repository has been archived on 2026-10-08. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
maestro/crates/maestro-tui/src/text_input.rs
T

463 lines
15 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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<usize>,
}
impl TextInput {
pub fn with_value(value: impl Into<String>) -> 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<char> = 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<char> = 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<char> = 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<InputKey> {
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);
}
}