aboutsummaryrefslogtreecommitdiffstats
path: root/default-plugins/sequence/src/ui
diff options
context:
space:
mode:
authorAram Drevekenin <aram@poor.dev>2026-02-17 15:43:28 +0100
committerGitHub <noreply@github.com>2026-02-17 15:43:28 +0100
commit4e66a0beff5e41bda1dbde521fa1d2858c46fe7b (patch)
tree380bee5eeac56bea2ebf341cccbe69615585e394 /default-plugins/sequence/src/ui
parentFix startup message and cache file creation (#4705) (diff)
downloadsl0ppij-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.rs115
-rw-r--r--default-plugins/sequence/src/ui/fuzzy_complete.rs321
-rw-r--r--default-plugins/sequence/src/ui/mod.rs2
-rw-r--r--default-plugins/sequence/src/ui/text_input.rs997
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(), "");
- }
-}