diff options
| author | Aram Drevekenin <aram@poor.dev> | 2026-02-17 15:43:28 +0100 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-02-17 15:43:28 +0100 |
| commit | 4e66a0beff5e41bda1dbde521fa1d2858c46fe7b (patch) | |
| tree | 380bee5eeac56bea2ebf341cccbe69615585e394 /default-plugins/sequence/src/ui/text_input.rs | |
| parent | Fix startup message and cache file creation (#4705) (diff) | |
| download | sl0ppij-4e66a0beff5e41bda1dbde521fa1d2858c46fe7b.tar.gz sl0ppij-4e66a0beff5e41bda1dbde521fa1d2858c46fe7b.tar.xz sl0ppij-4e66a0beff5e41bda1dbde521fa1d2858c46fe7b.zip | |
Revamp the sequence plugin (#4713)
* initial pipe interface
* initial open_x_pane_in_new_tab plugin api implementation
* fix: send CommandPaneExited for command panes opened as initial_terminals in new tabs from plugins
* open panes in place of specific pane id plugin commands - initial implementation
* sequence and plugin api ux
* focus self when starting
* combine ui and pipe paths
* highlight selected pane
* initial sequence removal of editing capabilities
* fix: translate plugin paths in api
* properly trim commands when loaded from pipe or pasted text
* minor ux fixes
* get ctrl c to work properly
* behavior fixes
* more ui fixes
* fix tests
* close tab and end sequence properly
* default to AND
* zellij sequence alias
* add alias
* consolidate paths
* rustfmt
* fix ui padding
* add pr
* fix: reset scrolling when resetting terminal state
Diffstat (limited to 'default-plugins/sequence/src/ui/text_input.rs')
| -rw-r--r-- | default-plugins/sequence/src/ui/text_input.rs | 997 |
1 files changed, 0 insertions, 997 deletions
diff --git a/default-plugins/sequence/src/ui/text_input.rs b/default-plugins/sequence/src/ui/text_input.rs deleted file mode 100644 index 2694e17a..00000000 --- a/default-plugins/sequence/src/ui/text_input.rs +++ /dev/null @@ -1,997 +0,0 @@ -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(), ""); - } -} |
