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 | |
| 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')
| -rw-r--r-- | default-plugins/sequence/src/ui/components.rs | 115 | ||||
| -rw-r--r-- | default-plugins/sequence/src/ui/fuzzy_complete.rs | 321 | ||||
| -rw-r--r-- | default-plugins/sequence/src/ui/mod.rs | 2 | ||||
| -rw-r--r-- | default-plugins/sequence/src/ui/text_input.rs | 997 |
4 files changed, 43 insertions, 1392 deletions
diff --git a/default-plugins/sequence/src/ui/components.rs b/default-plugins/sequence/src/ui/components.rs index 3fe2ae1a..bc319567 100644 --- a/default-plugins/sequence/src/ui/components.rs +++ b/default-plugins/sequence/src/ui/components.rs @@ -6,6 +6,11 @@ use zellij_tile::prelude::*; const SPINNER_FRAMES: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧"]; +const INTRO_PART1: &str = "Paste a series of commands to run in sequence"; +const INTRO_PART2: &str = "or <e> to edit in your editor"; +const INTRO_FULL: &str = + "Paste a series of commands to run in sequence or <e> to edit in your editor"; + const HELP_RUNNING_WITH_SELECTION: &str = "<Ctrl c> - interrupt, <Enter> - run from selected"; const HELP_RUNNING_NO_SELECTION: &str = "<Ctrl c> - interrupt, <↓↑> - navigate"; const HELP_STOPPED_WITH_SELECTION: &str = @@ -13,15 +18,7 @@ const HELP_STOPPED_WITH_SELECTION: &str = const HELP_STOPPED_NO_SELECTION: &str = "<Ctrl w> - close all, <↓↑> - navigate, <Enter> - run from first"; -const HELP_ONE_PENDING_COMMAND: &str = "<Enter> - run, <Ctrl Enter> - add command"; - -const HELP_ALL_COMMANDS_PENDING: &str = - "<Enter> - run, <Ctrl Enter> - add command, <↓↑> - navigate"; -const HELP_ALL_COMMANDS_PENDING_WITH_SELECTION: &str = - "<Enter> - run, <Ctrl Enter> - add command, <↓↑> - navigate, <e> - edit selected"; - -const HELP_EDITING_FIRST_LINE: &str = - "<Enter> - accept, <Ctrl Enter> - add command, <↓↑> - navigate"; +const HELP_PENDING: &str = "<e> - edit, <Enter> - run"; fn select_help_text(sequence: &State) -> &'static str { let is_running = sequence @@ -35,18 +32,9 @@ fn select_help_text(sequence: &State) -> &'static str { .iter() .all(|command| matches!(command.get_status(), CommandStatus::Pending)); let has_selection = sequence.selection.current_selected_command_index.is_some(); - let is_editing = sequence.editing.editing_input.is_some(); if all_pending && !sequence.execution.is_running { - if sequence.execution.all_commands.len() == 1 { - HELP_ONE_PENDING_COMMAND - } else if has_selection && !is_editing { - HELP_ALL_COMMANDS_PENDING_WITH_SELECTION - } else if is_editing { - HELP_EDITING_FIRST_LINE - } else { - HELP_ALL_COMMANDS_PENDING - } + HELP_PENDING } else if is_running { if has_selection { HELP_RUNNING_WITH_SELECTION @@ -54,10 +42,8 @@ fn select_help_text(sequence: &State) -> &'static str { HELP_RUNNING_NO_SELECTION } } else { - if has_selection && !is_editing { + if has_selection { HELP_STOPPED_WITH_SELECTION - } else if is_editing { - HELP_EDITING_FIRST_LINE } else { HELP_STOPPED_NO_SELECTION } @@ -71,7 +57,6 @@ fn style_help_text(text: &str) -> Text { .color_substring(3, "<↓↑>") .color_substring(3, "<Esc>") .color_substring(3, "<Enter>") - .color_substring(3, "<Ctrl Enter>") .color_substring(3, "<e>") } @@ -79,33 +64,6 @@ pub fn render_help_lines( sequence: &State, max_width: Option<usize>, ) -> (Text, usize, Option<(Text, usize)>) { - let is_editing = sequence.editing.editing_input.is_some(); - - if is_editing { - let help_text = select_help_text(sequence); - let (truncated_text, help_len) = if let Some(width) = max_width { - truncate_help_line(help_text, width) - } else { - (help_text.to_string(), help_text.chars().count()) - }; - let first_line = (style_help_text(&truncated_text).unbold_all(), help_len); - - let editing_help_text = "Navigate with cd, chain with ||, &&, ; "; - let editing_help = Text::new(editing_help_text) - .color_substring(3, "cd") - .color_substring(3, "||") - .color_substring(3, "&&") - .color_substring(3, ";") - .unbold_all(); - let editing_len = editing_help_text.len(); - - return ( - first_line.0, - first_line.1, - Some((editing_help, editing_len)), - ); - } - let help_text = select_help_text(sequence); if let Some(width) = max_width { @@ -139,6 +97,38 @@ pub fn render_help_lines( } } +/// Returns `(text, x, y)` tuples for the main screen intro hint. +/// The text is centered horizontally; if it does not fit on one line, two lines +/// are returned separated by one blank line (1-line padding). +pub fn render_intro_hint(rows: usize, cols: usize) -> Vec<(Text, usize, usize)> { + let full_len = INTRO_FULL.chars().count(); + + if full_len <= cols { + let x = cols.saturating_sub(full_len) / 2; + let y = rows / 2; + vec![(Text::new(INTRO_FULL).color_substring(3, "<e>"), x, y)] + } else { + let p1_len = INTRO_PART1.chars().count(); + let p2_len = INTRO_PART2.chars().count(); + let x1 = cols.saturating_sub(p1_len) / 2; + let x2 = cols.saturating_sub(p2_len) / 2; + // 3 display rows: line1, padding, line2 — center the group vertically + let start_y = rows.saturating_sub(3) / 2; + vec![ + ( + Text::new(INTRO_PART1).color_substring(3, "<e>"), + x1, + start_y, + ), + ( + Text::new(INTRO_PART2).color_substring(3, "<e>"), + x2, + start_y + 2, + ), + ] + } +} + fn split_help_line(help_text: &str, max_width: usize) -> Option<(String, String)> { let text_width = help_text.chars().count(); @@ -200,7 +190,7 @@ fn chain_cell(chain_type: &crate::state::ChainType) -> Text { } } -fn get_spinner_frame(frame: usize) -> &'static str { +pub fn get_spinner_frame(frame: usize) -> &'static str { SPINNER_FRAMES[frame % SPINNER_FRAMES.len()] } @@ -371,7 +361,8 @@ pub fn add_command_row( None => return table, }; - let cmd_text = &command.get_text(); + let cmd_text = command.get_text(); + let cmd_text = cmd_text.trim(); let chain_type = &command.get_chain_type(); let status = &command.get_status(); let command_cwd = command.get_cwd().or_else(|| state.cwd.clone()); @@ -398,26 +389,6 @@ pub fn add_command_row( return table; }; - if let Some(text_input) = &state.editing.editing_input { - if state.selection.current_selected_command_index == Some(index) { - let (truncated_editing_text, _) = truncate_middle( - text_input.get_text(), - available_cmd_width, - Some(text_input.cursor_position()), - ); - let (row, _) = command_sequence_row( - state, - &command_cwd, - &truncated_editing_text, - chain_type, - &CommandStatus::Pending, - false, - overflow_indicator, - ); - return table.add_styled_row(row); - } - } - let truncated_cmd_text = truncate_middle(cmd_text, available_cmd_width, None).0; let (row, _) = command_sequence_row( diff --git a/default-plugins/sequence/src/ui/fuzzy_complete.rs b/default-plugins/sequence/src/ui/fuzzy_complete.rs deleted file mode 100644 index 0feae7e4..00000000 --- a/default-plugins/sequence/src/ui/fuzzy_complete.rs +++ /dev/null @@ -1,321 +0,0 @@ -use fuzzy_matcher::skim::SkimMatcherV2; -use fuzzy_matcher::FuzzyMatcher; -use std::collections::BTreeMap; -use std::fs; -use std::path::PathBuf; - -/// Result of a fuzzy completion operation -#[derive(Debug, Clone)] -pub struct CompletionResult { - pub completed_text: String, - pub score: i64, - pub is_directory: bool, - pub is_prefix_completion: bool, // True if this is the shortest of multiple matches with same prefix -} - -/// Type of completion -#[derive(Debug, Clone, Copy, PartialEq)] -pub enum CompletionType { - Command, - Path, -} - -/// Fuzzy match executables (commands) from the PATH -pub fn fuzzy_complete_command( - query: &str, - executables: &BTreeMap<String, PathBuf>, -) -> Option<CompletionResult> { - if query.is_empty() { - return None; - } - - let matcher = SkimMatcherV2::default().ignore_case(); - - // Collect all matches with their scores - let mut all_matches: Vec<(String, i64)> = Vec::new(); - - for executable_name in executables.keys() { - if let Some((score, _indices)) = matcher.fuzzy_indices(executable_name, query) { - all_matches.push((executable_name.clone(), score)); - } - } - - if all_matches.is_empty() { - return None; - } - - // Sort by score (highest first), then by length (shortest first) - all_matches.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.len().cmp(&b.0.len()))); - - // Get the best match - let (best_name, best_score) = &all_matches[0]; - - // Check if there are multiple matches that share the same prefix - let is_prefix_completion = check_for_prefix_matches(&all_matches, best_name); - - Some(CompletionResult { - completed_text: best_name.clone(), - score: *best_score, - is_directory: false, // Commands are not directories - is_prefix_completion, - }) -} - -/// Fuzzy match paths (directories and files) relative to the current working directory -pub fn fuzzy_complete_path(query: &str, cwd: Option<&PathBuf>) -> Option<CompletionResult> { - if query.is_empty() { - return None; - } - - let matcher = SkimMatcherV2::default().ignore_case(); - - // Determine the base directory to search in - let (base_dir, search_prefix) = if query.starts_with('/') { - // Absolute path - let parts: Vec<&str> = query.rsplitn(2, '/').collect(); - if parts.len() == 2 { - // Has a directory component - let dir = parts[1]; - let prefix = parts[0]; - (PathBuf::from(dir), prefix.to_string()) - } else { - // Just "/" - (PathBuf::from("/"), String::new()) - } - } else if query.starts_with("~/") { - // Home directory - let home_dir = std::env::var("HOME").ok()?; - let query_without_tilde = &query[2..]; - let parts: Vec<&str> = query_without_tilde.rsplitn(2, '/').collect(); - if parts.len() == 2 { - let dir = PathBuf::from(&home_dir).join(parts[1]); - let prefix = parts[0]; - (dir, prefix.to_string()) - } else { - // Just "~/" or "~/something" - (PathBuf::from(home_dir), query_without_tilde.to_string()) - } - } else { - // Relative path - let base = cwd?; - let parts: Vec<&str> = query.rsplitn(2, '/').collect(); - if parts.len() == 2 { - // Has a directory component - let dir = base.join(parts[1]); - let prefix = parts[0]; - (dir, prefix.to_string()) - } else { - // Just a name in the current directory - (base.clone(), query.to_string()) - } - }; - - // Convert to host path - let host_base_dir = - PathBuf::from("/host").join(base_dir.strip_prefix("/").unwrap_or(&base_dir)); - - // Read directory entries - let entries = fs::read_dir(&host_base_dir).ok()?; - - // Collect all matches with their scores and directory status - let mut all_matches: Vec<(String, i64, bool)> = Vec::new(); - - for entry in entries.flatten() { - if let Some(name) = entry.file_name().to_str() { - // Skip hidden files unless the query starts with a dot - if name.starts_with('.') && !search_prefix.starts_with('.') { - continue; - } - - if let Some((score, _indices)) = matcher.fuzzy_indices(name, &search_prefix) { - // Check if this entry is a directory - let is_dir = entry.metadata().ok().map(|m| m.is_dir()).unwrap_or(false); - all_matches.push((name.to_string(), score, is_dir)); - } - } - } - - if all_matches.is_empty() { - return None; - } - - // Sort by score (highest first), then by length (shortest first) - all_matches.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.len().cmp(&b.0.len()))); - - // Get the best match - let (name, score, is_directory) = all_matches[0].clone(); - - // Check if there are multiple matches that share the same prefix - let name_matches: Vec<(String, i64)> = all_matches - .iter() - .map(|(n, s, _)| (n.clone(), *s)) - .collect(); - let is_prefix_completion = check_for_prefix_matches(&name_matches, &name); - - // Reconstruct the full path - let completed_text = if query.starts_with('/') { - // Absolute path - let dir_part = query.rsplitn(2, '/').nth(1).unwrap_or(""); - if dir_part.is_empty() { - format!("/{}", name) - } else { - format!("{}/{}", dir_part, name) - } - } else if query.starts_with("~/") { - // Home directory - let query_without_tilde = &query[2..]; - let dir_part = query_without_tilde.rsplitn(2, '/').nth(1).unwrap_or(""); - if dir_part.is_empty() { - format!("~/{}", name) - } else { - format!("~/{}/{}", dir_part, name) - } - } else { - // Relative path - let dir_part = query.rsplitn(2, '/').nth(1).unwrap_or(""); - if dir_part.is_empty() { - name.clone() - } else { - format!("{}/{}", dir_part, name) - } - }; - - Some(CompletionResult { - completed_text, - score, - is_directory, - is_prefix_completion, - }) -} - -/// Check if there are multiple matches that start with the best match's name -/// Returns true if the best match is a prefix of other matches -fn check_for_prefix_matches(matches: &[(String, i64)], best_match: &str) -> bool { - if matches.len() <= 1 { - return false; - } - - // Count how many matches start with the best_match text - let prefix_count = matches - .iter() - .filter(|(name, _)| name.starts_with(best_match) && name != best_match) - .count(); - - prefix_count > 0 -} - -/// Perform fuzzy completion on the current input -/// Returns the best completion (command or path) with appropriate suffix (space or slash) -pub fn fuzzy_complete( - input: &str, - executables: &BTreeMap<String, PathBuf>, - cwd: Option<&PathBuf>, -) -> Option<String> { - // If input is empty, nothing to complete - if input.is_empty() { - return None; - } - - // Extract the last word/token to complete - // For simplicity, we'll complete the entire input if it's a single word, - // or the last space-separated token if there are multiple words - let tokens: Vec<&str> = input.split_whitespace().collect(); - let to_complete = if tokens.is_empty() { - input - } else { - tokens.last().unwrap() - }; - - // Get both command and path completions - let command_result = fuzzy_complete_command(to_complete, executables); - let path_result = fuzzy_complete_path(to_complete, cwd); - - // Choose the better match based on score - match (command_result, path_result) { - (Some(cmd), Some(path)) => { - // Both matched, use the higher score - let (result, completion_type, is_dir, is_prefix) = if cmd.score >= path.score { - ( - cmd.completed_text, - CompletionType::Command, - false, - cmd.is_prefix_completion, - ) - } else { - ( - path.completed_text, - CompletionType::Path, - path.is_directory, - path.is_prefix_completion, - ) - }; - - // Add appropriate suffix (skip slash if this is a prefix completion) - let suffix = get_completion_suffix(completion_type, is_dir, is_prefix); - let completed = format!("{}{}", result, suffix); - - // Replace the last token with the completion - Some(replace_last_token(input, &completed)) - }, - (Some(cmd), None) => { - // Command completion - add space (unless it's a prefix completion) - let suffix = if cmd.is_prefix_completion { "" } else { " " }; - let completed = format!("{}{}", cmd.completed_text, suffix); - Some(replace_last_token(input, &completed)) - }, - (None, Some(path)) => { - // Path completion - add slash if directory (unless it's a prefix completion) - let suffix = if path.is_prefix_completion { - "" - } else if path.is_directory { - "/" - } else { - "" - }; - let completed = format!("{}{}", path.completed_text, suffix); - Some(replace_last_token(input, &completed)) - }, - (None, None) => None, - } -} - -/// Get the appropriate suffix for a completion -fn get_completion_suffix( - completion_type: CompletionType, - is_directory: bool, - is_prefix_completion: bool, -) -> &'static str { - // No suffix for prefix completions - if is_prefix_completion { - return ""; - } - - match completion_type { - CompletionType::Command => " ", - CompletionType::Path => { - if is_directory { - "/" - } else { - "" - } - }, - } -} - -/// Replace the last whitespace-separated token in the input with the completion -fn replace_last_token(input: &str, completion: &str) -> String { - let tokens: Vec<&str> = input.split_whitespace().collect(); - if tokens.is_empty() { - return completion.to_string(); - } - - if tokens.len() == 1 { - completion.to_string() - } else { - // Join all tokens except the last one, then add the completion - let mut result = tokens[..tokens.len() - 1].join(" "); - result.push(' '); - result.push_str(completion); - result - } -} diff --git a/default-plugins/sequence/src/ui/mod.rs b/default-plugins/sequence/src/ui/mod.rs index 6d1ec303..7255fa35 100644 --- a/default-plugins/sequence/src/ui/mod.rs +++ b/default-plugins/sequence/src/ui/mod.rs @@ -1,5 +1,3 @@ pub mod components; -pub mod fuzzy_complete; pub mod layout_calculations; -pub mod text_input; pub mod truncation; 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(), ""); - } -} |
