ui crate, editor
This commit is contained in:
@@ -0,0 +1,178 @@
|
||||
//! 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)));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user