diff options
| author | Mark Jaquith <mark@jaquith.me> | 2026-09-14 15:47:53 -0400 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-09-14 21:47:53 +0200 |
| commit | b0f21ed2cb85613050b794bf6c78a7c367a94e0d (patch) | |
| tree | 25c14e09338f5300bedbc8131aa23fab3d37add9 /src/client/shell/text_editor.rs | |
| parent | d8b36691d10c6055293ad45324b22af8b8f570e5 (diff) | |
feat: add cursor-based editing to text inputs (#3698)
* feat: add cursor-based editing to text inputs
refs #1803
* fix: preserve dialog actions with associated text
refs #1803
* refactor: simplify text editor outcomes and tests
refs #1803
---------
Co-authored-by: JJ Liebig <jonathan.liebig@gmail.com>
Diffstat (limited to 'src/client/shell/text_editor.rs')
| -rw-r--r-- | src/client/shell/text_editor.rs | 526 |
1 files changed, 526 insertions, 0 deletions
diff --git a/src/client/shell/text_editor.rs b/src/client/shell/text_editor.rs new file mode 100644 index 00000000..c903b919 --- /dev/null +++ b/src/client/shell/text_editor.rs @@ -0,0 +1,526 @@ +use crossterm::event::{KeyCode, KeyEventKind, KeyModifiers}; +use unicode_segmentation::UnicodeSegmentation; +use unicode_width::UnicodeWidthStr; + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub(super) struct TextEditor { + text: String, + cursor: usize, + replace_on_type: bool, + killed: String, +} + +impl std::ops::Deref for TextEditor { + type Target = str; + fn deref(&self) -> &str { + self.as_str() + } +} + +impl std::fmt::Display for TextEditor { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.text.fmt(f) + } +} + +impl From<&str> for TextEditor { + fn from(text: &str) -> Self { + Self::new(text, false) + } +} + +impl TextEditor { + pub fn new(text: &str, replace_on_type: bool) -> Self { + let mut editor = Self::default(); + editor.insert(text); + editor.replace_on_type = replace_on_type; + editor + } + + pub fn as_str(&self) -> &str { + &self.text + } + + pub fn clear(&mut self) { + self.text.clear(); + self.cursor = 0; + self.replace_on_type = false; + } + + pub fn trim_and_accept(&mut self) { + let leading = self.text.len() - self.text.trim_start().len(); + self.text = self.text.trim().to_owned(); + self.cursor = self.cursor.saturating_sub(leading).min(self.text.len()); + self.replace_on_type = false; + self.repair_cursor(); + } + + fn repair_cursor(&mut self) { + // Insertion/deletion can join clusters across the edit. Snap forward, never + // leave an insertion offset inside the newly formed grapheme. + self.cursor = self + .text + .grapheme_indices(true) + .map(|(index, _)| index) + .find(|index| *index >= self.cursor) + .unwrap_or(self.text.len()); + } + + pub fn insert(&mut self, text: &str) -> bool { + let mut normalized = String::new(); + let mut chars = text.chars().peekable(); + while let Some(ch) = chars.next() { + match ch { + '\r' => { + if chars.peek() == Some(&'\n') { + chars.next(); + } + normalized.push(' '); + } + '\n' | '\t' => normalized.push(' '), + ch if !ch.is_control() => normalized.push(ch), + _ => {} + } + } + if normalized.is_empty() { + return false; + } + let content_changed = !self.replace_on_type || self.text != normalized; + if self.replace_on_type { + self.clear(); + } + self.text.insert_str(self.cursor, &normalized); + self.cursor += normalized.len(); + self.repair_cursor(); + content_changed + } + + fn previous(&self) -> usize { + self.text[..self.cursor] + .grapheme_indices(true) + .next_back() + .map(|(index, _)| index) + .unwrap_or(0) + } + + fn next(&self) -> usize { + self.cursor + + self.text[self.cursor..] + .graphemes(true) + .next() + .map(str::len) + .unwrap_or(0) + } + + fn word_boundary(&self, backward: bool) -> usize { + let class = |grapheme: &str| { + let ch = grapheme.chars().next().unwrap_or(' '); + if ch.is_whitespace() { + 0 + } else if ch.is_alphanumeric() || ch == '_' { + 1 + } else { + 2 + } + }; + let mut boundary = self.cursor; + let mut run = 0; + if backward { + for (index, grapheme) in self.text[..self.cursor].grapheme_indices(true).rev() { + let current = class(grapheme); + if run != 0 && current != run { + break; + } + run = current; + boundary = index; + } + } else { + for (index, grapheme) in self.text[self.cursor..].grapheme_indices(true) { + let current = class(grapheme); + if run != 0 && current != run { + break; + } + run = current; + boundary = self.cursor + index + grapheme.len(); + } + } + boundary + } + + fn remove(&mut self, start: usize, end: usize, kill: bool) { + self.replace_on_type = false; + if start == end { + return; + } + if kill { + self.killed = self.text[start..end].to_owned(); + } + self.text.replace_range(start..end, ""); + self.cursor = start; + self.repair_cursor(); + } + + pub fn handle_key(&mut self, key: &crate::input::TerminalKey) -> Option<bool> { + if key.kind == KeyEventKind::Release { + return None; + } + if matches!(key.code, KeyCode::Enter | KeyCode::Esc) { + return None; + } + let previous_len = self.text.len(); + let mut content_changed = false; + let (code, modifiers) = crate::config::normalize_key_combo((key.code, key.modifiers)); + // Explicit text from the host is authoritative, including AltGr/composition. + if let Some(text) = key + .generated_text + .as_deref() + .filter(|text| !text.is_empty()) + { + content_changed = self.insert(text); + } else { + let ctrl = modifiers == KeyModifiers::CONTROL; + let alt = modifiers == KeyModifiers::ALT; + let plain = modifiers.is_empty(); + let movement = match code { + KeyCode::Left if plain => Some(self.previous()), + KeyCode::Char('b') if ctrl => Some(self.previous()), + KeyCode::Right if plain => Some(self.next()), + KeyCode::Char('f') if ctrl => Some(self.next()), + KeyCode::Home if plain => Some(0), + KeyCode::Char('a') if ctrl => Some(0), + KeyCode::End if plain => Some(self.text.len()), + KeyCode::Char('e') if ctrl => Some(self.text.len()), + KeyCode::Char('b') if alt => Some(self.word_boundary(true)), + KeyCode::Char('f') if alt => Some(self.word_boundary(false)), + _ => None, + }; + if let Some(position) = movement { + self.cursor = position; + self.replace_on_type = false; + } else { + match code { + KeyCode::Backspace | KeyCode::Char('h') + if code == KeyCode::Backspace && plain + || code == KeyCode::Char('h') && ctrl => + { + if self.replace_on_type { + self.clear(); + } else { + self.remove(self.previous(), self.cursor, false); + } + } + KeyCode::Delete if plain => self.remove(self.cursor, self.next(), false), + KeyCode::Char('d') if ctrl => self.remove(self.cursor, self.next(), false), + KeyCode::Char('u') if ctrl => self.remove(0, self.cursor, true), + KeyCode::Char('k') if ctrl => self.remove(self.cursor, self.text.len(), true), + KeyCode::Char('w') if ctrl => { + self.remove(self.word_boundary(true), self.cursor, true) + } + KeyCode::Backspace if ctrl || alt => { + self.remove(self.word_boundary(true), self.cursor, true) + } + KeyCode::Char('d') if alt => { + self.remove(self.cursor, self.word_boundary(false), true) + } + KeyCode::Char('y') if ctrl => { + content_changed = self.insert(&self.killed.clone()); + } + KeyCode::Char(_) if modifiers.difference(KeyModifiers::SHIFT).is_empty() => { + if let Some(ch) = crate::input::keybind_help_text_char(key) { + content_changed = self.insert(&ch.to_string()); + } + } + _ => return None, + } + } + } + Some(content_changed || self.text.len() != previous_len) + } + + pub fn viewport(&self, width: u16) -> (&str, u16) { + if width == 0 { + return ("", 0); + } + let mut start = self.cursor; + let mut cells = 0; + for (index, grapheme) in self.text[..self.cursor].grapheme_indices(true).rev() { + let next = cells + grapheme.width(); + if next >= usize::from(width) { + break; + } + start = index; + cells = next; + } + let mut end = self.cursor; + let mut used = cells; + for (index, grapheme) in self.text[self.cursor..].grapheme_indices(true) { + used += grapheme.width(); + if used > usize::from(width) { + break; + } + end = self.cursor + index + grapheme.len(); + } + (&self.text[start..end], cells as u16) + } +} + +pub(super) fn render( + buffer: &mut ratatui::buffer::Buffer, + area: ratatui::layout::Rect, + editor: &TextEditor, + style: ratatui::style::Style, +) -> Option<crate::protocol::CursorState> { + let area = area.intersection(buffer.area); + if area.is_empty() { + return None; + } + let (text, cursor) = editor.viewport(area.width); + for x in area.x..area.right() { + buffer[(x, area.y)].set_symbol(" ").set_style(style); + } + buffer.set_stringn(area.x, area.y, text, usize::from(area.width), style); + Some(crate::protocol::CursorState { + x: area.x + cursor, + y: area.y, + visible: true, + shape: 0, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::input::TerminalKey; + + fn key(editor: &mut TextEditor, code: KeyCode, modifiers: KeyModifiers) -> bool { + let result = editor + .handle_key(&TerminalKey::new(code, modifiers)) + .expect("editor binding"); + assert!( + editor.cursor == editor.len() + || editor + .grapheme_indices(true) + .any(|(i, _)| i == editor.cursor) + ); + result + } + + #[test] + fn every_binding_edits_at_the_cursor() { + use KeyCode::*; + let plain = KeyModifiers::NONE; + let ctrl = KeyModifiers::CONTROL; + let alt = KeyModifiers::ALT; + for (code, modifiers, text, cursor, killed) in [ + (Left, plain, "one two", 3, ""), + (Char('b'), ctrl, "one two", 3, ""), + (Right, plain, "one two", 5, ""), + (Char('f'), ctrl, "one two", 5, ""), + (Home, plain, "one two", 0, ""), + (Char('a'), ctrl, "one two", 0, ""), + (End, plain, "one two", 7, ""), + (Char('e'), ctrl, "one two", 7, ""), + (Backspace, plain, "onetwo", 3, ""), + (Char('h'), ctrl, "onetwo", 3, ""), + (Delete, plain, "one wo", 4, ""), + (Char('d'), ctrl, "one wo", 4, ""), + (Char('b'), alt, "one two", 0, ""), + (Char('f'), alt, "one two", 7, ""), + (Char('u'), ctrl, "two", 0, "one "), + (Char('k'), ctrl, "one ", 4, "two"), + (Char('w'), ctrl, "two", 0, "one "), + (Backspace, alt, "two", 0, "one "), + (Backspace, ctrl, "two", 0, "one "), + (Char('d'), alt, "one ", 4, "two"), + (Char('y'), ctrl, "one two", 4, ""), + (Char('X'), plain, "one Xtwo", 5, ""), + ] { + let mut editor = TextEditor::from("one two"); + editor.cursor = 4; + let result = key(&mut editor, code, modifiers); + assert_eq!( + (editor.as_str(), editor.cursor, editor.killed.as_str()), + (text, cursor, killed), + "{code:?} {modifiers:?}" + ); + assert_eq!(result, text != "one two"); + let mut empty = TextEditor::default(); + key(&mut empty, code, modifiers); + } + } + + #[test] + fn suggestions_movement_kills_and_yank() { + for (code, expected) in [ + (KeyCode::Left, "defaulxt"), + (KeyCode::Right, "defaultx"), + (KeyCode::Home, "xdefault"), + (KeyCode::End, "defaultx"), + ] { + let mut editor = TextEditor::new("default", true); + key(&mut editor, code, KeyModifiers::NONE); + editor.insert("x"); + assert_eq!(editor.as_str(), expected); + } + let mut editor = TextEditor::new("default", true); + editor.insert("new"); + assert_eq!(editor.as_str(), "new"); + for (replacement, changed) in [("default", false), ("another", true)] { + let mut editor = TextEditor::new("default", true); + let event = TerminalKey::new(KeyCode::Char('x'), KeyModifiers::NONE) + .with_generated_text(Some(replacement.into())); + assert_eq!(editor.handle_key(&event).expect("replacement"), changed); + assert_eq!(editor.as_str(), replacement); + assert!(!editor.replace_on_type); + } + let mut editor = TextEditor::new("default", true); + key(&mut editor, KeyCode::Backspace, KeyModifiers::NONE); + assert!(editor.is_empty()); + let mut editor = TextEditor::new("one two", true); + key(&mut editor, KeyCode::Char('w'), KeyModifiers::CONTROL); + assert_eq!(editor.as_str(), "one "); + assert!(!editor.replace_on_type); + key(&mut editor, KeyCode::Char('k'), KeyModifiers::CONTROL); + key(&mut editor, KeyCode::Backspace, KeyModifiers::NONE); + key(&mut editor, KeyCode::Char('y'), KeyModifiers::CONTROL); + key(&mut editor, KeyCode::Char('y'), KeyModifiers::CONTROL); + assert_eq!(editor.as_str(), "onetwotwo"); + key(&mut editor, KeyCode::Char('u'), KeyModifiers::CONTROL); + assert_eq!(editor.killed, "onetwotwo"); + let mut other = TextEditor::default(); + key(&mut other, KeyCode::Char('y'), KeyModifiers::CONTROL); + assert!(other.is_empty()); + } + + #[test] + fn unicode_graphemes_and_boundary_changing_edits() { + let mut editor = TextEditor::from("e\u{301}中👩💻"); + for expected in ["e\u{301}中", "e\u{301}", ""] { + key(&mut editor, KeyCode::Backspace, KeyModifiers::NONE); + assert_eq!(editor.as_str(), expected); + } + let mut editor = TextEditor::from("👩💻"); + key(&mut editor, KeyCode::Left, KeyModifiers::NONE); + editor.insert("\u{200d}"); + assert_eq!(editor.cursor, editor.len()); + key(&mut editor, KeyCode::Backspace, KeyModifiers::NONE); + assert!(editor.is_empty()); + let mut editor = TextEditor::from("\u{301}x"); + key(&mut editor, KeyCode::Home, KeyModifiers::NONE); + editor.insert("e"); + assert_eq!(editor.cursor, "e\u{301}".len()); + key(&mut editor, KeyCode::Delete, KeyModifiers::NONE); + assert_eq!(editor.as_str(), "e\u{301}"); + } + + #[test] + fn accepting_trimmed_branch_preserves_cursor_and_local_kill_buffer() { + let mut editor = TextEditor::new(" feature/name ", true); + editor.trim_and_accept(); + assert_eq!(editor.as_str(), "feature/name"); + assert!(!editor.replace_on_type); + assert_eq!(editor.cursor, editor.len()); + key(&mut editor, KeyCode::Char('w'), KeyModifiers::CONTROL); + editor.trim_and_accept(); + key(&mut editor, KeyCode::Char('y'), KeyModifiers::CONTROL); + assert_eq!(editor.as_str(), "feature/name"); + } + + #[test] + fn words_distinguish_paths_punctuation_and_whitespace() { + let mut editor = TextEditor::from("src/foo_bar.rs e\u{301}中 👩💻"); + for expected in [ + "src/foo_bar.rs e\u{301}中 ", + "src/foo_bar.rs ", + "src/foo_bar.", + "src/foo_bar", + "src/", + "src", + "", + ] { + key(&mut editor, KeyCode::Char('w'), KeyModifiers::CONTROL); + assert_eq!(editor.as_str(), expected); + } + let mut editor = TextEditor::from(" /tmp/foo_bar.rs"); + key(&mut editor, KeyCode::Home, KeyModifiers::NONE); + for expected in [2, 5, 6, 13, 14, 16] { + key(&mut editor, KeyCode::Char('f'), KeyModifiers::ALT); + assert_eq!(editor.cursor, expected); + } + } + + #[test] + fn insertion_normalizes_controls_and_respects_host_text() { + let mut editor = TextEditor::from("ab"); + key(&mut editor, KeyCode::Left, KeyModifiers::NONE); + editor.insert("中\r\n\r\n\t\x00\x1b\u{7f}e\u{301}"); + assert_eq!(editor.as_str(), "a中 e\u{301}b"); + let event = TerminalKey::new( + KeyCode::Char('b'), + KeyModifiers::CONTROL | KeyModifiers::ALT, + ) + .with_generated_text(Some("β".into())); + editor.handle_key(&event); + assert_eq!(editor.as_str(), "a中 e\u{301}βb"); + let before = editor.clone(); + assert!(editor + .handle_key(&event.with_kind(KeyEventKind::Release)) + .is_none()); + assert_eq!(editor, before); + let repeat = + TerminalKey::new(KeyCode::Left, KeyModifiers::NONE).with_kind(KeyEventKind::Repeat); + assert!(editor.handle_key(&repeat).is_some()); + } + + #[test] + fn enter_and_escape_ignore_generated_text() { + for code in [KeyCode::Enter, KeyCode::Esc] { + let mut editor = TextEditor::new("default", true); + let before = editor.clone(); + let event = TerminalKey::new(code, KeyModifiers::NONE) + .with_generated_text(Some("printable".into())); + assert_eq!(editor.handle_key(&event), None, "{code:?}"); + assert_eq!(editor, before, "{code:?}"); + } + } + + #[test] + fn viewport_and_render_are_pure_and_grapheme_safe() { + use ratatui::{buffer::Buffer, layout::Rect, style::Style}; + for text in [ + "abcdefghijklmnopqrstuvwxyz", + "e\u{301}中👩💻xyz", + "\u{301}abc", + ] { + let mut editor = TextEditor::from(text); + for cursor in text + .grapheme_indices(true) + .map(|(i, _)| i) + .chain([text.len()]) + { + editor.cursor = cursor; + for width in [0, 1, 2, 3, 8, 80] { + let before = editor.clone(); + let (visible, col) = editor.viewport(width); + assert!(visible.width() <= usize::from(width)); + assert!(width == 0 || col < width); + let mut buffer = Buffer::empty(Rect::new(0, 0, 80, 1)); + let result = render( + &mut buffer, + Rect::new(0, 0, width, 1), + &editor, + Style::default(), + ); + assert_eq!(result.map(|c| c.x), (width > 0).then_some(col)); + assert_eq!(editor, before); + } + } + } + let mut editor = TextEditor::from("abcdef"); + assert_eq!(editor.viewport(4), ("def", 3)); + editor.cursor = 2; + assert_eq!(editor.viewport(4), ("abcd", 2)); + editor.cursor = 0; + assert_eq!(editor.viewport(4), ("abcd", 0)); + } +} |
