1#![allow(clippy::type_complexity)]
2
3use crate::tui::autocomplete::AutocompleteProvider;
4use crate::tui::component::Component;
5use crate::tui::components::select_list::{SelectItem, SelectList, SelectListTheme};
6use crate::tui::focusable::{CURSOR_MARKER, Focusable};
7use crate::tui::keybindings::{
8 ACTION_EDITOR_CURSOR_DOWN, ACTION_EDITOR_CURSOR_LEFT, ACTION_EDITOR_CURSOR_LINE_END,
9 ACTION_EDITOR_CURSOR_LINE_START, ACTION_EDITOR_CURSOR_RIGHT, ACTION_EDITOR_CURSOR_UP,
10 ACTION_EDITOR_CURSOR_WORD_LEFT, ACTION_EDITOR_CURSOR_WORD_RIGHT,
11 ACTION_EDITOR_DELETE_CHAR_BACKWARD, ACTION_EDITOR_DELETE_CHAR_FORWARD,
12 ACTION_EDITOR_DELETE_TO_LINE_END, ACTION_EDITOR_DELETE_TO_LINE_START,
13 ACTION_EDITOR_DELETE_WORD_BACKWARD, ACTION_EDITOR_DELETE_WORD_FORWARD,
14 ACTION_EDITOR_JUMP_BACKWARD, ACTION_EDITOR_JUMP_FORWARD, ACTION_EDITOR_PAGE_DOWN,
15 ACTION_EDITOR_PAGE_UP, ACTION_EDITOR_UNDO, ACTION_EDITOR_YANK, ACTION_EDITOR_YANK_POP,
16 ACTION_INPUT_NEW_LINE, ACTION_INPUT_SUBMIT, ACTION_INPUT_TAB, ACTION_SELECT_CANCEL,
17 ACTION_SELECT_CONFIRM, ACTION_SELECT_DOWN, ACTION_SELECT_UP, get_keybindings,
18};
19use crate::tui::keys::key_event_to_string;
20use crate::tui::kill_ring::KillRing;
21use crate::tui::util::is_whitespace_char;
22use std::collections::HashMap;
23
24use crate::tui::undo_stack::UndoStack;
25use crate::tui::util::{visible_width, visual_col_to_byte_offset, wrap_text_with_ansi};
26use crate::tui::word_nav::{
27 WordNavigationOptions, find_word_backward_with, find_word_forward_with,
28};
29use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
30use unicode_segmentation::UnicodeSegmentation;
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34enum JumpDirection {
35 Forward,
36 Backward,
37}
38
39pub struct EditorOptions {
40 pub padding_x: usize,
41}
42
43impl Default for EditorOptions {
44 fn default() -> Self {
45 Self { padding_x: 1 }
46 }
47}
48
49pub struct Editor {
52 lines: Vec<String>,
53 cursor_line: usize,
54 cursor_col: usize,
55 padding_x: usize,
56 scroll_offset: usize,
57
58 focused: bool,
59 kill_ring: KillRing,
60 undo_stack: UndoStack<EditorSnapshot>,
61 history: Vec<String>,
62 history_index: i32,
63 history_draft: Option<EditorSnapshot>,
64 preferred_col: Option<usize>,
65 last_width: std::cell::Cell<usize>,
66 last_action: Option<String>,
67 pub on_submit: Option<Box<dyn FnMut(String) + Send>>,
68 pub on_change: Option<Box<dyn FnMut(&str)>>,
69 pub disable_submit: bool,
70 pub border_color: crate::tui::Style,
71
72 jump_mode: Option<JumpDirection>,
74
75 autocomplete_provider: Option<Box<dyn AutocompleteProvider>>,
77
78 pastes: HashMap<u32, String>,
80 paste_counter: u32,
81
82 pub just_submitted: bool,
84 pub last_submitted_text: String,
86
87 terminal_rows: usize,
90 autocomplete_max_visible: usize,
91 autocomplete_list: Option<SelectList>,
92 pub autocomplete_active: bool,
93 autocomplete_prefix: String,
97 last_autocomplete_trigger: std::time::Instant,
100}
101
102#[derive(Debug, Clone)]
103struct EditorSnapshot {
104 lines: Vec<String>,
105 cursor_line: usize,
106 cursor_col: usize,
107}
108
109impl Editor {
110 pub fn new(options: EditorOptions) -> Self {
111 Self {
112 lines: vec![String::new()],
113 cursor_line: 0,
114 cursor_col: 0,
115 padding_x: options.padding_x,
116 scroll_offset: 0,
117
118 focused: false,
119 kill_ring: KillRing::new(),
120 undo_stack: UndoStack::new(),
121 history: Vec::new(),
122 history_index: -1,
123 history_draft: None,
124 preferred_col: None,
125 last_width: std::cell::Cell::new(80),
126 last_action: None,
127 on_submit: None,
128 on_change: None,
129 disable_submit: false,
130 terminal_rows: 24,
131 autocomplete_max_visible: 5,
132 autocomplete_list: None,
133 autocomplete_active: false,
134 autocomplete_prefix: String::new(),
135 last_autocomplete_trigger: std::time::Instant::now(),
136 border_color: crate::tui::Style::default(),
137 autocomplete_provider: None,
138 pastes: HashMap::new(),
139 paste_counter: 0,
140 just_submitted: false,
141 last_submitted_text: String::new(),
142 jump_mode: None,
143 }
144 }
145
146 pub fn get_text(&self) -> String {
149 self.lines.join("\n")
150 }
151
152 pub fn get_lines(&self) -> &[String] {
153 &self.lines
154 }
155
156 pub fn get_cursor(&self) -> (usize, usize) {
157 (self.cursor_line, self.cursor_col)
158 }
159
160 pub fn set_terminal_rows(&mut self, rows: usize) {
163 self.terminal_rows = rows;
164 }
165
166 pub fn set_padding_x(&mut self, padding: usize) {
167 self.padding_x = padding;
168 }
169
170 pub fn set_autocomplete_max_visible(&mut self, max: usize) {
171 self.autocomplete_max_visible = max.clamp(3, 20);
172 }
173
174 fn set_text_internal(&mut self, text: &str) {
176 self.lines = if text.is_empty() {
177 vec![String::new()]
178 } else {
179 text.split('\n').map(|s| s.to_string()).collect()
180 };
181 self.cursor_line = self.lines.len().saturating_sub(1);
182 self.cursor_col = self.lines.last().map_or(0, |l| l.len());
183 self.scroll_offset = 0;
184 self.preferred_col = None;
185 }
186
187 pub fn set_text(&mut self, text: &str) {
188 self.clear_autocomplete();
190 self.last_action = None;
191 self.exit_history();
192 if self.get_text() != text {
193 self.push_undo();
194 }
195 self.set_text_internal(text);
196 self.notify_change();
197 }
198
199 pub fn add_to_history(&mut self, text: &str) {
200 let trimmed = text.trim().to_string();
201 if trimmed.is_empty() {
202 return;
203 }
204 if !self.history.is_empty() && self.history[0] == trimmed {
206 return;
207 }
208 self.history.insert(0, trimmed);
209 if self.history.len() > 100 {
210 self.history.pop();
211 }
212 self.history_index = -1;
213 }
214
215 pub fn insert_text_at_cursor(&mut self, text: &str) {
216 self.clear_autocomplete();
217 self.exit_history();
218 self.last_action = None;
219 self.push_undo();
220 self.insert_text_internal(text);
221 }
222
223 pub fn set_autocomplete_provider(&mut self, provider: Box<dyn AutocompleteProvider>) {
227 self.autocomplete_provider = Some(provider);
228 }
229
230 pub fn set_autocomplete(&mut self, items: Vec<SelectItem>) {
231 if items.is_empty() {
232 self.autocomplete_active = false;
233 self.autocomplete_list = None;
234 return;
235 }
236 self.set_autocomplete_with_layout(items, None);
237 }
238
239 fn set_autocomplete_with_layout(
242 &mut self,
243 items: Vec<SelectItem>,
244 layout: Option<crate::tui::components::select_list::SelectListLayoutOptions>,
245 ) {
246 if items.is_empty() {
247 self.autocomplete_active = false;
248 self.autocomplete_list = None;
249 return;
250 }
251 let theme = SelectListTheme {
252 selected_prefix: Box::new(|s| {
253 format!("\x1b[7m\x1b[38;2;138;190;183m→ {}\x1b[27m\x1b[39m", s)
254 }),
255 selected_text: Box::new(|s| {
256 format!("\x1b[7m\x1b[38;2;138;190;183m{}\x1b[27m\x1b[39m", s)
257 }),
258 normal_text: Box::new(|s| format!("\x1b[38;2;128;128;128m{}\x1b[39m", s)),
259 description: Box::new(|s| format!("\x1b[38;2;128;128;128m{}\x1b[39m", s)),
260 scroll_info: crate::tui::Style::new().fg("\x1b[38;2;128;128;128m".to_string()),
261 no_match: crate::tui::Style::new(),
262 hint: crate::tui::Style::new(),
263 };
264 let best = self.best_autocomplete_index(&items);
266 let mut list = SelectList::new(items, self.autocomplete_max_visible, theme, layout);
267 list.set_selected_index(best);
268 self.autocomplete_list = Some(list);
269 self.autocomplete_active = true;
270 }
271
272 fn best_autocomplete_index(&self, items: &[SelectItem]) -> usize {
275 let prefix = self.autocomplete_prefix.trim_start_matches(['/', '@', '#']);
276 if prefix.is_empty() {
277 return 0;
278 }
279 let mut first_prefix = None;
280 for (i, item) in items.iter().enumerate() {
281 if item.value == prefix {
282 return i; }
284 if first_prefix.is_none() && item.value.starts_with(prefix) {
285 first_prefix = Some(i);
286 }
287 }
288 first_prefix.unwrap_or(0)
289 }
290
291 pub fn clear_autocomplete(&mut self) {
292 self.autocomplete_active = false;
293 self.autocomplete_list = None;
294 self.autocomplete_prefix.clear();
295 }
296
297 fn apply_autocomplete_completion_value(&mut self, val: &str) {
299 if let Some(ref provider) = self.autocomplete_provider {
300 let prefix = if !self.autocomplete_prefix.is_empty() {
301 self.autocomplete_prefix.clone()
302 } else {
303 self.get_autocomplete_prefix()
304 };
305 let item = crate::tui::autocomplete::AutocompleteItem {
306 value: val.to_string(),
307 label: val.to_string(),
308 description: None,
309 };
310 let (new_lines, new_line, new_col) = provider.apply_completion(
311 &self.lines,
312 self.cursor_line,
313 self.cursor_col,
314 &item,
315 &prefix,
316 );
317 self.lines = new_lines;
318 self.cursor_line = new_line;
319 self.cursor_col = new_col;
320 } else {
321 self.set_text(&format!("/{} ", val));
322 }
323 }
324
325 fn update_autocomplete_if_active(&mut self) {
329 if self.autocomplete_active {
330 self.try_trigger_autocomplete();
331 }
332 }
333
334 fn retrigger_autocomplete_dismissed(&mut self) {
337 if self.autocomplete_active {
338 return; }
340 if self.is_in_slash_command_context() {
342 self.try_trigger_autocomplete();
343 return;
344 }
345 let line = self
346 .lines
347 .get(self.cursor_line)
348 .map(|l| l.as_str())
349 .unwrap_or("");
350 let before = &line[..self.cursor_col.min(line.len())];
351 if before.contains('@') || before.contains('#') {
353 self.try_trigger_autocomplete();
354 }
355 }
356
357 pub fn autocomplete_selected_value(&self) -> Option<String> {
358 self.autocomplete_list
359 .as_ref()
360 .and_then(|l| l.selected_item())
361 .map(|item| item.value.clone())
362 }
363
364 pub fn autocomplete_is_empty(&self) -> bool {
365 self.autocomplete_list
366 .as_ref()
367 .is_none_or(|l| l.items().is_empty())
368 }
369
370 fn maybe_push_undo(&mut self, ch: &str) {
376 if is_whitespace_char(ch) || self.last_action.as_deref() != Some("type_word") {
377 self.undo_stack.push(&EditorSnapshot {
378 lines: self.lines.clone(),
379 cursor_line: self.cursor_line,
380 cursor_col: self.cursor_col,
381 });
382 }
383 self.last_action = Some("type_word".into());
384 }
385
386 fn push_undo(&mut self) {
387 self.undo_stack.push(&EditorSnapshot {
388 lines: self.lines.clone(),
389 cursor_line: self.cursor_line,
390 cursor_col: self.cursor_col,
391 });
392 }
393
394 fn undo(&mut self) {
395 if let Some(snap) = self.undo_stack.pop() {
396 self.lines = snap.lines;
397 self.cursor_line = snap.cursor_line;
398 self.cursor_col = snap.cursor_col;
399 self.preferred_col = None;
400 }
401 }
402
403 fn set_cursor_col(&mut self, col: usize) {
406 self.cursor_col = col;
407 self.preferred_col = None;
408 }
409
410 fn insert_text_internal(&mut self, text: &str) {
413 if text.is_empty() {
414 return;
415 }
416 let normalized = text.replace("\r\n", "\n").replace('\t', " ");
417 let inserted_lines: Vec<&str> = normalized.split('\n').collect();
418 let current_line = self.lines[self.cursor_line].clone();
419 let before = ¤t_line[..self.cursor_col.min(current_line.len())];
420 let after = ¤t_line[self.cursor_col.min(current_line.len())..];
421
422 if inserted_lines.len() == 1 {
423 self.lines[self.cursor_line] = format!("{}{}{}", before, normalized, after);
424 self.set_cursor_col(self.cursor_col + normalized.len());
425 } else {
426 let mut new_lines: Vec<String> = Vec::new();
427 new_lines.extend(self.lines[..self.cursor_line].iter().cloned());
428 new_lines.push(format!("{}{}", before, inserted_lines[0]));
429 for line in &inserted_lines[1..inserted_lines.len() - 1] {
430 new_lines.push(line.to_string());
431 }
432 new_lines.push(format!("{}{}", inserted_lines.last().unwrap_or(&""), after));
433 new_lines.extend(self.lines[self.cursor_line + 1..].iter().cloned());
434 self.lines = new_lines;
435 self.cursor_line += inserted_lines.len() - 1;
436 self.set_cursor_col(inserted_lines.last().map_or(0, |l| l.len()));
437 }
438 self.notify_change();
439 }
440
441 fn insert_character(&mut self, ch: &str) {
442 self.exit_history();
443 self.maybe_push_undo(ch);
444 self.insert_text_internal(ch);
445
446 self.update_autocomplete(ch);
448 }
449
450 fn is_slash_menu_allowed(&self) -> bool {
456 self.cursor_line == 0
457 }
458
459 fn is_at_start_of_message(&self) -> bool {
461 if !self.is_slash_menu_allowed() {
462 return false;
463 }
464 let line = self
465 .lines
466 .get(self.cursor_line)
467 .map(|l| l.as_str())
468 .unwrap_or("");
469 let before = &line[..self.cursor_col.min(line.len())];
470 let trimmed = before.trim();
471 trimmed.is_empty() || trimmed == "/"
472 }
473
474 fn is_in_slash_command_context(&self) -> bool {
476 if !self.is_slash_menu_allowed() {
477 return false;
478 }
479 let line = self
480 .lines
481 .get(self.cursor_line)
482 .map(|l| l.as_str())
483 .unwrap_or("");
484 let before = &line[..self.cursor_col.min(line.len())];
485 before.trim_start().starts_with('/')
486 }
487
488 fn update_autocomplete(&mut self, ch: &str) {
489 if self.autocomplete_active {
491 self.try_trigger_autocomplete();
492 return;
493 }
494 let current_line = &self.lines[self.cursor_line];
495 let text_before = ¤t_line[..self.cursor_col.min(current_line.len())];
496
497 if ch == "/" && self.is_at_start_of_message() {
499 self.try_trigger_autocomplete();
500 return;
501 }
502
503 if ch == "@" || ch == "#" {
505 let before_char = text_before.chars().nth_back(1);
506 if text_before.len() == 1
507 || before_char.is_none_or(|c| c.is_whitespace() || c == ' ' || c == '\t')
508 {
509 self.try_trigger_autocomplete();
510 return;
511 }
512 }
513
514 if let Some(ref provider) = self.autocomplete_provider {
516 for tc in provider.trigger_characters() {
517 if ch.len() == 1 && ch == tc.to_string() && tc != &'/' && tc != &'@' && tc != &'#'
518 {
520 let before_char = text_before.chars().nth_back(1);
521 if text_before.len() == 1
522 || before_char.is_none_or(|c| c.is_whitespace() || c == ' ' || c == '\t')
523 {
524 self.try_trigger_autocomplete();
525 return;
526 }
527 }
528 }
529 }
530
531 if ch.len() == 1
533 && ch
534 .chars()
535 .next()
536 .is_some_and(|c| c.is_alphanumeric() || c == '-' || c == '_')
537 {
538 if self.is_in_slash_command_context() && !text_before.trim_start().contains(' ') {
539 self.try_trigger_autocomplete();
540 return;
541 }
542 if text_before.contains('@') || text_before.contains('#') {
544 self.try_trigger_autocomplete();
545 }
546 }
547 }
548
549 fn get_autocomplete_prefix(&self) -> String {
551 let line = self
552 .lines
553 .get(self.cursor_line)
554 .map(|l| l.as_str())
555 .unwrap_or("");
556 let before = &line[..self.cursor_col.min(line.len())];
557 if before.starts_with('/') && !before.contains(' ') {
559 before.to_string()
560 } else if let Some(pos) = before.rfind(['@', '#']) {
561 before[pos..].to_string()
562 } else if let Some(pos) = before.rfind(|c: char| c.is_whitespace()) {
563 before[pos + 1..].to_string()
564 } else {
565 before.to_string()
566 }
567 }
568
569 fn trigger_autocomplete(&mut self, force: bool) {
577 let Some(ref provider) = self.autocomplete_provider else {
578 return;
579 };
580
581 if !force {
585 let line = self
586 .lines
587 .get(self.cursor_line)
588 .map(|l| l.as_str())
589 .unwrap_or("");
590 let before = &line[..self.cursor_col.min(line.len())];
591 let is_slash = before.starts_with('/');
592 if !is_slash && !before.is_empty() {
593 let elapsed = self.last_autocomplete_trigger.elapsed();
594 if elapsed < std::time::Duration::from_millis(20) {
595 return;
596 }
597 }
598 }
599 self.last_autocomplete_trigger = std::time::Instant::now();
600
601 let Some(suggestions) =
602 provider.get_suggestions(&self.lines, self.cursor_line, self.cursor_col, force)
603 else {
604 self.clear_autocomplete();
605 return;
606 };
607
608 let items = suggestions.items;
609 let prefix = suggestions.prefix;
610
611 if items.is_empty() {
612 self.clear_autocomplete();
613 return;
614 }
615
616 if force && items.len() == 1 {
618 let (new_lines, new_line, new_col) = provider.apply_completion(
619 &self.lines,
620 self.cursor_line,
621 self.cursor_col,
622 &items[0],
623 &prefix,
624 );
625 self.lines = new_lines;
626 self.cursor_line = new_line;
627 self.cursor_col = new_col;
628 self.clear_autocomplete();
629 return;
630 }
631
632 let select_items: Vec<SelectItem> = items
634 .into_iter()
635 .map(|item| {
636 let mut si = SelectItem::new(item.value, item.label);
637 if let Some(desc) = item.description {
638 si = si.with_description(desc);
639 }
640 si
641 })
642 .collect();
643 let layout = if prefix.starts_with('/') {
645 Some(
646 crate::tui::components::select_list::SelectListLayoutOptions {
647 min_primary_column_width: Some(12),
648 max_primary_column_width: Some(32),
649 truncate_primary: None,
650 },
651 )
652 } else {
653 None
654 };
655 self.set_autocomplete_with_layout(select_items, layout);
656 self.autocomplete_prefix = prefix;
657 }
658
659 pub fn try_trigger_autocomplete(&mut self) {
660 self.trigger_autocomplete(false);
661 }
662
663 fn try_trigger_autocomplete_force(&mut self) {
665 self.trigger_autocomplete(true);
666 }
667
668 fn add_newline(&mut self) {
669 self.exit_history();
670 self.last_action = None;
671 self.push_undo();
672 let line = self.lines[self.cursor_line].clone();
673 let before = &line[..self.cursor_col.min(line.len())];
674 let after = &line[self.cursor_col.min(line.len())..];
675 self.lines[self.cursor_line] = before.to_string();
676 self.lines.insert(self.cursor_line + 1, after.to_string());
677 self.cursor_line += 1;
678 self.set_cursor_col(0);
679 self.notify_change();
680 }
681
682 fn grapheme_or_paste_before(&self, line: &str, cursor: usize) -> Option<(usize, usize)> {
687 for &(start, end) in &Self::find_paste_marker_spans(line) {
689 if cursor >= end && cursor < end + 10 {
690 if cursor == end {
694 return Some((start, end - start));
695 }
696 }
697 }
698 for &(start, end) in &Self::find_paste_marker_spans(line) {
700 if cursor > start && cursor < end {
701 return Some((start, end - start));
702 }
703 }
704 let graphemes: Vec<(usize, &str)> = line[..cursor].grapheme_indices(true).collect();
706 graphemes.last().map(|&(idx, g)| (idx, g.len()))
707 }
708
709 fn grapheme_or_paste_after(&self, line: &str, cursor: usize) -> Option<(usize, usize)> {
711 for &(start, end) in &Self::find_paste_marker_spans(line) {
713 if cursor == start {
714 return Some((start, end - start));
715 }
716 }
717 let graphemes: Vec<(usize, &str)> = line[cursor..].grapheme_indices(true).collect();
719 graphemes.first().map(|&(i, g)| (cursor + i, g.len()))
720 }
721
722 fn backspace(&mut self) {
723 self.exit_history();
724 self.last_action = None;
725 if self.cursor_col > 0 {
726 self.push_undo();
727 let line = self.lines[self.cursor_line].clone();
728 if let Some((idx, len)) = self.grapheme_or_paste_before(&line, self.cursor_col) {
729 self.lines[self.cursor_line].drain(idx..idx + len);
730 self.set_cursor_col(idx);
731 }
732 } else if self.cursor_line > 0 {
733 self.push_undo();
734 let current = self.lines.remove(self.cursor_line);
735 self.cursor_line -= 1;
736 let prev_len = self.lines[self.cursor_line].len();
737 self.lines[self.cursor_line].push_str(¤t);
738 self.set_cursor_col(prev_len);
739 }
740 self.notify_change();
741 }
742
743 fn delete_forward(&mut self) {
744 self.exit_history();
745 self.last_action = None;
746 let line = self.lines[self.cursor_line].clone();
747 if self.cursor_col < line.len() {
748 self.push_undo();
749 if let Some((idx, len)) = self.grapheme_or_paste_after(&line, self.cursor_col) {
750 self.lines[self.cursor_line].drain(idx..idx + len);
751 }
752 } else if self.cursor_line + 1 < self.lines.len() {
753 self.push_undo();
754 let next = self.lines.remove(self.cursor_line + 1);
755 self.lines[self.cursor_line].push_str(&next);
756 }
757 self.notify_change();
758
759 self.retrigger_autocomplete_dismissed();
761 }
762
763 fn delete_to_line_start(&mut self) {
766 self.exit_history();
767 let line = self.lines[self.cursor_line].clone();
768 if self.cursor_col > 0 {
769 self.push_undo();
770 let deleted = line[..self.cursor_col].to_string();
771 let accumulate = self.last_action.as_deref() == Some("kill");
772 self.kill_ring.push(&deleted, true, accumulate);
773 self.last_action = Some("kill".into());
774 self.lines[self.cursor_line] = line[self.cursor_col..].to_string();
775 self.set_cursor_col(0);
776 } else if self.cursor_line > 0 {
777 self.push_undo();
778 let accumulate = self.last_action.as_deref() == Some("kill");
779 self.kill_ring.push("\n", true, accumulate);
780 self.last_action = Some("kill".into());
781 let current = self.lines.remove(self.cursor_line);
782 self.cursor_line -= 1;
783 let prev_len = self.lines[self.cursor_line].len();
784 self.lines[self.cursor_line].push_str(¤t);
785 self.set_cursor_col(prev_len);
786 }
787 self.notify_change();
788 }
789
790 fn delete_to_line_end(&mut self) {
791 self.exit_history();
792 let line = self.lines[self.cursor_line].clone();
793 if self.cursor_col < line.len() {
794 self.push_undo();
795 let deleted = line[self.cursor_col..].to_string();
796 let accumulate = self.last_action.as_deref() == Some("kill");
797 self.kill_ring.push(&deleted, false, accumulate);
798 self.last_action = Some("kill".into());
799 self.lines[self.cursor_line] = line[..self.cursor_col].to_string();
800 } else if self.cursor_line + 1 < self.lines.len() {
801 self.push_undo();
802 let accumulate = self.last_action.as_deref() == Some("kill");
803 self.kill_ring.push("\n", false, accumulate);
804 self.last_action = Some("kill".into());
805 let next = self.lines.remove(self.cursor_line + 1);
806 self.lines[self.cursor_line].push_str(&next);
807 }
808 self.notify_change();
809 }
810
811 fn delete_word_backward(&mut self) {
812 self.exit_history();
813 let line = self.lines[self.cursor_line].clone();
814 if self.cursor_col == 0 {
815 return;
816 }
817 let opts = WordNavigationOptions {
818 segment: None,
819 is_atomic_segment: Some(&|s: &str| s.starts_with("[paste #") && s.ends_with(']')),
820 };
821 let new_col = find_word_backward_with(&line, self.cursor_col, &opts);
822 if new_col < self.cursor_col {
823 self.push_undo();
824 let deleted = line[new_col..self.cursor_col].to_string();
825 let accumulate = self.last_action.as_deref() == Some("kill");
826 self.kill_ring.push(&deleted, true, accumulate);
827 self.last_action = Some("kill".into());
828 self.lines[self.cursor_line].drain(new_col..self.cursor_col);
829 self.set_cursor_col(new_col);
830 self.notify_change();
831 }
832 }
833
834 fn delete_word_forward(&mut self) {
835 self.exit_history();
836 let line = self.lines[self.cursor_line].clone();
837 if self.cursor_col >= line.len() {
838 return;
839 }
840 let opts = WordNavigationOptions {
841 segment: None,
842 is_atomic_segment: Some(&|s: &str| s.starts_with("[paste #") && s.ends_with(']')),
843 };
844 let new_col = find_word_forward_with(&line, self.cursor_col, &opts);
845 if new_col > self.cursor_col {
846 self.push_undo();
847 let deleted = line[self.cursor_col..new_col].to_string();
848 let accumulate = self.last_action.as_deref() == Some("kill");
849 self.kill_ring.push(&deleted, false, accumulate);
850 self.last_action = Some("kill".into());
851 self.lines[self.cursor_line].drain(self.cursor_col..new_col);
852 self.notify_change();
853 }
854 }
855
856 fn yank(&mut self) {
859 self.exit_history();
860 let text = self.kill_ring.peek().map(|s| s.to_string());
861 if let Some(text) = text {
862 self.push_undo();
863 self.cursor_col += text.len();
864 self.lines[self.cursor_line].insert_str(self.cursor_col - text.len(), &text);
865 self.last_action = Some("yank".into());
866 self.notify_change();
867 }
868 }
869
870 fn yank_pop(&mut self) {
871 if self.last_action.as_deref() != Some("yank") || self.kill_ring.len() <= 1 {
873 return;
874 }
875 self.push_undo();
877
878 let prev = self.kill_ring.peek().map(|s| s.to_string());
880 if let Some(ref prev_text) = prev {
881 let line = &self.lines[self.cursor_line].clone();
882 if self.cursor_col >= prev_text.len() {
883 let before = &line[..self.cursor_col - prev_text.len()];
884 let after = &line[self.cursor_col..];
885 self.lines[self.cursor_line] = format!("{}{}", before, after);
886 self.cursor_col -= prev_text.len();
887 }
888 }
889
890 self.kill_ring.rotate();
892
893 let text = self.kill_ring.peek().map(|s| s.to_string());
895 if let Some(ref new_text) = text {
896 self.cursor_col += new_text.len();
897 self.lines[self.cursor_line].insert_str(self.cursor_col - new_text.len(), new_text);
898 }
899
900 self.last_action = Some("yank".into());
901 self.notify_change();
902 }
903
904 fn move_left(&mut self) {
907 self.last_action = None;
908 if self.cursor_col > 0 {
909 let line = &self.lines[self.cursor_line].clone();
910 let graphemes: Vec<(usize, &str)> =
911 line[..self.cursor_col].grapheme_indices(true).collect();
912 if let Some(&(idx, _g)) = graphemes.last() {
913 let raw = idx;
914 self.set_cursor_col(Self::snap_paste_marker(line, raw, true));
916 }
917 } else if self.cursor_line > 0 {
918 self.cursor_line -= 1;
919 self.set_cursor_col(self.lines[self.cursor_line].len());
920 }
921 }
922
923 fn move_right(&mut self) {
924 self.last_action = None;
925 let line = &self.lines[self.cursor_line].clone();
926 if self.cursor_col < line.len() {
927 let mut it = line[self.cursor_col..].grapheme_indices(true);
928 if let Some((idx, g)) = it.next() {
929 let raw = self.cursor_col + idx + g.len();
930 self.set_cursor_col(Self::snap_paste_marker(line, raw, false));
932 }
933 } else if self.cursor_line + 1 < self.lines.len() {
934 self.cursor_line += 1;
935 self.set_cursor_col(0);
936 }
937 }
938
939 fn move_up(&mut self) {
940 self.move_vertical(-1);
941 }
942
943 fn move_down(&mut self) {
944 self.move_vertical(1);
945 }
946
947 fn move_to_line_start(&mut self) {
948 self.last_action = None;
949 self.set_cursor_col(0);
950 }
951
952 fn move_to_line_end(&mut self) {
953 self.last_action = None;
954 let len = self.lines[self.cursor_line].len();
955 self.set_cursor_col(len);
956 }
957
958 fn build_visual_line_spans(&self, width: usize) -> Vec<(usize, usize, usize)> {
960 let mut spans = Vec::new();
961 for (i, line) in self.lines.iter().enumerate() {
962 let line_w = visible_width(line);
963 if line.is_empty() {
964 spans.push((i, 0, 0));
965 } else if line_w <= width {
966 spans.push((i, 0, line.len()));
967 } else {
968 let chunks = crate::tui::util::wrap_text_with_ansi(line, width);
969 let mut byte_pos = 0;
970 for chunk in &chunks {
971 let chunk_len = chunk.len();
972 spans.push((i, byte_pos, chunk_len));
973 byte_pos += chunk_len;
974 }
975 }
976 }
977 spans
978 }
979
980 fn find_current_visual_line(&self, spans: &[(usize, usize, usize)]) -> usize {
982 for (i, &(li, start, len)) in spans.iter().enumerate() {
983 if li != self.cursor_line {
984 continue;
985 }
986 let offset = self.cursor_col.saturating_sub(start);
987 let is_last = i + 1 >= spans.len() || spans[i + 1].0 != li;
988 if offset <= len || (is_last && offset == len) {
989 return i;
990 }
991 }
992 spans.len().saturating_sub(1)
993 }
994
995 fn move_to_visual_line(
998 &mut self,
999 spans: &[(usize, usize, usize)],
1000 current_vis: usize,
1001 target_vis: usize,
1002 ) {
1003 let (cur_li, _cur_start, cur_len) = spans[current_vis];
1004 let (tgt_li, tgt_start, tgt_len) = spans[target_vis];
1005 let cur_vis_col = self.cursor_col;
1006
1007 let is_last_source = current_vis + 1 >= spans.len() || spans[current_vis + 1].0 != cur_li;
1008 let src_max = if is_last_source {
1009 cur_len
1010 } else {
1011 cur_len.saturating_sub(1)
1012 };
1013
1014 let is_last_target = target_vis + 1 >= spans.len() || spans[target_vis + 1].0 != tgt_li;
1015 let tgt_max = if is_last_target {
1016 tgt_len
1017 } else {
1018 tgt_len.saturating_sub(1)
1019 };
1020
1021 let has_pref = self.preferred_col.is_some();
1023 let cursor_in_middle = cur_vis_col < src_max;
1024 let target_too_short = tgt_max < cur_vis_col;
1025
1026 let move_to_col = if !has_pref || cursor_in_middle {
1027 if target_too_short {
1028 self.preferred_col = Some(cur_vis_col);
1029 tgt_max
1030 } else {
1031 self.preferred_col = None;
1032 cur_vis_col
1033 }
1034 } else {
1035 let pref = self.preferred_col.unwrap_or(0);
1036 let target_cant_fit_pref = tgt_max < pref;
1037 if target_too_short || target_cant_fit_pref {
1038 tgt_max
1039 } else {
1040 self.preferred_col = None;
1041 pref
1042 }
1043 };
1044
1045 self.cursor_line = tgt_li;
1046 let raw_col = tgt_start + move_to_col;
1047 let line = &self.lines[tgt_li].clone();
1048 self.cursor_col = raw_col.min(line.len());
1049 let moving_up = target_vis < current_vis;
1055 self.cursor_col = Self::snap_paste_marker(line, self.cursor_col, moving_up);
1056 }
1057
1058 fn move_vertical(&mut self, delta: isize) {
1059 let width = self.last_width.get();
1060 let spans = self.build_visual_line_spans(width);
1061 let current_vis = self.find_current_visual_line(&spans);
1062
1063 let target_vis = if delta < 0 {
1064 if current_vis == 0 {
1065 return;
1066 }
1067 current_vis - 1
1068 } else if current_vis + 1 >= spans.len() {
1069 return;
1070 } else {
1071 current_vis + 1
1072 };
1073
1074 self.move_to_visual_line(&spans, current_vis, target_vis);
1075 }
1076
1077 fn jump_to_char(&mut self, ch: char, dir: JumpDirection) {
1082 let is_forward = dir == JumpDirection::Forward;
1083 let lines = &self.lines;
1084
1085 let start_line = self.cursor_line as isize;
1086 let end = if is_forward { lines.len() as isize } else { -1 };
1087 let step: isize = if is_forward { 1 } else { -1 };
1088
1089 let mut line_idx = start_line;
1090 while line_idx != end {
1091 let line = &lines[line_idx as usize];
1092 let is_current = line_idx == start_line;
1093 let search_from = if is_current {
1094 if is_forward {
1095 self.cursor_col + 1
1096 } else {
1097 self.cursor_col.saturating_sub(1)
1098 }
1099 } else if is_forward {
1100 0
1101 } else {
1102 line.len()
1103 };
1104
1105 let idx = if is_forward {
1106 line[search_from..].find(ch).map(|i| search_from + i)
1107 } else if search_from > 0 {
1108 line[..search_from].rfind(ch)
1109 } else {
1110 None
1111 };
1112
1113 if let Some(pos) = idx {
1114 self.cursor_line = line_idx as usize;
1115 self.set_cursor_col(pos);
1116 return;
1117 }
1118 line_idx += step;
1119 }
1120 }
1122
1123 fn exit_history(&mut self) {
1126 self.history_index = -1;
1127 self.history_draft = None;
1128 self.last_action = None;
1129 }
1130
1131 fn recall_older(&mut self) {
1132 if self.history.is_empty() {
1133 return;
1134 }
1135 let idx = if self.history_index < 0 {
1137 0
1138 } else {
1139 self.history_index + 1
1140 };
1141 if idx >= self.history.len() as i32 {
1142 return; }
1144
1145 if self.history_index < 0 && idx >= 0 {
1147 self.history_draft = Some(EditorSnapshot {
1148 lines: self.lines.clone(),
1149 cursor_line: self.cursor_line,
1150 cursor_col: self.cursor_col,
1151 });
1152 }
1153
1154 let text = self.history[idx as usize].clone();
1155 self.set_text_internal(&text);
1156 self.cursor_col = 0; self.history_index = idx;
1158 }
1159
1160 fn recall_newer(&mut self) {
1161 if self.history_index < 0 {
1162 return;
1163 }
1164 let idx = self.history_index - 1;
1166 if idx < 0 {
1167 if let Some(draft) = self.history_draft.take() {
1169 self.lines = draft.lines;
1170 self.cursor_line = draft.cursor_line;
1171 self.cursor_col = draft.cursor_col;
1172 self.preferred_col = None;
1173 } else {
1174 self.set_text_internal("");
1175 }
1176 self.history_index = -1;
1177 } else {
1178 let text = self.history[idx as usize].clone();
1179 self.set_text_internal(&text);
1180 self.history_index = idx;
1181 }
1182 }
1183
1184 fn decode_csi_u_in_paste(&self, text: &str) -> String {
1190 let re = regex::Regex::new(r"\x1b\[(\d+);5u").unwrap();
1192 re.replace_all(text, |caps: ®ex::Captures| {
1193 let cp: u32 = caps[1].parse().unwrap_or(0);
1194 if (97..=122).contains(&cp) {
1195 char::from_u32(cp - 96)
1197 .map(|c| c.to_string())
1198 .unwrap_or_default()
1199 } else if (65..=90).contains(&cp) {
1200 char::from_u32(cp - 64)
1202 .map(|c| c.to_string())
1203 .unwrap_or_default()
1204 } else {
1205 caps[0].to_string()
1206 }
1207 })
1208 .to_string()
1209 }
1210
1211 fn find_paste_marker_spans(line: &str) -> Vec<(usize, usize)> {
1216 let mut spans = Vec::new();
1217 let mut pos = 0;
1218 while let Some(start) = line[pos..].find("[paste #") {
1219 let abs_start = pos + start;
1220 if let Some(end) = line[abs_start..].find(']') {
1221 let abs_end = abs_start + end + 1;
1222 spans.push((abs_start, abs_end));
1223 pos = abs_end;
1224 } else {
1225 break;
1226 }
1227 }
1228 spans
1229 }
1230
1231 fn snap_paste_marker(line: &str, cursor: usize, moving_left: bool) -> usize {
1234 for &(start, end) in &Self::find_paste_marker_spans(line) {
1235 if cursor > start && cursor < end {
1236 return if moving_left { start } else { end };
1237 }
1238 }
1239 cursor
1240 }
1241
1242 pub fn handle_paste(&mut self, text: &str) {
1247 self.clear_autocomplete();
1248 self.exit_history();
1249 self.last_action = None;
1250 self.push_undo();
1251
1252 let decoded = self.decode_csi_u_in_paste(text);
1254
1255 let normalized = decoded
1257 .replace("\r\n", "\n")
1258 .replace('\r', "\n")
1259 .replace('\t', " ");
1260
1261 let filtered: String = normalized
1263 .chars()
1264 .filter(|&c| c == '\n' || c == ' ' || c as u32 >= 32)
1265 .collect();
1266
1267 let current_line = self.lines[self.cursor_line].clone();
1270 let space_prefix = if filtered.starts_with('/')
1271 || filtered.starts_with('~')
1272 || filtered.starts_with('.')
1273 {
1274 if self.cursor_col > 0 {
1275 let prev = current_line
1276 .as_bytes()
1277 .get(self.cursor_col - 1)
1278 .copied()
1279 .unwrap_or(b' ');
1280 if prev.is_ascii_alphanumeric() || prev == b'_' {
1281 " "
1282 } else {
1283 ""
1284 }
1285 } else {
1286 ""
1287 }
1288 } else {
1289 ""
1290 };
1291 let prepared = format!("{}{}", space_prefix, filtered);
1292
1293 let total_chars = prepared.len();
1294 let is_large = prepared.lines().count().max(1) > 10 || total_chars > 1000;
1295
1296 if is_large {
1297 let line_count = prepared.lines().count();
1298 self.paste_counter += 1;
1299 let paste_id = self.paste_counter;
1300 self.pastes.insert(paste_id, prepared);
1301
1302 let marker = if line_count > 10 {
1303 format!("[paste #{} +{} lines]", paste_id, line_count)
1304 } else {
1305 format!("[paste #{} {} chars]", paste_id, total_chars)
1306 };
1307 self.insert_text_internal(&marker);
1308 } else {
1309 self.insert_text_internal(&prepared);
1310 }
1311 }
1312
1313 pub fn expand_paste_markers(&self, text: &str) -> String {
1315 let mut result = text.to_string();
1316 let mut ids: Vec<u32> = self.pastes.keys().copied().collect();
1318 ids.sort_unstable_by(|a, b| b.cmp(a)); for paste_id in ids {
1320 if let Some(content) = self.pastes.get(&paste_id) {
1321 let marker1 = format!("[paste #{} ", paste_id);
1323 loop {
1324 let start = result.find(&marker1);
1325 match start {
1326 Some(pos) => {
1327 let end = result[pos..]
1328 .find(']')
1329 .map(|e| pos + e + 1)
1330 .unwrap_or(result.len());
1331 result.replace_range(pos..end, content);
1332 }
1333 None => break,
1334 }
1335 }
1336 }
1337 }
1338 result
1339 }
1340
1341 pub fn get_expanded_text(&self) -> String {
1344 self.expand_paste_markers(&self.lines.join("\n"))
1345 }
1346
1347 pub fn is_paste_marker(segment: &str) -> bool {
1349 segment.starts_with("[paste #") && segment.ends_with(']')
1350 }
1351
1352 fn page_size(&self) -> usize {
1355 std::cmp::max(5, (self.terminal_rows as f64 * 0.3) as usize)
1356 }
1357
1358 fn page_up(&mut self) {
1359 let size = self.page_size();
1360 self.scroll_offset = self.scroll_offset.saturating_sub(size);
1361 }
1362
1363 fn page_down(&mut self) {
1364 let size = self.page_size();
1365 self.scroll_offset += size;
1366 }
1367
1368 fn submit(&mut self) {
1371 let raw = self.lines.join("\n");
1373 let result = self.expand_paste_markers(&raw);
1374 self.last_submitted_text = result.clone();
1375 self.lines = vec![String::new()];
1376 self.cursor_line = 0;
1377 self.cursor_col = 0;
1378 self.scroll_offset = 0;
1379 self.pastes.clear();
1380 self.paste_counter = 0;
1381 self.undo_stack.clear();
1382 self.last_action = None;
1383 self.preferred_col = None;
1384 self.just_submitted = true;
1385 self.exit_history();
1386 if let Some(ref mut cb) = self.on_submit {
1387 cb(result);
1388 }
1389 self.notify_change();
1390 }
1391
1392 fn notify_change(&mut self) {
1395 let text = self.get_text();
1396 if let Some(ref mut cb) = self.on_change {
1397 cb(&text);
1398 }
1399 if self.autocomplete_active {
1401 self.try_trigger_autocomplete();
1402 }
1403 }
1404
1405 fn is_empty(&self) -> bool {
1406 self.lines.is_empty() || (self.lines.len() == 1 && self.lines[0].is_empty())
1407 }
1408
1409 fn is_first_visual_line(&self) -> bool {
1410 let width = self.last_width.get();
1411 let visual_lines = layout_text(&self.lines, width, self.cursor_line, self.cursor_col);
1412 let current = visual_lines
1413 .iter()
1414 .position(|vl| vl.has_cursor)
1415 .unwrap_or(0);
1416 current == 0
1417 }
1418
1419 fn is_last_visual_line(&self) -> bool {
1420 let width = self.last_width.get();
1421 let visual_lines = layout_text(&self.lines, width, self.cursor_line, self.cursor_col);
1422 let current = visual_lines
1423 .iter()
1424 .position(|vl| vl.has_cursor)
1425 .unwrap_or(0);
1426 current >= visual_lines.len().saturating_sub(1)
1427 }
1428}
1429
1430impl Component for Editor {
1433 fn render(&mut self, width: usize) -> Vec<String> {
1434 let max_padding = if width > 1 { (width - 1) / 2 } else { 0 };
1435 let pad_x = self.padding_x.min(max_padding);
1436 let content_width = if width > pad_x * 2 {
1437 width - pad_x * 2
1438 } else {
1439 1
1440 };
1441 let layout_width = content_width
1443 .max(1)
1444 .saturating_sub(if pad_x > 0 { 0 } else { 1 });
1445 self.last_width.set(layout_width);
1446
1447 let horizontal = "─";
1448 let left_pad = " ".repeat(pad_x);
1449 let right_pad = " ".repeat(pad_x);
1450 let mut result: Vec<String> = Vec::new();
1451
1452 let visual_lines =
1454 layout_text(&self.lines, layout_width, self.cursor_line, self.cursor_col);
1455 let total_visual = visual_lines.len().max(1);
1456
1457 let cursor_vis = visual_lines
1459 .iter()
1460 .position(|vl| vl.has_cursor)
1461 .unwrap_or(0);
1462
1463 let max_vis = std::cmp::max(5, (self.terminal_rows as f64 * 0.3) as usize).max(1);
1466 let mut scroll = self.scroll_offset;
1467 if cursor_vis < scroll {
1468 scroll = cursor_vis;
1469 } else if cursor_vis >= scroll + max_vis {
1470 scroll = cursor_vis - max_vis + 1;
1471 }
1472 let max_scroll = total_visual.saturating_sub(max_vis);
1473 scroll = scroll.min(max_scroll);
1474
1475 let visible_end = (scroll + max_vis).min(total_visual);
1476
1477 if scroll > 0 {
1479 let indicator = format!("─── ↑ {} more ", scroll);
1480 let indicator_w = visible_width(&indicator);
1481 let fill = if indicator_w < width {
1482 horizontal.repeat(width - indicator_w)
1483 } else {
1484 String::new()
1485 };
1486 result.push(self.border_color.apply(&format!("{}{}", indicator, fill)));
1487 } else {
1488 result.push(self.border_color.apply(&horizontal.repeat(width)));
1489 }
1490
1491 for vl in visual_lines.iter().skip(scroll).take(visible_end - scroll) {
1493 let text = &vl.text;
1494 let (display, line_width) = if vl.has_cursor {
1495 let cursor_pos = vl.cursor_pos.unwrap_or(0);
1496 let before = &text[..cursor_pos.min(text.len())];
1497 let after = &text[cursor_pos.min(text.len())..];
1498
1499 let marker = if self.focused {
1500 CURSOR_MARKER.to_string()
1501 } else {
1502 String::new()
1503 };
1504
1505 if !after.is_empty() {
1506 let after_graphemes: Vec<&str> = after.graphemes(true).collect();
1507 let first_g = after_graphemes.first().copied().unwrap_or(" ");
1508 let rest = &after[first_g.len()..];
1509 let cursor = format!("\x1b[7m{}\x1b[0m", first_g);
1510 (
1511 format!("{}{}{}{}", before, marker, cursor, rest),
1512 visible_width(text),
1513 )
1514 } else if !before.is_empty() {
1515 let cursor_block = "\x1b[7m \x1b[0m";
1517 (
1518 format!("{}{}{}", before, cursor_block, marker),
1519 visible_width(text) + 1,
1520 )
1521 } else {
1522 let cursor = "\x1b[7m \x1b[0m";
1524 (
1525 format!("{}{}{}", before, marker, cursor),
1526 visible_width(text) + 1,
1527 )
1528 }
1529 } else {
1530 (text.clone(), visible_width(text))
1531 };
1532
1533 let cursor_in_padding = line_width > content_width && pad_x > 0;
1535 let padding = if line_width < content_width {
1536 " ".repeat(content_width - line_width)
1537 } else {
1538 String::new()
1539 };
1540 let right_pad_used = if cursor_in_padding {
1541 &right_pad[1..]
1542 } else {
1543 &right_pad
1544 };
1545 result.push(format!(
1546 "{}{}{}{}",
1547 left_pad, display, padding, right_pad_used
1548 ));
1549 }
1550
1551 let below = total_visual.saturating_sub(visible_end);
1553 if below > 0 {
1554 let indicator = format!("─── ↓ {} more ", below);
1555 let indicator_w = visible_width(&indicator);
1556 let fill = if indicator_w < width {
1557 horizontal.repeat(width - indicator_w)
1558 } else {
1559 String::new()
1560 };
1561 result.push(self.border_color.apply(&format!("{}{}", indicator, fill)));
1562 } else {
1563 result.push(self.border_color.apply(&horizontal.repeat(width)));
1564 }
1565
1566 if self.autocomplete_active
1568 && let Some(ref mut list) = self.autocomplete_list
1569 {
1570 let list_lines = list.render(width);
1571 result.extend(list_lines);
1572 }
1573
1574 result
1575 }
1576
1577 fn handle_input(&mut self, key: &KeyEvent) -> bool {
1578 let kb = get_keybindings();
1579
1580 if let Some(dir) = self.jump_mode {
1582 if kb.matches(key, ACTION_EDITOR_JUMP_FORWARD)
1584 || kb.matches(key, ACTION_EDITOR_JUMP_BACKWARD)
1585 {
1586 self.jump_mode = None;
1587 return true;
1588 }
1589 if is_printable_plain(key)
1590 && let Some(s) = key_event_to_string(key)
1591 {
1592 let ch = s.chars().next().unwrap_or(' ');
1593 self.jump_mode = None;
1594 self.jump_to_char(ch, dir);
1595 return true;
1596 }
1597 self.jump_mode = None;
1599 }
1600
1601 let mut ac_selected: Option<String> = None;
1610 let mut ac_complete_and_return = false;
1611
1612 if let Some(ref mut list) = self.autocomplete_list {
1613 if kb.matches(key, ACTION_SELECT_CANCEL) {
1614 self.autocomplete_active = false;
1615 self.autocomplete_list = None;
1616 self.autocomplete_prefix.clear();
1617 return true;
1618 }
1619
1620 if kb.matches(key, ACTION_INPUT_TAB) {
1621 ac_selected = list.selected_item().map(|i| i.value.clone());
1622 ac_complete_and_return = true;
1623 } else if kb.matches(key, ACTION_SELECT_CONFIRM) {
1624 ac_selected = list.selected_item().map(|i| i.value.clone());
1625 let is_slash = self.autocomplete_prefix.starts_with('/');
1626 if !is_slash {
1627 ac_complete_and_return = true;
1628 }
1629 } else if kb.matches(key, ACTION_SELECT_UP) || kb.matches(key, ACTION_SELECT_DOWN) {
1631 list.handle_input(key);
1632 return true;
1633 }
1634 }
1637
1638 if let Some(val) = ac_selected {
1640 self.apply_autocomplete_completion_value(&val);
1641 self.clear_autocomplete();
1642 if ac_complete_and_return {
1643 return true;
1644 }
1645 }
1647
1648 if kb.matches(key, ACTION_INPUT_TAB) && self.autocomplete_provider.is_some() {
1650 self.try_trigger_autocomplete_force();
1651 return true;
1652 }
1653
1654 if kb.matches(key, ACTION_INPUT_SUBMIT) {
1656 if self.disable_submit {
1657 self.add_newline();
1658 return true;
1659 }
1660 let line = &self.lines[self.cursor_line];
1661 if self.cursor_col > 0 && line.as_bytes().get(self.cursor_col - 1) == Some(&b'\\') {
1662 self.backspace();
1663 self.add_newline();
1664 return true;
1665 }
1666 self.submit();
1667 return true;
1668 }
1669
1670 if kb.matches(key, ACTION_EDITOR_JUMP_FORWARD) {
1672 self.jump_mode = Some(JumpDirection::Forward);
1673 return true;
1674 }
1675 if kb.matches(key, ACTION_EDITOR_JUMP_BACKWARD) {
1676 self.jump_mode = Some(JumpDirection::Backward);
1677 return true;
1678 }
1679
1680 if is_printable_plain(key)
1682 && let Some(s) = key_event_to_string(key)
1683 {
1684 self.insert_character(&s);
1685 return true;
1686 }
1687
1688 if kb.matches(key, ACTION_EDITOR_CURSOR_LEFT) {
1690 self.move_left();
1691 self.update_autocomplete_if_active();
1692 return true;
1693 }
1694 if kb.matches(key, ACTION_EDITOR_CURSOR_RIGHT) {
1695 self.move_right();
1696 self.update_autocomplete_if_active();
1697 return true;
1698 }
1699 if kb.matches(key, ACTION_EDITOR_CURSOR_LINE_START) {
1700 self.move_to_line_start();
1701 self.update_autocomplete_if_active();
1702 return true;
1703 }
1704 if kb.matches(key, ACTION_EDITOR_CURSOR_LINE_END) {
1705 self.move_to_line_end();
1706 self.update_autocomplete_if_active();
1707 return true;
1708 }
1709
1710 if kb.matches(key, ACTION_EDITOR_CURSOR_UP) {
1712 if self.is_first_visual_line()
1713 && (self.is_empty() || self.history_index >= 0 || self.cursor_col == 0)
1714 {
1715 self.recall_older();
1716 } else if self.is_first_visual_line() {
1717 self.move_to_line_start();
1718 } else {
1719 self.move_up();
1720 }
1721 self.update_autocomplete_if_active();
1722 return true;
1723 }
1724 if kb.matches(key, ACTION_EDITOR_CURSOR_DOWN) {
1725 if self.history_index >= 0 && self.is_last_visual_line() {
1726 self.recall_newer();
1727 } else if self.is_last_visual_line() {
1728 self.move_to_line_end();
1729 } else {
1730 self.move_down();
1731 }
1732 self.update_autocomplete_if_active();
1733 return true;
1734 }
1735
1736 if kb.matches(key, ACTION_EDITOR_PAGE_UP) {
1738 self.page_up();
1739 self.update_autocomplete_if_active();
1740 return true;
1741 }
1742 if kb.matches(key, ACTION_EDITOR_PAGE_DOWN) {
1743 self.page_down();
1744 self.update_autocomplete_if_active();
1745 return true;
1746 }
1747
1748 if kb.matches(key, ACTION_EDITOR_CURSOR_WORD_LEFT) {
1750 let line = &self.lines[self.cursor_line].clone();
1751 if self.cursor_col > 0 {
1752 let opts = WordNavigationOptions {
1753 segment: None,
1754 is_atomic_segment: Some(&|s: &str| {
1755 s.starts_with("[paste #") && s.ends_with(']')
1756 }),
1757 };
1758 let c = find_word_backward_with(line, self.cursor_col, &opts);
1759 self.set_cursor_col(c);
1760 }
1761 self.update_autocomplete_if_active();
1762 return true;
1763 }
1764 if kb.matches(key, ACTION_EDITOR_CURSOR_WORD_RIGHT) {
1765 let line = &self.lines[self.cursor_line].clone();
1766 if self.cursor_col < line.len() {
1767 let opts = WordNavigationOptions {
1768 segment: None,
1769 is_atomic_segment: Some(&|s: &str| {
1770 s.starts_with("[paste #") && s.ends_with(']')
1771 }),
1772 };
1773 let c = find_word_forward_with(line, self.cursor_col, &opts);
1774 self.set_cursor_col(c);
1775 }
1776 self.update_autocomplete_if_active();
1777 return true;
1778 }
1779
1780 if kb.matches(key, ACTION_EDITOR_DELETE_CHAR_BACKWARD) {
1782 self.backspace();
1783 return true;
1785 }
1786 if kb.matches(key, ACTION_EDITOR_DELETE_CHAR_FORWARD) {
1787 self.delete_forward();
1788 return true;
1790 }
1791
1792 if kb.matches(key, ACTION_EDITOR_DELETE_WORD_BACKWARD) {
1794 self.delete_word_backward();
1795 return true;
1797 }
1798 if kb.matches(key, ACTION_EDITOR_DELETE_WORD_FORWARD) {
1799 self.delete_word_forward();
1800 return true;
1802 }
1803 if kb.matches(key, ACTION_EDITOR_DELETE_TO_LINE_START) {
1804 self.delete_to_line_start();
1805 return true;
1807 }
1808 if kb.matches(key, ACTION_EDITOR_DELETE_TO_LINE_END) {
1809 self.delete_to_line_end();
1810 return true;
1812 }
1813
1814 if kb.matches(key, ACTION_EDITOR_YANK) {
1816 self.yank();
1817 return true;
1818 }
1819 if kb.matches(key, ACTION_EDITOR_YANK_POP) {
1820 self.yank_pop();
1821 return true;
1822 }
1823
1824 if kb.matches(key, ACTION_EDITOR_UNDO) {
1826 self.last_action = None;
1827 self.undo();
1828 self.notify_change();
1829 return true;
1830 }
1831
1832 if kb.matches(key, ACTION_INPUT_NEW_LINE) {
1834 self.add_newline();
1835 return true;
1836 }
1837
1838 if kb.matches(key, ACTION_SELECT_CANCEL) {
1840 return false;
1841 }
1842
1843 false
1844 }
1845
1846 fn handle_paste(&mut self, text: &str) {
1847 Editor::handle_paste(self, text);
1848 }
1849
1850 fn is_focusable(&self) -> bool {
1851 true
1852 }
1853}
1854
1855impl Focusable for Editor {
1856 fn set_focused(&mut self, focused: bool) {
1857 self.focused = focused;
1858 }
1859
1860 fn focused(&self) -> bool {
1861 self.focused
1862 }
1863}
1864
1865#[derive(Debug)]
1868struct VisualLine {
1869 text: String,
1870 has_cursor: bool,
1871 cursor_pos: Option<usize>,
1872}
1873
1874fn layout_text(
1876 lines: &[String],
1877 max_width: usize,
1878 cursor_line: usize,
1879 cursor_col: usize,
1880) -> Vec<VisualLine> {
1881 let mut result: Vec<VisualLine> = Vec::new();
1882
1883 if lines.is_empty() || (lines.len() == 1 && lines[0].is_empty()) {
1884 result.push(VisualLine {
1885 text: String::new(),
1886 has_cursor: true,
1887 cursor_pos: Some(0),
1888 });
1889 return result;
1890 }
1891
1892 let mut _col_offset = 0;
1893
1894 for (line_idx, line) in lines.iter().enumerate() {
1895 let is_cursor_line = line_idx == cursor_line;
1896 let line_w = visible_width(line);
1897 _col_offset = 0;
1898
1899 if line_w <= max_width {
1900 result.push(VisualLine {
1902 text: line.clone(),
1903 has_cursor: is_cursor_line,
1904 cursor_pos: if is_cursor_line {
1905 Some(cursor_col.min(line.len()))
1906 } else {
1907 None
1908 },
1909 });
1910 } else {
1911 let wrapped = wrap_text_with_ansi(line, max_width);
1916
1917 let cursor_vis = if is_cursor_line {
1919 visible_width(&line[..cursor_col.min(line.len())])
1920 } else {
1921 0
1922 };
1923
1924 let mut vis_offset: usize = 0;
1925 for (chunk_idx, chunk) in wrapped.iter().enumerate() {
1926 let chunk_vis = visible_width(chunk);
1927 let chunk_vis_end = vis_offset + chunk_vis;
1928
1929 let cursor_in_chunk = is_cursor_line
1930 && cursor_vis >= vis_offset
1931 && (cursor_vis < chunk_vis_end || chunk_idx == wrapped.len() - 1);
1932
1933 let cursor_pos = if cursor_in_chunk {
1934 let local_vis = cursor_vis.saturating_sub(vis_offset);
1935 Some(visual_col_to_byte_offset(chunk, local_vis))
1937 } else {
1938 None
1939 };
1940
1941 result.push(VisualLine {
1942 text: chunk.clone(),
1943 has_cursor: cursor_in_chunk && cursor_pos.is_some(),
1944 cursor_pos,
1945 });
1946
1947 vis_offset = chunk_vis_end;
1948 }
1949 }
1950 }
1951
1952 result
1953}
1954
1955fn is_printable_plain(key: &KeyEvent) -> bool {
1956 matches!(key.code, KeyCode::Char(_))
1957 && !key.modifiers.contains(KeyModifiers::CONTROL)
1958 && !key.modifiers.contains(KeyModifiers::ALT)
1959 && key.code != KeyCode::Enter
1960 && key.code != KeyCode::Tab
1961 && key.code != KeyCode::Backspace
1962 && key.code != KeyCode::Delete
1963 && key.code != KeyCode::Esc
1964}
1965
1966#[cfg(test)]
1967mod tests {
1968 use super::*;
1969 use crate::tui::autocomplete::{
1970 AutocompleteItem, AutocompleteProvider, AutocompleteSuggestions, SlashCommand,
1971 };
1972
1973 struct MockSlashProvider {
1976 commands: Vec<SlashCommand>,
1977 }
1978
1979 impl MockSlashProvider {
1980 fn new(commands: Vec<&str>) -> Self {
1981 Self {
1982 commands: commands
1983 .into_iter()
1984 .map(|name| SlashCommand {
1985 name: name.to_string(),
1986 description: Some(format!("The {} command", name)),
1987 argument_hint: None,
1988 argument_completions: None,
1989 get_argument_completions: None,
1990 })
1991 .collect(),
1992 }
1993 }
1994 }
1995
1996 impl AutocompleteProvider for MockSlashProvider {
1997 fn trigger_characters(&self) -> &[char] {
1998 &['/', '@', '#']
1999 }
2000
2001 fn get_suggestions(
2002 &self,
2003 lines: &[String],
2004 cursor_line: usize,
2005 cursor_col: usize,
2006 _force: bool,
2007 ) -> Option<AutocompleteSuggestions> {
2008 let line = lines.get(cursor_line)?;
2009 let before = &line[..cursor_col.min(line.len())];
2010
2011 if before.starts_with('/') && !before.contains(' ') {
2013 let query = &before[1..].to_lowercase();
2014 let matching: Vec<AutocompleteItem> = self
2015 .commands
2016 .iter()
2017 .filter(|cmd| cmd.name.to_lowercase().starts_with(query))
2018 .map(|cmd| AutocompleteItem {
2019 value: cmd.name.clone(),
2020 label: format!("/{}", cmd.name),
2021 description: cmd.description.clone(),
2022 })
2023 .collect();
2024 if matching.is_empty() {
2025 return None;
2026 }
2027 return Some(AutocompleteSuggestions {
2028 items: matching,
2029 prefix: before.to_string(),
2030 });
2031 }
2032 None
2033 }
2034
2035 fn apply_completion(
2036 &self,
2037 lines: &[String],
2038 cursor_line: usize,
2039 cursor_col: usize,
2040 item: &AutocompleteItem,
2041 prefix: &str,
2042 ) -> (Vec<String>, usize, usize) {
2043 let current_line = lines[cursor_line].clone();
2044 let prefix_start = cursor_col.saturating_sub(prefix.len());
2045 let before = ¤t_line[..prefix_start];
2046 let after = ¤t_line[cursor_col..];
2047 (
2048 vec![format!("{}/{} {}", before, item.value, after)],
2049 cursor_line,
2050 before.len() + 1 + item.value.len() + 1,
2051 )
2052 }
2053
2054 fn should_trigger_file_completion(
2055 &self,
2056 lines: &[String],
2057 cursor_line: usize,
2058 cursor_col: usize,
2059 ) -> bool {
2060 let current_line = lines.get(cursor_line);
2061 match current_line {
2062 Some(text) => {
2063 let before = &text[..cursor_col.min(text.len())];
2064 if before.starts_with('/') && !before.contains(' ') {
2065 return false;
2066 }
2067 true
2068 }
2069 None => false,
2070 }
2071 }
2072 }
2073
2074 fn make_editor_with_slash_provider(commands: Vec<&str>) -> Editor {
2077 let mut editor = Editor::new(EditorOptions::default());
2078 let provider = Box::new(MockSlashProvider::new(commands));
2079 editor.set_autocomplete_provider(provider);
2080 editor
2081 }
2082
2083 #[test]
2084 fn autocomplete_triggers_on_slash() {
2085 let mut editor = make_editor_with_slash_provider(vec!["help", "history"]);
2086 editor.handle_input(&char_key('/'));
2087 assert!(
2088 editor.autocomplete_active,
2089 "autocomplete should activate after typing /"
2090 );
2091 let selected = editor.autocomplete_selected_value();
2092 assert_eq!(
2093 selected.as_deref(),
2094 Some("help"),
2095 "first item should be help"
2096 );
2097 }
2098
2099 #[test]
2100 fn autocomplete_filters_as_user_types() {
2101 let mut editor = make_editor_with_slash_provider(vec!["help", "history", "model"]);
2102 editor.handle_input(&char_key('/'));
2104 assert!(editor.autocomplete_active);
2105
2106 editor.handle_input(&char_key('h'));
2108 assert!(
2109 editor.autocomplete_active,
2110 "autocomplete should stay active after typing more letters"
2111 );
2112 editor.handle_input(&char_key('e'));
2116 assert!(editor.autocomplete_active);
2117 let selected = editor.autocomplete_selected_value();
2118 assert_eq!(selected.as_deref(), Some("help"));
2119 }
2120
2121 #[test]
2122 fn autocomplete_stays_active_on_printable_chars() {
2123 let mut editor = make_editor_with_slash_provider(vec!["help", "history"]);
2125 editor.handle_input(&char_key('/'));
2126 assert!(editor.autocomplete_active);
2127
2128 editor.handle_input(&char_key('h'));
2129 assert!(
2130 editor.autocomplete_active,
2131 "typing 'h' after '/' must keep autocomplete visible"
2132 );
2133
2134 editor.handle_input(&char_key('e'));
2135 assert!(
2136 editor.autocomplete_active,
2137 "typing 'e' after '/h' must keep autocomplete visible"
2138 );
2139
2140 let lines = editor.render(80);
2141 assert!(lines.len() > 3, "autocomplete lines should be rendered");
2143 }
2144
2145 #[test]
2146 fn escape_dismisses_autocomplete() {
2147 let mut editor = make_editor_with_slash_provider(vec!["help", "history"]);
2148 editor.handle_input(&char_key('/'));
2149 assert!(editor.autocomplete_active);
2150
2151 editor.handle_input(&escape());
2152 assert!(
2153 !editor.autocomplete_active,
2154 "escape should dismiss autocomplete"
2155 );
2156
2157 assert_eq!(editor.get_text(), "/");
2159 }
2160
2161 #[test]
2162 fn backspace_removing_slash_dismisses_autocomplete() {
2163 let mut editor = make_editor_with_slash_provider(vec!["help", "history"]);
2164 editor.handle_input(&char_key('/'));
2165 assert!(editor.autocomplete_active, "after /");
2166
2167 editor.handle_input(&backspace());
2168 assert!(
2169 !editor.autocomplete_active,
2170 "backspace removing / should dismiss autocomplete"
2171 );
2172 assert_eq!(editor.get_text(), "", "text should be empty");
2173 }
2174
2175 #[test]
2176 fn autocomplete_updates_after_backspace_char() {
2177 let mut editor = make_editor_with_slash_provider(vec!["help", "history"]);
2178 editor.handle_input(&char_key('/'));
2180 editor.handle_input(&char_key('h'));
2181 editor.handle_input(&char_key('e'));
2182 assert!(editor.autocomplete_active);
2183 let val1 = editor.autocomplete_selected_value();
2184 assert_eq!(val1.as_deref(), Some("help"));
2185
2186 editor.handle_input(&backspace());
2188 assert!(
2189 editor.autocomplete_active,
2190 "backspace should re-filter, not dismiss"
2191 );
2192 assert!(!editor.autocomplete_is_empty());
2194 assert_eq!(editor.get_text(), "/h");
2195 }
2196
2197 #[test]
2198 fn autocomplete_updates_on_cursor_movement() {
2199 let mut editor = make_editor_with_slash_provider(vec!["help", "history"]);
2200 editor.handle_input(&char_key('/'));
2202 editor.handle_input(&char_key('h'));
2203 editor.handle_input(&char_key('e'));
2204 editor.handle_input(&char_key('l'));
2205 editor.handle_input(&char_key('p'));
2206 assert!(editor.autocomplete_active);
2207
2208 editor.handle_input(&char_key(' '));
2211 assert!(
2212 !editor.autocomplete_active,
2213 "space after /cmd should dismiss slash autocomplete"
2214 );
2215
2216 editor.handle_input(&left_key());
2218 }
2222
2223 #[test]
2224 fn autocomplete_clears_when_provider_returns_none() {
2225 let mut editor = make_editor_with_slash_provider(vec!["help"]);
2227 editor.handle_input(&char_key('/'));
2228 assert!(editor.autocomplete_active);
2229
2230 editor.handle_input(&char_key('z'));
2232 assert!(
2233 !editor.autocomplete_active,
2234 "typing /z with no matching command should dismiss autocomplete"
2235 );
2236 }
2237
2238 #[test]
2239 fn autocomplete_does_not_interfere_with_normal_typing() {
2240 let mut editor = make_editor_with_slash_provider(vec!["help", "history"]);
2242 editor.handle_input(&char_key('h'));
2243 editor.handle_input(&char_key('e'));
2244 editor.handle_input(&char_key('l'));
2245 editor.handle_input(&char_key('l'));
2246 editor.handle_input(&char_key('o'));
2247 assert!(!editor.autocomplete_active, "no slash = no autocomplete");
2248 assert_eq!(editor.get_text(), "hello");
2249 }
2250
2251 #[test]
2252 fn autocomplete_renders_lines_below_editor() {
2253 let mut editor = make_editor_with_slash_provider(vec!["help", "history", "model"]);
2254 editor.handle_input(&char_key('/'));
2255 assert!(editor.autocomplete_active);
2256
2257 let lines = editor.render(80);
2258 assert!(
2260 lines.len() >= 5,
2261 "should have border lines + autocomplete items"
2262 );
2263 assert!(lines[2].contains('─'), "line 2 should be bottom border");
2265 let after_border = &lines[3..];
2267 let all_have_content = after_border.iter().any(|l| !l.trim().is_empty());
2268 assert!(all_have_content, "autocomplete lines should have content");
2269 }
2270
2271 #[test]
2272 fn autocomplete_stable_rendering_no_flash_on_extra_char() {
2273 let mut editor = make_editor_with_slash_provider(vec!["help", "history", "model"]);
2276 editor.handle_input(&char_key('/'));
2277 let lines_after_slash = editor.render(80).len();
2278
2279 editor.handle_input(&char_key('h'));
2280 let lines_after_h = editor.render(80).len();
2281
2282 let diff = lines_after_slash.abs_diff(lines_after_h);
2285 assert!(
2286 diff <= 1,
2287 "line count should not change dramatically: {} -> {} (diff {})",
2288 lines_after_slash,
2289 lines_after_h,
2290 diff
2291 );
2292 }
2293
2294 #[test]
2295 fn autocomplete_dismissed_on_submit() {
2296 let mut editor = make_editor_with_slash_provider(vec!["help"]);
2297 editor.handle_input(&char_key('/'));
2298 assert!(editor.autocomplete_active);
2299
2300 editor.handle_input(&enter_key());
2302 }
2304
2305 #[test]
2306 fn tab_force_triggers_autocomplete() {
2307 let mut editor = make_editor_with_slash_provider(vec!["help", "history"]);
2308 editor.handle_input(&char_key('/'));
2311 assert!(editor.autocomplete_active);
2313 }
2314
2315 #[test]
2316 fn autocomplete_persists_across_multiple_chars() {
2317 let mut editor = make_editor_with_slash_provider(vec!["help", "history", "hello", "heavy"]);
2319
2320 for ch in "/hel".chars() {
2321 editor.handle_input(&char_key(ch));
2322 assert!(
2323 editor.autocomplete_active,
2324 "autocomplete should stay active after '{}'",
2325 ch
2326 );
2327 }
2328
2329 assert!(
2331 !editor.autocomplete_is_empty(),
2332 "should have matching items"
2333 );
2334 assert_eq!(editor.get_text(), "/hel");
2335 }
2336
2337 #[test]
2338 fn test_new_editor() {
2339 let editor = Editor::new(EditorOptions::default());
2340 assert_eq!(editor.get_text(), "");
2341 }
2342
2343 #[test]
2344 fn test_set_text() {
2345 let mut editor = Editor::new(EditorOptions::default());
2346 editor.set_text("hello world");
2347 assert_eq!(editor.get_text(), "hello world");
2348 }
2349
2350 #[test]
2351 fn test_insert_and_move() {
2352 let mut editor = Editor::new(EditorOptions::default());
2353 editor.insert_character("h");
2354 editor.insert_character("i");
2355 assert_eq!(editor.get_text(), "hi");
2356 editor.move_left();
2357 assert_eq!(editor.cursor_col, 1);
2358 editor.move_right();
2359 assert_eq!(editor.cursor_col, 2);
2360 }
2361
2362 #[test]
2363 fn test_backspace() {
2364 let mut editor = Editor::new(EditorOptions::default());
2365 editor.set_text("hello");
2366 editor.backspace();
2367 assert_eq!(editor.get_text(), "hell");
2368 }
2369
2370 #[test]
2371 fn test_multiline() {
2372 let mut editor = Editor::new(EditorOptions::default());
2373 editor.set_text("line1\nline2");
2374 assert_eq!(editor.get_lines().len(), 2);
2375 }
2376
2377 #[test]
2378 fn test_undo() {
2379 let mut editor = Editor::new(EditorOptions::default());
2380 editor.push_undo();
2381 editor.insert_text_internal("a");
2382 editor.push_undo();
2383 editor.insert_text_internal("b");
2384 assert_eq!(editor.get_text(), "ab");
2385 editor.undo();
2386 assert_eq!(editor.get_text(), "a");
2387 editor.undo();
2388 assert_eq!(editor.get_text(), "");
2389 }
2390
2391 #[test]
2392 fn test_submit_clears() {
2393 let mut editor = Editor::new(EditorOptions::default());
2394 editor.set_text("hello");
2395 let result = editor.lines.join("\n");
2396 editor.lines = vec![String::new()];
2397 editor.cursor_line = 0;
2398 editor.cursor_col = 0;
2399 assert_eq!(result, "hello");
2400 assert_eq!(editor.get_text(), "");
2401 }
2402
2403 #[test]
2404 fn test_render_borders() {
2405 let mut editor = Editor::new(EditorOptions::default());
2406 let lines = editor.render(80);
2407 assert!(lines.len() >= 3);
2408 assert!(lines[0].contains('─'));
2409 assert!(lines.last().unwrap().contains('─'));
2410 }
2411
2412 #[test]
2413 fn test_scroll_indicator() {
2414 let mut editor = Editor::new(EditorOptions { padding_x: 1 });
2415 editor.set_terminal_rows(6);
2419 editor.set_text("line1\nline2\nline3\nline4\nline5\nline6");
2420 editor.cursor_line = 5;
2421 editor.cursor_col = 5;
2422 editor.scroll_offset = 2;
2423 let lines = editor.render(80);
2424 assert!(
2425 lines[0].contains("↑"),
2426 "Expected scroll-up indicator, got: {:?}",
2427 lines[0]
2428 );
2429 }
2430
2431 #[test]
2432 fn test_newline() {
2433 let mut editor = Editor::new(EditorOptions::default());
2434 editor.set_text("hello");
2435 editor.add_newline();
2436 assert_eq!(editor.get_text(), "hello\n");
2437 editor.insert_character("w");
2438 assert_eq!(editor.get_text(), "hello\nw");
2439 }
2440
2441 #[test]
2442 fn test_cursor_in_layout() {
2443 let editor = Editor::new(EditorOptions::default());
2444 let vl = layout_text(&editor.lines, 80, editor.cursor_line, editor.cursor_col);
2446 assert!(vl[0].has_cursor);
2447 assert_eq!(vl[0].cursor_pos, Some(0));
2448 }
2449
2450 #[test]
2451 fn test_cursor_in_layout_with_text() {
2452 let mut editor = Editor::new(EditorOptions::default());
2453 editor.set_text("abc");
2454 editor.cursor_col = 1;
2455 let vl = layout_text(&editor.lines, 80, editor.cursor_line, editor.cursor_col);
2456 assert!(vl[0].has_cursor);
2457 assert_eq!(vl[0].cursor_pos, Some(1));
2458 }
2459
2460 fn up_key() -> KeyEvent {
2463 KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)
2464 }
2465 fn left_key() -> KeyEvent {
2466 KeyEvent::new(KeyCode::Left, KeyModifiers::NONE)
2467 }
2468 fn char_key(c: char) -> KeyEvent {
2469 KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE)
2470 }
2471 fn enter_key() -> KeyEvent {
2472 KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)
2473 }
2474 fn escape() -> KeyEvent {
2475 KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)
2476 }
2477 fn backspace() -> KeyEvent {
2478 KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)
2479 }
2480
2481 #[test]
2482 fn test_history_empty_up_does_nothing() {
2483 let mut editor = Editor::new(EditorOptions::default());
2484 editor.handle_input(&up_key());
2485 assert_eq!(editor.get_text(), "");
2486 }
2487
2488 #[test]
2489 fn test_history_up_shows_most_recent() {
2490 let mut editor = Editor::new(EditorOptions::default());
2491 editor.add_to_history("first");
2492 editor.add_to_history("second");
2493 editor.handle_input(&up_key());
2494 assert_eq!(editor.get_text(), "second");
2495 }
2496
2497 #[test]
2498 fn test_history_cycles() {
2499 let mut editor = Editor::new(EditorOptions::default());
2500 editor.add_to_history("first");
2501 editor.add_to_history("second");
2502 editor.add_to_history("third");
2503 editor.handle_input(&up_key());
2504 assert_eq!(editor.get_text(), "third");
2505 editor.handle_input(&up_key());
2506 assert_eq!(editor.get_text(), "second");
2507 editor.handle_input(&up_key());
2508 assert_eq!(editor.get_text(), "first");
2509 editor.handle_input(&up_key()); assert_eq!(editor.get_text(), "first");
2511 }
2512
2513 #[test]
2514 fn test_history_exits_on_type() {
2515 let mut editor = Editor::new(EditorOptions::default());
2516 editor.add_to_history("old");
2517 editor.handle_input(&up_key());
2518 assert_eq!(editor.get_text(), "old");
2519 editor.handle_input(&char_key('x'));
2520 assert_eq!(editor.get_text(), "xold");
2521 }
2522
2523 #[test]
2524 fn test_backslash_enter_newline() {
2525 let mut editor = Editor::new(EditorOptions::default());
2526 editor.handle_input(&char_key('\\'));
2527 assert_eq!(editor.get_text(), "\\");
2528 editor.handle_input(&enter_key());
2529 assert_eq!(editor.get_text(), "\n");
2530 }
2531
2532 #[test]
2533 fn test_move_cursor_over_emoji() {
2534 let mut editor = Editor::new(EditorOptions::default());
2535 editor.set_text("a😀b");
2536 editor.cursor_col = 0;
2537 editor.move_right();
2538 assert_eq!(editor.cursor_col, 1);
2539 editor.move_right();
2540 assert_eq!(editor.cursor_col, 5);
2541 editor.move_right();
2542 assert_eq!(editor.cursor_col, 6);
2543 }
2544
2545 #[test]
2546 fn test_backspace_emoji() {
2547 let mut editor = Editor::new(EditorOptions::default());
2548 editor.set_text("a😀b");
2549 editor.cursor_col = 6;
2550 editor.backspace();
2551 assert_eq!(editor.get_text(), "a😀");
2552 editor.backspace();
2553 assert_eq!(editor.get_text(), "a");
2554 }
2555
2556 #[test]
2557 fn test_render_cursor_visible() {
2558 let mut editor = Editor::new(EditorOptions::default());
2559 editor.focused = true;
2560 editor.insert_character("x");
2561 let lines = editor.render(40);
2562 let content = &lines[1];
2563 assert!(content.contains("\x1b[7m"), "Cursor inverse not found");
2564 }
2565
2566 #[test]
2567 fn test_emits_cursor_marker_when_focused() {
2568 let mut editor = Editor::new(EditorOptions::default());
2569 editor.focused = true;
2570 editor.insert_character("hello");
2571 let lines = editor.render(40);
2572 let content = &lines[1];
2573 assert!(
2574 content.contains(CURSOR_MARKER),
2575 "Focused editor should emit cursor marker"
2576 );
2577 }
2578
2579 #[test]
2580 fn test_no_cursor_marker_when_not_focused() {
2581 let mut editor = Editor::new(EditorOptions::default());
2582 editor.focused = false;
2583 editor.insert_character("hello");
2584 let lines = editor.render(40);
2585 let content = &lines[1];
2586 assert!(
2587 !content.contains(CURSOR_MARKER),
2588 "Unfocused editor should not emit cursor marker"
2589 );
2590 }
2591
2592 #[test]
2593 fn test_render_borders_always_present() {
2594 let mut editor = Editor::new(EditorOptions::default());
2595 let lines = editor.render(80);
2596 assert_eq!(lines.len(), 3, "Empty editor should have 3 lines");
2597 assert!(lines[0].contains('─'), "Top border missing");
2598 assert!(lines[2].contains('─'), "Bottom border missing");
2599
2600 editor.insert_character("/");
2601 let lines = editor.render(80);
2602 assert_eq!(lines.len(), 3, "After typing / should still have 3 lines");
2603 assert!(lines[0].contains('─'), "Top border missing after /");
2604 assert!(lines[2].contains('─'), "Bottom border missing after /");
2605
2606 editor.set_text("hello world this is text");
2607 let lines = editor.render(40);
2608 assert!(lines.len() >= 3, "Wrapped text: {}", lines.len());
2609 assert!(lines[0].contains('─'), "Top border");
2610 assert!(lines.last().unwrap().contains('─'), "Bottom border");
2611 }
2612
2613 #[test]
2614 fn test_content_width_respected() {
2615 let mut editor = Editor::new(EditorOptions { padding_x: 1 });
2616 editor.set_text("hello world this is a test");
2617 let lines = editor.render(20);
2618 for line in &lines {
2619 let vw = crate::tui::util::visible_width(line);
2620 assert!(vw <= 20, "Width {} > 20: {:?}", vw, line);
2621 }
2622 }
2623
2624 #[test]
2627 fn test_no_duplicate_chunks_from_wrapping() {
2628 let texts = [
2632 "hello world this is a test of the wrapping system",
2633 "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",
2634 "short",
2635 "",
2636 "abc abc abc abc abc abc abc abc",
2637 " leading and trailing spaces ",
2638 "hello world extra spaces",
2639 ];
2640 for text in &texts {
2641 for width in [1, 2, 3, 5, 8, 12, 20, 40] {
2642 let wrapped = crate::tui::util::wrap_text_with_ansi(text, width);
2643
2644 let total_vis_wrapped: usize = wrapped.iter().map(|c| visible_width(c)).sum();
2646 let total_vis_original = visible_width(text);
2647 assert!(
2648 total_vis_wrapped <= total_vis_original,
2649 "Width={}: wrapped visible {} > original visible {} for {:?}",
2650 width,
2651 total_vis_wrapped,
2652 total_vis_original,
2653 text
2654 );
2655
2656 for a in &wrapped {
2659 if a.is_empty() {
2660 continue;
2661 }
2662 let count_in_wrapped = wrapped.iter().filter(|c| *c == a).count();
2663 let count_in_original = text.matches(a.as_str()).count();
2664 assert!(
2665 count_in_wrapped <= count_in_original || count_in_original == 0,
2666 "Width={}: chunk '{}' appears {}x in wrapped but {}x in original for {:?}",
2667 width,
2668 a,
2669 count_in_wrapped,
2670 count_in_original,
2671 text
2672 );
2673 }
2674 }
2675 }
2676 }
2677
2678 #[test]
2679 fn test_cursor_in_wrapped_text_first_chunk() {
2680 let mut editor = Editor::new(EditorOptions::default());
2682 let text = "hello world this is a test";
2683 editor.set_text(text);
2684 editor.cursor_col = 3;
2686 let vl = layout_text(&editor.lines, 10, editor.cursor_line, editor.cursor_col);
2687 assert!(vl.len() > 1, "Text should wrap into multiple visual lines");
2688 assert!(
2689 vl[0].has_cursor,
2690 "Cursor at col 3 should be in first visual line"
2691 );
2692 if let Some(pos) = vl[0].cursor_pos {
2693 assert_eq!(pos, 3, "Cursor byte offset in first chunk should be 3");
2694 }
2695 }
2696
2697 #[test]
2698 fn test_cursor_in_wrapped_text_middle_chunk() {
2699 let mut editor = Editor::new(EditorOptions::default());
2701 let text = "hello world this is a test";
2702 editor.set_text(text);
2703 editor.cursor_col = 16;
2707 let vl = layout_text(&editor.lines, 10, editor.cursor_line, editor.cursor_col);
2708 assert!(vl.len() > 1, "Text should wrap");
2709 let cursor_vl = vl.iter().position(|v| v.has_cursor);
2710 assert!(
2711 cursor_vl.is_some(),
2712 "Cursor should be found in some visual line"
2713 );
2714 }
2715
2716 #[test]
2717 fn test_cursor_last_chunk_on_boundary() {
2718 let mut editor = Editor::new(EditorOptions::default());
2720 let text = "hello world this is a test";
2721 editor.set_text(text);
2722 editor.cursor_col = text.len();
2723 let vl = layout_text(&editor.lines, 10, editor.cursor_line, editor.cursor_col);
2724 assert!(
2725 vl.last().is_some_and(|v| v.has_cursor),
2726 "Cursor at end should be in last visual line"
2727 );
2728 }
2729
2730 #[test]
2731 fn test_layout_text_each_chunk_unique() {
2732 let text = "hello world this is a test of the wrapping system";
2735 let vl = layout_text(&[text.to_string()], 12, 0, 0);
2736 let chunk_texts: Vec<&str> = vl.iter().map(|v| v.text.as_str()).collect();
2737 for i in 0..chunk_texts.len() {
2738 for j in (i + 1)..chunk_texts.len() {
2739 if chunk_texts[i] == chunk_texts[j] {
2740 if !chunk_texts[i].is_empty() {
2742 panic!(
2743 "Duplicate chunk text at positions {} and {}: '{}'",
2744 i, j, chunk_texts[i]
2745 );
2746 }
2747 }
2748 }
2749 }
2750 }
2751
2752 #[test]
2755 fn test_visual_col_to_byte_offset_ascii() {
2756 let text = "hello";
2757 assert_eq!(crate::tui::util::visual_col_to_byte_offset(text, 0), 0);
2758 assert_eq!(crate::tui::util::visual_col_to_byte_offset(text, 3), 3);
2759 assert_eq!(crate::tui::util::visual_col_to_byte_offset(text, 5), 5);
2760 }
2761
2762 #[test]
2763 fn test_visual_col_to_byte_offset_cjk() {
2764 let text = "世界hello";
2765 assert_eq!(crate::tui::util::visual_col_to_byte_offset(text, 0), 0);
2766 assert_eq!(crate::tui::util::visual_col_to_byte_offset(text, 2), 3);
2768 assert_eq!(crate::tui::util::visual_col_to_byte_offset(text, 4), 6);
2770 }
2771
2772 #[test]
2773 fn test_visual_col_to_byte_offset_ansi() {
2774 let text = "\x1b[31mhello\x1b[0m";
2776 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); }
2784
2785 #[test]
2786 fn test_visual_col_to_byte_offset_empty() {
2787 assert_eq!(crate::tui::util::visual_col_to_byte_offset("", 0), 0);
2788 assert_eq!(crate::tui::util::visual_col_to_byte_offset("", 5), 0);
2789 }
2790
2791 #[test]
2792 fn test_visual_col_to_byte_offset_zero_col() {
2793 assert_eq!(crate::tui::util::visual_col_to_byte_offset("abc", 0), 0);
2795 assert_eq!(
2797 crate::tui::util::visual_col_to_byte_offset("\x1b[31mabc", 0),
2798 5
2799 );
2800 }
2801
2802 #[test]
2805 fn test_large_paste_creates_marker() {
2806 let mut editor = Editor::new(EditorOptions::default());
2807 let large = "line1\nline2\nline3\nline4\nline5\nline6\nline7\nline8\nline9\nline10\nline11";
2808 editor.handle_paste(large);
2809 let text = editor.get_text();
2810 assert!(text.contains("[paste #"), "Should contain paste marker");
2811 assert!(
2812 !text.contains("line1"),
2813 "Should not contain original content"
2814 );
2815 assert_eq!(editor.pastes.len(), 1, "Should store one paste");
2816 }
2817
2818 #[test]
2819 fn test_small_paste_no_marker() {
2820 let mut editor = Editor::new(EditorOptions::default());
2821 editor.handle_paste("hello");
2822 let text = editor.get_text();
2823 assert!(
2824 !text.contains("[paste #"),
2825 "Small paste should not create marker"
2826 );
2827 assert_eq!(text, "hello");
2828 }
2829
2830 #[test]
2831 fn test_expand_paste_markers() {
2832 let mut editor = Editor::new(EditorOptions::default());
2833 editor.handle_paste(
2834 "line1\nline2\nline3\nline4\nline5\nline6\nline7\nline8\nline9\nline10\nline11",
2835 );
2836 let expanded = editor.get_expanded_text();
2837 assert!(
2838 expanded.contains("line1"),
2839 "Expanded text should contain original content"
2840 );
2841 assert!(
2842 !expanded.contains("[paste #"),
2843 "Expanded text should not contain markers"
2844 );
2845 }
2846
2847 #[test]
2848 fn test_submit_expands_markers() {
2849 let mut editor = Editor::new(EditorOptions::default());
2850 editor.handle_paste(
2851 "line1\nline2\nline3\nline4\nline5\nline6\nline7\nline8\nline9\nline10\nline11",
2852 );
2853 let large_content =
2854 "line1\nline2\nline3\nline4\nline5\nline6\nline7\nline8\nline9\nline10\nline11";
2855 let raw = editor.lines.join("\n");
2857 let expanded = editor.expand_paste_markers(&raw);
2858 assert_eq!(
2859 expanded, large_content,
2860 "Submit should expand to original content"
2861 );
2862 }
2863
2864 #[test]
2865 fn test_is_paste_marker() {
2866 assert!(Editor::is_paste_marker("[paste #1 +5 lines]"));
2867 assert!(Editor::is_paste_marker("[paste #123 456 chars]"));
2868 assert!(!Editor::is_paste_marker("normal text"));
2869 assert!(!Editor::is_paste_marker(""));
2870 }
2871
2872 #[test]
2873 fn test_get_expanded_text() {
2874 let mut editor = Editor::new(EditorOptions::default());
2875 editor.handle_paste(
2876 "line1\nline2\nline3\nline4\nline5\nline6\nline7\nline8\nline9\nline10\nline11",
2877 );
2878 let expanded = editor.get_expanded_text();
2879 assert!(
2880 expanded.contains("line1"),
2881 "get_expanded_text should expand markers"
2882 );
2883 assert!(
2884 expanded.starts_with("line1"),
2885 "Should start with original content"
2886 );
2887 }
2888
2889 #[test]
2892 fn test_multiline_render_no_duplicate_content() {
2893 let mut editor = Editor::new(EditorOptions::default());
2894 editor.set_text("hello");
2896 editor.add_newline();
2897 editor.insert_character("w");
2898 editor.insert_character("o");
2899 editor.insert_character("r");
2900 editor.insert_character("l");
2901 editor.insert_character("d");
2902 assert_eq!(editor.get_text(), "hello\nworld");
2903
2904 for width in [20, 40, 80] {
2906 let rendered = editor.render(width);
2907
2908 let content_lines: Vec<&str> = rendered
2910 .iter()
2911 .filter(|l| !l.contains('─'))
2912 .map(|l| l.trim())
2913 .collect();
2914
2915 assert!(
2917 content_lines.len() >= 2,
2918 "Width {}: expected >= 2 content lines, got {}: {:?}",
2919 width,
2920 content_lines.len(),
2921 rendered
2922 );
2923
2924 let mut seen = std::collections::HashSet::new();
2926 for line in &content_lines {
2927 if !line.is_empty() {
2928 let plain = line.replace("\x1b_pi:c\x07", "").to_string();
2929 if !seen.insert(plain.clone()) {
2930 panic!(
2931 "Width {}: duplicate content line '{}' in {:?}",
2932 width, line, rendered
2933 );
2934 }
2935 }
2936 }
2937 }
2938 }
2939
2940 #[test]
2941 fn test_editor_add_newline_adds_one_visual_line() {
2942 let mut editor = Editor::new(EditorOptions::default());
2943 editor.set_text("hello");
2944
2945 let before = editor.render(80).len();
2946 editor.add_newline();
2947 let after = editor.render(80).len();
2948
2949 assert_eq!(
2950 after,
2951 before + 1,
2952 "Adding newline should increase rendered line count by exactly 1. before={}, after={}",
2953 before,
2954 after
2955 );
2956 }
2957
2958 #[test]
2959 fn test_layout_text_no_extra_empty_visual_line() {
2960 let lines: Vec<String> = vec![String::new()];
2963 let vl = layout_text(&lines, 80, 0, 0);
2964 assert_eq!(vl.len(), 1, "Empty text should have 1 visual line");
2965 assert!(vl[0].has_cursor);
2966
2967 let lines = vec!["hello".to_string()];
2968 let vl = layout_text(&lines, 80, 0, 5);
2969 assert_eq!(vl.len(), 1, "Single line should have 1 visual line");
2970 assert!(vl[0].has_cursor);
2971
2972 let lines = vec!["hello".to_string(), "".to_string()];
2973 let vl = layout_text(&lines, 80, 0, 5);
2974 assert_eq!(
2975 vl.len(),
2976 2,
2977 "Two lines (one empty) should have 2 visual lines"
2978 );
2979 assert!(vl[0].has_cursor);
2981 assert!(!vl[1].has_cursor);
2982
2983 let lines = vec!["hello".to_string(), "".to_string()];
2984 let vl = layout_text(&lines, 80, 1, 0);
2985 assert_eq!(vl.len(), 2);
2986 assert!(!vl[0].has_cursor);
2988 assert!(vl[1].has_cursor);
2989
2990 let lines = vec!["".to_string(), "hello".to_string()];
2991 let vl = layout_text(&lines, 80, 1, 5);
2992 assert_eq!(
2993 vl.len(),
2994 2,
2995 "Two lines (one empty first) should have 2 visual lines"
2996 );
2997 assert!(!vl[0].has_cursor);
2998 assert!(vl[1].has_cursor);
2999 }
3000
3001 #[test]
3002 fn test_wrap_edge_cases_no_empty_lines() {
3003 let cases = vec![
3007 (" hello", 3, "leading spaces"),
3008 ("hello ", 3, "trailing spaces"),
3009 (" hello ", 3, "leading and trailing spaces"),
3010 ("abc def", 5, "double space in middle"),
3011 ("a b", 4, "triple space"),
3012 ("a b", 3, "double space at wrap boundary"),
3013 ];
3014 for (text, width, label) in &cases {
3015 let wrapped = crate::tui::util::wrap_text_with_ansi(text, *width);
3018 for chunk in &wrapped {
3019 if chunk.is_empty() {
3021 panic!(
3022 "Case '{}' (width {}): empty chunk found in wrapped: {:?}",
3023 label, width, wrapped
3024 );
3025 }
3026 let vis = crate::tui::util::visible_width(chunk);
3027 assert!(
3028 vis > 0,
3029 "Case '{}' (width {}): chunk with visible width 0: {:?} (wrapped: {:?})",
3030 label,
3031 width,
3032 chunk,
3033 wrapped
3034 );
3035 }
3036 }
3037 }
3038
3039 #[test]
3040 fn test_wrap_long_word_no_duplicate_chunks() {
3041 let long = "aaaaa bbbbb ccccc ddddd";
3043 for width in [5, 6, 7, 8, 10, 12] {
3044 let wrapped = crate::tui::util::wrap_text_with_ansi(long, width);
3045 let mut seen = std::collections::HashSet::new();
3047 for chunk in &wrapped {
3048 let trimmed = chunk.trim();
3049 if !trimmed.is_empty() && !seen.insert(trimmed.to_string()) {
3050 panic!(
3051 "Width {}: duplicate chunk '{}' in {:?}",
3052 width, chunk, wrapped
3053 );
3054 }
3055 }
3056 }
3057 }
3058
3059 #[test]
3060 fn test_wrap_typing_detailed_trace() {
3061 let mut editor = Editor::new(EditorOptions::default());
3064
3065 let sentence = "hello world";
3067 let width = 10;
3068
3069 for (i, ch) in sentence.chars().enumerate() {
3070 editor.handle_input(&char_key(ch));
3071
3072 let vl = layout_text(&editor.lines, width, editor.cursor_line, editor.cursor_col);
3074
3075 let mut seen = std::collections::HashSet::new();
3077 for vis in &vl {
3078 let trimmed = vis.text.trim();
3079 if !trimmed.is_empty() && !seen.insert(trimmed.to_string()) {
3080 panic!(
3081 "After char '{}' (pos {}): duplicate visual line '{}' in {:?}",
3082 ch, i, vis.text, vl
3083 );
3084 }
3085 }
3086
3087 let cursor_count = vl.iter().filter(|v| v.has_cursor).count();
3089 assert_eq!(
3090 cursor_count, 1,
3091 "After char '{}' (pos {}): expected exactly 1 cursor, got {}. vl: {:?}",
3092 ch, i, cursor_count, vl
3093 );
3094 }
3095 }
3096
3097 #[test]
3098 fn test_wrap_long_continuous_string_no_duplicates() {
3099 let mut editor = Editor::new(EditorOptions::default());
3102
3103 let url = "https://very-long-url-with-no-spaces.example.com/path/to/resource";
3105 for ch in url.chars() {
3106 editor.handle_input(&char_key(ch));
3107 }
3108
3109 for width in [5, 10, 15, 20, 30] {
3111 let rendered = editor.render(width);
3112 let content: Vec<&str> = rendered
3113 .iter()
3114 .filter(|l| !l.contains('─'))
3115 .map(|l| l.trim())
3116 .filter(|l| !l.is_empty())
3117 .collect();
3118
3119 let mut seen = std::collections::HashSet::new();
3120 for line in &content {
3121 let plain = line
3122 .replace("\x1b_pi:c\x07", "")
3123 .chars()
3124 .filter(|&c| c.is_ascii_graphic() || c == ' ')
3125 .collect::<String>()
3126 .trim()
3127 .to_string();
3128 if !plain.is_empty() && !seen.insert(plain.clone()) {
3129 panic!(
3130 "Width {}: duplicate content line '{}' (plain: '{}')\nFull render: {:?}",
3131 width, line, plain, rendered
3132 );
3133 }
3134 }
3135 }
3136 }
3137
3138 #[test]
3139 fn test_editor_typing_past_width_no_duplicate_render() {
3140 let mut editor = Editor::new(EditorOptions::default());
3143
3144 let input = "hello world this is a test of the emergency broadcast system";
3146 for ch in input.chars() {
3147 editor.handle_input(&char_key(ch));
3148 }
3149
3150 for width in [5, 8, 10, 12, 15, 20] {
3152 let rendered = editor.render(width);
3153
3154 let content: Vec<&str> = rendered
3156 .iter()
3157 .filter(|l| !l.contains('─'))
3158 .map(|l| l.trim())
3159 .filter(|l| !l.is_empty())
3160 .collect();
3161
3162 let mut seen = std::collections::HashSet::new();
3164 for line in &content {
3165 let plain = line
3167 .replace("\x1b_pi:c\x07", "")
3168 .chars()
3169 .filter(|&c| c.is_ascii_graphic() || c == ' ')
3170 .collect::<String>()
3171 .trim()
3172 .to_string();
3173 if !plain.is_empty() && !seen.insert(plain.clone()) {
3174 panic!(
3175 "Width {}: duplicate content line '{}' (plain: '{}')\nFull render: {:?}",
3176 width, line, plain, rendered
3177 );
3178 }
3179 }
3180
3181 let content_plain: String = content.join(" ");
3183 let content_plain = content_plain
3184 .replace("\x1b_pi:c\x07", "")
3185 .chars()
3186 .filter(|&c| c.is_ascii_graphic() || c == ' ')
3187 .collect::<String>();
3188 assert!(
3189 !content_plain.is_empty(),
3190 "Width {}: no visible content in render: {:?}",
3191 width,
3192 rendered
3193 );
3194 }
3195 }
3196}