179 lines
6.2 KiB
Rust
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)));
|
|
}
|
|
}
|