Files
kiln/kiln-ui/src/text_field.rs
T
2026-06-18 11:40:06 -05:00

179 lines
6.2 KiB
Rust

//! A single-line editable text field — a one-line sibling of [`code_editor`](crate::code_editor),
//! used by [`dialog`](crate::dialog) for its text and list-filter inputs.
//!
//! Presentation is left to the caller (it reads [`value`](TextField::value) and
//! [`display_cursor`](TextField::display_cursor)); the field only owns the text, the cursor,
//! and a [`Clipboard`] for paste. No newlines, no selection (yet) — deliberately minimal.
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use crate::clipboard::Clipboard;
use crate::text::{byte_of, display_width, next_word, prev_word, super_to_ctrl};
/// A single-line text input with a character-indexed cursor.
pub struct TextField {
/// The current text (never contains newlines).
text: String,
/// Cursor position as a character index in `0..=text.chars().count()`.
cursor: usize,
/// Clipboard for `ctrl`/`⌘`+`v` paste.
clipboard: Clipboard,
}
impl TextField {
/// Creates a field pre-filled with `initial`, cursor at the end.
pub fn new(initial: impl Into<String>) -> Self {
let text = initial.into();
let cursor = text.chars().count();
Self { text, cursor, clipboard: Clipboard::new() }
}
/// The current text.
pub fn value(&self) -> &str {
&self.text
}
/// The cursor's display column (terminal cells from the start), for placing the caret.
pub fn display_cursor(&self) -> usize {
display_width(&self.text, self.cursor)
}
/// Number of characters in the field.
fn len(&self) -> usize {
self.text.chars().count()
}
/// Handles a key event. Returns `true` if it was consumed (edited or moved the cursor).
///
/// Editing keys only: printable insert, Backspace/Delete, Left/Right (+ ctrl word-moves),
/// Home/End, and `ctrl`/`⌘`+`v` paste. Esc/Enter/Up/Down are left to the caller.
pub fn handle_key(&mut self, key: KeyEvent) -> bool {
let key = super_to_ctrl(key);
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
let alt = key.modifiers.contains(KeyModifiers::ALT);
match key.code {
KeyCode::Char('v') if ctrl => self.paste(),
KeyCode::Char(_) if ctrl => return false, // other chords aren't ours
KeyCode::Char(c) if !alt => self.insert(c),
KeyCode::Backspace => return self.backspace(),
KeyCode::Delete => return self.delete_forward(),
KeyCode::Left if ctrl => self.cursor = prev_word(&self.text, self.cursor),
KeyCode::Right if ctrl => self.cursor = next_word(&self.text, self.cursor),
KeyCode::Left => self.cursor = self.cursor.saturating_sub(1),
KeyCode::Right => self.cursor = (self.cursor + 1).min(self.len()),
KeyCode::Home => self.cursor = 0,
KeyCode::End => self.cursor = self.len(),
_ => return false,
}
true
}
fn insert(&mut self, c: char) {
let b = byte_of(&self.text, self.cursor);
self.text.insert(b, c);
self.cursor += 1;
}
fn backspace(&mut self) -> bool {
if self.cursor == 0 {
return false;
}
let b = byte_of(&self.text, self.cursor - 1);
self.text.remove(b);
self.cursor -= 1;
true
}
fn delete_forward(&mut self) -> bool {
if self.cursor >= self.len() {
return false;
}
let b = byte_of(&self.text, self.cursor);
self.text.remove(b);
true
}
/// Inserts clipboard text at the cursor, with any newlines stripped (single-line).
fn paste(&mut self) {
let clean: String = self.clipboard.get().chars().filter(|c| !matches!(c, '\n' | '\r')).collect();
let b = byte_of(&self.text, self.cursor);
self.text.insert_str(b, &clean);
self.cursor += clean.chars().count();
}
}
#[cfg(test)]
mod tests {
use super::*;
fn key(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::NONE)
}
#[test]
fn typing_inserts_and_value_reflects_it() {
let mut f = TextField::new("");
for c in "abc".chars() {
assert!(f.handle_key(key(KeyCode::Char(c))));
}
assert_eq!(f.value(), "abc");
assert_eq!(f.display_cursor(), 3);
}
#[test]
fn backspace_and_delete() {
let mut f = TextField::new("abc"); // cursor at end (3)
assert!(f.handle_key(key(KeyCode::Backspace)));
assert_eq!(f.value(), "ab");
f.handle_key(key(KeyCode::Home));
assert!(f.handle_key(key(KeyCode::Delete)));
assert_eq!(f.value(), "b");
// Nothing to delete at the boundaries.
assert!(!TextField::new("").handle_key(key(KeyCode::Backspace)));
}
#[test]
fn cursor_movement_inserts_at_right_place() {
let mut f = TextField::new("ac");
f.handle_key(key(KeyCode::Left)); // between a and c
f.handle_key(key(KeyCode::Char('b')));
assert_eq!(f.value(), "abc");
f.handle_key(key(KeyCode::Home));
f.handle_key(key(KeyCode::Char('X')));
assert_eq!(f.value(), "Xabc");
}
#[test]
fn ctrl_v_pastes_clipboard_with_newlines_stripped() {
let mut f = TextField::new("");
f.clipboard.set("multi\nline".to_string());
assert!(f.handle_key(KeyEvent::new(KeyCode::Char('v'), KeyModifiers::CONTROL)));
assert_eq!(f.value(), "multiline");
}
#[test]
fn cmd_v_maps_to_ctrl_v() {
let mut f = TextField::new("");
f.clipboard.set("hi".to_string());
f.handle_key(KeyEvent::new(KeyCode::Char('v'), KeyModifiers::SUPER));
assert_eq!(f.value(), "hi");
}
#[test]
fn display_cursor_counts_wide_chars() {
let mut f = TextField::new("世"); // one double-width char, cursor at end
assert_eq!(f.display_cursor(), 2);
f.handle_key(key(KeyCode::Home));
assert_eq!(f.display_cursor(), 0);
}
#[test]
fn unhandled_keys_return_false() {
let mut f = TextField::new("x");
assert!(!f.handle_key(key(KeyCode::Enter)));
assert!(!f.handle_key(key(KeyCode::Up)));
assert!(!f.handle_key(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL)));
}
}