mirror of
https://github.com/zellij-org/zellij.git
synced 2026-10-26 08:02:32 +00:00
* 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
998 lines
29 KiB
Rust
998 lines
29 KiB
Rust
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(), "");
|
|
}
|
|
}
|