aboutsummaryrefslogtreecommitdiffstats
path: root/default-plugins/sequence/src/ui/text_input.rs
diff options
context:
space:
mode:
authorAram Drevekenin <aram@poor.dev>2025-12-11 14:25:42 +0100
committerGitHub <noreply@github.com>2025-12-11 14:25:42 +0100
commit693fa082def5ef0fe030cd7fdc3b1df051b35dc6 (patch)
treeb3f651462bbc1cd2fa5b2d79207343413ef6b83c /default-plugins/sequence/src/ui/text_input.rs
parentfix(zellij-server): unblock building on 32bit targets (#4484) (diff)
downloadsl0ppij-693fa082def5ef0fe030cd7fdc3b1df051b35dc6.tar.gz
sl0ppij-693fa082def5ef0fe030cd7fdc3b1df051b35dc6.tar.xz
sl0ppij-693fa082def5ef0fe030cd7fdc3b1df051b35dc6.zip
Command Sequences, Blocking CLI Commands, New Plugin APIs (#4546)
* fix deadlocks * initial logging to plugin implementation * only log actions, include keys as Action::Write * send actions to plugins as a new Event::UserAction * add Action::Write metadata to plugin API * run_action plugin command and permission * block-until-exit/success/failure in the cli * fix tests * add NewBlockingPane to the plugin API * run actions in a different thread so as not to block plugin execution * add custom context to run_action flow * fixes for floating pane z_index sorting and unknown protobuf events not crashing plugins * always render the frame of pinned panes * macros module * add macros to config * KDL macro parsing * thread macros to the plugin thread * get/update/delete macros from plugins * add macros updated event * add ability to rename macros * fix deadlock * fix z_indices with pinned unselectable panes bug * allow plugins to opt-in to show_cursor * change show_cursor(bool) to show_cursor(Option<coordinates>) * test * upgrade strum to allow proper enum iteration and case insensitive fromstr implementation for Actions * better cwd tracking for exited command panes * add initial panes to NewTab Action (no-op for now) * add initial panes to cli newtab action * make initial panes work * add close_on_exit and start_suspended to the cli * properly convert newtab action (potentially breaking for plugins) * allow first pane in tab to block * make distinction between opening panes with the last active client and the current pane in the cli * improve some apis * fix fixed size closing pane bug * fix plugin cursor coordinates * fix issues with launch-or-focus-plugin having to do with in-place and suppressed panes * fix: set affected pane in replace pane * allow setting a pane in place when it is suppressed and being focused (tests failing) * fix tests * some fixes for unsuppression of panes * some fixes for completion_tx in new tab action * allow replace/suppressed rather than replace/close only * fixes for inserting suppressed panes without conflicts * pane focus history and explicit unsuppress plugin api * fix: prefer explicitly provided pane id in placement in actions that provide it * fix floating pane coordinate change and z_index * fix plugin cache dir to persist between sessions * fix some stack focusing issues when adding panes without a client id * make sure pinned panes are always above non-pinned floating panes * allow plugins to copy to clipboard * initial text styling additions implementations - buggy * fix dimming issues * remove extras, stay with just dim and unbold * unbold and dim as indices * allow ui components to render success/error colors * allow plugins to send sigint/sigkill to processes, as well as receive their pid * ui component fixes and send originating plugin context with run_action * originating plugin for new tab too * make plugn show pane command also expand stacked panes * add sequence plugin * remove macros * remove snapshots * add shortcut for sequence plugin * fix plugin tests * plugin tests for new apis * update snapshots for sequence shortcut * cleanups and formatting * Event::CwdChanged * moar cleanups * remove some warnings * remove mroe warnings * remove dead code * remove more dead code * fix minor bugs in sequence plugin * update sequence help and format codebase * fix conditional blocking cli commands * fix initial-command new-tab cli flag * add client_ids to CwdChanged Event * last fixes * add pr
Diffstat (limited to 'default-plugins/sequence/src/ui/text_input.rs')
-rw-r--r--default-plugins/sequence/src/ui/text_input.rs997
1 files changed, 997 insertions, 0 deletions
diff --git a/default-plugins/sequence/src/ui/text_input.rs b/default-plugins/sequence/src/ui/text_input.rs
new file mode 100644
index 00000000..2694e17a
--- /dev/null
+++ b/default-plugins/sequence/src/ui/text_input.rs
@@ -0,0 +1,997 @@
+use zellij_tile::prelude::*;
+
+const MAX_UNDO_STACK_SIZE: usize = 100;
+
+/// Action returned by TextInput after handling a key event
+#[derive(Debug, Clone, PartialEq)]
+#[cfg_attr(test, derive(Eq))]
+pub enum InputAction {
+ /// Continue editing
+ Continue,
+ /// User pressed Enter to submit
+ Submit,
+ /// User pressed Esc to cancel
+ Cancel,
+ /// User pressed Tab to request completion
+ Complete,
+ /// Key was not handled by the input
+ NoAction,
+}
+
+/// A reusable text input component with cursor support and standard editing keybindings
+#[derive(Debug, Clone)]
+pub struct TextInput {
+ buffer: String,
+ cursor_position: usize, // Character position (0-based), NOT byte position
+ undo_stack: Vec<(String, usize)>, // (buffer, cursor) snapshots
+ redo_stack: Vec<(String, usize)>,
+ last_edit_was_insert: bool, // For coalescing consecutive inserts
+}
+
+impl TextInput {
+ /// Create a new TextInput with the given initial text
+ /// Cursor is positioned at the end of the text
+ pub fn new(initial_text: String) -> Self {
+ let cursor_position = initial_text.chars().count();
+ Self {
+ buffer: initial_text,
+ cursor_position,
+ undo_stack: Vec::new(),
+ redo_stack: Vec::new(),
+ last_edit_was_insert: false,
+ }
+ }
+
+ /// Create an empty TextInput
+ pub fn empty() -> Self {
+ Self::new(String::new())
+ }
+
+ /// Get the current text
+ pub fn get_text(&self) -> &str {
+ &self.buffer
+ }
+
+ /// Get the cursor position (in characters, not bytes)
+ pub fn get_cursor_position(&self) -> usize {
+ self.cursor_position
+ }
+
+ /// Check if the input is empty
+ pub fn is_empty(&self) -> bool {
+ self.buffer.is_empty()
+ }
+
+ /// Get a shorthand for cursor_position
+ pub fn cursor_position(&self) -> usize {
+ self.cursor_position
+ }
+
+ /// Get mutable access to the underlying buffer for direct manipulation
+ pub fn get_text_mut(&mut self) -> &mut String {
+ &mut self.buffer
+ }
+
+ /// Set the text and move cursor to the end
+ pub fn set_text(&mut self, text: String) {
+ self.break_coalescing();
+ self.save_undo_state();
+ self.cursor_position = text.chars().count();
+ self.buffer = text;
+ }
+
+ /// Set cursor position (clamped to text length)
+ pub fn set_cursor_position(&mut self, pos: usize) {
+ let text_len = self.buffer.chars().count();
+ self.cursor_position = pos.min(text_len);
+ }
+
+ /// Clear all text and reset cursor
+ pub fn clear(&mut self) {
+ self.break_coalescing();
+ self.save_undo_state();
+ self.buffer.clear();
+ self.cursor_position = 0;
+ }
+
+ /// Insert a character at the current cursor position
+ pub fn insert_char(&mut self, c: char) {
+ self.save_undo_state_unless_coalescing();
+ // Convert cursor position (char index) to byte index
+ let byte_index = self.char_index_to_byte_index(self.cursor_position);
+ self.buffer.insert(byte_index, c);
+ self.cursor_position += 1;
+ }
+
+ /// Delete the character before the cursor (backspace)
+ pub fn backspace(&mut self) {
+ if self.cursor_position > 0 {
+ self.break_coalescing();
+ self.save_undo_state();
+ self.cursor_position -= 1;
+ let byte_index = self.char_index_to_byte_index(self.cursor_position);
+ self.buffer.remove(byte_index);
+ }
+ }
+
+ /// Delete the character at the cursor position (delete key)
+ pub fn delete(&mut self) {
+ let len = self.buffer.chars().count();
+ if self.cursor_position < len {
+ self.break_coalescing();
+ self.save_undo_state();
+ let byte_index = self.char_index_to_byte_index(self.cursor_position);
+ self.buffer.remove(byte_index);
+ }
+ }
+
+ /// Delete the word before the cursor (Ctrl/Alt + Backspace)
+ pub fn delete_word_backward(&mut self) {
+ if self.cursor_position == 0 {
+ return;
+ }
+
+ self.break_coalescing();
+ self.save_undo_state();
+
+ let old_position = self.cursor_position;
+ self.move_word_left();
+ let new_position = self.cursor_position;
+
+ // Delete from new position to old position
+ let start_byte = self.char_index_to_byte_index(new_position);
+ let end_byte = self.char_index_to_byte_index(old_position);
+ self.buffer.drain(start_byte..end_byte);
+ }
+
+ /// Delete the word after the cursor (Ctrl/Alt + Delete)
+ pub fn delete_word_forward(&mut self) {
+ let chars: Vec<char> = self.buffer.chars().collect();
+ let len = chars.len();
+
+ if self.cursor_position >= len {
+ return;
+ }
+
+ self.break_coalescing();
+ self.save_undo_state();
+
+ let start_position = self.cursor_position;
+ let mut end_position = start_position;
+
+ // Skip the current word
+ while end_position < len && !chars[end_position].is_whitespace() {
+ end_position += 1;
+ }
+
+ // Skip any whitespace after the word
+ while end_position < len && chars[end_position].is_whitespace() {
+ end_position += 1;
+ }
+
+ // Delete from start to end position
+ let start_byte = self.char_index_to_byte_index(start_position);
+ let end_byte = self.char_index_to_byte_index(end_position);
+ self.buffer.drain(start_byte..end_byte);
+ }
+
+ /// Move cursor one position to the left
+ pub fn move_left(&mut self) {
+ if self.cursor_position > 0 {
+ self.break_coalescing();
+ self.cursor_position -= 1;
+ }
+ }
+
+ /// Move cursor one position to the right
+ pub fn move_right(&mut self) {
+ let len = self.buffer.chars().count();
+ if self.cursor_position < len {
+ self.break_coalescing();
+ self.cursor_position += 1;
+ }
+ }
+
+ /// Move cursor to the start of the text (Ctrl-A / Home)
+ pub fn move_to_start(&mut self) {
+ self.break_coalescing();
+ self.cursor_position = 0;
+ }
+
+ /// Move cursor to the end of the text (Ctrl-E / End)
+ pub fn move_to_end(&mut self) {
+ self.break_coalescing();
+ self.cursor_position = self.buffer.chars().count();
+ }
+
+ /// Move cursor to the start of the previous word (Ctrl/Alt + Left)
+ pub fn move_word_left(&mut self) {
+ if self.cursor_position == 0 {
+ return;
+ }
+
+ self.break_coalescing();
+
+ let chars: Vec<char> = self.buffer.chars().collect();
+ let mut pos = self.cursor_position;
+
+ // Skip any whitespace immediately to the left
+ while pos > 0 && chars[pos - 1].is_whitespace() {
+ pos -= 1;
+ }
+
+ // Skip the word characters
+ while pos > 0 && !chars[pos - 1].is_whitespace() {
+ pos -= 1;
+ }
+
+ self.cursor_position = pos;
+ }
+
+ /// Move cursor to the start of the next word (Ctrl/Alt + Right)
+ pub fn move_word_right(&mut self) {
+ let chars: Vec<char> = self.buffer.chars().collect();
+ let len = chars.len();
+
+ if self.cursor_position >= len {
+ return;
+ }
+
+ self.break_coalescing();
+
+ let mut pos = self.cursor_position;
+
+ // Skip the current word
+ while pos < len && !chars[pos].is_whitespace() {
+ pos += 1;
+ }
+
+ // Skip any whitespace
+ while pos < len && chars[pos].is_whitespace() {
+ pos += 1;
+ }
+
+ self.cursor_position = pos;
+ }
+
+ /// Handle a key event and return the appropriate action
+ /// This is the main entry point for key handling
+ pub fn handle_key(&mut self, key: KeyWithModifier) -> InputAction {
+ // Check for Ctrl modifiers
+ if key.has_modifiers(&[KeyModifier::Ctrl]) {
+ match key.bare_key {
+ BareKey::Char('a') => {
+ self.move_to_start();
+ return InputAction::Continue;
+ },
+ BareKey::Char('e') => {
+ self.move_to_end();
+ return InputAction::Continue;
+ },
+ BareKey::Char('c') => {
+ // Ctrl-C clears the prompt
+ return InputAction::Cancel;
+ },
+ BareKey::Char('z') => {
+ // Ctrl-Z: Undo
+ self.undo();
+ return InputAction::Continue;
+ },
+ BareKey::Char('y') => {
+ // Ctrl-Y: Redo
+ self.redo();
+ return InputAction::Continue;
+ },
+ BareKey::Left => {
+ self.move_word_left();
+ return InputAction::Continue;
+ },
+ BareKey::Right => {
+ self.move_word_right();
+ return InputAction::Continue;
+ },
+ BareKey::Backspace => {
+ self.delete_word_backward();
+ return InputAction::Continue;
+ },
+ BareKey::Delete => {
+ self.delete_word_forward();
+ return InputAction::Continue;
+ },
+ _ => {},
+ }
+ }
+
+ // Check for Ctrl+Shift modifiers (alternative redo: Ctrl+Shift+Z)
+ if key.has_modifiers(&[KeyModifier::Ctrl, KeyModifier::Shift]) {
+ match key.bare_key {
+ BareKey::Char('Z') => {
+ // Ctrl-Shift-Z: Redo (alternative)
+ self.redo();
+ return InputAction::Continue;
+ },
+ _ => {},
+ }
+ }
+
+ // Check for Alt modifiers
+ if key.has_modifiers(&[KeyModifier::Alt]) {
+ match key.bare_key {
+ BareKey::Left => {
+ self.move_word_left();
+ return InputAction::Continue;
+ },
+ BareKey::Right => {
+ self.move_word_right();
+ return InputAction::Continue;
+ },
+ BareKey::Backspace => {
+ self.delete_word_backward();
+ return InputAction::Continue;
+ },
+ BareKey::Delete => {
+ self.delete_word_forward();
+ return InputAction::Continue;
+ },
+ _ => {},
+ }
+ }
+
+ // Handle bare keys (no modifiers)
+ match key.bare_key {
+ BareKey::Enter => InputAction::Submit,
+ BareKey::Esc => InputAction::Cancel,
+ BareKey::Tab => InputAction::Complete,
+ BareKey::Backspace => {
+ self.backspace();
+ InputAction::Continue
+ },
+ BareKey::Delete => {
+ self.delete();
+ InputAction::Continue
+ },
+ BareKey::Left => {
+ self.move_left();
+ InputAction::Continue
+ },
+ BareKey::Right => {
+ self.move_right();
+ InputAction::Continue
+ },
+ BareKey::Home => {
+ self.move_to_start();
+ InputAction::Continue
+ },
+ BareKey::End => {
+ self.move_to_end();
+ InputAction::Continue
+ },
+ BareKey::Char(c) => {
+ self.insert_char(c);
+ InputAction::Continue
+ },
+ _ => InputAction::NoAction,
+ }
+ }
+
+ /// Helper: Convert character index to byte index
+ fn char_index_to_byte_index(&self, char_index: usize) -> usize {
+ self.buffer
+ .char_indices()
+ .nth(char_index)
+ .map(|(byte_idx, _)| byte_idx)
+ .unwrap_or(self.buffer.len())
+ }
+
+ /// Save current state to undo stack before making changes
+ fn save_undo_state(&mut self) {
+ if self.undo_stack.len() >= MAX_UNDO_STACK_SIZE {
+ self.undo_stack.remove(0);
+ }
+ self.undo_stack
+ .push((self.buffer.clone(), self.cursor_position));
+ self.redo_stack.clear();
+ }
+
+ /// Save state only if not coalescing with previous insert
+ fn save_undo_state_unless_coalescing(&mut self) {
+ if !self.last_edit_was_insert {
+ self.save_undo_state();
+ }
+ self.last_edit_was_insert = true;
+ }
+
+ /// Mark that a non-insert edit occurred (breaks coalescing)
+ fn break_coalescing(&mut self) {
+ self.last_edit_was_insert = false;
+ }
+
+ /// Undo last change
+ pub fn undo(&mut self) -> bool {
+ if let Some((buffer, cursor)) = self.undo_stack.pop() {
+ self.redo_stack
+ .push((self.buffer.clone(), self.cursor_position));
+ self.buffer = buffer;
+ self.cursor_position = cursor;
+ self.break_coalescing();
+ true
+ } else {
+ false
+ }
+ }
+
+ /// Redo last undone change
+ pub fn redo(&mut self) -> bool {
+ if let Some((buffer, cursor)) = self.redo_stack.pop() {
+ self.undo_stack
+ .push((self.buffer.clone(), self.cursor_position));
+ self.buffer = buffer;
+ self.cursor_position = cursor;
+ self.break_coalescing();
+ true
+ } else {
+ false
+ }
+ }
+
+ /// Check if undo is available
+ pub fn can_undo(&self) -> bool {
+ !self.undo_stack.is_empty()
+ }
+
+ /// Check if redo is available
+ pub fn can_redo(&self) -> bool {
+ !self.redo_stack.is_empty()
+ }
+
+ pub fn drain_text(&mut self) -> String {
+ self.cursor_position = 0;
+ self.buffer.drain(..).collect()
+ }
+}
+
+// run with:
+// cargo test --lib --target x86_64-unknown-linux-gnu
+//
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_new_and_empty() {
+ let input = TextInput::new("hello".to_string());
+ assert_eq!(input.get_text(), "hello");
+ assert_eq!(input.get_cursor_position(), 5);
+
+ let empty = TextInput::empty();
+ assert_eq!(empty.get_text(), "");
+ assert_eq!(empty.get_cursor_position(), 0);
+ }
+
+ #[test]
+ fn test_insert_char() {
+ let mut input = TextInput::new("helo".to_string());
+ input.cursor_position = 3; // Position after "hel"
+ input.insert_char('l');
+ assert_eq!(input.get_text(), "hello");
+ assert_eq!(input.get_cursor_position(), 4);
+ }
+
+ #[test]
+ fn test_backspace() {
+ let mut input = TextInput::new("hello".to_string());
+ input.backspace();
+ assert_eq!(input.get_text(), "hell");
+ assert_eq!(input.get_cursor_position(), 4);
+
+ // Backspace at start does nothing
+ input.cursor_position = 0;
+ input.backspace();
+ assert_eq!(input.get_text(), "hell");
+ assert_eq!(input.get_cursor_position(), 0);
+ }
+
+ #[test]
+ fn test_delete() {
+ let mut input = TextInput::new("hello".to_string());
+ input.cursor_position = 0;
+ input.delete();
+ assert_eq!(input.get_text(), "ello");
+ assert_eq!(input.get_cursor_position(), 0);
+
+ // Delete at end does nothing
+ input.move_to_end();
+ input.delete();
+ assert_eq!(input.get_text(), "ello");
+ }
+
+ #[test]
+ fn test_cursor_movement() {
+ let mut input = TextInput::new("hello".to_string());
+ assert_eq!(input.get_cursor_position(), 5);
+
+ input.move_left();
+ assert_eq!(input.get_cursor_position(), 4);
+
+ input.move_right();
+ assert_eq!(input.get_cursor_position(), 5);
+
+ input.move_to_start();
+ assert_eq!(input.get_cursor_position(), 0);
+
+ input.move_to_end();
+ assert_eq!(input.get_cursor_position(), 5);
+ }
+
+ #[test]
+ fn test_unicode_support() {
+ let mut input = TextInput::new("hello 🦀 world".to_string());
+ assert_eq!(input.get_cursor_position(), 13); // 13 characters
+
+ input.cursor_position = 6; // After "hello "
+ input.insert_char('🐱');
+ assert_eq!(input.get_text(), "hello 🐱🦀 world");
+ }
+
+ #[test]
+ fn test_word_jump_right() {
+ let mut input = TextInput::new("hello world foo bar".to_string());
+ input.cursor_position = 0;
+
+ // Jump from start to "world"
+ input.move_word_right();
+ assert_eq!(input.get_cursor_position(), 6); // After "hello "
+
+ // Jump to "foo"
+ input.move_word_right();
+ assert_eq!(input.get_cursor_position(), 12); // After "world "
+
+ // Jump to "bar"
+ input.move_word_right();
+ assert_eq!(input.get_cursor_position(), 16); // After "foo "
+
+ // Jump to end
+ input.move_word_right();
+ assert_eq!(input.get_cursor_position(), 19); // At end
+ }
+
+ #[test]
+ fn test_word_jump_left() {
+ let mut input = TextInput::new("hello world foo bar".to_string());
+ input.move_to_end();
+ assert_eq!(input.get_cursor_position(), 19);
+
+ // Jump back to "bar"
+ input.move_word_left();
+ assert_eq!(input.get_cursor_position(), 16); // Start of "bar"
+
+ // Jump back to "foo"
+ input.move_word_left();
+ assert_eq!(input.get_cursor_position(), 12); // Start of "foo"
+
+ // Jump back to "world"
+ input.move_word_left();
+ assert_eq!(input.get_cursor_position(), 6); // Start of "world"
+
+ // Jump back to "hello"
+ input.move_word_left();
+ assert_eq!(input.get_cursor_position(), 0); // Start of "hello"
+ }
+
+ #[test]
+ fn test_word_jump_with_multiple_spaces() {
+ let mut input = TextInput::new("hello world".to_string());
+ input.cursor_position = 0;
+
+ // Jump over multiple spaces
+ input.move_word_right();
+ assert_eq!(input.get_cursor_position(), 8); // After "hello ", at start of "world"
+
+ // Jump back should skip spaces
+ input.move_word_left();
+ assert_eq!(input.get_cursor_position(), 0); // Back to start of "hello"
+ }
+
+ #[test]
+ fn test_word_jump_boundaries() {
+ let mut input = TextInput::new("test".to_string());
+
+ // At start - word left does nothing
+ input.cursor_position = 0;
+ input.move_word_left();
+ assert_eq!(input.get_cursor_position(), 0);
+
+ // At end - word right does nothing
+ input.move_to_end();
+ let end_pos = input.get_cursor_position();
+ input.move_word_right();
+ assert_eq!(input.get_cursor_position(), end_pos);
+ }
+
+ #[test]
+ fn test_up_down_arrows() {
+ let mut input = TextInput::new("hello world".to_string());
+
+ // Start in the middle
+ input.cursor_position = 5;
+ assert_eq!(input.get_cursor_position(), 5);
+
+ // Up arrow should go to start
+ input.move_to_start();
+ assert_eq!(input.get_cursor_position(), 0);
+
+ // Move back to middle
+ input.cursor_position = 5;
+
+ // Down arrow should go to end
+ input.move_to_end();
+ assert_eq!(input.get_cursor_position(), 11);
+ }
+
+ #[test]
+ fn test_delete_word_backward() {
+ let mut input = TextInput::new("hello world foo".to_string());
+
+ // Delete "foo" from end
+ input.move_to_end();
+ input.delete_word_backward();
+ assert_eq!(input.get_text(), "hello world ");
+ assert_eq!(input.get_cursor_position(), 12);
+
+ // Delete "world "
+ input.delete_word_backward();
+ assert_eq!(input.get_text(), "hello ");
+ assert_eq!(input.get_cursor_position(), 6);
+
+ // Delete "hello "
+ input.delete_word_backward();
+ assert_eq!(input.get_text(), "");
+ assert_eq!(input.get_cursor_position(), 0);
+
+ // Delete on empty buffer does nothing
+ input.delete_word_backward();
+ assert_eq!(input.get_text(), "");
+ assert_eq!(input.get_cursor_position(), 0);
+ }
+
+ #[test]
+ fn test_delete_word_backward_middle() {
+ let mut input = TextInput::new("hello world foo".to_string());
+
+ // Position in middle of "world"
+ input.cursor_position = 8; // After "hello wo"
+ input.delete_word_backward();
+ assert_eq!(input.get_text(), "hello rld foo");
+ assert_eq!(input.get_cursor_position(), 6); // After "hello "
+ }
+
+ #[test]
+ fn test_delete_word_forward() {
+ let mut input = TextInput::new("hello world foo".to_string());
+
+ // Delete "hello " from start
+ input.cursor_position = 0;
+ input.delete_word_forward();
+ assert_eq!(input.get_text(), "world foo");
+ assert_eq!(input.get_cursor_position(), 0);
+
+ // Delete "world "
+ input.delete_word_forward();
+ assert_eq!(input.get_text(), "foo");
+ assert_eq!(input.get_cursor_position(), 0);
+
+ // Delete "foo"
+ input.delete_word_forward();
+ assert_eq!(input.get_text(), "");
+ assert_eq!(input.get_cursor_position(), 0);
+
+ // Delete on empty buffer does nothing
+ input.delete_word_forward();
+ assert_eq!(input.get_text(), "");
+ assert_eq!(input.get_cursor_position(), 0);
+ }
+
+ #[test]
+ fn test_delete_word_forward_middle() {
+ let mut input = TextInput::new("hello world foo".to_string());
+
+ // Position in middle of "world"
+ input.cursor_position = 8; // After "hello wo"
+ input.delete_word_forward();
+ assert_eq!(input.get_text(), "hello wofoo");
+ assert_eq!(input.get_cursor_position(), 8); // Same position, text deleted forward
+ }
+
+ #[test]
+ fn test_delete_word_with_multiple_spaces() {
+ let mut input = TextInput::new("hello world".to_string());
+
+ // Delete forward includes trailing spaces
+ input.cursor_position = 0;
+ input.delete_word_forward();
+ assert_eq!(input.get_text(), "world");
+ assert_eq!(input.get_cursor_position(), 0);
+ }
+
+ #[test]
+ fn test_undo_redo_basic() {
+ let mut input = TextInput::empty();
+
+ // Type "hello"
+ input.insert_char('h');
+ input.insert_char('e');
+ input.insert_char('l');
+ input.insert_char('l');
+ input.insert_char('o');
+ assert_eq!(input.get_text(), "hello");
+
+ // Undo should remove all characters (coalesced into one undo entry)
+ assert!(input.can_undo());
+ assert!(input.undo());
+ assert_eq!(input.get_text(), "");
+ assert_eq!(input.get_cursor_position(), 0);
+
+ // Redo should restore "hello"
+ assert!(input.can_redo());
+ assert!(input.redo());
+ assert_eq!(input.get_text(), "hello");
+ assert_eq!(input.get_cursor_position(), 5);
+ }
+
+ #[test]
+ fn test_undo_coalescing_breaks_on_cursor_move() {
+ let mut input = TextInput::empty();
+
+ // Type "he"
+ input.insert_char('h');
+ input.insert_char('e');
+
+ // Move cursor to start (breaks coalescing)
+ input.move_to_start();
+
+ // Type "llo"
+ input.insert_char('l');
+ input.insert_char('l');
+ input.insert_char('o');
+
+ assert_eq!(input.get_text(), "llohe");
+
+ // First undo removes "llo" (second coalesced group)
+ input.undo();
+ assert_eq!(input.get_text(), "he");
+
+ // Second undo removes "he" (first coalesced group)
+ input.undo();
+ assert_eq!(input.get_text(), "");
+ }
+
+ #[test]
+ fn test_undo_backspace() {
+ let mut input = TextInput::new("hello".to_string());
+
+ // Backspace once
+ input.backspace();
+ assert_eq!(input.get_text(), "hell");
+
+ // Undo should restore "hello"
+ input.undo();
+ assert_eq!(input.get_text(), "hello");
+ assert_eq!(input.get_cursor_position(), 5);
+ }
+
+ #[test]
+ fn test_undo_delete() {
+ let mut input = TextInput::new("hello".to_string());
+ input.cursor_position = 0;
+
+ // Delete first character
+ input.delete();
+ assert_eq!(input.get_text(), "ello");
+
+ // Undo should restore "hello"
+ input.undo();
+ assert_eq!(input.get_text(), "hello");
+ assert_eq!(input.get_cursor_position(), 0);
+ }
+
+ #[test]
+ fn test_undo_word_delete() {
+ let mut input = TextInput::new("hello world".to_string());
+
+ // Delete "world" backward
+ input.delete_word_backward();
+ assert_eq!(input.get_text(), "hello ");
+
+ // Undo should restore "hello world"
+ input.undo();
+ assert_eq!(input.get_text(), "hello world");
+ assert_eq!(input.get_cursor_position(), 11);
+ }
+
+ #[test]
+ fn test_undo_clear() {
+ let mut input = TextInput::new("hello world".to_string());
+
+ // Clear the buffer
+ input.clear();
+ assert_eq!(input.get_text(), "");
+
+ // Undo should restore the text
+ input.undo();
+ assert_eq!(input.get_text(), "hello world");
+ }
+
+ #[test]
+ fn test_undo_set_text() {
+ let mut input = TextInput::new("hello".to_string());
+
+ // Replace with new text
+ input.set_text("goodbye".to_string());
+ assert_eq!(input.get_text(), "goodbye");
+
+ // Undo should restore "hello"
+ input.undo();
+ assert_eq!(input.get_text(), "hello");
+ }
+
+ #[test]
+ fn test_redo_clears_on_new_edit() {
+ let mut input = TextInput::empty();
+
+ // Type "hello"
+ input.insert_char('h');
+ input.insert_char('e');
+ input.insert_char('l');
+ input.insert_char('l');
+ input.insert_char('o');
+
+ // Undo
+ input.undo();
+ assert_eq!(input.get_text(), "");
+ assert!(input.can_redo());
+
+ // Make a new edit (should clear redo stack)
+ input.insert_char('x');
+ assert!(!input.can_redo());
+ }
+
+ #[test]
+ fn test_multiple_undo_redo() {
+ let mut input = TextInput::empty();
+
+ // First edit: type "hello"
+ for c in "hello".chars() {
+ input.insert_char(c);
+ }
+
+ // Break coalescing
+ input.move_left();
+
+ // Second edit: type "world"
+ for c in "world".chars() {
+ input.insert_char(c);
+ }
+
+ assert_eq!(input.get_text(), "hellworldo");
+
+ // Undo "world"
+ input.undo();
+ assert_eq!(input.get_text(), "hello");
+
+ // Undo "hello"
+ input.undo();
+ assert_eq!(input.get_text(), "");
+
+ // Redo "hello"
+ input.redo();
+ assert_eq!(input.get_text(), "hello");
+
+ // Redo "world"
+ input.redo();
+ assert_eq!(input.get_text(), "hellworldo");
+ }
+
+ #[test]
+ fn test_undo_stack_limit() {
+ let mut input = TextInput::empty();
+
+ // Perform 102 separate edits (breaking coalescing each time)
+ for _i in 0..102 {
+ input.backspace(); // Break coalescing
+ input.insert_char('x');
+ }
+
+ // Should have at most 100 undo entries
+ let mut undo_count = 0;
+ while input.undo() {
+ undo_count += 1;
+ }
+
+ // We should have 100 undo entries (the stack limit)
+ // Plus the final state change from the last coalescing break
+ assert!(
+ undo_count <= 100,
+ "Undo count should be at most 100, got {}",
+ undo_count
+ );
+ }
+
+ #[test]
+ fn test_undo_redo_empty_stack() {
+ let mut input = TextInput::empty();
+
+ // Undo on empty stack should return false
+ assert!(!input.can_undo());
+ assert!(!input.undo());
+
+ // Redo on empty stack should return false
+ assert!(!input.can_redo());
+ assert!(!input.redo());
+ }
+
+ #[test]
+ fn test_undo_restores_cursor_position() {
+ let mut input = TextInput::new("hello world".to_string());
+
+ // Move cursor to position 5 (before "world")
+ input.cursor_position = 5;
+
+ // Insert a space
+ input.insert_char(' ');
+ assert_eq!(input.get_text(), "hello world");
+ assert_eq!(input.get_cursor_position(), 6);
+
+ // Undo should restore both text and cursor position
+ input.undo();
+ assert_eq!(input.get_text(), "hello world");
+ assert_eq!(input.get_cursor_position(), 5);
+ }
+
+ #[test]
+ fn test_coalescing_consecutive_inserts() {
+ let mut input = TextInput::empty();
+
+ // Type several characters
+ input.insert_char('a');
+ input.insert_char('b');
+ input.insert_char('c');
+
+ assert_eq!(input.get_text(), "abc");
+
+ // Single undo should remove all three (they were coalesced)
+ input.undo();
+ assert_eq!(input.get_text(), "");
+
+ // No more undo available
+ assert!(!input.can_undo());
+ }
+
+ #[test]
+ fn test_backspace_breaks_coalescing() {
+ let mut input = TextInput::empty();
+
+ // Type "ab"
+ input.insert_char('a');
+ input.insert_char('b');
+
+ // Backspace
+ input.backspace();
+ assert_eq!(input.get_text(), "a");
+
+ // Type "c"
+ input.insert_char('c');
+ assert_eq!(input.get_text(), "ac");
+
+ // Undo should remove just "c"
+ input.undo();
+ assert_eq!(input.get_text(), "a");
+
+ // Undo should remove backspace operation
+ input.undo();
+ assert_eq!(input.get_text(), "ab");
+
+ // Undo should remove "ab"
+ input.undo();
+ assert_eq!(input.get_text(), "");
+ }
+}