mirror of
https://github.com/zellij-org/zellij.git
synced 2026-10-26 00:03:58 +00:00
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:
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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(¤t_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)
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user