463 lines
15 KiB
Rust
463 lines
15 KiB
Rust
//! 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);
|
||
}
|
||
}
|