Command Sequences, Blocking CLI Commands, New Plugin APIs (#4546)

* fix deadlocks

* initial logging to plugin implementation

* only log actions, include keys as Action::Write

* send actions to plugins as a new Event::UserAction

* add Action::Write metadata to plugin API

* run_action plugin command and permission

* block-until-exit/success/failure in the cli

* fix tests

* add NewBlockingPane to the plugin API

* run actions in a different thread so as not to block plugin execution

* add custom context to run_action flow

* fixes for floating pane z_index sorting and unknown protobuf events not crashing plugins

* always render the frame of pinned panes

* macros module

* add macros to config

* KDL macro parsing

* thread macros to the plugin thread

* get/update/delete macros from plugins

* add macros updated event

* add ability to rename macros

* fix deadlock

* fix z_indices with pinned unselectable panes bug

* allow plugins to opt-in to show_cursor

* change show_cursor(bool) to show_cursor(Option<coordinates>)

* test

* upgrade strum to allow proper enum iteration and case insensitive fromstr implementation for Actions

* better cwd tracking for exited command panes

* add initial panes to NewTab Action (no-op for now)

* add initial panes to cli newtab action

* make initial panes work

* add close_on_exit and start_suspended to the cli

* properly convert newtab action (potentially breaking for plugins)

* allow first pane in tab to block

* make distinction between opening panes with the last active client and the current pane in the cli

* improve some apis

* fix fixed size closing pane bug

* fix plugin cursor coordinates

* fix issues with launch-or-focus-plugin having to do with in-place and suppressed panes

* fix: set affected pane in replace pane

* allow setting a pane in place when it is suppressed and being focused (tests failing)

* fix tests

* some fixes for unsuppression of panes

* some fixes for completion_tx in new tab action

* allow replace/suppressed rather than replace/close only

* fixes for inserting suppressed panes without conflicts

* pane focus history and explicit unsuppress plugin api

* fix: prefer explicitly provided pane id in placement in actions that provide it

* fix floating pane coordinate change and z_index

* fix plugin cache dir to persist between sessions

* fix some stack focusing issues when adding panes without a client id

* make sure pinned panes are always above non-pinned floating panes

* allow plugins to copy to clipboard

* initial text styling additions implementations - buggy

* fix dimming issues

* remove extras, stay with just dim and unbold

* unbold and dim as indices

* allow ui components to render success/error colors

* allow plugins to send sigint/sigkill to processes, as well as receive their pid

* ui component fixes and send originating plugin context with run_action

* originating plugin for new tab too

* make plugn show pane command also expand stacked panes

* add sequence plugin

* remove macros

* remove snapshots

* add shortcut for sequence plugin

* fix plugin tests

* plugin tests for new apis

* update snapshots for sequence shortcut

* cleanups and formatting

* Event::CwdChanged

* moar cleanups

* remove some warnings

* remove mroe warnings

* remove dead code

* remove more dead code

* fix minor bugs in sequence plugin

* update sequence help and format codebase

* fix conditional blocking cli commands

* fix initial-command new-tab cli flag

* add client_ids to CwdChanged Event

* last fixes

* add pr
This commit is contained in:
Aram Drevekenin
2025-12-11 14:25:42 +01:00
committed by GitHub
parent 7c8945fbf3
commit 693fa082de
201 changed files with 12403 additions and 890 deletions
+5
View File
@@ -0,0 +1,5 @@
pub mod path_formatting;
pub mod state;
pub mod ui;
pub use state::State;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,71 @@
use std::path::PathBuf;
/// Expand a path string to an absolute PathBuf
/// Handles ~, ./, .., relative and absolute paths
pub fn expand_path(path_str: &str, current_cwd: Option<&PathBuf>) -> Option<PathBuf> {
let expanded = if path_str.starts_with("~/") || path_str == "~" {
let home_dir = std::env::var("HOME").ok()?;
if path_str == "~" {
PathBuf::from(home_dir)
} else {
PathBuf::from(home_dir).join(&path_str[2..])
}
} else if path_str.starts_with('/') {
PathBuf::from(path_str)
} else {
let base = current_cwd?;
base.join(path_str)
};
let mut normalized = PathBuf::new();
for component in expanded.components() {
match component {
std::path::Component::ParentDir => {
normalized.pop();
},
std::path::Component::CurDir => {},
_ => {
normalized.push(component);
},
}
}
Some(normalized)
}
/// Format a path for display in the prompt
/// Example: /home/user/some/long/path -> ~/s/l/path
pub fn format_cwd(cwd: &PathBuf) -> String {
let path_str = cwd.to_string_lossy().to_string();
let home_dir = std::env::var("HOME").unwrap_or_default();
let path_str = if !home_dir.is_empty() && path_str.starts_with(&home_dir) {
path_str.replacen(&home_dir, "~", 1)
} else {
path_str
};
let parts: Vec<&str> = path_str.split('/').collect();
if parts.len() <= 1 {
return path_str;
}
let mut formatted_parts = Vec::new();
for (i, part) in parts.iter().enumerate() {
if i == parts.len() - 1 {
formatted_parts.push(part.to_string());
} else if part.is_empty() {
formatted_parts.push(String::new());
} else {
formatted_parts.push(part.chars().next().unwrap_or_default().to_string());
}
}
formatted_parts.join("/")
}
/// Alias for expand_path for backwards compatibility
pub fn resolve_path(current_cwd: Option<&PathBuf>, path_str: &str) -> Option<PathBuf> {
expand_path(path_str, current_cwd)
}
@@ -0,0 +1,45 @@
use serde::{Deserialize, Serialize};
use zellij_tile::prelude::*;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum ChainType {
And,
Or,
Then,
None,
}
impl Default for ChainType {
fn default() -> Self {
ChainType::None
}
}
impl ChainType {
pub fn to_unblock_condition(&self) -> Option<UnblockCondition> {
match self {
ChainType::And => Some(UnblockCondition::OnExitSuccess),
ChainType::Or => Some(UnblockCondition::OnExitFailure),
ChainType::Then => Some(UnblockCondition::OnAnyExit),
ChainType::None => None,
}
}
pub fn as_str(&self) -> &str {
match self {
ChainType::And => "&&",
ChainType::Or => "||",
ChainType::Then => ";",
ChainType::None => "",
}
}
pub fn cycle_next(&mut self) {
*self = match self {
ChainType::And => ChainType::Or,
ChainType::Or => ChainType::Then,
ChainType::Then => ChainType::And,
ChainType::None => ChainType::And,
};
}
}
@@ -0,0 +1,86 @@
use super::{ChainType, CommandStatus};
use std::path::PathBuf;
use std::time::Instant;
use zellij_tile::prelude::PaneId;
#[derive(Debug, Clone)]
pub struct CommandEntry {
text: String,
cwd: Option<PathBuf>,
pub(super) chain_type: ChainType,
pub(super) status: CommandStatus,
pub(super) start_time: std::time::Instant,
}
impl Default for CommandEntry {
fn default() -> Self {
CommandEntry {
text: String::default(),
cwd: None,
chain_type: ChainType::default(),
status: CommandStatus::default(),
start_time: Instant::now(),
}
}
}
impl CommandEntry {
pub fn new(text: &str, cwd: Option<PathBuf>) -> Self {
CommandEntry {
text: text.to_owned(),
cwd,
..Default::default()
}
}
pub fn with_and(mut self) -> Self {
self.chain_type = ChainType::And;
self
}
pub fn get_text(&self) -> String {
self.text.clone()
}
pub fn set_text(&mut self, text: String) {
self.text = text;
}
pub fn clear_text(&mut self) {
self.text.clear();
}
pub fn get_chain_type(&self) -> ChainType {
self.chain_type
}
pub fn set_chain_type(&mut self, chain_type: ChainType) {
self.chain_type = chain_type;
}
pub fn get_status(&self) -> CommandStatus {
self.status.clone()
}
pub fn set_status(&mut self, status: CommandStatus) {
self.status = status;
}
pub fn get_pane_id(&self) -> Option<PaneId> {
self.status.get_pane_id()
}
pub fn is_empty(&self) -> bool {
self.text.trim().is_empty()
}
pub fn fill_chain_type_if_empty(&mut self) {
if let ChainType::None = self.chain_type {
self.chain_type = ChainType::And;
}
}
pub fn clear_chain_type(&mut self) {
self.chain_type = ChainType::None;
}
pub fn clear_status(&mut self) {
self.status = CommandStatus::Pending;
}
pub fn cycle_chain_type(&mut self) {
self.chain_type.cycle_next();
}
pub fn get_cwd(&self) -> Option<PathBuf> {
self.cwd.clone()
}
pub fn set_cwd(&mut self, cwd: Option<PathBuf>) {
self.cwd = cwd;
}
}
@@ -0,0 +1,327 @@
use super::ChainType;
#[derive(Debug, Clone, Copy, PartialEq)]
enum ParseState {
Normal,
InSingleQuote,
InDoubleQuote,
Escaped(EscapeContext),
}
#[derive(Debug, Clone, Copy, PartialEq)]
enum EscapeContext {
Normal,
DoubleQuote,
}
pub fn split_by_chain_operators(text: &str) -> Vec<(String, Option<ChainType>)> {
let mut segments = Vec::new();
let mut current_segment = String::new();
let mut state = ParseState::Normal;
let chars: Vec<char> = text.chars().collect();
let mut i = 0;
while i < chars.len() {
let ch = chars[i];
match state {
ParseState::Normal => {
if ch == '\\' {
state = ParseState::Escaped(EscapeContext::Normal);
current_segment.push(ch);
i += 1;
} else if ch == '\'' {
state = ParseState::InSingleQuote;
current_segment.push(ch);
i += 1;
} else if ch == '"' {
state = ParseState::InDoubleQuote;
current_segment.push(ch);
i += 1;
} else if ch == '&' && i + 1 < chars.len() && chars[i + 1] == '&' {
let segment_text = current_segment.trim().to_string();
if !segment_text.is_empty() {
segments.push((segment_text, Some(ChainType::And)));
}
current_segment.clear();
i += 2;
} else if ch == '|' && i + 1 < chars.len() && chars[i + 1] == '|' {
let segment_text = current_segment.trim().to_string();
if !segment_text.is_empty() {
segments.push((segment_text, Some(ChainType::Or)));
}
current_segment.clear();
i += 2;
} else if ch == ';' {
let segment_text = current_segment.trim().to_string();
if !segment_text.is_empty() {
segments.push((segment_text, Some(ChainType::Then)));
}
current_segment.clear();
i += 1;
} else {
current_segment.push(ch);
i += 1;
}
},
ParseState::InSingleQuote => {
current_segment.push(ch);
if ch == '\'' {
state = ParseState::Normal;
}
i += 1;
},
ParseState::InDoubleQuote => {
if ch == '\\' {
state = ParseState::Escaped(EscapeContext::DoubleQuote);
current_segment.push(ch);
i += 1;
} else if ch == '"' {
current_segment.push(ch);
state = ParseState::Normal;
i += 1;
} else {
current_segment.push(ch);
i += 1;
}
},
ParseState::Escaped(context) => {
current_segment.push(ch);
state = match context {
EscapeContext::Normal => ParseState::Normal,
EscapeContext::DoubleQuote => ParseState::InDoubleQuote,
};
i += 1;
},
}
}
let final_segment = current_segment.trim().to_string();
if !final_segment.is_empty() {
segments.push((final_segment, None));
}
segments
}
pub fn detect_chain_operator_at_end(text: &str) -> Option<(String, ChainType)> {
let segments = split_by_chain_operators(text);
if segments.is_empty() {
return None;
}
if segments.len() == 1 {
if let Some((text, Some(chain_type))) = segments.first() {
return Some((text.clone(), *chain_type));
}
return None;
}
if let Some((first_text, Some(chain_type))) = segments.first() {
return Some((first_text.clone(), *chain_type));
}
None
}
pub fn get_remaining_after_first_segment(text: &str) -> Option<String> {
let segments = split_by_chain_operators(text);
if segments.len() <= 1 {
return None;
}
let mut result = String::new();
for (i, (segment_text, chain_type_opt)) in segments.iter().enumerate().skip(1) {
if i > 1 {
if let Some(chain_type) = chain_type_opt {
result.push_str(&format!(" {} ", chain_type.as_str()));
}
}
result.push_str(segment_text);
if let Some(chain_type) = chain_type_opt {
result.push_str(&format!(" {} ", chain_type.as_str()));
}
}
Some(result.trim().to_string())
}
pub fn detect_cd_command(text: &str) -> Option<String> {
let trimmed = text.trim();
if trimmed == "cd" {
return Some("~".to_string());
}
if trimmed.starts_with("cd ") {
let path = trimmed[3..].trim();
return Some(path.to_string());
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_single_command_no_operator() {
let result = split_by_chain_operators("ls");
assert_eq!(result, vec![("ls".to_string(), None)]);
}
#[test]
fn test_two_commands_with_and() {
let result = split_by_chain_operators("ls && pwd");
assert_eq!(
result,
vec![
("ls".to_string(), Some(ChainType::And)),
("pwd".to_string(), None)
]
);
}
#[test]
fn test_two_commands_with_or() {
let result = split_by_chain_operators("cmd1 || cmd2");
assert_eq!(
result,
vec![
("cmd1".to_string(), Some(ChainType::Or)),
("cmd2".to_string(), None)
]
);
}
#[test]
fn test_two_commands_with_semicolon() {
let result = split_by_chain_operators("echo hi ; ls");
assert_eq!(
result,
vec![
("echo hi".to_string(), Some(ChainType::Then)),
("ls".to_string(), None)
]
);
}
#[test]
fn test_three_commands_mixed() {
let result = split_by_chain_operators("a && b || c");
assert_eq!(
result,
vec![
("a".to_string(), Some(ChainType::And)),
("b".to_string(), Some(ChainType::Or)),
("c".to_string(), None)
]
);
}
#[test]
fn test_operator_in_single_quotes() {
let result = split_by_chain_operators("echo '&&' && ls");
assert_eq!(
result,
vec![
("echo '&&'".to_string(), Some(ChainType::And)),
("ls".to_string(), None)
]
);
}
#[test]
fn test_operator_in_double_quotes() {
let result = split_by_chain_operators("echo \"||\" || pwd");
assert_eq!(
result,
vec![
("echo \"||\"".to_string(), Some(ChainType::Or)),
("pwd".to_string(), None)
]
);
}
#[test]
fn test_escaped_operator() {
let result = split_by_chain_operators("echo \\&& test");
assert_eq!(result, vec![("echo \\&& test".to_string(), None)]);
}
#[test]
fn test_complex_quoting() {
let result = split_by_chain_operators("cmd1 && echo \"a || b\" && cmd2");
assert_eq!(
result,
vec![
("cmd1".to_string(), Some(ChainType::And)),
("echo \"a || b\"".to_string(), Some(ChainType::And)),
("cmd2".to_string(), None)
]
);
}
#[test]
fn test_empty_segments_filtered() {
let result = split_by_chain_operators("cmd1 && && cmd2");
assert_eq!(
result,
vec![
("cmd1".to_string(), Some(ChainType::And)),
("cmd2".to_string(), None)
]
);
}
#[test]
fn test_detect_operator_just_typed_and() {
let result = detect_chain_operator_at_end("ls &&");
assert_eq!(result, Some(("ls".to_string(), ChainType::And)));
}
#[test]
fn test_detect_operator_just_typed_or() {
let result = detect_chain_operator_at_end("cmd ||");
assert_eq!(result, Some(("cmd".to_string(), ChainType::Or)));
}
#[test]
fn test_detect_operator_just_typed_semicolon() {
let result = detect_chain_operator_at_end("echo ;");
assert_eq!(result, Some(("echo".to_string(), ChainType::Then)));
}
#[test]
fn test_detect_operator_no_operator() {
let result = detect_chain_operator_at_end("ls");
assert_eq!(result, None);
}
#[test]
fn test_detect_operator_in_quotes() {
let result = detect_chain_operator_at_end("echo '&&'");
assert_eq!(result, None);
}
#[test]
fn test_whitespace_trimming() {
let result = split_by_chain_operators(" cmd1 && cmd2 ");
assert_eq!(
result,
vec![
("cmd1".to_string(), Some(ChainType::And)),
("cmd2".to_string(), None)
]
);
}
#[test]
fn test_trailing_operator_with_space() {
let result = split_by_chain_operators("ls && ");
assert_eq!(result, vec![("ls".to_string(), Some(ChainType::And))]);
}
}
@@ -0,0 +1,26 @@
use zellij_tile::prelude::*;
#[derive(Debug, Clone)]
pub enum CommandStatus {
Exited(Option<i32>, Option<PaneId>),
Running(Option<PaneId>),
Pending,
Interrupted(Option<PaneId>),
}
impl CommandStatus {
pub fn get_pane_id(&self) -> Option<PaneId> {
match self {
CommandStatus::Exited(_, pane_id) => *pane_id,
CommandStatus::Running(pane_id) => *pane_id,
CommandStatus::Pending => None,
CommandStatus::Interrupted(pane_id) => *pane_id,
}
}
}
impl Default for CommandStatus {
fn default() -> Self {
CommandStatus::Pending
}
}
@@ -0,0 +1,80 @@
use crate::path_formatting;
use crate::state::CommandEntry;
use crate::ui::text_input::TextInput;
use std::path::PathBuf;
pub struct Editing {
pub editing_input: Option<TextInput>,
}
impl Editing {
pub fn new() -> Self {
Self {
editing_input: Some(TextInput::new("".to_owned())),
}
}
pub fn start_editing(&mut self, text: String) {
self.editing_input = Some(TextInput::new(text));
}
pub fn cancel_editing(&mut self) {
self.editing_input = None;
}
pub fn input_text(&self) -> Option<String> {
self.editing_input.as_ref().map(|e| e.get_text().to_owned())
}
pub fn set_input_text(&mut self, text: String) {
self.editing_input.as_mut().map(|e| e.set_text(text));
}
pub fn handle_submit(
&mut self,
selection_index: Option<usize>,
all_commands: &mut Vec<CommandEntry>,
current_cwd: &Option<PathBuf>,
) -> (bool, Option<usize>) {
let mut handled_internally = false;
let mut new_selection_index = None;
if let Some(current_text) = self.editing_input.as_ref().map(|i| i.get_text()) {
if let Some(index) = selection_index {
if let Some(command) = all_commands.get_mut(index) {
if current_text.starts_with("cd ") || current_text == "cd" {
let path = if current_text == "cd" {
"~"
} else {
current_text[3..].trim()
};
if let Some(new_cwd) =
path_formatting::resolve_path(current_cwd.as_ref(), path)
{
command.set_cwd(Some(new_cwd));
}
command.set_text("".to_owned());
self.editing_input.as_mut().map(|c| c.clear());
new_selection_index = selection_index; // remain editing after a cd command
handled_internally = true;
return (handled_internally, new_selection_index); // avoid setting edit_input to None below, we
// still want to be in editing mode after
// changing directory with cd
} else {
command.set_text(current_text.to_owned());
}
}
}
}
self.editing_input = None;
(handled_internally, new_selection_index)
}
}
impl Default for Editing {
fn default() -> Self {
Self::new()
}
}
@@ -0,0 +1,265 @@
use crate::path_formatting::format_cwd;
use crate::state::{ChainType, CommandEntry, CommandStatus};
use std::collections::BTreeMap;
use std::path::PathBuf;
use std::time::Duration;
use zellij_tile::prelude::*;
pub struct Execution {
pub all_commands: Vec<CommandEntry>,
pub current_running_command_index: usize,
pub is_running: bool,
pub sequence_id: u64,
pub primary_pane_id_before_sequence: Option<PaneId>,
}
impl Execution {
pub fn new() -> Self {
Self {
all_commands: vec![CommandEntry::default()],
current_running_command_index: 0,
is_running: false,
sequence_id: 0,
primary_pane_id_before_sequence: None,
}
}
pub fn longest_cwd_display(&self, global_cwd: &Option<PathBuf>) -> String {
self.all_commands
.iter()
.map(|cmd| {
let cwd = cmd.get_cwd().or_else(|| global_cwd.clone());
if let Some(cwd) = &cwd {
format_cwd(cwd)
} else {
"~".to_string()
}
})
.max_by_key(|s| s.len())
.unwrap_or_else(|| "~".to_string())
}
pub fn remove_empty_commands(&mut self) {
if self.all_commands.iter().len() > 1 {
self.all_commands.retain(|c| !c.get_text().is_empty());
}
}
pub fn get_first_command(&self) -> Option<CommandEntry> {
self.all_commands.iter().next().cloned()
}
pub fn set_command_status(&mut self, command_index: usize, status: CommandStatus) {
self.all_commands
.get_mut(command_index)
.map(|c| c.set_status(status));
}
pub fn set_current_running_command_status(&mut self, status: CommandStatus) {
self.all_commands
.get_mut(self.current_running_command_index)
.map(|c| c.set_status(status));
}
pub fn get_current_running_command_status(&self) -> Option<CommandStatus> {
self.all_commands
.get(self.current_running_command_index)
.map(|c| c.get_status())
}
pub fn can_run_sequence(&self) -> bool {
self.all_commands.iter().any(|command| !command.is_empty())
}
pub fn copy_to_clipboard(&self) {
let text_to_copy = self
.all_commands
.iter()
.map(|c| format!("{}", c.get_text()))
.collect::<Vec<_>>()
.join("\n");
if !text_to_copy.is_empty() {
copy_to_clipboard(text_to_copy);
}
}
pub fn update_pane_id_for_command(&mut self, pane_id: PaneId, command_text: &str) {
for command in self.all_commands.iter_mut() {
if let CommandStatus::Pending | CommandStatus::Running(None) = command.get_status() {
let cmd_text = command.get_text();
if cmd_text == command_text {
command.set_status(CommandStatus::Running(Some(pane_id)));
break;
}
}
}
}
pub fn update_exited_command_statuses(&mut self, pane_manifest: &PaneManifest) -> bool {
let mut updated = false;
for command in self.all_commands.iter_mut() {
let status = command.get_status();
let pane_id_opt = match &status {
CommandStatus::Running(pid) => *pid,
CommandStatus::Exited(_, pid) => *pid,
CommandStatus::Interrupted(pid) => *pid,
_ => None,
};
if let Some(pane_id) = pane_id_opt {
let mut pane_found = false;
for (_tab_index, panes) in &pane_manifest.panes {
for pane_info in panes {
let pane_matches = match pane_id {
PaneId::Terminal(id) => !pane_info.is_plugin && pane_info.id == id,
PaneId::Plugin(id) => pane_info.is_plugin && pane_info.id == id,
};
if pane_matches {
pane_found = true;
if pane_info.exited {
command.set_status(CommandStatus::Exited(
pane_info.exit_status,
Some(pane_id),
));
updated = true;
}
break;
}
}
if pane_found {
break;
}
}
if !pane_found && command.start_time.elapsed() > Duration::from_millis(400) {
match command.get_status() {
CommandStatus::Running(_) => {
eprintln!(
"Pane {:?} was closed while running, setting pane_id to None",
pane_id
);
command.set_status(CommandStatus::Running(None));
updated = true;
},
CommandStatus::Exited(exit_code, _) => {
eprintln!(
"Pane {:?} was closed after exiting, setting pane_id to None",
pane_id
);
command.set_status(CommandStatus::Exited(exit_code, None));
updated = true;
},
CommandStatus::Interrupted(_) => {
eprintln!("Pane {:?} was closed after being interrupted, setting pane_id to None", pane_id);
command.set_status(CommandStatus::Interrupted(None));
updated = true;
},
_ => {},
}
}
}
}
updated
}
pub fn update_sequence_stopped_state(&mut self) -> bool {
let mut needs_rerender = false;
if self.is_running {
let current_idx = self.current_running_command_index;
if let Some(command) = self.all_commands.get_mut(current_idx) {
if let CommandStatus::Exited(exit_code, _) = command.get_status() {
let should_stop;
if current_idx >= self.all_commands.len().saturating_sub(1) {
should_stop = true;
} else {
if let Some(chain_type) = &self
.all_commands
.get(current_idx)
.map(|c| c.get_chain_type())
{
match chain_type {
ChainType::And => {
should_stop = exit_code.unwrap_or(0) != 0;
},
ChainType::Or => {
should_stop = exit_code.unwrap_or(0) == 0;
},
ChainType::Then => {
should_stop = false;
},
ChainType::None => {
should_stop = true;
},
}
} else {
should_stop = true;
}
};
if should_stop {
self.is_running = false;
}
}
}
needs_rerender = true;
}
needs_rerender
}
pub fn execute_command_sequence(
&mut self,
shell: &Option<PathBuf>,
global_cwd: &Option<PathBuf>,
primary_pane_id: Option<PaneId>,
) {
use zellij_tile::prelude::actions::{Action, RunCommandAction};
self.all_commands.retain(|c| !c.is_empty());
let Some(first_active_sequence_command) = self.get_first_command() else {
return;
};
let shell = shell.clone().unwrap_or_else(|| PathBuf::from("/bin/bash"));
let first_command = first_active_sequence_command.get_text();
let first_chain_type = first_active_sequence_command.get_chain_type();
let command_cwd = first_active_sequence_command
.get_cwd()
.or_else(|| global_cwd.clone());
let command = RunCommandAction {
command: shell.clone(),
args: vec!["-ic".to_string(), first_command.trim().to_string()],
cwd: command_cwd,
hold_on_close: true,
..Default::default()
};
let placement = NewPanePlacement::InPlace {
pane_id_to_replace: primary_pane_id,
close_replaced_pane: false,
};
let action = Action::NewBlockingPane {
placement,
command: Some(command),
pane_name: Some(first_command.trim().to_string()),
unblock_condition: first_chain_type.to_unblock_condition(),
near_current_pane: true,
};
self.sequence_id += 1;
let mut context = BTreeMap::new();
context.insert("sequence_id".to_string(), self.sequence_id.to_string());
run_action(action, context);
self.set_command_status(0, CommandStatus::Running(None));
}
}
impl Default for Execution {
fn default() -> Self {
Self::new()
}
}
@@ -0,0 +1,73 @@
use crate::state::positioning;
use std::path::PathBuf;
use zellij_tile::prelude::*;
pub struct Layout {
pub needs_reposition: bool,
pub cached_cursor_position: Option<(usize, usize)>,
pub spinner_frame: usize,
pub spinner_timer_scheduled: bool,
}
impl Layout {
pub fn new() -> Self {
Self {
needs_reposition: false,
cached_cursor_position: None,
spinner_frame: 0,
spinner_timer_scheduled: false,
}
}
pub fn reposition_plugin_based_on_sequence_state(
&mut self,
is_running: bool,
first_command_pane_id: Option<PaneId>,
pane_manifest: &PaneManifest,
plugin_id: Option<u32>,
total_viewport_columns: Option<usize>,
total_viewport_rows: Option<usize>,
total_commands: usize,
_selected_index: Option<usize>,
_running_index: usize,
_cwd: &Option<PathBuf>,
) {
let Some(plugin_id) = plugin_id else {
return;
};
let (Some(total_cols), Some(total_rows)) = (total_viewport_columns, total_viewport_rows)
else {
return;
};
if !self.needs_reposition || !is_running {
return;
}
let Some(first_pane_id) = first_command_pane_id else {
return;
};
for (_tab_index, panes) in &pane_manifest.panes {
for pane in panes {
if !pane.is_plugin && PaneId::Terminal(pane.id) == first_pane_id {
positioning::reposition_plugin_for_sequence(
plugin_id,
total_cols,
total_rows,
total_commands,
);
self.needs_reposition = false;
return;
}
}
}
}
}
impl Default for Layout {
fn default() -> Self {
Self::new()
}
}
+446
View File
@@ -0,0 +1,446 @@
mod chain_type;
mod command_entry;
mod command_parser;
mod command_status;
mod editing;
mod execution;
mod layout;
pub mod positioning;
mod selection;
pub use chain_type::ChainType;
pub use command_entry::CommandEntry;
pub use command_parser::{
detect_cd_command, detect_chain_operator_at_end, get_remaining_after_first_segment,
split_by_chain_operators,
};
pub use command_status::CommandStatus;
pub use editing::Editing;
pub use execution::Execution;
pub use layout::Layout;
pub use selection::Selection;
use crate::ui::text_input::TextInput;
use std::path::PathBuf;
use zellij_tile::prelude::*;
pub struct State {
pub shell: Option<PathBuf>,
pub plugin_id: Option<u32>,
pub client_id: Option<u16>,
pub own_rows: Option<usize>,
pub own_columns: Option<usize>,
pub cwd: Option<PathBuf>,
pub original_pane_id: Option<PaneId>,
pub primary_pane_id: Option<PaneId>,
pub is_first_run: bool,
pub pane_manifest: Option<PaneManifest>,
pub total_viewport_columns: Option<usize>,
pub total_viewport_rows: Option<usize>,
pub selection: Selection,
pub editing: Editing,
pub execution: Execution,
pub layout: Layout,
pub current_position: Option<FloatingPaneCoordinates>,
}
impl State {
pub fn new() -> Self {
Self {
shell: None,
plugin_id: None,
client_id: None,
own_rows: None,
own_columns: None,
cwd: None,
original_pane_id: None,
primary_pane_id: None,
is_first_run: true,
pane_manifest: None,
total_viewport_columns: None,
total_viewport_rows: None,
selection: Selection::new(),
editing: Editing::new(),
execution: Execution::new(),
layout: Layout::new(),
current_position: None,
}
}
pub fn set_plugin_id(&mut self, plugin_id: u32) {
self.plugin_id = Some(plugin_id);
}
/// Check if the sequence has finished executing (all commands are done)
pub fn has_finished(&self) -> bool {
self.execution
.all_commands
.iter()
.all(|command| matches!(command.status, CommandStatus::Exited(_, _)))
}
pub fn add_empty_command_after_current_selected(&mut self) {
self.selection.add_empty_command_after_current_selected(
&mut self.execution.all_commands,
&mut self.editing,
&self.cwd,
);
}
pub fn current_selected_command_is_empty(&self) -> bool {
self.selection
.current_selected_command_is_empty(&self.execution.all_commands, &self.editing)
}
pub fn remove_current_selected_command(&mut self) {
let removed_pane_id = self
.selection
.remove_current_selected_command(&mut self.execution.all_commands, &mut self.editing);
if let Some(removed_pane_id) = removed_pane_id {
close_pane_with_id(removed_pane_id);
}
}
pub fn clear_current_selected_command(&mut self) {
self.selection
.clear_current_selected_command(&mut self.execution.all_commands, &mut self.editing);
}
pub fn move_selection_up(&mut self) {
self.selection
.move_up(&mut self.execution.all_commands, &mut self.editing);
if let Some(pane_id) = self
.current_selected_command()
.and_then(|c| c.get_pane_id())
{
show_pane_with_id(pane_id, true, false);
}
}
pub fn move_selection_down(&mut self) {
self.selection
.move_down(&mut self.execution.all_commands, &mut self.editing);
if let Some(pane_id) = self
.current_selected_command()
.and_then(|c| c.get_pane_id())
{
show_pane_with_id(pane_id, true, false);
}
}
pub fn start_editing_selected(&mut self) {
if let Some(current_command_text) = self.current_selected_command().map(|c| c.get_text()) {
self.editing.start_editing(current_command_text);
}
}
pub fn cancel_editing_selected(&mut self) {
self.editing.cancel_editing();
}
pub fn editing_input_text(&self) -> Option<String> {
self.editing.input_text()
}
pub fn set_editing_input_text(&mut self, text: String) {
self.editing.set_input_text(text);
}
fn handle_first_pasted_segment(
&mut self,
current_text: &str,
segment_text: &str,
chain_type_opt: &Option<ChainType>,
) {
let new_text = if current_text.trim().is_empty() {
segment_text.to_string()
} else {
format!("{}{}", current_text, segment_text)
};
if let Some(path) = detect_cd_command(&new_text) {
use crate::path_formatting;
if let Some(new_cwd) = path_formatting::resolve_path(self.cwd.as_ref(), &path) {
self.current_selected_command_mut().map(|c| {
c.set_cwd(Some(new_cwd));
c.set_text("".to_owned());
if let Some(chain_type) = chain_type_opt {
c.set_chain_type(*chain_type);
}
});
return;
}
}
self.current_selected_command_mut().map(|c| {
c.set_text(new_text);
if let Some(chain_type) = chain_type_opt {
c.set_chain_type(*chain_type);
}
});
}
fn insert_new_pasted_segment(
&mut self,
segment_text: &str,
chain_type_opt: &Option<ChainType>,
is_last_line: bool,
) {
use crate::path_formatting;
let cd_path = detect_cd_command(segment_text);
if let Some(path) = cd_path {
if let Some(new_cwd) = path_formatting::resolve_path(self.cwd.as_ref(), &path) {
self.current_selected_command_mut().map(|c| {
c.set_cwd(Some(new_cwd));
if let Some(chain_type) = chain_type_opt {
c.set_chain_type(*chain_type);
}
});
return;
}
}
let Some(new_selected_index) = self.selection.current_selected_command_index.map(|i| i + 1)
else {
return;
};
let mut new_command = CommandEntry::new(segment_text, self.cwd.clone());
if let Some(chain_type) = chain_type_opt {
new_command.set_chain_type(*chain_type);
} else if !is_last_line {
new_command.set_chain_type(ChainType::And);
}
self.execution
.all_commands
.insert(new_selected_index, new_command);
self.selection.current_selected_command_index = Some(new_selected_index);
}
fn ensure_line_end_chain_type(&mut self) {
if let Some(last_cmd_index) = self.selection.current_selected_command_index {
self.execution
.all_commands
.get_mut(last_cmd_index)
.map(|c| {
if matches!(c.get_chain_type(), ChainType::None) {
c.set_chain_type(ChainType::And);
}
});
}
}
pub fn pasted_lines(&mut self, lines: Vec<&str>) {
let Some(current_text) = self.editing_input_text() else {
return;
};
for (line_index, line) in lines.iter().enumerate() {
let segments = split_by_chain_operators(line);
let is_last_line = line_index == lines.len().saturating_sub(1);
for (seg_index, (segment_text, chain_type_opt)) in segments.iter().enumerate() {
if line_index == 0 && seg_index == 0 {
self.handle_first_pasted_segment(&current_text, segment_text, chain_type_opt);
} else {
self.insert_new_pasted_segment(segment_text, chain_type_opt, is_last_line);
}
}
if !is_last_line {
self.ensure_line_end_chain_type();
}
}
self.start_editing_selected();
}
pub fn remove_empty_commands(&mut self) {
self.execution.remove_empty_commands();
}
pub fn get_first_command(&self) -> Option<CommandEntry> {
self.execution.get_first_command()
}
pub fn set_command_status(&mut self, command_index: usize, status: CommandStatus) {
self.execution.set_command_status(command_index, status);
}
pub fn set_current_running_command_status(&mut self, status: CommandStatus) {
self.execution.set_current_running_command_status(status);
}
pub fn get_current_running_command_status(&mut self) -> Option<CommandStatus> {
self.execution.get_current_running_command_status()
}
pub fn handle_editing_submit(&mut self, current_cwd: &Option<PathBuf>) -> bool {
let (handled_internally, new_selection_index) = self.editing.handle_submit(
self.selection.current_selected_command_index,
&mut self.execution.all_commands,
current_cwd,
);
self.selection.current_selected_command_index = new_selection_index;
handled_internally
}
pub fn update_pane_id_for_command(&mut self, pane_id: PaneId, command_text: &str) {
self.execution
.update_pane_id_for_command(pane_id, command_text);
}
pub fn update_exited_command_statuses(&mut self, pane_manifest: &PaneManifest) -> bool {
self.execution.update_exited_command_statuses(pane_manifest)
}
pub fn update_sequence_stopped_state(&mut self) -> bool {
self.execution.update_sequence_stopped_state()
}
pub fn cycle_chain_type(&mut self) {
self.current_selected_command_mut()
.map(|c| c.cycle_chain_type());
}
pub fn set_primary_pane_id_before_sequence(&mut self, pane_id: Option<PaneId>) {
if pane_id.is_some() {
self.execution.primary_pane_id_before_sequence = pane_id;
}
}
pub fn clear_all_commands(&mut self) {
self.execution.all_commands = vec![CommandEntry::new("", self.cwd.clone())];
self.editing.editing_input = Some(TextInput::new("".to_owned()));
self.selection.current_selected_command_index = Some(0);
self.execution.current_running_command_index = 0;
}
pub fn update_running_state(&mut self, primary_pane_id: Option<PaneId>) {
self.remove_empty_commands();
self.set_primary_pane_id_before_sequence(primary_pane_id);
self.layout.needs_reposition = true;
self.execution.is_running = true;
}
pub fn can_run_sequence(&self) -> bool {
self.execution.can_run_sequence()
}
pub fn copy_to_clipboard(&mut self) {
self.execution.copy_to_clipboard();
}
pub fn current_selected_command_mut(&mut self) -> Option<&mut CommandEntry> {
let Some(i) = self.selection.current_selected_command_index else {
return None;
};
self.execution.all_commands.get_mut(i)
}
pub fn reposition_plugin(&mut self) -> bool {
let mut repositioned = false;
let Some(plugin_id) = self.plugin_id else {
return repositioned;
};
let Some(total_viewport_rows) = self.total_viewport_rows else {
return repositioned;
};
let Some(total_viewport_columns) = self.total_viewport_columns else {
return repositioned;
};
let total_commands = std::cmp::max(self.execution.all_commands.iter().len(), 1);
let height_padding = 7;
if self.all_commands_are_pending() {
let initial_height = total_commands + height_padding;
let y = total_viewport_rows.saturating_sub(initial_height) / 2;
let coordinates = FloatingPaneCoordinates::new(
Some(format!("25%")),
Some(format!("{}", y)),
Some(format!("50%")),
Some(format!("{}", initial_height)),
Some(false), // should not be pinned when running sequence
);
coordinates.map(|coordinates| {
if Some(&coordinates) != self.current_position.as_ref() {
repositioned = true;
self.current_position = Some(coordinates.clone());
change_floating_panes_coordinates(vec![(
PaneId::Plugin(plugin_id),
coordinates,
)]);
}
});
} else {
// Calculate longest table row width
let longest_cwd_display = self.execution.longest_cwd_display(&self.cwd);
let longest_command = self
.execution
.all_commands
.iter()
.map(|cmd| cmd.get_text().chars().count())
.max()
.unwrap_or(1);
let (max_chain_width, max_status_width) = crate::ui::components::calculate_max_widths(
&self.execution.all_commands,
self.layout.spinner_frame,
);
let longest_line = crate::ui::components::calculate_longest_line(
&longest_cwd_display,
longest_command,
max_chain_width,
max_status_width,
);
let ui_width = std::cmp::max(longest_line, 50) + 4; // 2 for ui-padding, 2 for pane frame
let width = std::cmp::min(ui_width, total_viewport_columns / 2);
let height = (height_padding + total_commands).min((total_viewport_rows * 25) / 100);
// Position: top-right with 1-space margins
let x = total_viewport_columns.saturating_sub(width);
let y = 1;
let coordinates = FloatingPaneCoordinates::new(
Some(x.to_string()),
Some(y.to_string()),
Some(width.to_string()),
Some(height.to_string()),
Some(true), // should be pinned for sequence
);
coordinates.map(|coordinates| {
if Some(&coordinates) != self.current_position.as_ref() {
self.current_position = Some(coordinates.clone());
repositioned = true;
change_floating_panes_coordinates(vec![(
PaneId::Plugin(plugin_id),
coordinates,
)]);
}
});
}
repositioned
}
pub fn all_commands_are_pending(&self) -> bool {
self.execution
.all_commands
.iter()
.all(|command| matches!(command.get_status(), CommandStatus::Pending))
}
fn current_selected_command(&self) -> Option<&CommandEntry> {
let Some(i) = self.selection.current_selected_command_index else {
return None;
};
self.execution.all_commands.get(i)
}
pub fn execute_command_sequence(&mut self) {
self.execution
.execute_command_sequence(&self.shell, &self.cwd, self.primary_pane_id);
}
}
impl Default for State {
fn default() -> Self {
let mut state = Self::new();
state.is_first_run = true;
state
}
}
@@ -0,0 +1,48 @@
use zellij_tile::prelude::*;
pub fn reposition_plugin_for_sequence(
plugin_id: u32,
total_cols: usize,
total_rows: usize,
total_commands: usize,
) {
// Width: 30% of viewport
let width = std::cmp::max((total_cols * 30) / 100, 50);
// Height: UI overhead + commands, capped at 25% of viewport
let overhead_rows = 6;
let height = (overhead_rows + total_commands).min((total_rows * 25) / 100);
// Position: top-right with 1-space margins
let x = total_cols.saturating_sub(width);
let y = 1;
change_floating_pane_coordinates_absolute(
plugin_id,
Some(x),
Some(y),
Some(width),
Some(height),
true,
);
}
pub fn change_floating_pane_coordinates_absolute(
own_plugin_id: u32,
x: Option<usize>,
y: Option<usize>,
width: Option<usize>,
height: Option<usize>,
should_be_pinned: bool,
) {
let coordinates = FloatingPaneCoordinates::new(
x.map(|x| x.to_string()),
y.map(|y| y.to_string()),
width.map(|width| width.to_string()),
height.map(|height| height.to_string()),
Some(should_be_pinned),
);
if let Some(coordinates) = coordinates {
change_floating_panes_coordinates(vec![(PaneId::Plugin(own_plugin_id), coordinates)]);
}
}
@@ -0,0 +1,214 @@
use crate::state::{CommandEntry, Editing};
use std::path::PathBuf;
use zellij_tile::prelude::*;
pub struct Selection {
pub current_selected_command_index: Option<usize>,
pub scroll_offset: usize,
}
impl Selection {
pub fn new() -> Self {
Self {
current_selected_command_index: Some(0),
scroll_offset: 0,
}
}
pub fn add_empty_command_after_current_selected(
&mut self,
all_commands: &mut Vec<CommandEntry>,
editing: &mut Editing,
cwd: &Option<PathBuf>,
) {
self.save_editing_buffer_to_current_selected(all_commands, editing);
editing.editing_input.as_mut().map(|i| i.clear());
if !self.current_selected_command_is_empty(all_commands, editing) {
self.current_selected_command_mut(all_commands)
.map(|c| c.fill_chain_type_if_empty());
if let Some(current_selected_command_index) = self.current_selected_command_index.take()
{
let new_command = CommandEntry::new("", cwd.clone());
if current_selected_command_index == all_commands.len().saturating_sub(1) {
all_commands.push(new_command);
} else {
all_commands.insert(current_selected_command_index + 1, new_command);
}
self.current_selected_command_index = Some(current_selected_command_index + 1);
}
}
}
pub fn current_selected_command_is_empty(
&self,
all_commands: &[CommandEntry],
editing: &Editing,
) -> bool {
let text_input_is_empty = editing
.editing_input
.as_ref()
.map(|i| i.is_empty())
.unwrap_or(true);
let current_selected_command_is_empty = self
.current_selected_command_index
.and_then(|i| all_commands.get(i).map(|c| c.is_empty()))
.unwrap_or(true);
text_input_is_empty && current_selected_command_is_empty
}
pub fn remove_current_selected_command(
&mut self,
all_commands: &mut Vec<CommandEntry>,
editing: &mut Editing,
) -> Option<PaneId> {
// returns the pane_id of the removed command, if any
let mut removed_pane_id = None;
let Some(mut current_selected_command_index) = self.current_selected_command_index else {
return removed_pane_id;
};
if all_commands.len() > 1 && all_commands.len() > current_selected_command_index {
let command = all_commands.remove(current_selected_command_index);
removed_pane_id = command.get_pane_id();
} else {
self.clear_current_selected_command(all_commands, editing);
}
if current_selected_command_index >= all_commands.len()
&& current_selected_command_index > 0
{
current_selected_command_index -= 1;
}
self.update_current_selected_command_index(
current_selected_command_index,
all_commands,
editing,
);
if current_selected_command_index == all_commands.len().saturating_sub(1) {
self.current_selected_command_mut(all_commands)
.map(|c| c.clear_chain_type());
}
removed_pane_id
}
pub fn clear_current_selected_command(
&mut self,
all_commands: &mut [CommandEntry],
editing: &mut Editing,
) {
let Some(current_selected_command_index) = self.current_selected_command_index else {
return;
};
self.current_selected_command_mut(all_commands)
.map(|c| c.clear_text());
self.clear_editing_buffer(editing);
if current_selected_command_index == all_commands.len().saturating_sub(1) {
self.current_selected_command_mut(all_commands)
.map(|c| c.clear_chain_type());
}
}
pub fn move_up(&mut self, all_commands: &mut [CommandEntry], editing: &mut Editing) {
self.save_editing_buffer_to_current_selected(all_commands, editing);
match self.current_selected_command_index.as_mut() {
Some(i) if *i > 0 => {
*i -= 1;
},
None => {
self.current_selected_command_index = Some(all_commands.len().saturating_sub(1));
},
_ => {
self.current_selected_command_index = None;
},
}
self.update_editing_buffer_to_current_selected(all_commands, editing);
}
pub fn move_down(&mut self, all_commands: &mut [CommandEntry], editing: &mut Editing) {
self.save_editing_buffer_to_current_selected(all_commands, editing);
match self.current_selected_command_index.as_mut() {
Some(i) if *i < all_commands.len().saturating_sub(1) => {
*i += 1;
},
None => {
self.current_selected_command_index = Some(0);
},
_ => {
self.current_selected_command_index = None;
},
}
self.update_editing_buffer_to_current_selected(all_commands, editing);
}
fn current_selected_command_mut<'a>(
&self,
all_commands: &'a mut [CommandEntry],
) -> Option<&'a mut CommandEntry> {
let Some(i) = self.current_selected_command_index else {
return None;
};
all_commands.get_mut(i)
}
fn current_selected_command<'a>(
&self,
all_commands: &'a [CommandEntry],
) -> Option<&'a CommandEntry> {
let Some(i) = self.current_selected_command_index else {
return None;
};
all_commands.get(i)
}
fn clear_editing_buffer(&self, editing: &mut Editing) {
editing.editing_input.as_mut().map(|c| c.clear());
}
fn update_current_selected_command_index(
&mut self,
new_index: usize,
all_commands: &[CommandEntry],
editing: &mut Editing,
) {
self.current_selected_command_index = Some(new_index);
self.update_editing_buffer_to_current_selected(all_commands, editing);
}
fn get_text_of_current_selected_command(
&self,
all_commands: &[CommandEntry],
) -> Option<String> {
self.current_selected_command(all_commands)
.map(|c| c.get_text())
}
fn save_editing_buffer_to_current_selected(
&self,
all_commands: &mut [CommandEntry],
editing: &mut Editing,
) {
if let Some(text_input) = editing.editing_input.as_ref().map(|i| i.get_text()) {
self.current_selected_command_mut(all_commands)
.map(|c| c.set_text(text_input.to_owned()));
}
}
fn update_editing_buffer_to_current_selected(
&self,
all_commands: &[CommandEntry],
editing: &mut Editing,
) {
let new_text_of_current_selected_command = self
.get_text_of_current_selected_command(all_commands)
.unwrap_or_else(|| String::new());
if let Some(editing_input) = editing.editing_input.as_mut() {
editing_input.set_text(new_text_of_current_selected_command);
}
}
}
impl Default for Selection {
fn default() -> Self {
Self::new()
}
}
@@ -0,0 +1,596 @@
use crate::path_formatting::format_cwd;
use crate::state::{CommandStatus, State};
use crate::ui::truncation::{calculate_available_cmd_width, truncate_middle};
use std::path::PathBuf;
use zellij_tile::prelude::*;
const SPINNER_FRAMES: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧"];
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 =
"<Ctrl w> - close all, <Enter> - run from selected, <e> - edit";
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";
fn select_help_text(sequence: &State) -> &'static str {
let is_running = sequence
.execution
.all_commands
.iter()
.any(|command| matches!(command.get_status(), CommandStatus::Running(_)));
let all_pending = sequence
.execution
.all_commands
.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
}
} else if is_running {
if has_selection {
HELP_RUNNING_WITH_SELECTION
} else {
HELP_RUNNING_NO_SELECTION
}
} else {
if has_selection && !is_editing {
HELP_STOPPED_WITH_SELECTION
} else if is_editing {
HELP_EDITING_FIRST_LINE
} else {
HELP_STOPPED_NO_SELECTION
}
}
}
fn style_help_text(text: &str) -> Text {
Text::new(text)
.color_substring(3, "<Ctrl c>")
.color_substring(3, "<Ctrl w>")
.color_substring(3, "<↓↑>")
.color_substring(3, "<Esc>")
.color_substring(3, "<Enter>")
.color_substring(3, "<Ctrl Enter>")
.color_substring(3, "<e>")
}
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 {
if let Some((first_line, second_line)) = split_help_line(help_text, width) {
let first_len = first_line.chars().count();
let first_styled = style_help_text(&first_line).unbold_all();
let second_line_width = second_line.chars().count();
let (second_styled, second_len) = if second_line_width > width {
let (truncated, len) = truncate_help_line(&second_line, width);
(style_help_text(&truncated).unbold_all(), len)
} else {
(
style_help_text(&second_line).unbold_all(),
second_line_width,
)
};
return (first_styled, first_len, Some((second_styled, second_len)));
}
let (truncated_text, help_len) = truncate_help_line(help_text, width);
(
style_help_text(&truncated_text).unbold_all(),
help_len,
None,
)
} else {
let help_len = help_text.chars().count();
(style_help_text(help_text).unbold_all(), help_len, None)
}
}
fn split_help_line(help_text: &str, max_width: usize) -> Option<(String, String)> {
let text_width = help_text.chars().count();
if text_width <= max_width {
return None;
}
let keybindings = parse_help_keybindings(help_text);
if keybindings.is_empty() {
return None;
}
for split_point in 1..keybindings.len() {
let first_part: Vec<String> = keybindings
.iter()
.take(split_point)
.map(|(key, _, action)| format!("{} - {}", key, action))
.collect();
let first_line = first_part.join(", ");
let second_part: Vec<String> = keybindings
.iter()
.skip(split_point)
.map(|(key, _, action)| format!("{} - {}", key, action))
.collect();
let second_line = second_part.join(", ");
if first_line.chars().count() <= max_width && second_line.chars().count() <= max_width {
return Some((first_line, second_line));
}
}
None
}
fn folder_cell(cwd: &Option<PathBuf>) -> (Text, String) {
let cwd_display = if let Some(cwd) = cwd {
format_cwd(cwd)
} else {
"~".to_string()
};
let folder_display = format!("{} >", cwd_display);
let text = Text::new(&folder_display).color_range(0, 0..cwd_display.len());
(text, folder_display)
}
fn command_cell(text: &str) -> Text {
let text = if text.is_empty() { " " } else { text };
Text::new(text)
}
fn chain_cell(chain_type: &crate::state::ChainType) -> Text {
let text = chain_type_to_str(chain_type);
if !text.is_empty() {
Text::new(text).color_range(1, 0..text.len())
} else {
Text::new(text)
}
}
fn get_spinner_frame(frame: usize) -> &'static str {
SPINNER_FRAMES[frame % SPINNER_FRAMES.len()]
}
pub fn format_status_text(status: &CommandStatus, spinner_frame: usize) -> String {
match status {
CommandStatus::Running(_) => {
let spinner = get_spinner_frame(spinner_frame);
format!("[RUNNING] {}", spinner)
},
CommandStatus::Interrupted(_) => "[INTERRUPTED]".to_string(),
CommandStatus::Exited(Some(code), _) => format!("[EXIT CODE: {}]", code),
CommandStatus::Exited(None, _) => "[EXITED]".to_string(),
CommandStatus::Pending => " ".to_string(),
}
}
fn apply_status_color(text: Text, status: &CommandStatus, status_str: &str) -> Text {
match status {
CommandStatus::Running(_) => text.color_range(3, 0..status_str.len()),
CommandStatus::Interrupted(_) => text.error_color_range(0..status_str.len()),
CommandStatus::Exited(Some(0), _) => {
let number_start = 12;
let number_end = status_str.len() - 1;
text.success_color_range(number_start..number_end)
},
CommandStatus::Exited(Some(_), _) => {
let number_start = 12;
let number_end = status_str.len() - 1;
text.error_color_range(number_start..number_end)
},
_ => text,
}
}
fn status_cell(status: &CommandStatus, spinner_frame: usize) -> Text {
let status_str = format_status_text(status, spinner_frame);
let text = Text::new(&status_str);
apply_status_color(text, status, &status_str)
}
fn apply_row_styles(cells: Vec<Text>, is_selected: bool) -> Vec<Text> {
let mut styled_cells = cells;
if is_selected {
styled_cells = styled_cells.into_iter().map(|c| c.selected()).collect();
}
styled_cells
}
fn overflow_cell(indicator: &str, is_selected: bool) -> Text {
let mut text = Text::new(indicator).dim_all();
if is_selected {
text = text.selected();
}
text
}
fn command_sequence_row(
state: &State,
cwd: &Option<PathBuf>,
truncated_cmd_text: &str,
chain_type: &crate::state::ChainType,
status: &crate::state::CommandStatus,
is_selected: bool,
overflow_indicator: Option<String>,
) -> (Vec<Text>, usize) {
let (folder_text, folder_display) = folder_cell(cwd);
let mut cells = vec![
folder_text,
command_cell(truncated_cmd_text),
chain_cell(chain_type),
status_cell(status, state.layout.spinner_frame),
];
cells = apply_row_styles(cells, is_selected);
let truncated_cmd_text = if truncated_cmd_text.is_empty() {
" "
} else {
truncated_cmd_text
};
let mut row_length = folder_display.chars().count()
+ truncated_cmd_text.chars().count()
+ chain_type_to_str(chain_type).chars().count()
+ format_status_text(status, state.layout.spinner_frame)
.chars()
.count()
+ 3;
if let Some(indicator) = overflow_indicator {
let indicator_len = indicator.chars().count();
cells.push(overflow_cell(&indicator, is_selected));
row_length += indicator_len + 1;
}
(cells, row_length)
}
pub fn build_table_header(has_overflow: bool) -> Table {
let header_cols = if has_overflow {
vec![" ", " ", " ", " ", " "]
} else {
vec![" ", " ", " ", " "]
};
Table::new().add_row(header_cols)
}
fn calculate_overflow_indicator(
visible_idx: usize,
visible_count: usize,
hidden_above: usize,
hidden_below: usize,
) -> Option<String> {
if visible_idx == 0 && hidden_above > 0 {
Some(format!("[+{}]", hidden_above))
} else if visible_idx == visible_count.saturating_sub(1) && hidden_below > 0 {
Some(format!("[+{}]", hidden_below))
} else if hidden_above > 0 || hidden_below > 0 {
Some(format!(" "))
} else {
None
}
}
pub fn calculate_row_layout_info(
index: usize,
offset: usize,
visible_count: usize,
hidden_above: usize,
hidden_below: usize,
cols: usize,
folder_width: usize,
max_chain_width: usize,
max_status_width: usize,
) -> Option<(Option<String>, usize)> {
if index < offset || index >= offset + visible_count {
return None;
}
let visible_idx = index.saturating_sub(offset);
let overflow_indicator =
calculate_overflow_indicator(visible_idx, visible_count, hidden_above, hidden_below);
let available_cmd_width = calculate_available_cmd_width(
cols,
folder_width,
overflow_indicator.as_ref(),
max_chain_width,
max_status_width,
);
Some((overflow_indicator, available_cmd_width))
}
pub fn add_command_row(
table: Table,
state: &State,
index: usize,
offset: usize,
visible_count: usize,
hidden_above: usize,
hidden_below: usize,
) -> Table {
let command = match state.execution.all_commands.get(index) {
Some(cmd) => cmd,
None => return table,
};
let cmd_text = &command.get_text();
let chain_type = &command.get_chain_type();
let status = &command.get_status();
let command_cwd = command.get_cwd().or_else(|| state.cwd.clone());
let cols = state.own_columns.unwrap_or(80);
let longest_cwd_display = state.execution.longest_cwd_display(&state.cwd);
let folder_display = format!("{} >", longest_cwd_display);
let folder_width = folder_display.chars().count();
let (max_chain_width, max_status_width) =
calculate_max_widths(&state.execution.all_commands, state.layout.spinner_frame);
let Some((overflow_indicator, available_cmd_width)) = calculate_row_layout_info(
index,
offset,
visible_count,
hidden_above,
hidden_below,
cols,
folder_width,
max_chain_width,
max_status_width,
) else {
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(
state,
&command_cwd,
&truncated_cmd_text,
chain_type,
status,
state.selection.current_selected_command_index == Some(index),
overflow_indicator,
);
table.add_styled_row(row)
}
pub fn calculate_max_widths(
commands: &[crate::state::CommandEntry],
spinner_frame: usize,
) -> (usize, usize) {
let max_chain_width = commands
.iter()
.map(|cmd| chain_type_to_str(&cmd.get_chain_type()).chars().count())
.max()
.unwrap_or(0);
let max_status_width = commands
.iter()
.map(|cmd| {
format_status_text(&cmd.get_status(), spinner_frame)
.chars()
.count()
})
.max()
.unwrap_or(0);
(max_chain_width, max_status_width)
}
pub fn calculate_longest_line(
longest_cwd_display: &str,
longest_command: usize,
max_chain_width: usize,
max_status_width: usize,
) -> usize {
let folder_display = format!("{} >", longest_cwd_display);
let cell_padding = 3;
folder_display.chars().count()
+ longest_command
+ max_chain_width
+ max_status_width
+ cell_padding
}
pub fn chain_type_to_str(chain_type: &crate::state::ChainType) -> &'static str {
match chain_type {
crate::state::ChainType::And => "AND",
crate::state::ChainType::Or => "OR",
crate::state::ChainType::Then => "THEN",
crate::state::ChainType::None => " ",
}
}
pub fn truncate_help_line(help_text: &str, max_width: usize) -> (String, usize) {
let text_width = help_text.chars().count();
if text_width <= max_width {
return (help_text.to_string(), text_width);
}
let keybindings = parse_help_keybindings(help_text);
if let Some(result) = try_shortened_help(&keybindings, max_width) {
return result;
}
if let Some(result) = try_keys_spaced(&keybindings, max_width) {
return result;
}
if let Some(result) = try_keys_tight(&keybindings, max_width) {
return result;
}
truncate_with_ellipsis(
&keybindings
.iter()
.map(|(k, _, _)| *k)
.collect::<Vec<_>>()
.join("/"),
max_width,
)
}
fn parse_help_keybindings(help_text: &str) -> Vec<(&str, &str, &str)> {
help_text
.split(", ")
.filter_map(|part| {
part.find(" - ").map(|dash_pos| {
let key = &part[..dash_pos];
let action = &part[dash_pos + 3..];
let first_word = action.split_whitespace().next().unwrap_or(action);
(key, first_word, action)
})
})
.collect()
}
fn try_shortened_help(
keybindings: &[(&str, &str, &str)],
max_width: usize,
) -> Option<(String, usize)> {
let shortened: Vec<String> = keybindings
.iter()
.map(|(key, first_word, _)| format!("{} - {}", key, first_word))
.collect();
let shortened_text = shortened.join(", ");
let shortened_width = shortened_text.chars().count();
if shortened_width <= max_width {
Some((shortened_text, shortened_width))
} else {
None
}
}
fn try_keys_spaced(
keybindings: &[(&str, &str, &str)],
max_width: usize,
) -> Option<(String, usize)> {
let keys_spaced_text = keybindings
.iter()
.map(|(key, _, _)| *key)
.collect::<Vec<_>>()
.join(" / ");
let keys_spaced_width = keys_spaced_text.chars().count();
if keys_spaced_width <= max_width {
Some((keys_spaced_text, keys_spaced_width))
} else {
None
}
}
fn try_keys_tight(keybindings: &[(&str, &str, &str)], max_width: usize) -> Option<(String, usize)> {
let keys_tight_text = keybindings
.iter()
.map(|(key, _, _)| *key)
.collect::<Vec<_>>()
.join("/");
let keys_tight_width = keys_tight_text.chars().count();
if keys_tight_width <= max_width {
Some((keys_tight_text, keys_tight_width))
} else {
None
}
}
fn truncate_with_ellipsis(text: &str, max_width: usize) -> (String, usize) {
let available = max_width.saturating_sub(3);
let mut truncated = String::new();
let mut current_width = 0;
for ch in text.chars() {
if current_width + 1 <= available {
truncated.push(ch);
current_width += 1;
} else {
break;
}
}
truncated.push_str("...");
let truncated_width = truncated.chars().count();
(truncated, truncated_width)
}
@@ -0,0 +1,321 @@
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
}
}
@@ -0,0 +1,28 @@
pub fn calculate_viewport(
total_commands: usize,
max_visible: usize,
selected_index: Option<usize>,
running_index: usize,
) -> (usize, usize, usize, usize) {
if total_commands <= max_visible {
return (0, total_commands, 0, 0);
}
let focus_index = selected_index.unwrap_or(running_index);
let half_visible = max_visible / 2;
let offset = if focus_index < half_visible {
0
} else if focus_index >= total_commands.saturating_sub(half_visible) {
total_commands.saturating_sub(max_visible)
} else {
focus_index.saturating_sub(half_visible)
};
let offset = offset.min(total_commands.saturating_sub(max_visible));
let visible_count = max_visible.min(total_commands.saturating_sub(offset));
let hidden_above = offset;
let hidden_below = total_commands.saturating_sub(offset + visible_count);
(offset, visible_count, hidden_above, hidden_below)
}
+5
View File
@@ -0,0 +1,5 @@
pub mod components;
pub mod fuzzy_complete;
pub mod layout_calculations;
pub mod text_input;
pub mod truncation;
@@ -0,0 +1,997 @@
use zellij_tile::prelude::*;
const MAX_UNDO_STACK_SIZE: usize = 100;
/// Action returned by TextInput after handling a key event
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(test, derive(Eq))]
pub enum InputAction {
/// Continue editing
Continue,
/// User pressed Enter to submit
Submit,
/// User pressed Esc to cancel
Cancel,
/// User pressed Tab to request completion
Complete,
/// Key was not handled by the input
NoAction,
}
/// A reusable text input component with cursor support and standard editing keybindings
#[derive(Debug, Clone)]
pub struct TextInput {
buffer: String,
cursor_position: usize, // Character position (0-based), NOT byte position
undo_stack: Vec<(String, usize)>, // (buffer, cursor) snapshots
redo_stack: Vec<(String, usize)>,
last_edit_was_insert: bool, // For coalescing consecutive inserts
}
impl TextInput {
/// Create a new TextInput with the given initial text
/// Cursor is positioned at the end of the text
pub fn new(initial_text: String) -> Self {
let cursor_position = initial_text.chars().count();
Self {
buffer: initial_text,
cursor_position,
undo_stack: Vec::new(),
redo_stack: Vec::new(),
last_edit_was_insert: false,
}
}
/// Create an empty TextInput
pub fn empty() -> Self {
Self::new(String::new())
}
/// Get the current text
pub fn get_text(&self) -> &str {
&self.buffer
}
/// Get the cursor position (in characters, not bytes)
pub fn get_cursor_position(&self) -> usize {
self.cursor_position
}
/// Check if the input is empty
pub fn is_empty(&self) -> bool {
self.buffer.is_empty()
}
/// Get a shorthand for cursor_position
pub fn cursor_position(&self) -> usize {
self.cursor_position
}
/// Get mutable access to the underlying buffer for direct manipulation
pub fn get_text_mut(&mut self) -> &mut String {
&mut self.buffer
}
/// Set the text and move cursor to the end
pub fn set_text(&mut self, text: String) {
self.break_coalescing();
self.save_undo_state();
self.cursor_position = text.chars().count();
self.buffer = text;
}
/// Set cursor position (clamped to text length)
pub fn set_cursor_position(&mut self, pos: usize) {
let text_len = self.buffer.chars().count();
self.cursor_position = pos.min(text_len);
}
/// Clear all text and reset cursor
pub fn clear(&mut self) {
self.break_coalescing();
self.save_undo_state();
self.buffer.clear();
self.cursor_position = 0;
}
/// Insert a character at the current cursor position
pub fn insert_char(&mut self, c: char) {
self.save_undo_state_unless_coalescing();
// Convert cursor position (char index) to byte index
let byte_index = self.char_index_to_byte_index(self.cursor_position);
self.buffer.insert(byte_index, c);
self.cursor_position += 1;
}
/// Delete the character before the cursor (backspace)
pub fn backspace(&mut self) {
if self.cursor_position > 0 {
self.break_coalescing();
self.save_undo_state();
self.cursor_position -= 1;
let byte_index = self.char_index_to_byte_index(self.cursor_position);
self.buffer.remove(byte_index);
}
}
/// Delete the character at the cursor position (delete key)
pub fn delete(&mut self) {
let len = self.buffer.chars().count();
if self.cursor_position < len {
self.break_coalescing();
self.save_undo_state();
let byte_index = self.char_index_to_byte_index(self.cursor_position);
self.buffer.remove(byte_index);
}
}
/// Delete the word before the cursor (Ctrl/Alt + Backspace)
pub fn delete_word_backward(&mut self) {
if self.cursor_position == 0 {
return;
}
self.break_coalescing();
self.save_undo_state();
let old_position = self.cursor_position;
self.move_word_left();
let new_position = self.cursor_position;
// Delete from new position to old position
let start_byte = self.char_index_to_byte_index(new_position);
let end_byte = self.char_index_to_byte_index(old_position);
self.buffer.drain(start_byte..end_byte);
}
/// Delete the word after the cursor (Ctrl/Alt + Delete)
pub fn delete_word_forward(&mut self) {
let chars: Vec<char> = self.buffer.chars().collect();
let len = chars.len();
if self.cursor_position >= len {
return;
}
self.break_coalescing();
self.save_undo_state();
let start_position = self.cursor_position;
let mut end_position = start_position;
// Skip the current word
while end_position < len && !chars[end_position].is_whitespace() {
end_position += 1;
}
// Skip any whitespace after the word
while end_position < len && chars[end_position].is_whitespace() {
end_position += 1;
}
// Delete from start to end position
let start_byte = self.char_index_to_byte_index(start_position);
let end_byte = self.char_index_to_byte_index(end_position);
self.buffer.drain(start_byte..end_byte);
}
/// Move cursor one position to the left
pub fn move_left(&mut self) {
if self.cursor_position > 0 {
self.break_coalescing();
self.cursor_position -= 1;
}
}
/// Move cursor one position to the right
pub fn move_right(&mut self) {
let len = self.buffer.chars().count();
if self.cursor_position < len {
self.break_coalescing();
self.cursor_position += 1;
}
}
/// Move cursor to the start of the text (Ctrl-A / Home)
pub fn move_to_start(&mut self) {
self.break_coalescing();
self.cursor_position = 0;
}
/// Move cursor to the end of the text (Ctrl-E / End)
pub fn move_to_end(&mut self) {
self.break_coalescing();
self.cursor_position = self.buffer.chars().count();
}
/// Move cursor to the start of the previous word (Ctrl/Alt + Left)
pub fn move_word_left(&mut self) {
if self.cursor_position == 0 {
return;
}
self.break_coalescing();
let chars: Vec<char> = self.buffer.chars().collect();
let mut pos = self.cursor_position;
// Skip any whitespace immediately to the left
while pos > 0 && chars[pos - 1].is_whitespace() {
pos -= 1;
}
// Skip the word characters
while pos > 0 && !chars[pos - 1].is_whitespace() {
pos -= 1;
}
self.cursor_position = pos;
}
/// Move cursor to the start of the next word (Ctrl/Alt + Right)
pub fn move_word_right(&mut self) {
let chars: Vec<char> = self.buffer.chars().collect();
let len = chars.len();
if self.cursor_position >= len {
return;
}
self.break_coalescing();
let mut pos = self.cursor_position;
// Skip the current word
while pos < len && !chars[pos].is_whitespace() {
pos += 1;
}
// Skip any whitespace
while pos < len && chars[pos].is_whitespace() {
pos += 1;
}
self.cursor_position = pos;
}
/// Handle a key event and return the appropriate action
/// This is the main entry point for key handling
pub fn handle_key(&mut self, key: KeyWithModifier) -> InputAction {
// Check for Ctrl modifiers
if key.has_modifiers(&[KeyModifier::Ctrl]) {
match key.bare_key {
BareKey::Char('a') => {
self.move_to_start();
return InputAction::Continue;
},
BareKey::Char('e') => {
self.move_to_end();
return InputAction::Continue;
},
BareKey::Char('c') => {
// Ctrl-C clears the prompt
return InputAction::Cancel;
},
BareKey::Char('z') => {
// Ctrl-Z: Undo
self.undo();
return InputAction::Continue;
},
BareKey::Char('y') => {
// Ctrl-Y: Redo
self.redo();
return InputAction::Continue;
},
BareKey::Left => {
self.move_word_left();
return InputAction::Continue;
},
BareKey::Right => {
self.move_word_right();
return InputAction::Continue;
},
BareKey::Backspace => {
self.delete_word_backward();
return InputAction::Continue;
},
BareKey::Delete => {
self.delete_word_forward();
return InputAction::Continue;
},
_ => {},
}
}
// Check for Ctrl+Shift modifiers (alternative redo: Ctrl+Shift+Z)
if key.has_modifiers(&[KeyModifier::Ctrl, KeyModifier::Shift]) {
match key.bare_key {
BareKey::Char('Z') => {
// Ctrl-Shift-Z: Redo (alternative)
self.redo();
return InputAction::Continue;
},
_ => {},
}
}
// Check for Alt modifiers
if key.has_modifiers(&[KeyModifier::Alt]) {
match key.bare_key {
BareKey::Left => {
self.move_word_left();
return InputAction::Continue;
},
BareKey::Right => {
self.move_word_right();
return InputAction::Continue;
},
BareKey::Backspace => {
self.delete_word_backward();
return InputAction::Continue;
},
BareKey::Delete => {
self.delete_word_forward();
return InputAction::Continue;
},
_ => {},
}
}
// Handle bare keys (no modifiers)
match key.bare_key {
BareKey::Enter => InputAction::Submit,
BareKey::Esc => InputAction::Cancel,
BareKey::Tab => InputAction::Complete,
BareKey::Backspace => {
self.backspace();
InputAction::Continue
},
BareKey::Delete => {
self.delete();
InputAction::Continue
},
BareKey::Left => {
self.move_left();
InputAction::Continue
},
BareKey::Right => {
self.move_right();
InputAction::Continue
},
BareKey::Home => {
self.move_to_start();
InputAction::Continue
},
BareKey::End => {
self.move_to_end();
InputAction::Continue
},
BareKey::Char(c) => {
self.insert_char(c);
InputAction::Continue
},
_ => InputAction::NoAction,
}
}
/// Helper: Convert character index to byte index
fn char_index_to_byte_index(&self, char_index: usize) -> usize {
self.buffer
.char_indices()
.nth(char_index)
.map(|(byte_idx, _)| byte_idx)
.unwrap_or(self.buffer.len())
}
/// Save current state to undo stack before making changes
fn save_undo_state(&mut self) {
if self.undo_stack.len() >= MAX_UNDO_STACK_SIZE {
self.undo_stack.remove(0);
}
self.undo_stack
.push((self.buffer.clone(), self.cursor_position));
self.redo_stack.clear();
}
/// Save state only if not coalescing with previous insert
fn save_undo_state_unless_coalescing(&mut self) {
if !self.last_edit_was_insert {
self.save_undo_state();
}
self.last_edit_was_insert = true;
}
/// Mark that a non-insert edit occurred (breaks coalescing)
fn break_coalescing(&mut self) {
self.last_edit_was_insert = false;
}
/// Undo last change
pub fn undo(&mut self) -> bool {
if let Some((buffer, cursor)) = self.undo_stack.pop() {
self.redo_stack
.push((self.buffer.clone(), self.cursor_position));
self.buffer = buffer;
self.cursor_position = cursor;
self.break_coalescing();
true
} else {
false
}
}
/// Redo last undone change
pub fn redo(&mut self) -> bool {
if let Some((buffer, cursor)) = self.redo_stack.pop() {
self.undo_stack
.push((self.buffer.clone(), self.cursor_position));
self.buffer = buffer;
self.cursor_position = cursor;
self.break_coalescing();
true
} else {
false
}
}
/// Check if undo is available
pub fn can_undo(&self) -> bool {
!self.undo_stack.is_empty()
}
/// Check if redo is available
pub fn can_redo(&self) -> bool {
!self.redo_stack.is_empty()
}
pub fn drain_text(&mut self) -> String {
self.cursor_position = 0;
self.buffer.drain(..).collect()
}
}
// run with:
// cargo test --lib --target x86_64-unknown-linux-gnu
//
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_new_and_empty() {
let input = TextInput::new("hello".to_string());
assert_eq!(input.get_text(), "hello");
assert_eq!(input.get_cursor_position(), 5);
let empty = TextInput::empty();
assert_eq!(empty.get_text(), "");
assert_eq!(empty.get_cursor_position(), 0);
}
#[test]
fn test_insert_char() {
let mut input = TextInput::new("helo".to_string());
input.cursor_position = 3; // Position after "hel"
input.insert_char('l');
assert_eq!(input.get_text(), "hello");
assert_eq!(input.get_cursor_position(), 4);
}
#[test]
fn test_backspace() {
let mut input = TextInput::new("hello".to_string());
input.backspace();
assert_eq!(input.get_text(), "hell");
assert_eq!(input.get_cursor_position(), 4);
// Backspace at start does nothing
input.cursor_position = 0;
input.backspace();
assert_eq!(input.get_text(), "hell");
assert_eq!(input.get_cursor_position(), 0);
}
#[test]
fn test_delete() {
let mut input = TextInput::new("hello".to_string());
input.cursor_position = 0;
input.delete();
assert_eq!(input.get_text(), "ello");
assert_eq!(input.get_cursor_position(), 0);
// Delete at end does nothing
input.move_to_end();
input.delete();
assert_eq!(input.get_text(), "ello");
}
#[test]
fn test_cursor_movement() {
let mut input = TextInput::new("hello".to_string());
assert_eq!(input.get_cursor_position(), 5);
input.move_left();
assert_eq!(input.get_cursor_position(), 4);
input.move_right();
assert_eq!(input.get_cursor_position(), 5);
input.move_to_start();
assert_eq!(input.get_cursor_position(), 0);
input.move_to_end();
assert_eq!(input.get_cursor_position(), 5);
}
#[test]
fn test_unicode_support() {
let mut input = TextInput::new("hello 🦀 world".to_string());
assert_eq!(input.get_cursor_position(), 13); // 13 characters
input.cursor_position = 6; // After "hello "
input.insert_char('🐱');
assert_eq!(input.get_text(), "hello 🐱🦀 world");
}
#[test]
fn test_word_jump_right() {
let mut input = TextInput::new("hello world foo bar".to_string());
input.cursor_position = 0;
// Jump from start to "world"
input.move_word_right();
assert_eq!(input.get_cursor_position(), 6); // After "hello "
// Jump to "foo"
input.move_word_right();
assert_eq!(input.get_cursor_position(), 12); // After "world "
// Jump to "bar"
input.move_word_right();
assert_eq!(input.get_cursor_position(), 16); // After "foo "
// Jump to end
input.move_word_right();
assert_eq!(input.get_cursor_position(), 19); // At end
}
#[test]
fn test_word_jump_left() {
let mut input = TextInput::new("hello world foo bar".to_string());
input.move_to_end();
assert_eq!(input.get_cursor_position(), 19);
// Jump back to "bar"
input.move_word_left();
assert_eq!(input.get_cursor_position(), 16); // Start of "bar"
// Jump back to "foo"
input.move_word_left();
assert_eq!(input.get_cursor_position(), 12); // Start of "foo"
// Jump back to "world"
input.move_word_left();
assert_eq!(input.get_cursor_position(), 6); // Start of "world"
// Jump back to "hello"
input.move_word_left();
assert_eq!(input.get_cursor_position(), 0); // Start of "hello"
}
#[test]
fn test_word_jump_with_multiple_spaces() {
let mut input = TextInput::new("hello world".to_string());
input.cursor_position = 0;
// Jump over multiple spaces
input.move_word_right();
assert_eq!(input.get_cursor_position(), 8); // After "hello ", at start of "world"
// Jump back should skip spaces
input.move_word_left();
assert_eq!(input.get_cursor_position(), 0); // Back to start of "hello"
}
#[test]
fn test_word_jump_boundaries() {
let mut input = TextInput::new("test".to_string());
// At start - word left does nothing
input.cursor_position = 0;
input.move_word_left();
assert_eq!(input.get_cursor_position(), 0);
// At end - word right does nothing
input.move_to_end();
let end_pos = input.get_cursor_position();
input.move_word_right();
assert_eq!(input.get_cursor_position(), end_pos);
}
#[test]
fn test_up_down_arrows() {
let mut input = TextInput::new("hello world".to_string());
// Start in the middle
input.cursor_position = 5;
assert_eq!(input.get_cursor_position(), 5);
// Up arrow should go to start
input.move_to_start();
assert_eq!(input.get_cursor_position(), 0);
// Move back to middle
input.cursor_position = 5;
// Down arrow should go to end
input.move_to_end();
assert_eq!(input.get_cursor_position(), 11);
}
#[test]
fn test_delete_word_backward() {
let mut input = TextInput::new("hello world foo".to_string());
// Delete "foo" from end
input.move_to_end();
input.delete_word_backward();
assert_eq!(input.get_text(), "hello world ");
assert_eq!(input.get_cursor_position(), 12);
// Delete "world "
input.delete_word_backward();
assert_eq!(input.get_text(), "hello ");
assert_eq!(input.get_cursor_position(), 6);
// Delete "hello "
input.delete_word_backward();
assert_eq!(input.get_text(), "");
assert_eq!(input.get_cursor_position(), 0);
// Delete on empty buffer does nothing
input.delete_word_backward();
assert_eq!(input.get_text(), "");
assert_eq!(input.get_cursor_position(), 0);
}
#[test]
fn test_delete_word_backward_middle() {
let mut input = TextInput::new("hello world foo".to_string());
// Position in middle of "world"
input.cursor_position = 8; // After "hello wo"
input.delete_word_backward();
assert_eq!(input.get_text(), "hello rld foo");
assert_eq!(input.get_cursor_position(), 6); // After "hello "
}
#[test]
fn test_delete_word_forward() {
let mut input = TextInput::new("hello world foo".to_string());
// Delete "hello " from start
input.cursor_position = 0;
input.delete_word_forward();
assert_eq!(input.get_text(), "world foo");
assert_eq!(input.get_cursor_position(), 0);
// Delete "world "
input.delete_word_forward();
assert_eq!(input.get_text(), "foo");
assert_eq!(input.get_cursor_position(), 0);
// Delete "foo"
input.delete_word_forward();
assert_eq!(input.get_text(), "");
assert_eq!(input.get_cursor_position(), 0);
// Delete on empty buffer does nothing
input.delete_word_forward();
assert_eq!(input.get_text(), "");
assert_eq!(input.get_cursor_position(), 0);
}
#[test]
fn test_delete_word_forward_middle() {
let mut input = TextInput::new("hello world foo".to_string());
// Position in middle of "world"
input.cursor_position = 8; // After "hello wo"
input.delete_word_forward();
assert_eq!(input.get_text(), "hello wofoo");
assert_eq!(input.get_cursor_position(), 8); // Same position, text deleted forward
}
#[test]
fn test_delete_word_with_multiple_spaces() {
let mut input = TextInput::new("hello world".to_string());
// Delete forward includes trailing spaces
input.cursor_position = 0;
input.delete_word_forward();
assert_eq!(input.get_text(), "world");
assert_eq!(input.get_cursor_position(), 0);
}
#[test]
fn test_undo_redo_basic() {
let mut input = TextInput::empty();
// Type "hello"
input.insert_char('h');
input.insert_char('e');
input.insert_char('l');
input.insert_char('l');
input.insert_char('o');
assert_eq!(input.get_text(), "hello");
// Undo should remove all characters (coalesced into one undo entry)
assert!(input.can_undo());
assert!(input.undo());
assert_eq!(input.get_text(), "");
assert_eq!(input.get_cursor_position(), 0);
// Redo should restore "hello"
assert!(input.can_redo());
assert!(input.redo());
assert_eq!(input.get_text(), "hello");
assert_eq!(input.get_cursor_position(), 5);
}
#[test]
fn test_undo_coalescing_breaks_on_cursor_move() {
let mut input = TextInput::empty();
// Type "he"
input.insert_char('h');
input.insert_char('e');
// Move cursor to start (breaks coalescing)
input.move_to_start();
// Type "llo"
input.insert_char('l');
input.insert_char('l');
input.insert_char('o');
assert_eq!(input.get_text(), "llohe");
// First undo removes "llo" (second coalesced group)
input.undo();
assert_eq!(input.get_text(), "he");
// Second undo removes "he" (first coalesced group)
input.undo();
assert_eq!(input.get_text(), "");
}
#[test]
fn test_undo_backspace() {
let mut input = TextInput::new("hello".to_string());
// Backspace once
input.backspace();
assert_eq!(input.get_text(), "hell");
// Undo should restore "hello"
input.undo();
assert_eq!(input.get_text(), "hello");
assert_eq!(input.get_cursor_position(), 5);
}
#[test]
fn test_undo_delete() {
let mut input = TextInput::new("hello".to_string());
input.cursor_position = 0;
// Delete first character
input.delete();
assert_eq!(input.get_text(), "ello");
// Undo should restore "hello"
input.undo();
assert_eq!(input.get_text(), "hello");
assert_eq!(input.get_cursor_position(), 0);
}
#[test]
fn test_undo_word_delete() {
let mut input = TextInput::new("hello world".to_string());
// Delete "world" backward
input.delete_word_backward();
assert_eq!(input.get_text(), "hello ");
// Undo should restore "hello world"
input.undo();
assert_eq!(input.get_text(), "hello world");
assert_eq!(input.get_cursor_position(), 11);
}
#[test]
fn test_undo_clear() {
let mut input = TextInput::new("hello world".to_string());
// Clear the buffer
input.clear();
assert_eq!(input.get_text(), "");
// Undo should restore the text
input.undo();
assert_eq!(input.get_text(), "hello world");
}
#[test]
fn test_undo_set_text() {
let mut input = TextInput::new("hello".to_string());
// Replace with new text
input.set_text("goodbye".to_string());
assert_eq!(input.get_text(), "goodbye");
// Undo should restore "hello"
input.undo();
assert_eq!(input.get_text(), "hello");
}
#[test]
fn test_redo_clears_on_new_edit() {
let mut input = TextInput::empty();
// Type "hello"
input.insert_char('h');
input.insert_char('e');
input.insert_char('l');
input.insert_char('l');
input.insert_char('o');
// Undo
input.undo();
assert_eq!(input.get_text(), "");
assert!(input.can_redo());
// Make a new edit (should clear redo stack)
input.insert_char('x');
assert!(!input.can_redo());
}
#[test]
fn test_multiple_undo_redo() {
let mut input = TextInput::empty();
// First edit: type "hello"
for c in "hello".chars() {
input.insert_char(c);
}
// Break coalescing
input.move_left();
// Second edit: type "world"
for c in "world".chars() {
input.insert_char(c);
}
assert_eq!(input.get_text(), "hellworldo");
// Undo "world"
input.undo();
assert_eq!(input.get_text(), "hello");
// Undo "hello"
input.undo();
assert_eq!(input.get_text(), "");
// Redo "hello"
input.redo();
assert_eq!(input.get_text(), "hello");
// Redo "world"
input.redo();
assert_eq!(input.get_text(), "hellworldo");
}
#[test]
fn test_undo_stack_limit() {
let mut input = TextInput::empty();
// Perform 102 separate edits (breaking coalescing each time)
for _i in 0..102 {
input.backspace(); // Break coalescing
input.insert_char('x');
}
// Should have at most 100 undo entries
let mut undo_count = 0;
while input.undo() {
undo_count += 1;
}
// We should have 100 undo entries (the stack limit)
// Plus the final state change from the last coalescing break
assert!(
undo_count <= 100,
"Undo count should be at most 100, got {}",
undo_count
);
}
#[test]
fn test_undo_redo_empty_stack() {
let mut input = TextInput::empty();
// Undo on empty stack should return false
assert!(!input.can_undo());
assert!(!input.undo());
// Redo on empty stack should return false
assert!(!input.can_redo());
assert!(!input.redo());
}
#[test]
fn test_undo_restores_cursor_position() {
let mut input = TextInput::new("hello world".to_string());
// Move cursor to position 5 (before "world")
input.cursor_position = 5;
// Insert a space
input.insert_char(' ');
assert_eq!(input.get_text(), "hello world");
assert_eq!(input.get_cursor_position(), 6);
// Undo should restore both text and cursor position
input.undo();
assert_eq!(input.get_text(), "hello world");
assert_eq!(input.get_cursor_position(), 5);
}
#[test]
fn test_coalescing_consecutive_inserts() {
let mut input = TextInput::empty();
// Type several characters
input.insert_char('a');
input.insert_char('b');
input.insert_char('c');
assert_eq!(input.get_text(), "abc");
// Single undo should remove all three (they were coalesced)
input.undo();
assert_eq!(input.get_text(), "");
// No more undo available
assert!(!input.can_undo());
}
#[test]
fn test_backspace_breaks_coalescing() {
let mut input = TextInput::empty();
// Type "ab"
input.insert_char('a');
input.insert_char('b');
// Backspace
input.backspace();
assert_eq!(input.get_text(), "a");
// Type "c"
input.insert_char('c');
assert_eq!(input.get_text(), "ac");
// Undo should remove just "c"
input.undo();
assert_eq!(input.get_text(), "a");
// Undo should remove backspace operation
input.undo();
assert_eq!(input.get_text(), "ab");
// Undo should remove "ab"
input.undo();
assert_eq!(input.get_text(), "");
}
}
@@ -0,0 +1,373 @@
use unicode_width::UnicodeWidthStr;
const BASE_SEPARATOR_WIDTH: usize = 2;
pub fn calculate_available_cmd_width(
cols: usize,
folder_width: usize,
overflow_indicator: Option<&String>,
chain_width: usize,
status_width: usize,
) -> usize {
let mut separator_width = BASE_SEPARATOR_WIDTH;
if overflow_indicator.is_some() {
separator_width += 1;
}
if status_width > 0 {
separator_width += 1;
}
let overflow_width = overflow_indicator.map(|s| s.chars().count()).unwrap_or(0);
cols.saturating_sub(folder_width)
.saturating_sub(chain_width)
.saturating_sub(status_width)
.saturating_sub(separator_width)
.saturating_sub(overflow_width)
.saturating_sub(2)
.max(1)
}
pub fn truncate_middle(
text: &str,
max_width: usize,
cursor_position: Option<usize>,
) -> (String, Option<usize>) {
let text_width = text.width();
if text_width <= max_width {
return (text.to_string(), cursor_position);
}
if max_width < 5 {
return truncate_minimal(text, max_width, cursor_position);
}
if let Some(cursor_char_idx) = cursor_position {
return truncate_with_cursor(text, max_width, cursor_char_idx);
}
truncate_no_cursor(text, max_width)
}
fn truncate_minimal(
text: &str,
max_width: usize,
cursor_position: Option<usize>,
) -> (String, Option<usize>) {
let mut result = String::new();
let mut current_width = 0;
let mut new_cursor_pos = None;
let mut char_pos = 0;
for ch in text.chars() {
let ch_width = ch.to_string().width();
if current_width + ch_width <= max_width {
result.push(ch);
if let Some(cursor_char) = cursor_position {
if char_pos == cursor_char {
new_cursor_pos = Some(char_pos);
}
}
char_pos += 1;
current_width += ch_width;
} else {
break;
}
}
(result, new_cursor_pos)
}
fn truncate_no_cursor(text: &str, max_width: usize) -> (String, Option<usize>) {
let available_for_text = max_width.saturating_sub(3);
let left_width = available_for_text / 2;
let right_width = available_for_text - left_width;
let mut left_part = String::new();
let mut current_width = 0;
for ch in text.chars() {
let ch_width = ch.to_string().width();
if current_width + ch_width <= left_width {
left_part.push(ch);
current_width += ch_width;
} else {
break;
}
}
let chars: Vec<char> = text.chars().collect();
let mut right_part = String::new();
let mut current_width = 0;
for ch in chars.iter().rev() {
let ch_width = ch.to_string().width();
if current_width + ch_width <= right_width {
right_part.insert(0, *ch);
current_width += ch_width;
} else {
break;
}
}
(format!("{}...{}", left_part, right_part), None)
}
fn truncate_with_cursor(
text: &str,
max_width: usize,
cursor_char_idx: usize,
) -> (String, Option<usize>) {
let chars: Vec<char> = text.chars().collect();
let char_widths: Vec<usize> = chars.iter().map(|ch| ch.to_string().width()).collect();
let width_before_cursor: usize = char_widths[..cursor_char_idx].iter().sum();
let width_after_cursor: usize = char_widths[cursor_char_idx..].iter().sum();
let available_one_ellipsis = max_width.saturating_sub(3);
let (start_idx, end_idx) = if width_before_cursor <= available_one_ellipsis {
calculate_end_truncation(&chars, &char_widths, available_one_ellipsis)
} else if width_after_cursor <= available_one_ellipsis {
calculate_start_truncation(&chars, &char_widths, available_one_ellipsis)
} else {
calculate_middle_truncation(&char_widths, max_width, cursor_char_idx)
};
let (start_idx, end_idx) =
adjust_small_truncations(start_idx, end_idx, &char_widths, cursor_char_idx, &chars);
let (start_idx, end_idx) =
trim_excess(&char_widths, start_idx, end_idx, max_width, cursor_char_idx);
build_truncated_result(&chars, start_idx, end_idx, cursor_char_idx)
}
fn calculate_end_truncation(
chars: &[char],
char_widths: &[usize],
available: usize,
) -> (usize, usize) {
let mut end_idx = 0;
let mut width = 0;
while end_idx < chars.len() && width + char_widths[end_idx] <= available {
width += char_widths[end_idx];
end_idx += 1;
}
(0, end_idx)
}
fn calculate_start_truncation(
chars: &[char],
char_widths: &[usize],
available: usize,
) -> (usize, usize) {
let mut start_idx = chars.len();
let mut width = 0;
while start_idx > 0 && width + char_widths[start_idx - 1] <= available {
start_idx -= 1;
width += char_widths[start_idx];
}
(start_idx, chars.len())
}
fn calculate_middle_truncation(
char_widths: &[usize],
max_width: usize,
cursor_char_idx: usize,
) -> (usize, usize) {
let available_both_ellipsis = max_width.saturating_sub(6);
let target_before = available_both_ellipsis / 2;
let target_after = available_both_ellipsis - target_before;
let mut start_idx = cursor_char_idx;
let mut width_before = 0;
while start_idx > 0 && width_before + char_widths[start_idx - 1] <= target_before {
start_idx -= 1;
width_before += char_widths[start_idx];
}
let mut end_idx = cursor_char_idx;
let mut width_after = 0;
while end_idx < char_widths.len() && width_after + char_widths[end_idx] <= target_after {
width_after += char_widths[end_idx];
end_idx += 1;
}
let leftover_before = target_before.saturating_sub(width_before);
let leftover_after = target_after.saturating_sub(width_after);
if leftover_before > 0 {
let mut extra = leftover_before;
while end_idx < char_widths.len() && char_widths[end_idx] <= extra {
extra -= char_widths[end_idx];
end_idx += 1;
}
}
if leftover_after > 0 {
let mut extra = leftover_after;
while start_idx > 0 && char_widths[start_idx - 1] <= extra {
start_idx -= 1;
extra -= char_widths[start_idx];
}
}
(start_idx, end_idx)
}
fn adjust_small_truncations(
start_idx: usize,
end_idx: usize,
char_widths: &[usize],
cursor_char_idx: usize,
chars: &[char],
) -> (usize, usize) {
let width_truncated_start: usize = char_widths[..start_idx].iter().sum();
let width_truncated_end: usize = char_widths[end_idx..].iter().sum();
let mut start_idx = start_idx;
let mut end_idx = end_idx;
if width_truncated_start > 0 && width_truncated_start < 3 && end_idx < chars.len() {
let gained = width_truncated_start;
start_idx = 0;
let mut removed = 0;
while end_idx > cursor_char_idx + 1 && removed < gained {
end_idx -= 1;
removed += char_widths[end_idx];
}
} else if width_truncated_end > 0 && width_truncated_end < 3 && start_idx > 0 {
let gained = width_truncated_end;
end_idx = chars.len();
let mut removed = 0;
while start_idx < cursor_char_idx && removed < gained {
removed += char_widths[start_idx];
start_idx += 1;
}
}
(start_idx, end_idx)
}
fn trim_excess(
char_widths: &[usize],
start_idx: usize,
end_idx: usize,
max_width: usize,
cursor_char_idx: usize,
) -> (usize, usize) {
let truncate_start = start_idx > 0;
let truncate_end = end_idx < char_widths.len();
let ellipsis_width = match (truncate_start, truncate_end) {
(true, true) => 6,
(true, false) | (false, true) => 3,
(false, false) => 0,
};
let visible_width: usize = char_widths[start_idx..end_idx].iter().sum();
let mut start_idx = start_idx;
let mut end_idx = end_idx;
if visible_width + ellipsis_width > max_width {
let mut excess = visible_width + ellipsis_width - max_width;
if truncate_end {
while excess > 0 && end_idx > cursor_char_idx + 1 {
end_idx -= 1;
excess = excess.saturating_sub(char_widths[end_idx]);
}
}
if excess > 0 && truncate_start {
while excess > 0 && start_idx < cursor_char_idx {
excess = excess.saturating_sub(char_widths[start_idx]);
start_idx += 1;
}
}
}
(start_idx, end_idx)
}
fn build_truncated_result(
chars: &[char],
start_idx: usize,
end_idx: usize,
cursor_char_idx: usize,
) -> (String, Option<usize>) {
let mut result = String::new();
let mut new_cursor_char_pos = 0;
let mut current_char_pos = 0;
if start_idx > 0 {
result.push_str("...");
current_char_pos = 3;
}
for i in start_idx..end_idx {
if i == cursor_char_idx {
new_cursor_char_pos = current_char_pos;
}
result.push(chars[i]);
current_char_pos += 1;
}
if cursor_char_idx >= end_idx {
new_cursor_char_pos = current_char_pos;
}
if end_idx < chars.len() {
result.push_str("...");
}
(result, Some(new_cursor_char_pos))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_truncate_middle_no_truncation_needed() {
let text = "hello world";
let (result, cursor_pos) = truncate_middle(text, 20, Some(6));
assert_eq!(result, "hello world");
assert_eq!(cursor_pos, Some(6));
}
#[test]
fn test_truncate_middle_no_cursor() {
let text = "this is a very long string that needs truncation";
let (result, cursor_pos) = truncate_middle(text, 20, None);
assert!(result.contains("..."));
assert_eq!(cursor_pos, None);
}
#[test]
fn test_truncate_middle_cursor_at_start() {
let text = "this is a very long string that needs truncation";
let (result, cursor_pos) = truncate_middle(text, 20, Some(0));
assert!(result.starts_with("this"));
assert!(result.ends_with("..."));
assert_eq!(cursor_pos, Some(0));
}
#[test]
fn test_truncate_middle_cursor_at_end() {
let text = "this is a very long string that needs truncation";
let cursor_char = text.chars().count();
let (result, _cursor_pos) = truncate_middle(text, 20, Some(cursor_char));
assert!(result.starts_with("..."));
assert!(result.ends_with("truncation"));
}
#[test]
fn test_truncate_middle_cursor_in_middle() {
let text = "this is a very long string that needs truncation";
let cursor_char = "this is a very ".chars().count();
let (result, cursor_pos) = truncate_middle(text, 20, Some(cursor_char));
assert!(result.contains("very"));
assert!(cursor_pos.is_some());
}
#[test]
fn test_truncate_middle_wide_chars() {
let text = "こんにちは世界";
let (result, _) = truncate_middle(text, 10, None);
assert!(result.width() <= 10);
}
}