#![allow(clippy::type_complexity)]
use crate::tui::autocomplete::AutocompleteProvider;
use crate::tui::component::Component;
use crate::tui::components::select_list::{SelectItem, SelectList, SelectListTheme};
use crate::tui::focusable::{CURSOR_MARKER, Focusable};
use crate::tui::keybindings::{
ACTION_EDITOR_CURSOR_DOWN, ACTION_EDITOR_CURSOR_LEFT, ACTION_EDITOR_CURSOR_LINE_END,
ACTION_EDITOR_CURSOR_LINE_START, ACTION_EDITOR_CURSOR_RIGHT, ACTION_EDITOR_CURSOR_UP,
ACTION_EDITOR_CURSOR_WORD_LEFT, ACTION_EDITOR_CURSOR_WORD_RIGHT,
ACTION_EDITOR_DELETE_CHAR_BACKWARD, ACTION_EDITOR_DELETE_CHAR_FORWARD,
ACTION_EDITOR_DELETE_TO_LINE_END, ACTION_EDITOR_DELETE_TO_LINE_START,
ACTION_EDITOR_DELETE_WORD_BACKWARD, ACTION_EDITOR_DELETE_WORD_FORWARD,
ACTION_EDITOR_JUMP_BACKWARD, ACTION_EDITOR_JUMP_FORWARD, ACTION_EDITOR_PAGE_DOWN,
ACTION_EDITOR_PAGE_UP, ACTION_EDITOR_UNDO, ACTION_EDITOR_YANK, ACTION_EDITOR_YANK_POP,
ACTION_INPUT_NEW_LINE, ACTION_INPUT_SUBMIT, ACTION_INPUT_TAB, ACTION_SELECT_CANCEL,
ACTION_SELECT_CONFIRM, ACTION_SELECT_DOWN, ACTION_SELECT_UP, get_keybindings,
};
use crate::tui::keys::key_event_to_string;
use crate::tui::kill_ring::KillRing;
use crate::tui::util::is_whitespace_char;
use std::collections::HashMap;
use crate::tui::undo_stack::UndoStack;
use crate::tui::util::{visible_width, visual_col_to_byte_offset, wrap_text_with_ansi};
use crate::tui::word_nav::{
WordNavigationOptions, find_word_backward_with, find_word_forward_with,
};
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use unicode_segmentation::UnicodeSegmentation;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum JumpDirection {
Forward,
Backward,
}
pub struct EditorOptions {
pub padding_x: usize,
}
impl Default for EditorOptions {
fn default() -> Self {
Self { padding_x: 1 }
}
}
pub struct Editor {
lines: Vec<String>,
cursor_line: usize,
cursor_col: usize,
padding_x: usize,
scroll_offset: usize,
focused: bool,
kill_ring: KillRing,
undo_stack: UndoStack<EditorSnapshot>,
history: Vec<String>,
history_index: i32,
history_draft: Option<EditorSnapshot>,
preferred_col: Option<usize>,
last_width: std::cell::Cell<usize>,
last_action: Option<String>,
pub on_submit: Option<Box<dyn FnMut(String) + Send>>,
pub on_change: Option<Box<dyn FnMut(&str)>>,
pub disable_submit: bool,
pub border_color: crate::tui::Style,
jump_mode: Option<JumpDirection>,
autocomplete_provider: Option<Box<dyn AutocompleteProvider>>,
pastes: HashMap<u32, String>,
paste_counter: u32,
pub just_submitted: bool,
pub last_submitted_text: String,
terminal_rows: usize,
autocomplete_max_visible: usize,
autocomplete_list: Option<SelectList>,
pub autocomplete_active: bool,
autocomplete_prefix: String,
last_autocomplete_trigger: std::time::Instant,
}
#[derive(Debug, Clone)]
struct EditorSnapshot {
lines: Vec<String>,
cursor_line: usize,
cursor_col: usize,
}
impl Editor {
pub fn new(options: EditorOptions) -> Self {
Self {
lines: vec![String::new()],
cursor_line: 0,
cursor_col: 0,
padding_x: options.padding_x,
scroll_offset: 0,
focused: false,
kill_ring: KillRing::new(),
undo_stack: UndoStack::new(),
history: Vec::new(),
history_index: -1,
history_draft: None,
preferred_col: None,
last_width: std::cell::Cell::new(80),
last_action: None,
on_submit: None,
on_change: None,
disable_submit: false,
terminal_rows: 24,
autocomplete_max_visible: 5,
autocomplete_list: None,
autocomplete_active: false,
autocomplete_prefix: String::new(),
last_autocomplete_trigger: std::time::Instant::now(),
border_color: crate::tui::Style::default(),
autocomplete_provider: None,
pastes: HashMap::new(),
paste_counter: 0,
just_submitted: false,
last_submitted_text: String::new(),
jump_mode: None,
}
}
pub fn get_text(&self) -> String {
self.lines.join("\n")
}
pub fn get_lines(&self) -> &[String] {
&self.lines
}
pub fn get_cursor(&self) -> (usize, usize) {
(self.cursor_line, self.cursor_col)
}
pub fn set_terminal_rows(&mut self, rows: usize) {
self.terminal_rows = rows;
}
pub fn set_padding_x(&mut self, padding: usize) {
self.padding_x = padding;
}
pub fn set_autocomplete_max_visible(&mut self, max: usize) {
self.autocomplete_max_visible = max.clamp(3, 20);
}
fn set_text_internal(&mut self, text: &str) {
self.lines = if text.is_empty() {
vec![String::new()]
} else {
text.split('\n').map(|s| s.to_string()).collect()
};
self.cursor_line = self.lines.len().saturating_sub(1);
self.cursor_col = self.lines.last().map_or(0, |l| l.len());
self.scroll_offset = 0;
self.preferred_col = None;
}
pub fn set_text(&mut self, text: &str) {
self.clear_autocomplete();
self.last_action = None;
self.exit_history();
if self.get_text() != text {
self.push_undo();
}
self.set_text_internal(text);
self.notify_change();
}
pub fn add_to_history(&mut self, text: &str) {
let trimmed = text.trim().to_string();
if trimmed.is_empty() {
return;
}
if !self.history.is_empty() && self.history[0] == trimmed {
return;
}
self.history.insert(0, trimmed);
if self.history.len() > 100 {
self.history.pop();
}
self.history_index = -1;
}
pub fn insert_text_at_cursor(&mut self, text: &str) {
self.clear_autocomplete();
self.exit_history();
self.last_action = None;
self.push_undo();
self.insert_text_internal(text);
}
pub fn set_autocomplete_provider(&mut self, provider: Box<dyn AutocompleteProvider>) {
self.autocomplete_provider = Some(provider);
}
pub fn set_autocomplete(&mut self, items: Vec<SelectItem>) {
if items.is_empty() {
self.autocomplete_active = false;
self.autocomplete_list = None;
return;
}
self.set_autocomplete_with_layout(items, None);
}
fn set_autocomplete_with_layout(
&mut self,
items: Vec<SelectItem>,
layout: Option<crate::tui::components::select_list::SelectListLayoutOptions>,
) {
if items.is_empty() {
self.autocomplete_active = false;
self.autocomplete_list = None;
return;
}
let theme = SelectListTheme {
selected_prefix: Box::new(|s| {
format!("\x1b[7m\x1b[38;2;138;190;183m→ {}\x1b[27m\x1b[39m", s)
}),
selected_text: Box::new(|s| {
format!("\x1b[7m\x1b[38;2;138;190;183m{}\x1b[27m\x1b[39m", s)
}),
normal_text: Box::new(|s| format!("\x1b[38;2;128;128;128m{}\x1b[39m", s)),
description: Box::new(|s| format!("\x1b[38;2;128;128;128m{}\x1b[39m", s)),
scroll_info: crate::tui::Style::new().fg("\x1b[38;2;128;128;128m".to_string()),
no_match: crate::tui::Style::new(),
hint: crate::tui::Style::new(),
};
let best = self.best_autocomplete_index(&items);
let mut list = SelectList::new(items, self.autocomplete_max_visible, theme, layout);
list.set_selected_index(best);
self.autocomplete_list = Some(list);
self.autocomplete_active = true;
}
fn best_autocomplete_index(&self, items: &[SelectItem]) -> usize {
let prefix = self.autocomplete_prefix.trim_start_matches(['/', '@', '#']);
if prefix.is_empty() {
return 0;
}
let mut first_prefix = None;
for (i, item) in items.iter().enumerate() {
if item.value == prefix {
return i; }
if first_prefix.is_none() && item.value.starts_with(prefix) {
first_prefix = Some(i);
}
}
first_prefix.unwrap_or(0)
}
pub fn clear_autocomplete(&mut self) {
self.autocomplete_active = false;
self.autocomplete_list = None;
self.autocomplete_prefix.clear();
}
fn apply_autocomplete_completion_value(&mut self, val: &str) {
if let Some(ref provider) = self.autocomplete_provider {
let prefix = if !self.autocomplete_prefix.is_empty() {
self.autocomplete_prefix.clone()
} else {
self.get_autocomplete_prefix()
};
let item = crate::tui::autocomplete::AutocompleteItem {
value: val.to_string(),
label: val.to_string(),
description: None,
};
let (new_lines, new_line, new_col) = provider.apply_completion(
&self.lines,
self.cursor_line,
self.cursor_col,
&item,
&prefix,
);
self.lines = new_lines;
self.cursor_line = new_line;
self.cursor_col = new_col;
} else {
self.set_text(&format!("/{} ", val));
}
}
fn update_autocomplete_if_active(&mut self) {
if self.autocomplete_active {
self.try_trigger_autocomplete();
}
}
fn retrigger_autocomplete_dismissed(&mut self) {
if self.autocomplete_active {
return; }
if self.is_in_slash_command_context() {
self.try_trigger_autocomplete();
return;
}
let line = self
.lines
.get(self.cursor_line)
.map(|l| l.as_str())
.unwrap_or("");
let before = &line[..self.cursor_col.min(line.len())];
if before.contains('@') || before.contains('#') || before.contains('~') {
self.try_trigger_autocomplete();
}
}
pub fn autocomplete_selected_value(&self) -> Option<String> {
self.autocomplete_list
.as_ref()
.and_then(|l| l.selected_item())
.map(|item| item.value.clone())
}
pub fn autocomplete_is_empty(&self) -> bool {
self.autocomplete_list
.as_ref()
.is_none_or(|l| l.items().is_empty())
}
fn maybe_push_undo(&mut self, ch: &str) {
if is_whitespace_char(ch) || self.last_action.as_deref() != Some("type_word") {
self.undo_stack.push(&EditorSnapshot {
lines: self.lines.clone(),
cursor_line: self.cursor_line,
cursor_col: self.cursor_col,
});
}
self.last_action = Some("type_word".into());
}
fn push_undo(&mut self) {
self.undo_stack.push(&EditorSnapshot {
lines: self.lines.clone(),
cursor_line: self.cursor_line,
cursor_col: self.cursor_col,
});
}
fn undo(&mut self) {
if let Some(snap) = self.undo_stack.pop() {
self.lines = snap.lines;
self.cursor_line = snap.cursor_line;
self.cursor_col = snap.cursor_col;
self.preferred_col = None;
}
}
fn set_cursor_col(&mut self, col: usize) {
self.cursor_col = col;
self.preferred_col = None;
}
fn insert_text_internal(&mut self, text: &str) {
if text.is_empty() {
return;
}
let normalized = text.replace("\r\n", "\n").replace('\t', " ");
let inserted_lines: Vec<&str> = normalized.split('\n').collect();
let current_line = self.lines[self.cursor_line].clone();
let before = ¤t_line[..self.cursor_col.min(current_line.len())];
let after = ¤t_line[self.cursor_col.min(current_line.len())..];
if inserted_lines.len() == 1 {
self.lines[self.cursor_line] = format!("{}{}{}", before, normalized, after);
self.set_cursor_col(self.cursor_col + normalized.len());
} else {
let mut new_lines: Vec<String> = Vec::new();
new_lines.extend(self.lines[..self.cursor_line].iter().cloned());
new_lines.push(format!("{}{}", before, inserted_lines[0]));
for line in &inserted_lines[1..inserted_lines.len() - 1] {
new_lines.push(line.to_string());
}
new_lines.push(format!("{}{}", inserted_lines.last().unwrap_or(&""), after));
new_lines.extend(self.lines[self.cursor_line + 1..].iter().cloned());
self.lines = new_lines;
self.cursor_line += inserted_lines.len() - 1;
self.set_cursor_col(inserted_lines.last().map_or(0, |l| l.len()));
}
self.notify_change();
}
fn insert_character(&mut self, ch: &str) {
self.exit_history();
self.maybe_push_undo(ch);
self.insert_text_internal(ch);
self.update_autocomplete(ch);
}
fn is_slash_menu_allowed(&self) -> bool {
self.cursor_line == 0
}
fn is_at_start_of_message(&self) -> bool {
if !self.is_slash_menu_allowed() {
return false;
}
let line = self
.lines
.get(self.cursor_line)
.map(|l| l.as_str())
.unwrap_or("");
let before = &line[..self.cursor_col.min(line.len())];
let trimmed = before.trim();
trimmed.is_empty() || trimmed == "/"
}
fn is_in_slash_command_context(&self) -> bool {
if !self.is_slash_menu_allowed() {
return false;
}
let line = self
.lines
.get(self.cursor_line)
.map(|l| l.as_str())
.unwrap_or("");
let before = &line[..self.cursor_col.min(line.len())];
before.trim_start().starts_with('/')
}
fn update_autocomplete(&mut self, ch: &str) {
if self.autocomplete_active {
self.try_trigger_autocomplete();
return;
}
let current_line = &self.lines[self.cursor_line];
let text_before = ¤t_line[..self.cursor_col.min(current_line.len())];
if ch == "/" && self.is_at_start_of_message() {
self.try_trigger_autocomplete();
return;
}
if ch == "/" && !self.is_at_start_of_message() {
let before_char = text_before.chars().nth_back(1);
if text_before.len() >= 2
&& before_char
.is_some_and(|c| c == '~' || c == '@' || c == '#' || c.is_whitespace())
{
self.try_trigger_autocomplete();
return;
}
}
if ch == "@" || ch == "#" {
let before_char = text_before.chars().nth_back(1);
if text_before.len() == 1
|| before_char.is_none_or(|c| c.is_whitespace() || c == ' ' || c == '\t')
{
self.try_trigger_autocomplete();
return;
}
}
if ch == "~" {
let before_char = text_before.chars().nth_back(1);
if text_before.len() == 1
|| before_char.is_none_or(|c| c.is_whitespace() || c == ' ' || c == '\t')
{
self.try_trigger_autocomplete();
return;
}
}
if let Some(ref provider) = self.autocomplete_provider {
for tc in provider.trigger_characters() {
if ch.len() == 1 && ch == tc.to_string() && tc != &'/' && tc != &'@' && tc != &'#'
{
let before_char = text_before.chars().nth_back(1);
if text_before.len() == 1
|| before_char.is_none_or(|c| c.is_whitespace() || c == ' ' || c == '\t')
{
self.try_trigger_autocomplete();
return;
}
}
}
}
if ch.len() == 1
&& ch
.chars()
.next()
.is_some_and(|c| c.is_alphanumeric() || c == '-' || c == '_')
{
if self.is_in_slash_command_context() && !text_before.trim_start().contains(' ') {
self.try_trigger_autocomplete();
return;
}
if text_before.contains('@') || text_before.contains('#') || text_before.contains('~') {
self.try_trigger_autocomplete();
}
}
}
fn get_autocomplete_prefix(&self) -> String {
let line = self
.lines
.get(self.cursor_line)
.map(|l| l.as_str())
.unwrap_or("");
let before = &line[..self.cursor_col.min(line.len())];
if before.starts_with('/') && !before.contains(' ') {
before.to_string()
} else if let Some(pos) = before.rfind(['@', '#']) {
before[pos..].to_string()
} else if let Some(pos) = before.rfind(|c: char| c.is_whitespace()) {
before[pos + 1..].to_string()
} else {
before.to_string()
}
}
fn trigger_autocomplete(&mut self, force: bool) {
let Some(ref provider) = self.autocomplete_provider else {
return;
};
if !force {
let line = self
.lines
.get(self.cursor_line)
.map(|l| l.as_str())
.unwrap_or("");
let before = &line[..self.cursor_col.min(line.len())];
let is_slash = before.starts_with('/');
if !is_slash && !before.is_empty() {
let elapsed = self.last_autocomplete_trigger.elapsed();
if elapsed < std::time::Duration::from_millis(20) {
return;
}
}
}
self.last_autocomplete_trigger = std::time::Instant::now();
let Some(suggestions) =
provider.get_suggestions(&self.lines, self.cursor_line, self.cursor_col, force)
else {
self.clear_autocomplete();
return;
};
let items = suggestions.items;
let prefix = suggestions.prefix;
if items.is_empty() {
self.clear_autocomplete();
return;
}
if force && items.len() == 1 {
let (new_lines, new_line, new_col) = provider.apply_completion(
&self.lines,
self.cursor_line,
self.cursor_col,
&items[0],
&prefix,
);
self.lines = new_lines;
self.cursor_line = new_line;
self.cursor_col = new_col;
self.clear_autocomplete();
return;
}
let select_items: Vec<SelectItem> = items
.into_iter()
.map(|item| {
let mut si = SelectItem::new(item.value, item.label);
if let Some(desc) = item.description {
si = si.with_description(desc);
}
si
})
.collect();
let layout = if prefix.starts_with('/') {
Some(
crate::tui::components::select_list::SelectListLayoutOptions {
min_primary_column_width: Some(12),
max_primary_column_width: Some(32),
truncate_primary: None,
},
)
} else {
None
};
self.set_autocomplete_with_layout(select_items, layout);
self.autocomplete_prefix = prefix;
}
pub fn try_trigger_autocomplete(&mut self) {
self.trigger_autocomplete(false);
}
fn try_trigger_autocomplete_force(&mut self) {
self.trigger_autocomplete(true);
}
fn add_newline(&mut self) {
self.exit_history();
self.last_action = None;
self.push_undo();
let line = self.lines[self.cursor_line].clone();
let before = &line[..self.cursor_col.min(line.len())];
let after = &line[self.cursor_col.min(line.len())..];
self.lines[self.cursor_line] = before.to_string();
self.lines.insert(self.cursor_line + 1, after.to_string());
self.cursor_line += 1;
self.set_cursor_col(0);
self.notify_change();
}
fn grapheme_or_paste_before(&self, line: &str, cursor: usize) -> Option<(usize, usize)> {
for &(start, end) in &Self::find_paste_marker_spans(line) {
if cursor >= end && cursor < end + 10 {
if cursor == end {
return Some((start, end - start));
}
}
}
for &(start, end) in &Self::find_paste_marker_spans(line) {
if cursor > start && cursor < end {
return Some((start, end - start));
}
}
let graphemes: Vec<(usize, &str)> = line[..cursor].grapheme_indices(true).collect();
graphemes.last().map(|&(idx, g)| (idx, g.len()))
}
fn grapheme_or_paste_after(&self, line: &str, cursor: usize) -> Option<(usize, usize)> {
for &(start, end) in &Self::find_paste_marker_spans(line) {
if cursor == start {
return Some((start, end - start));
}
}
let graphemes: Vec<(usize, &str)> = line[cursor..].grapheme_indices(true).collect();
graphemes.first().map(|&(i, g)| (cursor + i, g.len()))
}
fn backspace(&mut self) {
self.exit_history();
self.last_action = None;
if self.cursor_col > 0 {
self.push_undo();
let line = self.lines[self.cursor_line].clone();
if let Some((idx, len)) = self.grapheme_or_paste_before(&line, self.cursor_col) {
self.lines[self.cursor_line].drain(idx..idx + len);
self.set_cursor_col(idx);
}
} else if self.cursor_line > 0 {
self.push_undo();
let current = self.lines.remove(self.cursor_line);
self.cursor_line -= 1;
let prev_len = self.lines[self.cursor_line].len();
self.lines[self.cursor_line].push_str(¤t);
self.set_cursor_col(prev_len);
}
self.notify_change();
}
fn delete_forward(&mut self) {
self.exit_history();
self.last_action = None;
let line = self.lines[self.cursor_line].clone();
if self.cursor_col < line.len() {
self.push_undo();
if let Some((idx, len)) = self.grapheme_or_paste_after(&line, self.cursor_col) {
self.lines[self.cursor_line].drain(idx..idx + len);
}
} else if self.cursor_line + 1 < self.lines.len() {
self.push_undo();
let next = self.lines.remove(self.cursor_line + 1);
self.lines[self.cursor_line].push_str(&next);
}
self.notify_change();
self.retrigger_autocomplete_dismissed();
}
fn delete_to_line_start(&mut self) {
self.exit_history();
let line = self.lines[self.cursor_line].clone();
if self.cursor_col > 0 {
self.push_undo();
let deleted = line[..self.cursor_col].to_string();
let accumulate = self.last_action.as_deref() == Some("kill");
self.kill_ring.push(&deleted, true, accumulate);
self.last_action = Some("kill".into());
self.lines[self.cursor_line] = line[self.cursor_col..].to_string();
self.set_cursor_col(0);
} else if self.cursor_line > 0 {
self.push_undo();
let accumulate = self.last_action.as_deref() == Some("kill");
self.kill_ring.push("\n", true, accumulate);
self.last_action = Some("kill".into());
let current = self.lines.remove(self.cursor_line);
self.cursor_line -= 1;
let prev_len = self.lines[self.cursor_line].len();
self.lines[self.cursor_line].push_str(¤t);
self.set_cursor_col(prev_len);
}
self.notify_change();
}
fn delete_to_line_end(&mut self) {
self.exit_history();
let line = self.lines[self.cursor_line].clone();
if self.cursor_col < line.len() {
self.push_undo();
let deleted = line[self.cursor_col..].to_string();
let accumulate = self.last_action.as_deref() == Some("kill");
self.kill_ring.push(&deleted, false, accumulate);
self.last_action = Some("kill".into());
self.lines[self.cursor_line] = line[..self.cursor_col].to_string();
} else if self.cursor_line + 1 < self.lines.len() {
self.push_undo();
let accumulate = self.last_action.as_deref() == Some("kill");
self.kill_ring.push("\n", false, accumulate);
self.last_action = Some("kill".into());
let next = self.lines.remove(self.cursor_line + 1);
self.lines[self.cursor_line].push_str(&next);
}
self.notify_change();
}
fn delete_word_backward(&mut self) {
self.exit_history();
let line = self.lines[self.cursor_line].clone();
if self.cursor_col == 0 {
return;
}
let opts = WordNavigationOptions {
segment: None,
is_atomic_segment: Some(&|s: &str| s.starts_with("[paste #") && s.ends_with(']')),
};
let new_col = find_word_backward_with(&line, self.cursor_col, &opts);
if new_col < self.cursor_col {
self.push_undo();
let deleted = line[new_col..self.cursor_col].to_string();
let accumulate = self.last_action.as_deref() == Some("kill");
self.kill_ring.push(&deleted, true, accumulate);
self.last_action = Some("kill".into());
self.lines[self.cursor_line].drain(new_col..self.cursor_col);
self.set_cursor_col(new_col);
self.notify_change();
}
}
fn delete_word_forward(&mut self) {
self.exit_history();
let line = self.lines[self.cursor_line].clone();
if self.cursor_col >= line.len() {
return;
}
let opts = WordNavigationOptions {
segment: None,
is_atomic_segment: Some(&|s: &str| s.starts_with("[paste #") && s.ends_with(']')),
};
let new_col = find_word_forward_with(&line, self.cursor_col, &opts);
if new_col > self.cursor_col {
self.push_undo();
let deleted = line[self.cursor_col..new_col].to_string();
let accumulate = self.last_action.as_deref() == Some("kill");
self.kill_ring.push(&deleted, false, accumulate);
self.last_action = Some("kill".into());
self.lines[self.cursor_line].drain(self.cursor_col..new_col);
self.notify_change();
}
}
fn yank(&mut self) {
self.exit_history();
let text = self.kill_ring.peek().map(|s| s.to_string());
if let Some(text) = text {
self.push_undo();
self.cursor_col += text.len();
self.lines[self.cursor_line].insert_str(self.cursor_col - text.len(), &text);
self.last_action = Some("yank".into());
self.notify_change();
}
}
fn yank_pop(&mut self) {
if self.last_action.as_deref() != Some("yank") || self.kill_ring.len() <= 1 {
return;
}
self.push_undo();
let prev = self.kill_ring.peek().map(|s| s.to_string());
if let Some(ref prev_text) = prev {
let line = &self.lines[self.cursor_line].clone();
if self.cursor_col >= prev_text.len() {
let before = &line[..self.cursor_col - prev_text.len()];
let after = &line[self.cursor_col..];
self.lines[self.cursor_line] = format!("{}{}", before, after);
self.cursor_col -= prev_text.len();
}
}
self.kill_ring.rotate();
let text = self.kill_ring.peek().map(|s| s.to_string());
if let Some(ref new_text) = text {
self.cursor_col += new_text.len();
self.lines[self.cursor_line].insert_str(self.cursor_col - new_text.len(), new_text);
}
self.last_action = Some("yank".into());
self.notify_change();
}
fn move_left(&mut self) {
self.last_action = None;
if self.cursor_col > 0 {
let line = &self.lines[self.cursor_line].clone();
let graphemes: Vec<(usize, &str)> =
line[..self.cursor_col].grapheme_indices(true).collect();
if let Some(&(idx, _g)) = graphemes.last() {
let raw = idx;
self.set_cursor_col(Self::snap_paste_marker(line, raw, true));
}
} else if self.cursor_line > 0 {
self.cursor_line -= 1;
self.set_cursor_col(self.lines[self.cursor_line].len());
}
}
fn move_right(&mut self) {
self.last_action = None;
let line = &self.lines[self.cursor_line].clone();
if self.cursor_col < line.len() {
let mut it = line[self.cursor_col..].grapheme_indices(true);
if let Some((idx, g)) = it.next() {
let raw = self.cursor_col + idx + g.len();
self.set_cursor_col(Self::snap_paste_marker(line, raw, false));
}
} else if self.cursor_line + 1 < self.lines.len() {
self.cursor_line += 1;
self.set_cursor_col(0);
}
}
fn move_up(&mut self) {
self.move_vertical(-1);
}
fn move_down(&mut self) {
self.move_vertical(1);
}
fn move_to_line_start(&mut self) {
self.last_action = None;
self.set_cursor_col(0);
}
fn move_to_line_end(&mut self) {
self.last_action = None;
let len = self.lines[self.cursor_line].len();
self.set_cursor_col(len);
}
fn build_visual_line_spans(&self, width: usize) -> Vec<(usize, usize, usize)> {
let mut spans = Vec::new();
for (i, line) in self.lines.iter().enumerate() {
let line_w = visible_width(line);
if line.is_empty() {
spans.push((i, 0, 0));
} else if line_w <= width {
spans.push((i, 0, line.len()));
} else {
let chunks = crate::tui::util::wrap_text_with_ansi(line, width);
let mut byte_pos = 0;
for chunk in &chunks {
let chunk_len = chunk.len();
spans.push((i, byte_pos, chunk_len));
byte_pos += chunk_len;
}
}
}
spans
}
fn find_current_visual_line(&self, spans: &[(usize, usize, usize)]) -> usize {
for (i, &(li, start, len)) in spans.iter().enumerate() {
if li != self.cursor_line {
continue;
}
let offset = self.cursor_col.saturating_sub(start);
let is_last = i + 1 >= spans.len() || spans[i + 1].0 != li;
if offset <= len || (is_last && offset == len) {
return i;
}
}
spans.len().saturating_sub(1)
}
fn move_to_visual_line(
&mut self,
spans: &[(usize, usize, usize)],
current_vis: usize,
target_vis: usize,
) {
let (cur_li, _cur_start, cur_len) = spans[current_vis];
let (tgt_li, tgt_start, tgt_len) = spans[target_vis];
let cur_vis_col = self.cursor_col;
let is_last_source = current_vis + 1 >= spans.len() || spans[current_vis + 1].0 != cur_li;
let src_max = if is_last_source {
cur_len
} else {
cur_len.saturating_sub(1)
};
let is_last_target = target_vis + 1 >= spans.len() || spans[target_vis + 1].0 != tgt_li;
let tgt_max = if is_last_target {
tgt_len
} else {
tgt_len.saturating_sub(1)
};
let has_pref = self.preferred_col.is_some();
let cursor_in_middle = cur_vis_col < src_max;
let target_too_short = tgt_max < cur_vis_col;
let move_to_col = if !has_pref || cursor_in_middle {
if target_too_short {
self.preferred_col = Some(cur_vis_col);
tgt_max
} else {
self.preferred_col = None;
cur_vis_col
}
} else {
let pref = self.preferred_col.unwrap_or(0);
let target_cant_fit_pref = tgt_max < pref;
if target_too_short || target_cant_fit_pref {
tgt_max
} else {
self.preferred_col = None;
pref
}
};
self.cursor_line = tgt_li;
let raw_col = tgt_start + move_to_col;
let line = &self.lines[tgt_li].clone();
self.cursor_col = raw_col.min(line.len());
let moving_up = target_vis < current_vis;
self.cursor_col = Self::snap_paste_marker(line, self.cursor_col, moving_up);
}
fn move_vertical(&mut self, delta: isize) {
let width = self.last_width.get();
let spans = self.build_visual_line_spans(width);
let current_vis = self.find_current_visual_line(&spans);
let target_vis = if delta < 0 {
if current_vis == 0 {
return;
}
current_vis - 1
} else if current_vis + 1 >= spans.len() {
return;
} else {
current_vis + 1
};
self.move_to_visual_line(&spans, current_vis, target_vis);
}
fn jump_to_char(&mut self, ch: char, dir: JumpDirection) {
let is_forward = dir == JumpDirection::Forward;
let lines = &self.lines;
let start_line = self.cursor_line as isize;
let end = if is_forward { lines.len() as isize } else { -1 };
let step: isize = if is_forward { 1 } else { -1 };
let mut line_idx = start_line;
while line_idx != end {
let line = &lines[line_idx as usize];
let is_current = line_idx == start_line;
let search_from = if is_current {
if is_forward {
self.cursor_col + 1
} else {
self.cursor_col.saturating_sub(1)
}
} else if is_forward {
0
} else {
line.len()
};
let idx = if is_forward {
line[search_from..].find(ch).map(|i| search_from + i)
} else if search_from > 0 {
line[..search_from].rfind(ch)
} else {
None
};
if let Some(pos) = idx {
self.cursor_line = line_idx as usize;
self.set_cursor_col(pos);
return;
}
line_idx += step;
}
}
fn exit_history(&mut self) {
self.history_index = -1;
self.history_draft = None;
self.last_action = None;
}
fn recall_older(&mut self) {
if self.history.is_empty() {
return;
}
let idx = if self.history_index < 0 {
0
} else {
self.history_index + 1
};
if idx >= self.history.len() as i32 {
return; }
if self.history_index < 0 && idx >= 0 {
self.history_draft = Some(EditorSnapshot {
lines: self.lines.clone(),
cursor_line: self.cursor_line,
cursor_col: self.cursor_col,
});
}
let text = self.history[idx as usize].clone();
self.set_text_internal(&text);
self.cursor_col = 0; self.history_index = idx;
}
fn recall_newer(&mut self) {
if self.history_index < 0 {
return;
}
let idx = self.history_index - 1;
if idx < 0 {
if let Some(draft) = self.history_draft.take() {
self.lines = draft.lines;
self.cursor_line = draft.cursor_line;
self.cursor_col = draft.cursor_col;
self.preferred_col = None;
} else {
self.set_text_internal("");
}
self.history_index = -1;
} else {
let text = self.history[idx as usize].clone();
self.set_text_internal(&text);
self.history_index = idx;
}
}
fn decode_csi_u_in_paste(&self, text: &str) -> String {
let re = regex::Regex::new(r"\x1b\[(\d+);5u").unwrap();
re.replace_all(text, |caps: ®ex::Captures| {
let cp: u32 = caps[1].parse().unwrap_or(0);
if (97..=122).contains(&cp) {
char::from_u32(cp - 96)
.map(|c| c.to_string())
.unwrap_or_default()
} else if (65..=90).contains(&cp) {
char::from_u32(cp - 64)
.map(|c| c.to_string())
.unwrap_or_default()
} else {
caps[0].to_string()
}
})
.to_string()
}
fn find_paste_marker_spans(line: &str) -> Vec<(usize, usize)> {
let mut spans = Vec::new();
let mut pos = 0;
while let Some(start) = line[pos..].find("[paste #") {
let abs_start = pos + start;
if let Some(end) = line[abs_start..].find(']') {
let abs_end = abs_start + end + 1;
spans.push((abs_start, abs_end));
pos = abs_end;
} else {
break;
}
}
spans
}
fn snap_paste_marker(line: &str, cursor: usize, moving_left: bool) -> usize {
for &(start, end) in &Self::find_paste_marker_spans(line) {
if cursor > start && cursor < end {
return if moving_left { start } else { end };
}
}
cursor
}
pub fn handle_paste(&mut self, text: &str) {
self.clear_autocomplete();
self.exit_history();
self.last_action = None;
self.push_undo();
let decoded = self.decode_csi_u_in_paste(text);
let normalized = decoded
.replace("\r\n", "\n")
.replace('\r', "\n")
.replace('\t', " ");
let filtered: String = normalized
.chars()
.filter(|&c| c == '\n' || c == ' ' || c as u32 >= 32)
.collect();
let current_line = self.lines[self.cursor_line].clone();
let space_prefix = if filtered.starts_with('/')
|| filtered.starts_with('~')
|| filtered.starts_with('.')
{
if self.cursor_col > 0 {
let prev = current_line
.as_bytes()
.get(self.cursor_col - 1)
.copied()
.unwrap_or(b' ');
if prev.is_ascii_alphanumeric() || prev == b'_' {
" "
} else {
""
}
} else {
""
}
} else {
""
};
let prepared = format!("{}{}", space_prefix, filtered);
let total_chars = prepared.len();
let is_large = prepared.lines().count().max(1) > 10 || total_chars > 1000;
if is_large {
let line_count = prepared.lines().count();
self.paste_counter += 1;
let paste_id = self.paste_counter;
self.pastes.insert(paste_id, prepared);
let marker = if line_count > 10 {
format!("[paste #{} +{} lines]", paste_id, line_count)
} else {
format!("[paste #{} {} chars]", paste_id, total_chars)
};
self.insert_text_internal(&marker);
} else {
self.insert_text_internal(&prepared);
}
}
pub fn expand_paste_markers(&self, text: &str) -> String {
let mut result = text.to_string();
let mut ids: Vec<u32> = self.pastes.keys().copied().collect();
ids.sort_unstable_by(|a, b| b.cmp(a)); for paste_id in ids {
if let Some(content) = self.pastes.get(&paste_id) {
let marker1 = format!("[paste #{} ", paste_id);
loop {
let start = result.find(&marker1);
match start {
Some(pos) => {
let end = result[pos..]
.find(']')
.map(|e| pos + e + 1)
.unwrap_or(result.len());
result.replace_range(pos..end, content);
}
None => break,
}
}
}
}
result
}
pub fn get_expanded_text(&self) -> String {
self.expand_paste_markers(&self.lines.join("\n"))
}
pub fn is_paste_marker(segment: &str) -> bool {
segment.starts_with("[paste #") && segment.ends_with(']')
}
fn page_size(&self) -> usize {
std::cmp::max(5, (self.terminal_rows as f64 * 0.3) as usize)
}
fn page_up(&mut self) {
let size = self.page_size();
self.scroll_offset = self.scroll_offset.saturating_sub(size);
}
fn page_down(&mut self) {
let size = self.page_size();
self.scroll_offset += size;
}
fn submit(&mut self) {
let raw = self.lines.join("\n");
let result = self.expand_paste_markers(&raw);
self.last_submitted_text = result.clone();
self.lines = vec![String::new()];
self.cursor_line = 0;
self.cursor_col = 0;
self.scroll_offset = 0;
self.pastes.clear();
self.paste_counter = 0;
self.undo_stack.clear();
self.last_action = None;
self.preferred_col = None;
self.just_submitted = true;
self.exit_history();
if let Some(ref mut cb) = self.on_submit {
cb(result);
}
self.notify_change();
}
fn notify_change(&mut self) {
let text = self.get_text();
if let Some(ref mut cb) = self.on_change {
cb(&text);
}
if self.autocomplete_active {
self.try_trigger_autocomplete();
}
}
fn is_empty(&self) -> bool {
self.lines.is_empty() || (self.lines.len() == 1 && self.lines[0].is_empty())
}
fn is_first_visual_line(&self) -> bool {
let width = self.last_width.get();
let visual_lines = layout_text(&self.lines, width, self.cursor_line, self.cursor_col);
let current = visual_lines
.iter()
.position(|vl| vl.has_cursor)
.unwrap_or(0);
current == 0
}
fn is_last_visual_line(&self) -> bool {
let width = self.last_width.get();
let visual_lines = layout_text(&self.lines, width, self.cursor_line, self.cursor_col);
let current = visual_lines
.iter()
.position(|vl| vl.has_cursor)
.unwrap_or(0);
current >= visual_lines.len().saturating_sub(1)
}
}
impl Component for Editor {
fn render(&mut self, width: usize) -> Vec<String> {
let max_padding = if width > 1 { (width - 1) / 2 } else { 0 };
let pad_x = self.padding_x.min(max_padding);
let content_width = if width > pad_x * 2 {
width - pad_x * 2
} else {
1
};
let layout_width = content_width
.max(1)
.saturating_sub(if pad_x > 0 { 0 } else { 1 });
self.last_width.set(layout_width);
let horizontal = "─";
let left_pad = " ".repeat(pad_x);
let right_pad = " ".repeat(pad_x);
let mut result: Vec<String> = Vec::new();
let visual_lines =
layout_text(&self.lines, layout_width, self.cursor_line, self.cursor_col);
let total_visual = visual_lines.len().max(1);
let cursor_vis = visual_lines
.iter()
.position(|vl| vl.has_cursor)
.unwrap_or(0);
let max_vis = std::cmp::max(5, (self.terminal_rows as f64 * 0.3) as usize).max(1);
let mut scroll = self.scroll_offset;
if cursor_vis < scroll {
scroll = cursor_vis;
} else if cursor_vis >= scroll + max_vis {
scroll = cursor_vis - max_vis + 1;
}
let max_scroll = total_visual.saturating_sub(max_vis);
scroll = scroll.min(max_scroll);
let visible_end = (scroll + max_vis).min(total_visual);
if scroll > 0 {
let indicator = format!("─── ↑ {} more ", scroll);
let indicator_w = visible_width(&indicator);
let fill = if indicator_w < width {
horizontal.repeat(width - indicator_w)
} else {
String::new()
};
result.push(self.border_color.apply(&format!("{}{}", indicator, fill)));
} else {
result.push(self.border_color.apply(&horizontal.repeat(width)));
}
for vl in visual_lines.iter().skip(scroll).take(visible_end - scroll) {
let text = &vl.text;
let (display, line_width) = if vl.has_cursor {
let cursor_pos = vl.cursor_pos.unwrap_or(0);
let before = &text[..cursor_pos.min(text.len())];
let after = &text[cursor_pos.min(text.len())..];
let marker = if self.focused {
CURSOR_MARKER.to_string()
} else {
String::new()
};
if !after.is_empty() {
let after_graphemes: Vec<&str> = after.graphemes(true).collect();
let first_g = after_graphemes.first().copied().unwrap_or(" ");
let rest = &after[first_g.len()..];
let cursor = format!("\x1b[7m{}\x1b[0m", first_g);
(
format!("{}{}{}{}", before, marker, cursor, rest),
visible_width(text),
)
} else if !before.is_empty() {
let cursor_block = "\x1b[7m \x1b[0m";
(
format!("{}{}{}", before, cursor_block, marker),
visible_width(text) + 1,
)
} else {
let cursor = "\x1b[7m \x1b[0m";
(
format!("{}{}{}", before, marker, cursor),
visible_width(text) + 1,
)
}
} else {
(text.clone(), visible_width(text))
};
let cursor_in_padding = line_width > content_width && pad_x > 0;
let padding = if line_width < content_width {
" ".repeat(content_width - line_width)
} else {
String::new()
};
let right_pad_used = if cursor_in_padding {
&right_pad[1..]
} else {
&right_pad
};
result.push(format!(
"{}{}{}{}",
left_pad, display, padding, right_pad_used
));
}
let below = total_visual.saturating_sub(visible_end);
if below > 0 {
let indicator = format!("─── ↓ {} more ", below);
let indicator_w = visible_width(&indicator);
let fill = if indicator_w < width {
horizontal.repeat(width - indicator_w)
} else {
String::new()
};
result.push(self.border_color.apply(&format!("{}{}", indicator, fill)));
} else {
result.push(self.border_color.apply(&horizontal.repeat(width)));
}
if self.autocomplete_active
&& let Some(ref mut list) = self.autocomplete_list
{
let list_lines = list.render(width);
result.extend(list_lines);
}
result
}
fn handle_input(&mut self, key: &KeyEvent) -> bool {
let kb = get_keybindings();
if let Some(dir) = self.jump_mode {
if kb.matches(key, ACTION_EDITOR_JUMP_FORWARD)
|| kb.matches(key, ACTION_EDITOR_JUMP_BACKWARD)
{
self.jump_mode = None;
return true;
}
if is_printable_plain(key)
&& let Some(s) = key_event_to_string(key)
{
let ch = s.chars().next().unwrap_or(' ');
self.jump_mode = None;
self.jump_to_char(ch, dir);
return true;
}
self.jump_mode = None;
}
let mut ac_selected: Option<String> = None;
let mut ac_complete_and_return = false;
if let Some(ref mut list) = self.autocomplete_list {
if kb.matches(key, ACTION_SELECT_CANCEL) {
self.autocomplete_active = false;
self.autocomplete_list = None;
self.autocomplete_prefix.clear();
return true;
}
if kb.matches(key, ACTION_INPUT_TAB) {
ac_selected = list.selected_item().map(|i| i.value.clone());
ac_complete_and_return = true;
} else if kb.matches(key, ACTION_SELECT_CONFIRM) {
ac_selected = list.selected_item().map(|i| i.value.clone());
let is_slash = self.autocomplete_prefix.starts_with('/');
if !is_slash {
ac_complete_and_return = true;
}
} else if kb.matches(key, ACTION_SELECT_UP) || kb.matches(key, ACTION_SELECT_DOWN) {
list.handle_input(key);
return true;
}
}
if let Some(val) = ac_selected {
self.apply_autocomplete_completion_value(&val);
self.clear_autocomplete();
if ac_complete_and_return {
return true;
}
}
if kb.matches(key, ACTION_INPUT_TAB) && self.autocomplete_provider.is_some() {
self.try_trigger_autocomplete_force();
return true;
}
if kb.matches(key, ACTION_INPUT_SUBMIT) {
if self.disable_submit {
self.add_newline();
return true;
}
let line = &self.lines[self.cursor_line];
if self.cursor_col > 0 && line.as_bytes().get(self.cursor_col - 1) == Some(&b'\\') {
self.backspace();
self.add_newline();
return true;
}
self.submit();
return true;
}
if kb.matches(key, ACTION_EDITOR_JUMP_FORWARD) {
self.jump_mode = Some(JumpDirection::Forward);
return true;
}
if kb.matches(key, ACTION_EDITOR_JUMP_BACKWARD) {
self.jump_mode = Some(JumpDirection::Backward);
return true;
}
if is_printable_plain(key)
&& let Some(s) = key_event_to_string(key)
{
self.insert_character(&s);
return true;
}
if kb.matches(key, ACTION_EDITOR_CURSOR_LEFT) {
self.move_left();
self.update_autocomplete_if_active();
return true;
}
if kb.matches(key, ACTION_EDITOR_CURSOR_RIGHT) {
self.move_right();
self.update_autocomplete_if_active();
return true;
}
if kb.matches(key, ACTION_EDITOR_CURSOR_LINE_START) {
self.move_to_line_start();
self.update_autocomplete_if_active();
return true;
}
if kb.matches(key, ACTION_EDITOR_CURSOR_LINE_END) {
self.move_to_line_end();
self.update_autocomplete_if_active();
return true;
}
if kb.matches(key, ACTION_EDITOR_CURSOR_UP) {
if self.is_first_visual_line()
&& (self.is_empty() || self.history_index >= 0 || self.cursor_col == 0)
{
self.recall_older();
} else if self.is_first_visual_line() {
self.move_to_line_start();
} else {
self.move_up();
}
self.update_autocomplete_if_active();
return true;
}
if kb.matches(key, ACTION_EDITOR_CURSOR_DOWN) {
if self.history_index >= 0 && self.is_last_visual_line() {
self.recall_newer();
} else if self.is_last_visual_line() {
self.move_to_line_end();
} else {
self.move_down();
}
self.update_autocomplete_if_active();
return true;
}
if kb.matches(key, ACTION_EDITOR_PAGE_UP) {
self.page_up();
self.update_autocomplete_if_active();
return true;
}
if kb.matches(key, ACTION_EDITOR_PAGE_DOWN) {
self.page_down();
self.update_autocomplete_if_active();
return true;
}
if kb.matches(key, ACTION_EDITOR_CURSOR_WORD_LEFT) {
let line = &self.lines[self.cursor_line].clone();
if self.cursor_col > 0 {
let opts = WordNavigationOptions {
segment: None,
is_atomic_segment: Some(&|s: &str| {
s.starts_with("[paste #") && s.ends_with(']')
}),
};
let c = find_word_backward_with(line, self.cursor_col, &opts);
self.set_cursor_col(c);
}
self.update_autocomplete_if_active();
return true;
}
if kb.matches(key, ACTION_EDITOR_CURSOR_WORD_RIGHT) {
let line = &self.lines[self.cursor_line].clone();
if self.cursor_col < line.len() {
let opts = WordNavigationOptions {
segment: None,
is_atomic_segment: Some(&|s: &str| {
s.starts_with("[paste #") && s.ends_with(']')
}),
};
let c = find_word_forward_with(line, self.cursor_col, &opts);
self.set_cursor_col(c);
}
self.update_autocomplete_if_active();
return true;
}
if kb.matches(key, ACTION_EDITOR_DELETE_CHAR_BACKWARD) {
self.backspace();
return true;
}
if kb.matches(key, ACTION_EDITOR_DELETE_CHAR_FORWARD) {
self.delete_forward();
return true;
}
if kb.matches(key, ACTION_EDITOR_DELETE_WORD_BACKWARD) {
self.delete_word_backward();
return true;
}
if kb.matches(key, ACTION_EDITOR_DELETE_WORD_FORWARD) {
self.delete_word_forward();
return true;
}
if kb.matches(key, ACTION_EDITOR_DELETE_TO_LINE_START) {
self.delete_to_line_start();
return true;
}
if kb.matches(key, ACTION_EDITOR_DELETE_TO_LINE_END) {
self.delete_to_line_end();
return true;
}
if kb.matches(key, ACTION_EDITOR_YANK) {
self.yank();
return true;
}
if kb.matches(key, ACTION_EDITOR_YANK_POP) {
self.yank_pop();
return true;
}
if kb.matches(key, ACTION_EDITOR_UNDO) {
self.last_action = None;
self.undo();
self.notify_change();
return true;
}
if kb.matches(key, ACTION_INPUT_NEW_LINE) {
self.add_newline();
return true;
}
if kb.matches(key, ACTION_SELECT_CANCEL) {
return false;
}
false
}
fn handle_paste(&mut self, text: &str) {
Editor::handle_paste(self, text);
}
fn is_focusable(&self) -> bool {
true
}
}
impl Focusable for Editor {
fn set_focused(&mut self, focused: bool) {
self.focused = focused;
}
fn focused(&self) -> bool {
self.focused
}
}
#[derive(Debug)]
struct VisualLine {
text: String,
has_cursor: bool,
cursor_pos: Option<usize>,
}
fn layout_text(
lines: &[String],
max_width: usize,
cursor_line: usize,
cursor_col: usize,
) -> Vec<VisualLine> {
let mut result: Vec<VisualLine> = Vec::new();
if lines.is_empty() || (lines.len() == 1 && lines[0].is_empty()) {
result.push(VisualLine {
text: String::new(),
has_cursor: true,
cursor_pos: Some(0),
});
return result;
}
let mut _col_offset = 0;
for (line_idx, line) in lines.iter().enumerate() {
let is_cursor_line = line_idx == cursor_line;
let line_w = visible_width(line);
_col_offset = 0;
if line_w <= max_width {
result.push(VisualLine {
text: line.clone(),
has_cursor: is_cursor_line,
cursor_pos: if is_cursor_line {
Some(cursor_col.min(line.len()))
} else {
None
},
});
} else {
let wrapped = wrap_text_with_ansi(line, max_width);
let cursor_vis = if is_cursor_line {
visible_width(&line[..cursor_col.min(line.len())])
} else {
0
};
let mut vis_offset: usize = 0;
for (chunk_idx, chunk) in wrapped.iter().enumerate() {
let chunk_vis = visible_width(chunk);
let chunk_vis_end = vis_offset + chunk_vis;
let cursor_in_chunk = is_cursor_line
&& cursor_vis >= vis_offset
&& (cursor_vis < chunk_vis_end || chunk_idx == wrapped.len() - 1);
let cursor_pos = if cursor_in_chunk {
let local_vis = cursor_vis.saturating_sub(vis_offset);
Some(visual_col_to_byte_offset(chunk, local_vis))
} else {
None
};
result.push(VisualLine {
text: chunk.clone(),
has_cursor: cursor_in_chunk && cursor_pos.is_some(),
cursor_pos,
});
vis_offset = chunk_vis_end;
}
}
}
result
}
fn is_printable_plain(key: &KeyEvent) -> bool {
matches!(key.code, KeyCode::Char(_))
&& !key.modifiers.contains(KeyModifiers::CONTROL)
&& !key.modifiers.contains(KeyModifiers::ALT)
&& key.code != KeyCode::Enter
&& key.code != KeyCode::Tab
&& key.code != KeyCode::Backspace
&& key.code != KeyCode::Delete
&& key.code != KeyCode::Esc
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tui::autocomplete::{
AutocompleteItem, AutocompleteProvider, AutocompleteSuggestions, SlashCommand,
};
struct MockSlashProvider {
commands: Vec<SlashCommand>,
}
impl MockSlashProvider {
fn new(commands: Vec<&str>) -> Self {
Self {
commands: commands
.into_iter()
.map(|name| SlashCommand {
name: name.to_string(),
description: Some(format!("The {} command", name)),
argument_hint: None,
argument_completions: None,
get_argument_completions: None,
})
.collect(),
}
}
}
impl AutocompleteProvider for MockSlashProvider {
fn trigger_characters(&self) -> &[char] {
&['/', '@', '#']
}
fn get_suggestions(
&self,
lines: &[String],
cursor_line: usize,
cursor_col: usize,
_force: bool,
) -> Option<AutocompleteSuggestions> {
let line = lines.get(cursor_line)?;
let before = &line[..cursor_col.min(line.len())];
if before.starts_with('/') && !before.contains(' ') {
let query = &before[1..].to_lowercase();
let matching: Vec<AutocompleteItem> = self
.commands
.iter()
.filter(|cmd| cmd.name.to_lowercase().starts_with(query))
.map(|cmd| AutocompleteItem {
value: cmd.name.clone(),
label: format!("/{}", cmd.name),
description: cmd.description.clone(),
})
.collect();
if matching.is_empty() {
return None;
}
return Some(AutocompleteSuggestions {
items: matching,
prefix: before.to_string(),
});
}
None
}
fn apply_completion(
&self,
lines: &[String],
cursor_line: usize,
cursor_col: usize,
item: &AutocompleteItem,
prefix: &str,
) -> (Vec<String>, usize, usize) {
let current_line = lines[cursor_line].clone();
let prefix_start = cursor_col.saturating_sub(prefix.len());
let before = ¤t_line[..prefix_start];
let after = ¤t_line[cursor_col..];
(
vec![format!("{}/{} {}", before, item.value, after)],
cursor_line,
before.len() + 1 + item.value.len() + 1,
)
}
fn should_trigger_file_completion(
&self,
lines: &[String],
cursor_line: usize,
cursor_col: usize,
) -> bool {
let current_line = lines.get(cursor_line);
match current_line {
Some(text) => {
let before = &text[..cursor_col.min(text.len())];
if before.starts_with('/') && !before.contains(' ') {
return false;
}
true
}
None => false,
}
}
}
fn make_editor_with_slash_provider(commands: Vec<&str>) -> Editor {
let mut editor = Editor::new(EditorOptions::default());
let provider = Box::new(MockSlashProvider::new(commands));
editor.set_autocomplete_provider(provider);
editor
}
#[test]
fn autocomplete_triggers_on_slash() {
let mut editor = make_editor_with_slash_provider(vec!["help", "history"]);
editor.handle_input(&char_key('/'));
assert!(
editor.autocomplete_active,
"autocomplete should activate after typing /"
);
let selected = editor.autocomplete_selected_value();
assert_eq!(
selected.as_deref(),
Some("help"),
"first item should be help"
);
}
#[test]
fn autocomplete_filters_as_user_types() {
let mut editor = make_editor_with_slash_provider(vec!["help", "history", "model"]);
editor.handle_input(&char_key('/'));
assert!(editor.autocomplete_active);
editor.handle_input(&char_key('h'));
assert!(
editor.autocomplete_active,
"autocomplete should stay active after typing more letters"
);
editor.handle_input(&char_key('e'));
assert!(editor.autocomplete_active);
let selected = editor.autocomplete_selected_value();
assert_eq!(selected.as_deref(), Some("help"));
}
#[test]
fn autocomplete_stays_active_on_printable_chars() {
let mut editor = make_editor_with_slash_provider(vec!["help", "history"]);
editor.handle_input(&char_key('/'));
assert!(editor.autocomplete_active);
editor.handle_input(&char_key('h'));
assert!(
editor.autocomplete_active,
"typing 'h' after '/' must keep autocomplete visible"
);
editor.handle_input(&char_key('e'));
assert!(
editor.autocomplete_active,
"typing 'e' after '/h' must keep autocomplete visible"
);
let lines = editor.render(80);
assert!(lines.len() > 3, "autocomplete lines should be rendered");
}
#[test]
fn escape_dismisses_autocomplete() {
let mut editor = make_editor_with_slash_provider(vec!["help", "history"]);
editor.handle_input(&char_key('/'));
assert!(editor.autocomplete_active);
editor.handle_input(&escape());
assert!(
!editor.autocomplete_active,
"escape should dismiss autocomplete"
);
assert_eq!(editor.get_text(), "/");
}
#[test]
fn backspace_removing_slash_dismisses_autocomplete() {
let mut editor = make_editor_with_slash_provider(vec!["help", "history"]);
editor.handle_input(&char_key('/'));
assert!(editor.autocomplete_active, "after /");
editor.handle_input(&backspace());
assert!(
!editor.autocomplete_active,
"backspace removing / should dismiss autocomplete"
);
assert_eq!(editor.get_text(), "", "text should be empty");
}
#[test]
fn autocomplete_updates_after_backspace_char() {
let mut editor = make_editor_with_slash_provider(vec!["help", "history"]);
editor.handle_input(&char_key('/'));
editor.handle_input(&char_key('h'));
editor.handle_input(&char_key('e'));
assert!(editor.autocomplete_active);
let val1 = editor.autocomplete_selected_value();
assert_eq!(val1.as_deref(), Some("help"));
editor.handle_input(&backspace());
assert!(
editor.autocomplete_active,
"backspace should re-filter, not dismiss"
);
assert!(!editor.autocomplete_is_empty());
assert_eq!(editor.get_text(), "/h");
}
#[test]
fn autocomplete_updates_on_cursor_movement() {
let mut editor = make_editor_with_slash_provider(vec!["help", "history"]);
editor.handle_input(&char_key('/'));
editor.handle_input(&char_key('h'));
editor.handle_input(&char_key('e'));
editor.handle_input(&char_key('l'));
editor.handle_input(&char_key('p'));
assert!(editor.autocomplete_active);
editor.handle_input(&char_key(' '));
assert!(
!editor.autocomplete_active,
"space after /cmd should dismiss slash autocomplete"
);
editor.handle_input(&left_key());
}
#[test]
fn autocomplete_clears_when_provider_returns_none() {
let mut editor = make_editor_with_slash_provider(vec!["help"]);
editor.handle_input(&char_key('/'));
assert!(editor.autocomplete_active);
editor.handle_input(&char_key('z'));
assert!(
!editor.autocomplete_active,
"typing /z with no matching command should dismiss autocomplete"
);
}
#[test]
fn autocomplete_does_not_interfere_with_normal_typing() {
let mut editor = make_editor_with_slash_provider(vec!["help", "history"]);
editor.handle_input(&char_key('h'));
editor.handle_input(&char_key('e'));
editor.handle_input(&char_key('l'));
editor.handle_input(&char_key('l'));
editor.handle_input(&char_key('o'));
assert!(!editor.autocomplete_active, "no slash = no autocomplete");
assert_eq!(editor.get_text(), "hello");
}
#[test]
fn autocomplete_renders_lines_below_editor() {
let mut editor = make_editor_with_slash_provider(vec!["help", "history", "model"]);
editor.handle_input(&char_key('/'));
assert!(editor.autocomplete_active);
let lines = editor.render(80);
assert!(
lines.len() >= 5,
"should have border lines + autocomplete items"
);
assert!(lines[2].contains('─'), "line 2 should be bottom border");
let after_border = &lines[3..];
let all_have_content = after_border.iter().any(|l| !l.trim().is_empty());
assert!(all_have_content, "autocomplete lines should have content");
}
#[test]
fn autocomplete_stable_rendering_no_flash_on_extra_char() {
let mut editor = make_editor_with_slash_provider(vec!["help", "history", "model"]);
editor.handle_input(&char_key('/'));
let lines_after_slash = editor.render(80).len();
editor.handle_input(&char_key('h'));
let lines_after_h = editor.render(80).len();
let diff = lines_after_slash.abs_diff(lines_after_h);
assert!(
diff <= 1,
"line count should not change dramatically: {} -> {} (diff {})",
lines_after_slash,
lines_after_h,
diff
);
}
#[test]
fn autocomplete_dismissed_on_submit() {
let mut editor = make_editor_with_slash_provider(vec!["help"]);
editor.handle_input(&char_key('/'));
assert!(editor.autocomplete_active);
editor.handle_input(&enter_key());
}
#[test]
fn tab_force_triggers_autocomplete() {
let mut editor = make_editor_with_slash_provider(vec!["help", "history"]);
editor.handle_input(&char_key('/'));
assert!(editor.autocomplete_active);
}
#[test]
fn autocomplete_persists_across_multiple_chars() {
let mut editor = make_editor_with_slash_provider(vec!["help", "history", "hello", "heavy"]);
for ch in "/hel".chars() {
editor.handle_input(&char_key(ch));
assert!(
editor.autocomplete_active,
"autocomplete should stay active after '{}'",
ch
);
}
assert!(
!editor.autocomplete_is_empty(),
"should have matching items"
);
assert_eq!(editor.get_text(), "/hel");
}
#[test]
fn test_new_editor() {
let editor = Editor::new(EditorOptions::default());
assert_eq!(editor.get_text(), "");
}
#[test]
fn test_set_text() {
let mut editor = Editor::new(EditorOptions::default());
editor.set_text("hello world");
assert_eq!(editor.get_text(), "hello world");
}
#[test]
fn test_insert_and_move() {
let mut editor = Editor::new(EditorOptions::default());
editor.insert_character("h");
editor.insert_character("i");
assert_eq!(editor.get_text(), "hi");
editor.move_left();
assert_eq!(editor.cursor_col, 1);
editor.move_right();
assert_eq!(editor.cursor_col, 2);
}
#[test]
fn test_backspace() {
let mut editor = Editor::new(EditorOptions::default());
editor.set_text("hello");
editor.backspace();
assert_eq!(editor.get_text(), "hell");
}
#[test]
fn test_multiline() {
let mut editor = Editor::new(EditorOptions::default());
editor.set_text("line1\nline2");
assert_eq!(editor.get_lines().len(), 2);
}
#[test]
fn test_undo() {
let mut editor = Editor::new(EditorOptions::default());
editor.push_undo();
editor.insert_text_internal("a");
editor.push_undo();
editor.insert_text_internal("b");
assert_eq!(editor.get_text(), "ab");
editor.undo();
assert_eq!(editor.get_text(), "a");
editor.undo();
assert_eq!(editor.get_text(), "");
}
#[test]
fn test_submit_clears() {
let mut editor = Editor::new(EditorOptions::default());
editor.set_text("hello");
let result = editor.lines.join("\n");
editor.lines = vec![String::new()];
editor.cursor_line = 0;
editor.cursor_col = 0;
assert_eq!(result, "hello");
assert_eq!(editor.get_text(), "");
}
#[test]
fn test_render_borders() {
let mut editor = Editor::new(EditorOptions::default());
let lines = editor.render(80);
assert!(lines.len() >= 3);
assert!(lines[0].contains('─'));
assert!(lines.last().unwrap().contains('─'));
}
#[test]
fn test_scroll_indicator() {
let mut editor = Editor::new(EditorOptions { padding_x: 1 });
editor.set_terminal_rows(6);
editor.set_text("line1\nline2\nline3\nline4\nline5\nline6");
editor.cursor_line = 5;
editor.cursor_col = 5;
editor.scroll_offset = 2;
let lines = editor.render(80);
assert!(
lines[0].contains("↑"),
"Expected scroll-up indicator, got: {:?}",
lines[0]
);
}
#[test]
fn test_newline() {
let mut editor = Editor::new(EditorOptions::default());
editor.set_text("hello");
editor.add_newline();
assert_eq!(editor.get_text(), "hello\n");
editor.insert_character("w");
assert_eq!(editor.get_text(), "hello\nw");
}
#[test]
fn test_cursor_in_layout() {
let editor = Editor::new(EditorOptions::default());
let vl = layout_text(&editor.lines, 80, editor.cursor_line, editor.cursor_col);
assert!(vl[0].has_cursor);
assert_eq!(vl[0].cursor_pos, Some(0));
}
#[test]
fn test_cursor_in_layout_with_text() {
let mut editor = Editor::new(EditorOptions::default());
editor.set_text("abc");
editor.cursor_col = 1;
let vl = layout_text(&editor.lines, 80, editor.cursor_line, editor.cursor_col);
assert!(vl[0].has_cursor);
assert_eq!(vl[0].cursor_pos, Some(1));
}
fn up_key() -> KeyEvent {
KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)
}
fn left_key() -> KeyEvent {
KeyEvent::new(KeyCode::Left, KeyModifiers::NONE)
}
fn char_key(c: char) -> KeyEvent {
KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE)
}
fn enter_key() -> KeyEvent {
KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)
}
fn escape() -> KeyEvent {
KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)
}
fn backspace() -> KeyEvent {
KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)
}
#[test]
fn test_history_empty_up_does_nothing() {
let mut editor = Editor::new(EditorOptions::default());
editor.handle_input(&up_key());
assert_eq!(editor.get_text(), "");
}
#[test]
fn test_history_up_shows_most_recent() {
let mut editor = Editor::new(EditorOptions::default());
editor.add_to_history("first");
editor.add_to_history("second");
editor.handle_input(&up_key());
assert_eq!(editor.get_text(), "second");
}
#[test]
fn test_history_cycles() {
let mut editor = Editor::new(EditorOptions::default());
editor.add_to_history("first");
editor.add_to_history("second");
editor.add_to_history("third");
editor.handle_input(&up_key());
assert_eq!(editor.get_text(), "third");
editor.handle_input(&up_key());
assert_eq!(editor.get_text(), "second");
editor.handle_input(&up_key());
assert_eq!(editor.get_text(), "first");
editor.handle_input(&up_key()); assert_eq!(editor.get_text(), "first");
}
#[test]
fn test_history_exits_on_type() {
let mut editor = Editor::new(EditorOptions::default());
editor.add_to_history("old");
editor.handle_input(&up_key());
assert_eq!(editor.get_text(), "old");
editor.handle_input(&char_key('x'));
assert_eq!(editor.get_text(), "xold");
}
#[test]
fn test_backslash_enter_newline() {
let mut editor = Editor::new(EditorOptions::default());
editor.handle_input(&char_key('\\'));
assert_eq!(editor.get_text(), "\\");
editor.handle_input(&enter_key());
assert_eq!(editor.get_text(), "\n");
}
#[test]
fn test_move_cursor_over_emoji() {
let mut editor = Editor::new(EditorOptions::default());
editor.set_text("a😀b");
editor.cursor_col = 0;
editor.move_right();
assert_eq!(editor.cursor_col, 1);
editor.move_right();
assert_eq!(editor.cursor_col, 5);
editor.move_right();
assert_eq!(editor.cursor_col, 6);
}
#[test]
fn test_backspace_emoji() {
let mut editor = Editor::new(EditorOptions::default());
editor.set_text("a😀b");
editor.cursor_col = 6;
editor.backspace();
assert_eq!(editor.get_text(), "a😀");
editor.backspace();
assert_eq!(editor.get_text(), "a");
}
#[test]
fn test_render_cursor_visible() {
let mut editor = Editor::new(EditorOptions::default());
editor.focused = true;
editor.insert_character("x");
let lines = editor.render(40);
let content = &lines[1];
assert!(content.contains("\x1b[7m"), "Cursor inverse not found");
}
#[test]
fn test_emits_cursor_marker_when_focused() {
let mut editor = Editor::new(EditorOptions::default());
editor.focused = true;
editor.insert_character("hello");
let lines = editor.render(40);
let content = &lines[1];
assert!(
content.contains(CURSOR_MARKER),
"Focused editor should emit cursor marker"
);
}
#[test]
fn test_no_cursor_marker_when_not_focused() {
let mut editor = Editor::new(EditorOptions::default());
editor.focused = false;
editor.insert_character("hello");
let lines = editor.render(40);
let content = &lines[1];
assert!(
!content.contains(CURSOR_MARKER),
"Unfocused editor should not emit cursor marker"
);
}
#[test]
fn test_render_borders_always_present() {
let mut editor = Editor::new(EditorOptions::default());
let lines = editor.render(80);
assert_eq!(lines.len(), 3, "Empty editor should have 3 lines");
assert!(lines[0].contains('─'), "Top border missing");
assert!(lines[2].contains('─'), "Bottom border missing");
editor.insert_character("/");
let lines = editor.render(80);
assert_eq!(lines.len(), 3, "After typing / should still have 3 lines");
assert!(lines[0].contains('─'), "Top border missing after /");
assert!(lines[2].contains('─'), "Bottom border missing after /");
editor.set_text("hello world this is text");
let lines = editor.render(40);
assert!(lines.len() >= 3, "Wrapped text: {}", lines.len());
assert!(lines[0].contains('─'), "Top border");
assert!(lines.last().unwrap().contains('─'), "Bottom border");
}
#[test]
fn test_content_width_respected() {
let mut editor = Editor::new(EditorOptions { padding_x: 1 });
editor.set_text("hello world this is a test");
let lines = editor.render(20);
for line in &lines {
let vw = crate::tui::util::visible_width(line);
assert!(vw <= 20, "Width {} > 20: {:?}", vw, line);
}
}
#[test]
fn test_no_duplicate_chunks_from_wrapping() {
let texts = [
"hello world this is a test of the wrapping system",
"a b c d e f g h i j k l m n o p q r s t u v w x y z",
"short",
"",
"abc abc abc abc abc abc abc abc",
" leading and trailing spaces ",
"hello world extra spaces",
];
for text in &texts {
for width in [1, 2, 3, 5, 8, 12, 20, 40] {
let wrapped = crate::tui::util::wrap_text_with_ansi(text, width);
let total_vis_wrapped: usize = wrapped.iter().map(|c| visible_width(c)).sum();
let total_vis_original = visible_width(text);
assert!(
total_vis_wrapped <= total_vis_original,
"Width={}: wrapped visible {} > original visible {} for {:?}",
width,
total_vis_wrapped,
total_vis_original,
text
);
for a in &wrapped {
if a.is_empty() {
continue;
}
let count_in_wrapped = wrapped.iter().filter(|c| *c == a).count();
let count_in_original = text.matches(a.as_str()).count();
assert!(
count_in_wrapped <= count_in_original || count_in_original == 0,
"Width={}: chunk '{}' appears {}x in wrapped but {}x in original for {:?}",
width,
a,
count_in_wrapped,
count_in_original,
text
);
}
}
}
}
#[test]
fn test_cursor_in_wrapped_text_first_chunk() {
let mut editor = Editor::new(EditorOptions::default());
let text = "hello world this is a test";
editor.set_text(text);
editor.cursor_col = 3;
let vl = layout_text(&editor.lines, 10, editor.cursor_line, editor.cursor_col);
assert!(vl.len() > 1, "Text should wrap into multiple visual lines");
assert!(
vl[0].has_cursor,
"Cursor at col 3 should be in first visual line"
);
if let Some(pos) = vl[0].cursor_pos {
assert_eq!(pos, 3, "Cursor byte offset in first chunk should be 3");
}
}
#[test]
fn test_cursor_in_wrapped_text_middle_chunk() {
let mut editor = Editor::new(EditorOptions::default());
let text = "hello world this is a test";
editor.set_text(text);
editor.cursor_col = 16;
let vl = layout_text(&editor.lines, 10, editor.cursor_line, editor.cursor_col);
assert!(vl.len() > 1, "Text should wrap");
let cursor_vl = vl.iter().position(|v| v.has_cursor);
assert!(
cursor_vl.is_some(),
"Cursor should be found in some visual line"
);
}
#[test]
fn test_cursor_last_chunk_on_boundary() {
let mut editor = Editor::new(EditorOptions::default());
let text = "hello world this is a test";
editor.set_text(text);
editor.cursor_col = text.len();
let vl = layout_text(&editor.lines, 10, editor.cursor_line, editor.cursor_col);
assert!(
vl.last().is_some_and(|v| v.has_cursor),
"Cursor at end should be in last visual line"
);
}
#[test]
fn test_layout_text_each_chunk_unique() {
let text = "hello world this is a test of the wrapping system";
let vl = layout_text(&[text.to_string()], 12, 0, 0);
let chunk_texts: Vec<&str> = vl.iter().map(|v| v.text.as_str()).collect();
for i in 0..chunk_texts.len() {
for j in (i + 1)..chunk_texts.len() {
if chunk_texts[i] == chunk_texts[j] {
if !chunk_texts[i].is_empty() {
panic!(
"Duplicate chunk text at positions {} and {}: '{}'",
i, j, chunk_texts[i]
);
}
}
}
}
}
#[test]
fn test_visual_col_to_byte_offset_ascii() {
let text = "hello";
assert_eq!(crate::tui::util::visual_col_to_byte_offset(text, 0), 0);
assert_eq!(crate::tui::util::visual_col_to_byte_offset(text, 3), 3);
assert_eq!(crate::tui::util::visual_col_to_byte_offset(text, 5), 5);
}
#[test]
fn test_visual_col_to_byte_offset_cjk() {
let text = "世界hello";
assert_eq!(crate::tui::util::visual_col_to_byte_offset(text, 0), 0);
assert_eq!(crate::tui::util::visual_col_to_byte_offset(text, 2), 3);
assert_eq!(crate::tui::util::visual_col_to_byte_offset(text, 4), 6);
}
#[test]
fn test_visual_col_to_byte_offset_ansi() {
let text = "\x1b[31mhello\x1b[0m";
assert_eq!(crate::tui::util::visual_col_to_byte_offset(text, 0), 5); assert_eq!(crate::tui::util::visual_col_to_byte_offset(text, 1), 6); assert_eq!(crate::tui::util::visual_col_to_byte_offset(text, 2), 7); assert_eq!(crate::tui::util::visual_col_to_byte_offset(text, 3), 8); assert_eq!(crate::tui::util::visual_col_to_byte_offset(text, 4), 9); assert_eq!(crate::tui::util::visual_col_to_byte_offset(text, 5), 14); }
#[test]
fn test_visual_col_to_byte_offset_empty() {
assert_eq!(crate::tui::util::visual_col_to_byte_offset("", 0), 0);
assert_eq!(crate::tui::util::visual_col_to_byte_offset("", 5), 0);
}
#[test]
fn test_visual_col_to_byte_offset_zero_col() {
assert_eq!(crate::tui::util::visual_col_to_byte_offset("abc", 0), 0);
assert_eq!(
crate::tui::util::visual_col_to_byte_offset("\x1b[31mabc", 0),
5
);
}
#[test]
fn test_large_paste_creates_marker() {
let mut editor = Editor::new(EditorOptions::default());
let large = "line1\nline2\nline3\nline4\nline5\nline6\nline7\nline8\nline9\nline10\nline11";
editor.handle_paste(large);
let text = editor.get_text();
assert!(text.contains("[paste #"), "Should contain paste marker");
assert!(
!text.contains("line1"),
"Should not contain original content"
);
assert_eq!(editor.pastes.len(), 1, "Should store one paste");
}
#[test]
fn test_small_paste_no_marker() {
let mut editor = Editor::new(EditorOptions::default());
editor.handle_paste("hello");
let text = editor.get_text();
assert!(
!text.contains("[paste #"),
"Small paste should not create marker"
);
assert_eq!(text, "hello");
}
#[test]
fn test_expand_paste_markers() {
let mut editor = Editor::new(EditorOptions::default());
editor.handle_paste(
"line1\nline2\nline3\nline4\nline5\nline6\nline7\nline8\nline9\nline10\nline11",
);
let expanded = editor.get_expanded_text();
assert!(
expanded.contains("line1"),
"Expanded text should contain original content"
);
assert!(
!expanded.contains("[paste #"),
"Expanded text should not contain markers"
);
}
#[test]
fn test_submit_expands_markers() {
let mut editor = Editor::new(EditorOptions::default());
editor.handle_paste(
"line1\nline2\nline3\nline4\nline5\nline6\nline7\nline8\nline9\nline10\nline11",
);
let large_content =
"line1\nline2\nline3\nline4\nline5\nline6\nline7\nline8\nline9\nline10\nline11";
let raw = editor.lines.join("\n");
let expanded = editor.expand_paste_markers(&raw);
assert_eq!(
expanded, large_content,
"Submit should expand to original content"
);
}
#[test]
fn test_is_paste_marker() {
assert!(Editor::is_paste_marker("[paste #1 +5 lines]"));
assert!(Editor::is_paste_marker("[paste #123 456 chars]"));
assert!(!Editor::is_paste_marker("normal text"));
assert!(!Editor::is_paste_marker(""));
}
#[test]
fn test_get_expanded_text() {
let mut editor = Editor::new(EditorOptions::default());
editor.handle_paste(
"line1\nline2\nline3\nline4\nline5\nline6\nline7\nline8\nline9\nline10\nline11",
);
let expanded = editor.get_expanded_text();
assert!(
expanded.contains("line1"),
"get_expanded_text should expand markers"
);
assert!(
expanded.starts_with("line1"),
"Should start with original content"
);
}
#[test]
fn test_multiline_render_no_duplicate_content() {
let mut editor = Editor::new(EditorOptions::default());
editor.set_text("hello");
editor.add_newline();
editor.insert_character("w");
editor.insert_character("o");
editor.insert_character("r");
editor.insert_character("l");
editor.insert_character("d");
assert_eq!(editor.get_text(), "hello\nworld");
for width in [20, 40, 80] {
let rendered = editor.render(width);
let content_lines: Vec<&str> = rendered
.iter()
.filter(|l| !l.contains('─'))
.map(|l| l.trim())
.collect();
assert!(
content_lines.len() >= 2,
"Width {}: expected >= 2 content lines, got {}: {:?}",
width,
content_lines.len(),
rendered
);
let mut seen = std::collections::HashSet::new();
for line in &content_lines {
if !line.is_empty() {
let plain = line.replace("\x1b_pi:c\x07", "").to_string();
if !seen.insert(plain.clone()) {
panic!(
"Width {}: duplicate content line '{}' in {:?}",
width, line, rendered
);
}
}
}
}
}
#[test]
fn test_editor_add_newline_adds_one_visual_line() {
let mut editor = Editor::new(EditorOptions::default());
editor.set_text("hello");
let before = editor.render(80).len();
editor.add_newline();
let after = editor.render(80).len();
assert_eq!(
after,
before + 1,
"Adding newline should increase rendered line count by exactly 1. before={}, after={}",
before,
after
);
}
#[test]
fn test_layout_text_no_extra_empty_visual_line() {
let lines: Vec<String> = vec![String::new()];
let vl = layout_text(&lines, 80, 0, 0);
assert_eq!(vl.len(), 1, "Empty text should have 1 visual line");
assert!(vl[0].has_cursor);
let lines = vec!["hello".to_string()];
let vl = layout_text(&lines, 80, 0, 5);
assert_eq!(vl.len(), 1, "Single line should have 1 visual line");
assert!(vl[0].has_cursor);
let lines = vec!["hello".to_string(), "".to_string()];
let vl = layout_text(&lines, 80, 0, 5);
assert_eq!(
vl.len(),
2,
"Two lines (one empty) should have 2 visual lines"
);
assert!(vl[0].has_cursor);
assert!(!vl[1].has_cursor);
let lines = vec!["hello".to_string(), "".to_string()];
let vl = layout_text(&lines, 80, 1, 0);
assert_eq!(vl.len(), 2);
assert!(!vl[0].has_cursor);
assert!(vl[1].has_cursor);
let lines = vec!["".to_string(), "hello".to_string()];
let vl = layout_text(&lines, 80, 1, 5);
assert_eq!(
vl.len(),
2,
"Two lines (one empty first) should have 2 visual lines"
);
assert!(!vl[0].has_cursor);
assert!(vl[1].has_cursor);
}
#[test]
fn test_wrap_edge_cases_no_empty_lines() {
let cases = vec![
(" hello", 3, "leading spaces"),
("hello ", 3, "trailing spaces"),
(" hello ", 3, "leading and trailing spaces"),
("abc def", 5, "double space in middle"),
("a b", 4, "triple space"),
("a b", 3, "double space at wrap boundary"),
];
for (text, width, label) in &cases {
let wrapped = crate::tui::util::wrap_text_with_ansi(text, *width);
for chunk in &wrapped {
if chunk.is_empty() {
panic!(
"Case '{}' (width {}): empty chunk found in wrapped: {:?}",
label, width, wrapped
);
}
let vis = crate::tui::util::visible_width(chunk);
assert!(
vis > 0,
"Case '{}' (width {}): chunk with visible width 0: {:?} (wrapped: {:?})",
label,
width,
chunk,
wrapped
);
}
}
}
#[test]
fn test_wrap_long_word_no_duplicate_chunks() {
let long = "aaaaa bbbbb ccccc ddddd";
for width in [5, 6, 7, 8, 10, 12] {
let wrapped = crate::tui::util::wrap_text_with_ansi(long, width);
let mut seen = std::collections::HashSet::new();
for chunk in &wrapped {
let trimmed = chunk.trim();
if !trimmed.is_empty() && !seen.insert(trimmed.to_string()) {
panic!(
"Width {}: duplicate chunk '{}' in {:?}",
width, chunk, wrapped
);
}
}
}
}
#[test]
fn test_wrap_typing_detailed_trace() {
let mut editor = Editor::new(EditorOptions::default());
let sentence = "hello world";
let width = 10;
for (i, ch) in sentence.chars().enumerate() {
editor.handle_input(&char_key(ch));
let vl = layout_text(&editor.lines, width, editor.cursor_line, editor.cursor_col);
let mut seen = std::collections::HashSet::new();
for vis in &vl {
let trimmed = vis.text.trim();
if !trimmed.is_empty() && !seen.insert(trimmed.to_string()) {
panic!(
"After char '{}' (pos {}): duplicate visual line '{}' in {:?}",
ch, i, vis.text, vl
);
}
}
let cursor_count = vl.iter().filter(|v| v.has_cursor).count();
assert_eq!(
cursor_count, 1,
"After char '{}' (pos {}): expected exactly 1 cursor, got {}. vl: {:?}",
ch, i, cursor_count, vl
);
}
}
#[test]
fn test_wrap_long_continuous_string_no_duplicates() {
let mut editor = Editor::new(EditorOptions::default());
let url = "https://very-long-url-with-no-spaces.example.com/path/to/resource";
for ch in url.chars() {
editor.handle_input(&char_key(ch));
}
for width in [5, 10, 15, 20, 30] {
let rendered = editor.render(width);
let content: Vec<&str> = rendered
.iter()
.filter(|l| !l.contains('─'))
.map(|l| l.trim())
.filter(|l| !l.is_empty())
.collect();
let mut seen = std::collections::HashSet::new();
for line in &content {
let plain = line
.replace("\x1b_pi:c\x07", "")
.chars()
.filter(|&c| c.is_ascii_graphic() || c == ' ')
.collect::<String>()
.trim()
.to_string();
if !plain.is_empty() && !seen.insert(plain.clone()) {
panic!(
"Width {}: duplicate content line '{}' (plain: '{}')\nFull render: {:?}",
width, line, plain, rendered
);
}
}
}
}
#[test]
fn test_editor_typing_past_width_no_duplicate_render() {
let mut editor = Editor::new(EditorOptions::default());
let input = "hello world this is a test of the emergency broadcast system";
for ch in input.chars() {
editor.handle_input(&char_key(ch));
}
for width in [5, 8, 10, 12, 15, 20] {
let rendered = editor.render(width);
let content: Vec<&str> = rendered
.iter()
.filter(|l| !l.contains('─'))
.map(|l| l.trim())
.filter(|l| !l.is_empty())
.collect();
let mut seen = std::collections::HashSet::new();
for line in &content {
let plain = line
.replace("\x1b_pi:c\x07", "")
.chars()
.filter(|&c| c.is_ascii_graphic() || c == ' ')
.collect::<String>()
.trim()
.to_string();
if !plain.is_empty() && !seen.insert(plain.clone()) {
panic!(
"Width {}: duplicate content line '{}' (plain: '{}')\nFull render: {:?}",
width, line, plain, rendered
);
}
}
let content_plain: String = content.join(" ");
let content_plain = content_plain
.replace("\x1b_pi:c\x07", "")
.chars()
.filter(|&c| c.is_ascii_graphic() || c == ' ')
.collect::<String>();
assert!(
!content_plain.is_empty(),
"Width {}: no visible content in render: {:?}",
width,
rendered
);
}
}
}