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('#') || 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 == "/" && !self.is_at_start_of_message() {
505 let before_char = text_before.chars().nth_back(1);
506 if text_before.len() >= 2
507 && before_char
508 .is_some_and(|c| c == '~' || c == '@' || c == '#' || c.is_whitespace())
509 {
510 self.try_trigger_autocomplete();
511 return;
512 }
513 }
514
515 if ch == "@" || ch == "#" {
517 let before_char = text_before.chars().nth_back(1);
518 if text_before.len() == 1
519 || before_char.is_none_or(|c| c.is_whitespace() || c == ' ' || c == '\t')
520 {
521 self.try_trigger_autocomplete();
522 return;
523 }
524 }
525
526 if ch == "~" {
528 let before_char = text_before.chars().nth_back(1);
529 if text_before.len() == 1
530 || before_char.is_none_or(|c| c.is_whitespace() || c == ' ' || c == '\t')
531 {
532 self.try_trigger_autocomplete();
533 return;
534 }
535 }
536
537 if let Some(ref provider) = self.autocomplete_provider {
539 for tc in provider.trigger_characters() {
540 if ch.len() == 1 && ch == tc.to_string() && tc != &'/' && tc != &'@' && tc != &'#'
541 {
543 let before_char = text_before.chars().nth_back(1);
544 if text_before.len() == 1
545 || before_char.is_none_or(|c| c.is_whitespace() || c == ' ' || c == '\t')
546 {
547 self.try_trigger_autocomplete();
548 return;
549 }
550 }
551 }
552 }
553
554 if ch.len() == 1
556 && ch
557 .chars()
558 .next()
559 .is_some_and(|c| c.is_alphanumeric() || c == '-' || c == '_')
560 {
561 if self.is_in_slash_command_context() && !text_before.trim_start().contains(' ') {
562 self.try_trigger_autocomplete();
563 return;
564 }
565 if text_before.contains('@') || text_before.contains('#') || text_before.contains('~') {
567 self.try_trigger_autocomplete();
568 }
569 }
570 }
571
572 fn get_autocomplete_prefix(&self) -> String {
577 let line = self
578 .lines
579 .get(self.cursor_line)
580 .map(|l| l.as_str())
581 .unwrap_or("");
582 let before = &line[..self.cursor_col.min(line.len())];
583 if before.starts_with('/') && !before.contains(' ') {
585 before.to_string()
586 } else if let Some(pos) = before.rfind(['@', '#']) {
587 before[pos + 1..].to_string()
589 } else if let Some(pos) = before.rfind(|c: char| c.is_whitespace()) {
590 before[pos + 1..].to_string()
591 } else {
592 before.to_string()
593 }
594 }
595
596 fn trigger_autocomplete(&mut self, force: bool) {
604 let Some(ref provider) = self.autocomplete_provider else {
605 return;
606 };
607
608 if !force {
612 let line = self
613 .lines
614 .get(self.cursor_line)
615 .map(|l| l.as_str())
616 .unwrap_or("");
617 let before = &line[..self.cursor_col.min(line.len())];
618 let is_slash = before.starts_with('/');
619 if !is_slash && !before.is_empty() {
620 let elapsed = self.last_autocomplete_trigger.elapsed();
621 if elapsed < std::time::Duration::from_millis(20) {
622 return;
623 }
624 }
625 }
626 self.last_autocomplete_trigger = std::time::Instant::now();
627
628 let Some(suggestions) =
629 provider.get_suggestions(&self.lines, self.cursor_line, self.cursor_col, force)
630 else {
631 self.clear_autocomplete();
632 return;
633 };
634
635 let items = suggestions.items;
636 let prefix = suggestions.prefix;
637
638 if items.is_empty() {
639 self.clear_autocomplete();
640 return;
641 }
642
643 if force && items.len() == 1 {
645 let (new_lines, new_line, new_col) = provider.apply_completion(
646 &self.lines,
647 self.cursor_line,
648 self.cursor_col,
649 &items[0],
650 &prefix,
651 );
652 self.lines = new_lines;
653 self.cursor_line = new_line;
654 self.cursor_col = new_col;
655 self.clear_autocomplete();
656 return;
657 }
658
659 let select_items: Vec<SelectItem> = items
661 .into_iter()
662 .map(|item| {
663 let mut si = SelectItem::new(item.value, item.label);
664 if let Some(desc) = item.description {
665 si = si.with_description(desc);
666 }
667 si
668 })
669 .collect();
670 let layout = if prefix.starts_with('/') {
672 Some(
673 crate::tui::components::select_list::SelectListLayoutOptions {
674 min_primary_column_width: Some(12),
675 max_primary_column_width: Some(32),
676 truncate_primary: None,
677 },
678 )
679 } else {
680 None
681 };
682 self.set_autocomplete_with_layout(select_items, layout);
683 self.autocomplete_prefix = prefix;
684 }
685
686 pub fn try_trigger_autocomplete(&mut self) {
687 self.trigger_autocomplete(false);
688 }
689
690 fn try_trigger_autocomplete_force(&mut self) {
692 self.trigger_autocomplete(true);
693 }
694
695 fn add_newline(&mut self) {
696 self.exit_history();
697 self.last_action = None;
698 self.push_undo();
699 let line = self.lines[self.cursor_line].clone();
700 let before = &line[..self.cursor_col.min(line.len())];
701 let after = &line[self.cursor_col.min(line.len())..];
702 self.lines[self.cursor_line] = before.to_string();
703 self.lines.insert(self.cursor_line + 1, after.to_string());
704 self.cursor_line += 1;
705 self.set_cursor_col(0);
706 self.notify_change();
707 }
708
709 fn grapheme_or_paste_before(&self, line: &str, cursor: usize) -> Option<(usize, usize)> {
714 for &(start, end) in &Self::find_paste_marker_spans(line) {
716 if cursor >= end && cursor < end + 10 {
717 if cursor == end {
721 return Some((start, end - start));
722 }
723 }
724 }
725 for &(start, end) in &Self::find_paste_marker_spans(line) {
727 if cursor > start && cursor < end {
728 return Some((start, end - start));
729 }
730 }
731 let graphemes: Vec<(usize, &str)> = line[..cursor].grapheme_indices(true).collect();
733 graphemes.last().map(|&(idx, g)| (idx, g.len()))
734 }
735
736 fn grapheme_or_paste_after(&self, line: &str, cursor: usize) -> Option<(usize, usize)> {
738 for &(start, end) in &Self::find_paste_marker_spans(line) {
740 if cursor == start {
741 return Some((start, end - start));
742 }
743 }
744 let graphemes: Vec<(usize, &str)> = line[cursor..].grapheme_indices(true).collect();
746 graphemes.first().map(|&(i, g)| (cursor + i, g.len()))
747 }
748
749 fn backspace(&mut self) {
750 self.exit_history();
751 self.last_action = None;
752 if self.cursor_col > 0 {
753 self.push_undo();
754 let line = self.lines[self.cursor_line].clone();
755 if let Some((idx, len)) = self.grapheme_or_paste_before(&line, self.cursor_col) {
756 self.lines[self.cursor_line].drain(idx..idx + len);
757 self.set_cursor_col(idx);
758 }
759 } else if self.cursor_line > 0 {
760 self.push_undo();
761 let current = self.lines.remove(self.cursor_line);
762 self.cursor_line -= 1;
763 let prev_len = self.lines[self.cursor_line].len();
764 self.lines[self.cursor_line].push_str(¤t);
765 self.set_cursor_col(prev_len);
766 }
767 self.notify_change();
768 }
769
770 fn delete_forward(&mut self) {
771 self.exit_history();
772 self.last_action = None;
773 let line = self.lines[self.cursor_line].clone();
774 if self.cursor_col < line.len() {
775 self.push_undo();
776 if let Some((idx, len)) = self.grapheme_or_paste_after(&line, self.cursor_col) {
777 self.lines[self.cursor_line].drain(idx..idx + len);
778 }
779 } else if self.cursor_line + 1 < self.lines.len() {
780 self.push_undo();
781 let next = self.lines.remove(self.cursor_line + 1);
782 self.lines[self.cursor_line].push_str(&next);
783 }
784 self.notify_change();
785
786 self.retrigger_autocomplete_dismissed();
788 }
789
790 fn delete_to_line_start(&mut self) {
793 self.exit_history();
794 let line = self.lines[self.cursor_line].clone();
795 if self.cursor_col > 0 {
796 self.push_undo();
797 let deleted = line[..self.cursor_col].to_string();
798 let accumulate = self.last_action.as_deref() == Some("kill");
799 self.kill_ring.push(&deleted, true, accumulate);
800 self.last_action = Some("kill".into());
801 self.lines[self.cursor_line] = line[self.cursor_col..].to_string();
802 self.set_cursor_col(0);
803 } else if self.cursor_line > 0 {
804 self.push_undo();
805 let accumulate = self.last_action.as_deref() == Some("kill");
806 self.kill_ring.push("\n", true, accumulate);
807 self.last_action = Some("kill".into());
808 let current = self.lines.remove(self.cursor_line);
809 self.cursor_line -= 1;
810 let prev_len = self.lines[self.cursor_line].len();
811 self.lines[self.cursor_line].push_str(¤t);
812 self.set_cursor_col(prev_len);
813 }
814 self.notify_change();
815 }
816
817 fn delete_to_line_end(&mut self) {
818 self.exit_history();
819 let line = self.lines[self.cursor_line].clone();
820 if self.cursor_col < line.len() {
821 self.push_undo();
822 let deleted = line[self.cursor_col..].to_string();
823 let accumulate = self.last_action.as_deref() == Some("kill");
824 self.kill_ring.push(&deleted, false, accumulate);
825 self.last_action = Some("kill".into());
826 self.lines[self.cursor_line] = line[..self.cursor_col].to_string();
827 } else if self.cursor_line + 1 < self.lines.len() {
828 self.push_undo();
829 let accumulate = self.last_action.as_deref() == Some("kill");
830 self.kill_ring.push("\n", false, accumulate);
831 self.last_action = Some("kill".into());
832 let next = self.lines.remove(self.cursor_line + 1);
833 self.lines[self.cursor_line].push_str(&next);
834 }
835 self.notify_change();
836 }
837
838 fn delete_word_backward(&mut self) {
839 self.exit_history();
840 let line = self.lines[self.cursor_line].clone();
841 if self.cursor_col == 0 {
842 return;
843 }
844 let opts = WordNavigationOptions {
845 segment: None,
846 is_atomic_segment: Some(&|s: &str| s.starts_with("[paste #") && s.ends_with(']')),
847 };
848 let new_col = find_word_backward_with(&line, self.cursor_col, &opts);
849 if new_col < self.cursor_col {
850 self.push_undo();
851 let deleted = line[new_col..self.cursor_col].to_string();
852 let accumulate = self.last_action.as_deref() == Some("kill");
853 self.kill_ring.push(&deleted, true, accumulate);
854 self.last_action = Some("kill".into());
855 self.lines[self.cursor_line].drain(new_col..self.cursor_col);
856 self.set_cursor_col(new_col);
857 self.notify_change();
858 }
859 }
860
861 fn delete_word_forward(&mut self) {
862 self.exit_history();
863 let line = self.lines[self.cursor_line].clone();
864 if self.cursor_col >= line.len() {
865 return;
866 }
867 let opts = WordNavigationOptions {
868 segment: None,
869 is_atomic_segment: Some(&|s: &str| s.starts_with("[paste #") && s.ends_with(']')),
870 };
871 let new_col = find_word_forward_with(&line, self.cursor_col, &opts);
872 if new_col > self.cursor_col {
873 self.push_undo();
874 let deleted = line[self.cursor_col..new_col].to_string();
875 let accumulate = self.last_action.as_deref() == Some("kill");
876 self.kill_ring.push(&deleted, false, accumulate);
877 self.last_action = Some("kill".into());
878 self.lines[self.cursor_line].drain(self.cursor_col..new_col);
879 self.notify_change();
880 }
881 }
882
883 fn yank(&mut self) {
886 self.exit_history();
887 let text = self.kill_ring.peek().map(|s| s.to_string());
888 if let Some(text) = text {
889 self.push_undo();
890 self.cursor_col += text.len();
891 self.lines[self.cursor_line].insert_str(self.cursor_col - text.len(), &text);
892 self.last_action = Some("yank".into());
893 self.notify_change();
894 }
895 }
896
897 fn yank_pop(&mut self) {
898 if self.last_action.as_deref() != Some("yank") || self.kill_ring.len() <= 1 {
900 return;
901 }
902 self.push_undo();
904
905 let prev = self.kill_ring.peek().map(|s| s.to_string());
907 if let Some(ref prev_text) = prev {
908 let line = &self.lines[self.cursor_line].clone();
909 if self.cursor_col >= prev_text.len() {
910 let before = &line[..self.cursor_col - prev_text.len()];
911 let after = &line[self.cursor_col..];
912 self.lines[self.cursor_line] = format!("{}{}", before, after);
913 self.cursor_col -= prev_text.len();
914 }
915 }
916
917 self.kill_ring.rotate();
919
920 let text = self.kill_ring.peek().map(|s| s.to_string());
922 if let Some(ref new_text) = text {
923 self.cursor_col += new_text.len();
924 self.lines[self.cursor_line].insert_str(self.cursor_col - new_text.len(), new_text);
925 }
926
927 self.last_action = Some("yank".into());
928 self.notify_change();
929 }
930
931 fn move_left(&mut self) {
934 self.last_action = None;
935 if self.cursor_col > 0 {
936 let line = &self.lines[self.cursor_line].clone();
937 let graphemes: Vec<(usize, &str)> =
938 line[..self.cursor_col].grapheme_indices(true).collect();
939 if let Some(&(idx, _g)) = graphemes.last() {
940 let raw = idx;
941 self.set_cursor_col(Self::snap_paste_marker(line, raw, true));
943 }
944 } else if self.cursor_line > 0 {
945 self.cursor_line -= 1;
946 self.set_cursor_col(self.lines[self.cursor_line].len());
947 }
948 }
949
950 fn move_right(&mut self) {
951 self.last_action = None;
952 let line = &self.lines[self.cursor_line].clone();
953 if self.cursor_col < line.len() {
954 let mut it = line[self.cursor_col..].grapheme_indices(true);
955 if let Some((idx, g)) = it.next() {
956 let raw = self.cursor_col + idx + g.len();
957 self.set_cursor_col(Self::snap_paste_marker(line, raw, false));
959 }
960 } else if self.cursor_line + 1 < self.lines.len() {
961 self.cursor_line += 1;
962 self.set_cursor_col(0);
963 }
964 }
965
966 fn move_up(&mut self) {
967 self.move_vertical(-1);
968 }
969
970 fn move_down(&mut self) {
971 self.move_vertical(1);
972 }
973
974 fn move_to_line_start(&mut self) {
975 self.last_action = None;
976 self.set_cursor_col(0);
977 }
978
979 fn move_to_line_end(&mut self) {
980 self.last_action = None;
981 let len = self.lines[self.cursor_line].len();
982 self.set_cursor_col(len);
983 }
984
985 fn build_visual_line_spans(&self, width: usize) -> Vec<(usize, usize, usize)> {
987 let mut spans = Vec::new();
988 for (i, line) in self.lines.iter().enumerate() {
989 let line_w = visible_width(line);
990 if line.is_empty() {
991 spans.push((i, 0, 0));
992 } else if line_w <= width {
993 spans.push((i, 0, line.len()));
994 } else {
995 let chunks = crate::tui::util::wrap_text_with_ansi(line, width);
996 let mut byte_pos = 0;
997 for chunk in &chunks {
998 let chunk_len = chunk.len();
999 spans.push((i, byte_pos, chunk_len));
1000 byte_pos += chunk_len;
1001 }
1002 }
1003 }
1004 spans
1005 }
1006
1007 fn find_current_visual_line(&self, spans: &[(usize, usize, usize)]) -> usize {
1009 for (i, &(li, start, len)) in spans.iter().enumerate() {
1010 if li != self.cursor_line {
1011 continue;
1012 }
1013 let offset = self.cursor_col.saturating_sub(start);
1014 let is_last = i + 1 >= spans.len() || spans[i + 1].0 != li;
1015 if offset <= len || (is_last && offset == len) {
1016 return i;
1017 }
1018 }
1019 spans.len().saturating_sub(1)
1020 }
1021
1022 fn move_to_visual_line(
1025 &mut self,
1026 spans: &[(usize, usize, usize)],
1027 current_vis: usize,
1028 target_vis: usize,
1029 ) {
1030 let (cur_li, _cur_start, cur_len) = spans[current_vis];
1031 let (tgt_li, tgt_start, tgt_len) = spans[target_vis];
1032 let cur_vis_col = self.cursor_col;
1033
1034 let is_last_source = current_vis + 1 >= spans.len() || spans[current_vis + 1].0 != cur_li;
1035 let src_max = if is_last_source {
1036 cur_len
1037 } else {
1038 cur_len.saturating_sub(1)
1039 };
1040
1041 let is_last_target = target_vis + 1 >= spans.len() || spans[target_vis + 1].0 != tgt_li;
1042 let tgt_max = if is_last_target {
1043 tgt_len
1044 } else {
1045 tgt_len.saturating_sub(1)
1046 };
1047
1048 let has_pref = self.preferred_col.is_some();
1050 let cursor_in_middle = cur_vis_col < src_max;
1051 let target_too_short = tgt_max < cur_vis_col;
1052
1053 let move_to_col = if !has_pref || cursor_in_middle {
1054 if target_too_short {
1055 self.preferred_col = Some(cur_vis_col);
1056 tgt_max
1057 } else {
1058 self.preferred_col = None;
1059 cur_vis_col
1060 }
1061 } else {
1062 let pref = self.preferred_col.unwrap_or(0);
1063 let target_cant_fit_pref = tgt_max < pref;
1064 if target_too_short || target_cant_fit_pref {
1065 tgt_max
1066 } else {
1067 self.preferred_col = None;
1068 pref
1069 }
1070 };
1071
1072 self.cursor_line = tgt_li;
1073 let raw_col = tgt_start + move_to_col;
1074 let line = &self.lines[tgt_li].clone();
1075 self.cursor_col = raw_col.min(line.len());
1076 let moving_up = target_vis < current_vis;
1082 self.cursor_col = Self::snap_paste_marker(line, self.cursor_col, moving_up);
1083 }
1084
1085 fn move_vertical(&mut self, delta: isize) {
1086 let width = self.last_width.get();
1087 let spans = self.build_visual_line_spans(width);
1088 let current_vis = self.find_current_visual_line(&spans);
1089
1090 let target_vis = if delta < 0 {
1091 if current_vis == 0 {
1092 return;
1093 }
1094 current_vis - 1
1095 } else if current_vis + 1 >= spans.len() {
1096 return;
1097 } else {
1098 current_vis + 1
1099 };
1100
1101 self.move_to_visual_line(&spans, current_vis, target_vis);
1102 }
1103
1104 fn jump_to_char(&mut self, ch: char, dir: JumpDirection) {
1109 let is_forward = dir == JumpDirection::Forward;
1110 let lines = &self.lines;
1111
1112 let start_line = self.cursor_line as isize;
1113 let end = if is_forward { lines.len() as isize } else { -1 };
1114 let step: isize = if is_forward { 1 } else { -1 };
1115
1116 let mut line_idx = start_line;
1117 while line_idx != end {
1118 let line = &lines[line_idx as usize];
1119 let is_current = line_idx == start_line;
1120 let search_from = if is_current {
1121 if is_forward {
1122 self.cursor_col + 1
1123 } else {
1124 self.cursor_col.saturating_sub(1)
1125 }
1126 } else if is_forward {
1127 0
1128 } else {
1129 line.len()
1130 };
1131
1132 let idx = if is_forward {
1133 line[search_from..].find(ch).map(|i| search_from + i)
1134 } else if search_from > 0 {
1135 line[..search_from].rfind(ch)
1136 } else {
1137 None
1138 };
1139
1140 if let Some(pos) = idx {
1141 self.cursor_line = line_idx as usize;
1142 self.set_cursor_col(pos);
1143 return;
1144 }
1145 line_idx += step;
1146 }
1147 }
1149
1150 fn exit_history(&mut self) {
1153 self.history_index = -1;
1154 self.history_draft = None;
1155 self.last_action = None;
1156 }
1157
1158 fn recall_older(&mut self) {
1159 if self.history.is_empty() {
1160 return;
1161 }
1162 let idx = if self.history_index < 0 {
1164 0
1165 } else {
1166 self.history_index + 1
1167 };
1168 if idx >= self.history.len() as i32 {
1169 return; }
1171
1172 if self.history_index < 0 && idx >= 0 {
1174 self.history_draft = Some(EditorSnapshot {
1175 lines: self.lines.clone(),
1176 cursor_line: self.cursor_line,
1177 cursor_col: self.cursor_col,
1178 });
1179 }
1180
1181 let text = self.history[idx as usize].clone();
1182 self.set_text_internal(&text);
1183 self.cursor_col = 0; self.history_index = idx;
1185 }
1186
1187 fn recall_newer(&mut self) {
1188 if self.history_index < 0 {
1189 return;
1190 }
1191 let idx = self.history_index - 1;
1193 if idx < 0 {
1194 if let Some(draft) = self.history_draft.take() {
1196 self.lines = draft.lines;
1197 self.cursor_line = draft.cursor_line;
1198 self.cursor_col = draft.cursor_col;
1199 self.preferred_col = None;
1200 } else {
1201 self.set_text_internal("");
1202 }
1203 self.history_index = -1;
1204 } else {
1205 let text = self.history[idx as usize].clone();
1206 self.set_text_internal(&text);
1207 self.history_index = idx;
1208 }
1209 }
1210
1211 fn decode_csi_u_in_paste(&self, text: &str) -> String {
1217 let re = regex::Regex::new(r"\x1b\[(\d+);5u").unwrap();
1219 re.replace_all(text, |caps: ®ex::Captures| {
1220 let cp: u32 = caps[1].parse().unwrap_or(0);
1221 if (97..=122).contains(&cp) {
1222 char::from_u32(cp - 96)
1224 .map(|c| c.to_string())
1225 .unwrap_or_default()
1226 } else if (65..=90).contains(&cp) {
1227 char::from_u32(cp - 64)
1229 .map(|c| c.to_string())
1230 .unwrap_or_default()
1231 } else {
1232 caps[0].to_string()
1233 }
1234 })
1235 .to_string()
1236 }
1237
1238 fn find_paste_marker_spans(line: &str) -> Vec<(usize, usize)> {
1243 let mut spans = Vec::new();
1244 let mut pos = 0;
1245 while let Some(start) = line[pos..].find("[paste #") {
1246 let abs_start = pos + start;
1247 if let Some(end) = line[abs_start..].find(']') {
1248 let abs_end = abs_start + end + 1;
1249 spans.push((abs_start, abs_end));
1250 pos = abs_end;
1251 } else {
1252 break;
1253 }
1254 }
1255 spans
1256 }
1257
1258 fn snap_paste_marker(line: &str, cursor: usize, moving_left: bool) -> usize {
1261 for &(start, end) in &Self::find_paste_marker_spans(line) {
1262 if cursor > start && cursor < end {
1263 return if moving_left { start } else { end };
1264 }
1265 }
1266 cursor
1267 }
1268
1269 pub fn handle_paste(&mut self, text: &str) {
1274 self.clear_autocomplete();
1275 self.exit_history();
1276 self.last_action = None;
1277 self.push_undo();
1278
1279 let decoded = self.decode_csi_u_in_paste(text);
1281
1282 let normalized = decoded
1284 .replace("\r\n", "\n")
1285 .replace('\r', "\n")
1286 .replace('\t', " ");
1287
1288 let filtered: String = normalized
1290 .chars()
1291 .filter(|&c| c == '\n' || c == ' ' || c as u32 >= 32)
1292 .collect();
1293
1294 let current_line = self.lines[self.cursor_line].clone();
1297 let space_prefix = if filtered.starts_with('/')
1298 || filtered.starts_with('~')
1299 || filtered.starts_with('.')
1300 {
1301 if self.cursor_col > 0 {
1302 let prev = current_line
1303 .as_bytes()
1304 .get(self.cursor_col - 1)
1305 .copied()
1306 .unwrap_or(b' ');
1307 if prev.is_ascii_alphanumeric() || prev == b'_' {
1308 " "
1309 } else {
1310 ""
1311 }
1312 } else {
1313 ""
1314 }
1315 } else {
1316 ""
1317 };
1318 let prepared = format!("{}{}", space_prefix, filtered);
1319
1320 let total_chars = prepared.len();
1321 let is_large = prepared.lines().count().max(1) > 10 || total_chars > 1000;
1322
1323 if is_large {
1324 let line_count = prepared.lines().count();
1325 self.paste_counter += 1;
1326 let paste_id = self.paste_counter;
1327 self.pastes.insert(paste_id, prepared);
1328
1329 let marker = if line_count > 10 {
1330 format!("[paste #{} +{} lines]", paste_id, line_count)
1331 } else {
1332 format!("[paste #{} {} chars]", paste_id, total_chars)
1333 };
1334 self.insert_text_internal(&marker);
1335 } else {
1336 self.insert_text_internal(&prepared);
1337 }
1338 }
1339
1340 pub fn expand_paste_markers(&self, text: &str) -> String {
1342 let mut result = text.to_string();
1343 let mut ids: Vec<u32> = self.pastes.keys().copied().collect();
1345 ids.sort_unstable_by(|a, b| b.cmp(a)); for paste_id in ids {
1347 if let Some(content) = self.pastes.get(&paste_id) {
1348 let marker1 = format!("[paste #{} ", paste_id);
1350 loop {
1351 let start = result.find(&marker1);
1352 match start {
1353 Some(pos) => {
1354 let end = result[pos..]
1355 .find(']')
1356 .map(|e| pos + e + 1)
1357 .unwrap_or(result.len());
1358 result.replace_range(pos..end, content);
1359 }
1360 None => break,
1361 }
1362 }
1363 }
1364 }
1365 result
1366 }
1367
1368 pub fn get_expanded_text(&self) -> String {
1371 self.expand_paste_markers(&self.lines.join("\n"))
1372 }
1373
1374 pub fn is_paste_marker(segment: &str) -> bool {
1376 segment.starts_with("[paste #") && segment.ends_with(']')
1377 }
1378
1379 fn page_size(&self) -> usize {
1382 std::cmp::max(5, (self.terminal_rows as f64 * 0.3) as usize)
1383 }
1384
1385 fn page_up(&mut self) {
1386 let size = self.page_size();
1387 self.scroll_offset = self.scroll_offset.saturating_sub(size);
1388 }
1389
1390 fn page_down(&mut self) {
1391 let size = self.page_size();
1392 self.scroll_offset += size;
1393 }
1394
1395 fn submit(&mut self) {
1398 let raw = self.lines.join("\n");
1400 let result = self.expand_paste_markers(&raw);
1401 self.last_submitted_text = result.clone();
1402 self.lines = vec![String::new()];
1403 self.cursor_line = 0;
1404 self.cursor_col = 0;
1405 self.scroll_offset = 0;
1406 self.pastes.clear();
1407 self.paste_counter = 0;
1408 self.undo_stack.clear();
1409 self.last_action = None;
1410 self.preferred_col = None;
1411 self.just_submitted = true;
1412 self.exit_history();
1413 if let Some(ref mut cb) = self.on_submit {
1414 cb(result);
1415 }
1416 self.notify_change();
1417 }
1418
1419 fn notify_change(&mut self) {
1422 let text = self.get_text();
1423 if let Some(ref mut cb) = self.on_change {
1424 cb(&text);
1425 }
1426 if self.autocomplete_active {
1428 self.try_trigger_autocomplete();
1429 }
1430 }
1431
1432 fn is_empty(&self) -> bool {
1433 self.lines.is_empty() || (self.lines.len() == 1 && self.lines[0].is_empty())
1434 }
1435
1436 fn is_first_visual_line(&self) -> bool {
1437 let width = self.last_width.get();
1438 let visual_lines = layout_text(&self.lines, width, self.cursor_line, self.cursor_col);
1439 let current = visual_lines
1440 .iter()
1441 .position(|vl| vl.has_cursor)
1442 .unwrap_or(0);
1443 current == 0
1444 }
1445
1446 fn is_last_visual_line(&self) -> bool {
1447 let width = self.last_width.get();
1448 let visual_lines = layout_text(&self.lines, width, self.cursor_line, self.cursor_col);
1449 let current = visual_lines
1450 .iter()
1451 .position(|vl| vl.has_cursor)
1452 .unwrap_or(0);
1453 current >= visual_lines.len().saturating_sub(1)
1454 }
1455}
1456
1457impl Component for Editor {
1460 fn render(&mut self, width: usize) -> Vec<String> {
1461 let max_padding = if width > 1 { (width - 1) / 2 } else { 0 };
1462 let pad_x = self.padding_x.min(max_padding);
1463 let content_width = if width > pad_x * 2 {
1464 width - pad_x * 2
1465 } else {
1466 1
1467 };
1468 let layout_width = content_width
1470 .max(1)
1471 .saturating_sub(if pad_x > 0 { 0 } else { 1 });
1472 self.last_width.set(layout_width);
1473
1474 let horizontal = "─";
1475 let left_pad = " ".repeat(pad_x);
1476 let right_pad = " ".repeat(pad_x);
1477 let mut result: Vec<String> = Vec::new();
1478
1479 let visual_lines =
1481 layout_text(&self.lines, layout_width, self.cursor_line, self.cursor_col);
1482 let total_visual = visual_lines.len().max(1);
1483
1484 let cursor_vis = visual_lines
1486 .iter()
1487 .position(|vl| vl.has_cursor)
1488 .unwrap_or(0);
1489
1490 let max_vis = std::cmp::max(5, (self.terminal_rows as f64 * 0.3) as usize).max(1);
1493 let mut scroll = self.scroll_offset;
1494 if cursor_vis < scroll {
1495 scroll = cursor_vis;
1496 } else if cursor_vis >= scroll + max_vis {
1497 scroll = cursor_vis - max_vis + 1;
1498 }
1499 let max_scroll = total_visual.saturating_sub(max_vis);
1500 scroll = scroll.min(max_scroll);
1501
1502 let visible_end = (scroll + max_vis).min(total_visual);
1503
1504 if scroll > 0 {
1506 let indicator = format!("─── ↑ {} more ", scroll);
1507 let indicator_w = visible_width(&indicator);
1508 let fill = if indicator_w < width {
1509 horizontal.repeat(width - indicator_w)
1510 } else {
1511 String::new()
1512 };
1513 result.push(self.border_color.apply(&format!("{}{}", indicator, fill)));
1514 } else {
1515 result.push(self.border_color.apply(&horizontal.repeat(width)));
1516 }
1517
1518 for vl in visual_lines.iter().skip(scroll).take(visible_end - scroll) {
1520 let text = &vl.text;
1521 let (display, line_width) = if vl.has_cursor {
1522 let cursor_pos = vl.cursor_pos.unwrap_or(0);
1523 let before = &text[..cursor_pos.min(text.len())];
1524 let after = &text[cursor_pos.min(text.len())..];
1525
1526 let marker = if self.focused {
1527 CURSOR_MARKER.to_string()
1528 } else {
1529 String::new()
1530 };
1531
1532 if !after.is_empty() {
1533 let after_graphemes: Vec<&str> = after.graphemes(true).collect();
1534 let first_g = after_graphemes.first().copied().unwrap_or(" ");
1535 let rest = &after[first_g.len()..];
1536 let cursor = format!("\x1b[7m{}\x1b[0m", first_g);
1537 (
1538 format!("{}{}{}{}", before, marker, cursor, rest),
1539 visible_width(text),
1540 )
1541 } else if !before.is_empty() {
1542 let cursor_block = "\x1b[7m \x1b[0m";
1544 (
1545 format!("{}{}{}", before, cursor_block, marker),
1546 visible_width(text) + 1,
1547 )
1548 } else {
1549 let cursor = "\x1b[7m \x1b[0m";
1551 (
1552 format!("{}{}{}", before, marker, cursor),
1553 visible_width(text) + 1,
1554 )
1555 }
1556 } else {
1557 (text.clone(), visible_width(text))
1558 };
1559
1560 let cursor_in_padding = line_width > content_width && pad_x > 0;
1562 let padding = if line_width < content_width {
1563 " ".repeat(content_width - line_width)
1564 } else {
1565 String::new()
1566 };
1567 let right_pad_used = if cursor_in_padding {
1568 &right_pad[1..]
1569 } else {
1570 &right_pad
1571 };
1572 result.push(format!(
1573 "{}{}{}{}",
1574 left_pad, display, padding, right_pad_used
1575 ));
1576 }
1577
1578 let below = total_visual.saturating_sub(visible_end);
1580 if below > 0 {
1581 let indicator = format!("─── ↓ {} more ", below);
1582 let indicator_w = visible_width(&indicator);
1583 let fill = if indicator_w < width {
1584 horizontal.repeat(width - indicator_w)
1585 } else {
1586 String::new()
1587 };
1588 result.push(self.border_color.apply(&format!("{}{}", indicator, fill)));
1589 } else {
1590 result.push(self.border_color.apply(&horizontal.repeat(width)));
1591 }
1592
1593 if self.autocomplete_active
1595 && let Some(ref mut list) = self.autocomplete_list
1596 {
1597 let list_lines = list.render(width);
1598 result.extend(list_lines);
1599 }
1600
1601 result
1602 }
1603
1604 fn handle_input(&mut self, key: &KeyEvent) -> bool {
1605 let kb = get_keybindings();
1606
1607 if let Some(dir) = self.jump_mode {
1609 if kb.matches(key, ACTION_EDITOR_JUMP_FORWARD)
1611 || kb.matches(key, ACTION_EDITOR_JUMP_BACKWARD)
1612 {
1613 self.jump_mode = None;
1614 return true;
1615 }
1616 if is_printable_plain(key)
1617 && let Some(s) = key_event_to_string(key)
1618 {
1619 let ch = s.chars().next().unwrap_or(' ');
1620 self.jump_mode = None;
1621 self.jump_to_char(ch, dir);
1622 return true;
1623 }
1624 self.jump_mode = None;
1626 }
1627
1628 let mut ac_selected: Option<String> = None;
1637 let mut ac_complete_and_return = false;
1638
1639 if let Some(ref mut list) = self.autocomplete_list {
1640 if kb.matches(key, ACTION_SELECT_CANCEL) {
1641 self.autocomplete_active = false;
1642 self.autocomplete_list = None;
1643 self.autocomplete_prefix.clear();
1644 return true;
1645 }
1646
1647 if kb.matches(key, ACTION_INPUT_TAB) {
1648 ac_selected = list.selected_item().map(|i| i.value.clone());
1649 ac_complete_and_return = true;
1650 } else if kb.matches(key, ACTION_SELECT_CONFIRM) {
1651 ac_selected = list.selected_item().map(|i| i.value.clone());
1652 let is_slash = self.autocomplete_prefix.starts_with('/');
1653 if !is_slash {
1654 ac_complete_and_return = true;
1655 }
1656 } else if kb.matches(key, ACTION_SELECT_UP) || kb.matches(key, ACTION_SELECT_DOWN) {
1658 list.handle_input(key);
1659 return true;
1660 }
1661 }
1664
1665 if let Some(val) = ac_selected {
1667 self.apply_autocomplete_completion_value(&val);
1668 self.clear_autocomplete();
1669 if ac_complete_and_return {
1670 return true;
1671 }
1672 }
1674
1675 if kb.matches(key, ACTION_INPUT_TAB) && self.autocomplete_provider.is_some() {
1677 self.try_trigger_autocomplete_force();
1678 return true;
1679 }
1680
1681 if kb.matches(key, ACTION_INPUT_SUBMIT) {
1683 if self.disable_submit {
1684 self.add_newline();
1685 return true;
1686 }
1687 let line = &self.lines[self.cursor_line];
1688 if self.cursor_col > 0 && line.as_bytes().get(self.cursor_col - 1) == Some(&b'\\') {
1689 self.backspace();
1690 self.add_newline();
1691 return true;
1692 }
1693 self.submit();
1694 return true;
1695 }
1696
1697 if kb.matches(key, ACTION_EDITOR_JUMP_FORWARD) {
1699 self.jump_mode = Some(JumpDirection::Forward);
1700 return true;
1701 }
1702 if kb.matches(key, ACTION_EDITOR_JUMP_BACKWARD) {
1703 self.jump_mode = Some(JumpDirection::Backward);
1704 return true;
1705 }
1706
1707 if is_printable_plain(key)
1709 && let Some(s) = key_event_to_string(key)
1710 {
1711 self.insert_character(&s);
1712 return true;
1713 }
1714
1715 if kb.matches(key, ACTION_EDITOR_CURSOR_LEFT) {
1717 self.move_left();
1718 self.update_autocomplete_if_active();
1719 return true;
1720 }
1721 if kb.matches(key, ACTION_EDITOR_CURSOR_RIGHT) {
1722 self.move_right();
1723 self.update_autocomplete_if_active();
1724 return true;
1725 }
1726 if kb.matches(key, ACTION_EDITOR_CURSOR_LINE_START) {
1727 self.move_to_line_start();
1728 self.update_autocomplete_if_active();
1729 return true;
1730 }
1731 if kb.matches(key, ACTION_EDITOR_CURSOR_LINE_END) {
1732 self.move_to_line_end();
1733 self.update_autocomplete_if_active();
1734 return true;
1735 }
1736
1737 if kb.matches(key, ACTION_EDITOR_CURSOR_UP) {
1739 if self.is_first_visual_line()
1740 && (self.is_empty() || self.history_index >= 0 || self.cursor_col == 0)
1741 {
1742 self.recall_older();
1743 } else if self.is_first_visual_line() {
1744 self.move_to_line_start();
1745 } else {
1746 self.move_up();
1747 }
1748 self.update_autocomplete_if_active();
1749 return true;
1750 }
1751 if kb.matches(key, ACTION_EDITOR_CURSOR_DOWN) {
1752 if self.history_index >= 0 && self.is_last_visual_line() {
1753 self.recall_newer();
1754 } else if self.is_last_visual_line() {
1755 self.move_to_line_end();
1756 } else {
1757 self.move_down();
1758 }
1759 self.update_autocomplete_if_active();
1760 return true;
1761 }
1762
1763 if kb.matches(key, ACTION_EDITOR_PAGE_UP) {
1765 self.page_up();
1766 self.update_autocomplete_if_active();
1767 return true;
1768 }
1769 if kb.matches(key, ACTION_EDITOR_PAGE_DOWN) {
1770 self.page_down();
1771 self.update_autocomplete_if_active();
1772 return true;
1773 }
1774
1775 if kb.matches(key, ACTION_EDITOR_CURSOR_WORD_LEFT) {
1777 let line = &self.lines[self.cursor_line].clone();
1778 if self.cursor_col > 0 {
1779 let opts = WordNavigationOptions {
1780 segment: None,
1781 is_atomic_segment: Some(&|s: &str| {
1782 s.starts_with("[paste #") && s.ends_with(']')
1783 }),
1784 };
1785 let c = find_word_backward_with(line, self.cursor_col, &opts);
1786 self.set_cursor_col(c);
1787 }
1788 self.update_autocomplete_if_active();
1789 return true;
1790 }
1791 if kb.matches(key, ACTION_EDITOR_CURSOR_WORD_RIGHT) {
1792 let line = &self.lines[self.cursor_line].clone();
1793 if self.cursor_col < line.len() {
1794 let opts = WordNavigationOptions {
1795 segment: None,
1796 is_atomic_segment: Some(&|s: &str| {
1797 s.starts_with("[paste #") && s.ends_with(']')
1798 }),
1799 };
1800 let c = find_word_forward_with(line, self.cursor_col, &opts);
1801 self.set_cursor_col(c);
1802 }
1803 self.update_autocomplete_if_active();
1804 return true;
1805 }
1806
1807 if kb.matches(key, ACTION_EDITOR_DELETE_CHAR_BACKWARD) {
1809 self.backspace();
1810 return true;
1812 }
1813 if kb.matches(key, ACTION_EDITOR_DELETE_CHAR_FORWARD) {
1814 self.delete_forward();
1815 return true;
1817 }
1818
1819 if kb.matches(key, ACTION_EDITOR_DELETE_WORD_BACKWARD) {
1821 self.delete_word_backward();
1822 return true;
1824 }
1825 if kb.matches(key, ACTION_EDITOR_DELETE_WORD_FORWARD) {
1826 self.delete_word_forward();
1827 return true;
1829 }
1830 if kb.matches(key, ACTION_EDITOR_DELETE_TO_LINE_START) {
1831 self.delete_to_line_start();
1832 return true;
1834 }
1835 if kb.matches(key, ACTION_EDITOR_DELETE_TO_LINE_END) {
1836 self.delete_to_line_end();
1837 return true;
1839 }
1840
1841 if kb.matches(key, ACTION_EDITOR_YANK) {
1843 self.yank();
1844 return true;
1845 }
1846 if kb.matches(key, ACTION_EDITOR_YANK_POP) {
1847 self.yank_pop();
1848 return true;
1849 }
1850
1851 if kb.matches(key, ACTION_EDITOR_UNDO) {
1853 self.last_action = None;
1854 self.undo();
1855 self.notify_change();
1856 return true;
1857 }
1858
1859 if kb.matches(key, ACTION_INPUT_NEW_LINE) {
1861 self.add_newline();
1862 return true;
1863 }
1864
1865 if kb.matches(key, ACTION_SELECT_CANCEL) {
1867 return false;
1868 }
1869
1870 false
1871 }
1872
1873 fn handle_paste(&mut self, text: &str) {
1874 Editor::handle_paste(self, text);
1875 }
1876
1877 fn is_focusable(&self) -> bool {
1878 true
1879 }
1880}
1881
1882impl Focusable for Editor {
1883 fn set_focused(&mut self, focused: bool) {
1884 self.focused = focused;
1885 }
1886
1887 fn focused(&self) -> bool {
1888 self.focused
1889 }
1890}
1891
1892#[derive(Debug)]
1895struct VisualLine {
1896 text: String,
1897 has_cursor: bool,
1898 cursor_pos: Option<usize>,
1899}
1900
1901fn layout_text(
1903 lines: &[String],
1904 max_width: usize,
1905 cursor_line: usize,
1906 cursor_col: usize,
1907) -> Vec<VisualLine> {
1908 let mut result: Vec<VisualLine> = Vec::new();
1909
1910 if lines.is_empty() || (lines.len() == 1 && lines[0].is_empty()) {
1911 result.push(VisualLine {
1912 text: String::new(),
1913 has_cursor: true,
1914 cursor_pos: Some(0),
1915 });
1916 return result;
1917 }
1918
1919 let mut _col_offset = 0;
1920
1921 for (line_idx, line) in lines.iter().enumerate() {
1922 let is_cursor_line = line_idx == cursor_line;
1923 let line_w = visible_width(line);
1924 _col_offset = 0;
1925
1926 if line_w <= max_width {
1927 result.push(VisualLine {
1929 text: line.clone(),
1930 has_cursor: is_cursor_line,
1931 cursor_pos: if is_cursor_line {
1932 Some(cursor_col.min(line.len()))
1933 } else {
1934 None
1935 },
1936 });
1937 } else {
1938 let wrapped = wrap_text_with_ansi(line, max_width);
1943
1944 let cursor_vis = if is_cursor_line {
1946 visible_width(&line[..cursor_col.min(line.len())])
1947 } else {
1948 0
1949 };
1950
1951 let mut vis_offset: usize = 0;
1952 for (chunk_idx, chunk) in wrapped.iter().enumerate() {
1953 let chunk_vis = visible_width(chunk);
1954 let chunk_vis_end = vis_offset + chunk_vis;
1955
1956 let cursor_in_chunk = is_cursor_line
1957 && cursor_vis >= vis_offset
1958 && (cursor_vis < chunk_vis_end || chunk_idx == wrapped.len() - 1);
1959
1960 let cursor_pos = if cursor_in_chunk {
1961 let local_vis = cursor_vis.saturating_sub(vis_offset);
1962 Some(visual_col_to_byte_offset(chunk, local_vis))
1964 } else {
1965 None
1966 };
1967
1968 result.push(VisualLine {
1969 text: chunk.clone(),
1970 has_cursor: cursor_in_chunk && cursor_pos.is_some(),
1971 cursor_pos,
1972 });
1973
1974 vis_offset = chunk_vis_end;
1975 }
1976 }
1977 }
1978
1979 result
1980}
1981
1982fn is_printable_plain(key: &KeyEvent) -> bool {
1983 matches!(key.code, KeyCode::Char(_))
1984 && !key.modifiers.contains(KeyModifiers::CONTROL)
1985 && !key.modifiers.contains(KeyModifiers::ALT)
1986 && key.code != KeyCode::Enter
1987 && key.code != KeyCode::Tab
1988 && key.code != KeyCode::Backspace
1989 && key.code != KeyCode::Delete
1990 && key.code != KeyCode::Esc
1991}
1992
1993#[cfg(test)]
1994mod tests {
1995 use super::*;
1996 use crate::tui::autocomplete::{
1997 AutocompleteItem, AutocompleteProvider, AutocompleteSuggestions, SlashCommand,
1998 };
1999
2000 struct MockSlashProvider {
2003 commands: Vec<SlashCommand>,
2004 }
2005
2006 impl MockSlashProvider {
2007 fn new(commands: Vec<&str>) -> Self {
2008 Self {
2009 commands: commands
2010 .into_iter()
2011 .map(|name| SlashCommand {
2012 name: name.to_string(),
2013 description: Some(format!("The {} command", name)),
2014 argument_hint: None,
2015 argument_completions: None,
2016 get_argument_completions: None,
2017 })
2018 .collect(),
2019 }
2020 }
2021 }
2022
2023 impl AutocompleteProvider for MockSlashProvider {
2024 fn trigger_characters(&self) -> &[char] {
2025 &['/', '@', '#']
2026 }
2027
2028 fn get_suggestions(
2029 &self,
2030 lines: &[String],
2031 cursor_line: usize,
2032 cursor_col: usize,
2033 _force: bool,
2034 ) -> Option<AutocompleteSuggestions> {
2035 let line = lines.get(cursor_line)?;
2036 let before = &line[..cursor_col.min(line.len())];
2037
2038 if before.starts_with('/') && !before.contains(' ') {
2040 let query = &before[1..].to_lowercase();
2041 let matching: Vec<AutocompleteItem> = self
2042 .commands
2043 .iter()
2044 .filter(|cmd| cmd.name.to_lowercase().starts_with(query))
2045 .map(|cmd| AutocompleteItem {
2046 value: cmd.name.clone(),
2047 label: format!("/{}", cmd.name),
2048 description: cmd.description.clone(),
2049 })
2050 .collect();
2051 if matching.is_empty() {
2052 return None;
2053 }
2054 return Some(AutocompleteSuggestions {
2055 items: matching,
2056 prefix: before.to_string(),
2057 });
2058 }
2059 None
2060 }
2061
2062 fn apply_completion(
2063 &self,
2064 lines: &[String],
2065 cursor_line: usize,
2066 cursor_col: usize,
2067 item: &AutocompleteItem,
2068 prefix: &str,
2069 ) -> (Vec<String>, usize, usize) {
2070 let current_line = lines[cursor_line].clone();
2071 let prefix_start = cursor_col.saturating_sub(prefix.len());
2072 let before = ¤t_line[..prefix_start];
2073 let after = ¤t_line[cursor_col..];
2074 (
2075 vec![format!("{}/{} {}", before, item.value, after)],
2076 cursor_line,
2077 before.len() + 1 + item.value.len() + 1,
2078 )
2079 }
2080
2081 fn should_trigger_file_completion(
2082 &self,
2083 lines: &[String],
2084 cursor_line: usize,
2085 cursor_col: usize,
2086 ) -> bool {
2087 let current_line = lines.get(cursor_line);
2088 match current_line {
2089 Some(text) => {
2090 let before = &text[..cursor_col.min(text.len())];
2091 if before.starts_with('/') && !before.contains(' ') {
2092 return false;
2093 }
2094 true
2095 }
2096 None => false,
2097 }
2098 }
2099 }
2100
2101 fn make_editor_with_slash_provider(commands: Vec<&str>) -> Editor {
2104 let mut editor = Editor::new(EditorOptions::default());
2105 let provider = Box::new(MockSlashProvider::new(commands));
2106 editor.set_autocomplete_provider(provider);
2107 editor
2108 }
2109
2110 #[test]
2111 fn autocomplete_triggers_on_slash() {
2112 let mut editor = make_editor_with_slash_provider(vec!["help", "history"]);
2113 editor.handle_input(&char_key('/'));
2114 assert!(
2115 editor.autocomplete_active,
2116 "autocomplete should activate after typing /"
2117 );
2118 let selected = editor.autocomplete_selected_value();
2119 assert_eq!(
2120 selected.as_deref(),
2121 Some("help"),
2122 "first item should be help"
2123 );
2124 }
2125
2126 #[test]
2127 fn autocomplete_filters_as_user_types() {
2128 let mut editor = make_editor_with_slash_provider(vec!["help", "history", "model"]);
2129 editor.handle_input(&char_key('/'));
2131 assert!(editor.autocomplete_active);
2132
2133 editor.handle_input(&char_key('h'));
2135 assert!(
2136 editor.autocomplete_active,
2137 "autocomplete should stay active after typing more letters"
2138 );
2139 editor.handle_input(&char_key('e'));
2143 assert!(editor.autocomplete_active);
2144 let selected = editor.autocomplete_selected_value();
2145 assert_eq!(selected.as_deref(), Some("help"));
2146 }
2147
2148 #[test]
2149 fn autocomplete_stays_active_on_printable_chars() {
2150 let mut editor = make_editor_with_slash_provider(vec!["help", "history"]);
2152 editor.handle_input(&char_key('/'));
2153 assert!(editor.autocomplete_active);
2154
2155 editor.handle_input(&char_key('h'));
2156 assert!(
2157 editor.autocomplete_active,
2158 "typing 'h' after '/' must keep autocomplete visible"
2159 );
2160
2161 editor.handle_input(&char_key('e'));
2162 assert!(
2163 editor.autocomplete_active,
2164 "typing 'e' after '/h' must keep autocomplete visible"
2165 );
2166
2167 let lines = editor.render(80);
2168 assert!(lines.len() > 3, "autocomplete lines should be rendered");
2170 }
2171
2172 #[test]
2173 fn escape_dismisses_autocomplete() {
2174 let mut editor = make_editor_with_slash_provider(vec!["help", "history"]);
2175 editor.handle_input(&char_key('/'));
2176 assert!(editor.autocomplete_active);
2177
2178 editor.handle_input(&escape());
2179 assert!(
2180 !editor.autocomplete_active,
2181 "escape should dismiss autocomplete"
2182 );
2183
2184 assert_eq!(editor.get_text(), "/");
2186 }
2187
2188 #[test]
2189 fn backspace_removing_slash_dismisses_autocomplete() {
2190 let mut editor = make_editor_with_slash_provider(vec!["help", "history"]);
2191 editor.handle_input(&char_key('/'));
2192 assert!(editor.autocomplete_active, "after /");
2193
2194 editor.handle_input(&backspace());
2195 assert!(
2196 !editor.autocomplete_active,
2197 "backspace removing / should dismiss autocomplete"
2198 );
2199 assert_eq!(editor.get_text(), "", "text should be empty");
2200 }
2201
2202 #[test]
2203 fn autocomplete_updates_after_backspace_char() {
2204 let mut editor = make_editor_with_slash_provider(vec!["help", "history"]);
2205 editor.handle_input(&char_key('/'));
2207 editor.handle_input(&char_key('h'));
2208 editor.handle_input(&char_key('e'));
2209 assert!(editor.autocomplete_active);
2210 let val1 = editor.autocomplete_selected_value();
2211 assert_eq!(val1.as_deref(), Some("help"));
2212
2213 editor.handle_input(&backspace());
2215 assert!(
2216 editor.autocomplete_active,
2217 "backspace should re-filter, not dismiss"
2218 );
2219 assert!(!editor.autocomplete_is_empty());
2221 assert_eq!(editor.get_text(), "/h");
2222 }
2223
2224 #[test]
2225 fn autocomplete_updates_on_cursor_movement() {
2226 let mut editor = make_editor_with_slash_provider(vec!["help", "history"]);
2227 editor.handle_input(&char_key('/'));
2229 editor.handle_input(&char_key('h'));
2230 editor.handle_input(&char_key('e'));
2231 editor.handle_input(&char_key('l'));
2232 editor.handle_input(&char_key('p'));
2233 assert!(editor.autocomplete_active);
2234
2235 editor.handle_input(&char_key(' '));
2238 assert!(
2239 !editor.autocomplete_active,
2240 "space after /cmd should dismiss slash autocomplete"
2241 );
2242
2243 editor.handle_input(&left_key());
2245 }
2249
2250 #[test]
2251 fn autocomplete_clears_when_provider_returns_none() {
2252 let mut editor = make_editor_with_slash_provider(vec!["help"]);
2254 editor.handle_input(&char_key('/'));
2255 assert!(editor.autocomplete_active);
2256
2257 editor.handle_input(&char_key('z'));
2259 assert!(
2260 !editor.autocomplete_active,
2261 "typing /z with no matching command should dismiss autocomplete"
2262 );
2263 }
2264
2265 #[test]
2266 fn autocomplete_does_not_interfere_with_normal_typing() {
2267 let mut editor = make_editor_with_slash_provider(vec!["help", "history"]);
2269 editor.handle_input(&char_key('h'));
2270 editor.handle_input(&char_key('e'));
2271 editor.handle_input(&char_key('l'));
2272 editor.handle_input(&char_key('l'));
2273 editor.handle_input(&char_key('o'));
2274 assert!(!editor.autocomplete_active, "no slash = no autocomplete");
2275 assert_eq!(editor.get_text(), "hello");
2276 }
2277
2278 #[test]
2279 fn autocomplete_renders_lines_below_editor() {
2280 let mut editor = make_editor_with_slash_provider(vec!["help", "history", "model"]);
2281 editor.handle_input(&char_key('/'));
2282 assert!(editor.autocomplete_active);
2283
2284 let lines = editor.render(80);
2285 assert!(
2287 lines.len() >= 5,
2288 "should have border lines + autocomplete items"
2289 );
2290 assert!(lines[2].contains('─'), "line 2 should be bottom border");
2292 let after_border = &lines[3..];
2294 let all_have_content = after_border.iter().any(|l| !l.trim().is_empty());
2295 assert!(all_have_content, "autocomplete lines should have content");
2296 }
2297
2298 #[test]
2299 fn autocomplete_stable_rendering_no_flash_on_extra_char() {
2300 let mut editor = make_editor_with_slash_provider(vec!["help", "history", "model"]);
2303 editor.handle_input(&char_key('/'));
2304 let lines_after_slash = editor.render(80).len();
2305
2306 editor.handle_input(&char_key('h'));
2307 let lines_after_h = editor.render(80).len();
2308
2309 let diff = lines_after_slash.abs_diff(lines_after_h);
2312 assert!(
2313 diff <= 1,
2314 "line count should not change dramatically: {} -> {} (diff {})",
2315 lines_after_slash,
2316 lines_after_h,
2317 diff
2318 );
2319 }
2320
2321 #[test]
2322 fn autocomplete_dismissed_on_submit() {
2323 let mut editor = make_editor_with_slash_provider(vec!["help"]);
2324 editor.handle_input(&char_key('/'));
2325 assert!(editor.autocomplete_active);
2326
2327 editor.handle_input(&enter_key());
2329 }
2331
2332 #[test]
2333 fn tab_force_triggers_autocomplete() {
2334 let mut editor = make_editor_with_slash_provider(vec!["help", "history"]);
2335 editor.handle_input(&char_key('/'));
2338 assert!(editor.autocomplete_active);
2340 }
2341
2342 #[test]
2343 fn autocomplete_persists_across_multiple_chars() {
2344 let mut editor = make_editor_with_slash_provider(vec!["help", "history", "hello", "heavy"]);
2346
2347 for ch in "/hel".chars() {
2348 editor.handle_input(&char_key(ch));
2349 assert!(
2350 editor.autocomplete_active,
2351 "autocomplete should stay active after '{}'",
2352 ch
2353 );
2354 }
2355
2356 assert!(
2358 !editor.autocomplete_is_empty(),
2359 "should have matching items"
2360 );
2361 assert_eq!(editor.get_text(), "/hel");
2362 }
2363
2364 #[test]
2365 fn test_new_editor() {
2366 let editor = Editor::new(EditorOptions::default());
2367 assert_eq!(editor.get_text(), "");
2368 }
2369
2370 #[test]
2371 fn test_set_text() {
2372 let mut editor = Editor::new(EditorOptions::default());
2373 editor.set_text("hello world");
2374 assert_eq!(editor.get_text(), "hello world");
2375 }
2376
2377 #[test]
2378 fn test_insert_and_move() {
2379 let mut editor = Editor::new(EditorOptions::default());
2380 editor.insert_character("h");
2381 editor.insert_character("i");
2382 assert_eq!(editor.get_text(), "hi");
2383 editor.move_left();
2384 assert_eq!(editor.cursor_col, 1);
2385 editor.move_right();
2386 assert_eq!(editor.cursor_col, 2);
2387 }
2388
2389 #[test]
2390 fn test_backspace() {
2391 let mut editor = Editor::new(EditorOptions::default());
2392 editor.set_text("hello");
2393 editor.backspace();
2394 assert_eq!(editor.get_text(), "hell");
2395 }
2396
2397 #[test]
2398 fn test_multiline() {
2399 let mut editor = Editor::new(EditorOptions::default());
2400 editor.set_text("line1\nline2");
2401 assert_eq!(editor.get_lines().len(), 2);
2402 }
2403
2404 #[test]
2405 fn test_undo() {
2406 let mut editor = Editor::new(EditorOptions::default());
2407 editor.push_undo();
2408 editor.insert_text_internal("a");
2409 editor.push_undo();
2410 editor.insert_text_internal("b");
2411 assert_eq!(editor.get_text(), "ab");
2412 editor.undo();
2413 assert_eq!(editor.get_text(), "a");
2414 editor.undo();
2415 assert_eq!(editor.get_text(), "");
2416 }
2417
2418 #[test]
2419 fn test_submit_clears() {
2420 let mut editor = Editor::new(EditorOptions::default());
2421 editor.set_text("hello");
2422 let result = editor.lines.join("\n");
2423 editor.lines = vec![String::new()];
2424 editor.cursor_line = 0;
2425 editor.cursor_col = 0;
2426 assert_eq!(result, "hello");
2427 assert_eq!(editor.get_text(), "");
2428 }
2429
2430 #[test]
2431 fn test_render_borders() {
2432 let mut editor = Editor::new(EditorOptions::default());
2433 let lines = editor.render(80);
2434 assert!(lines.len() >= 3);
2435 assert!(lines[0].contains('─'));
2436 assert!(lines.last().unwrap().contains('─'));
2437 }
2438
2439 #[test]
2440 fn test_scroll_indicator() {
2441 let mut editor = Editor::new(EditorOptions { padding_x: 1 });
2442 editor.set_terminal_rows(6);
2446 editor.set_text("line1\nline2\nline3\nline4\nline5\nline6");
2447 editor.cursor_line = 5;
2448 editor.cursor_col = 5;
2449 editor.scroll_offset = 2;
2450 let lines = editor.render(80);
2451 assert!(
2452 lines[0].contains("↑"),
2453 "Expected scroll-up indicator, got: {:?}",
2454 lines[0]
2455 );
2456 }
2457
2458 #[test]
2459 fn test_newline() {
2460 let mut editor = Editor::new(EditorOptions::default());
2461 editor.set_text("hello");
2462 editor.add_newline();
2463 assert_eq!(editor.get_text(), "hello\n");
2464 editor.insert_character("w");
2465 assert_eq!(editor.get_text(), "hello\nw");
2466 }
2467
2468 #[test]
2469 fn test_cursor_in_layout() {
2470 let editor = Editor::new(EditorOptions::default());
2471 let vl = layout_text(&editor.lines, 80, editor.cursor_line, editor.cursor_col);
2473 assert!(vl[0].has_cursor);
2474 assert_eq!(vl[0].cursor_pos, Some(0));
2475 }
2476
2477 #[test]
2478 fn test_cursor_in_layout_with_text() {
2479 let mut editor = Editor::new(EditorOptions::default());
2480 editor.set_text("abc");
2481 editor.cursor_col = 1;
2482 let vl = layout_text(&editor.lines, 80, editor.cursor_line, editor.cursor_col);
2483 assert!(vl[0].has_cursor);
2484 assert_eq!(vl[0].cursor_pos, Some(1));
2485 }
2486
2487 fn up_key() -> KeyEvent {
2490 KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)
2491 }
2492 fn left_key() -> KeyEvent {
2493 KeyEvent::new(KeyCode::Left, KeyModifiers::NONE)
2494 }
2495 fn char_key(c: char) -> KeyEvent {
2496 KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE)
2497 }
2498 fn enter_key() -> KeyEvent {
2499 KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)
2500 }
2501 fn escape() -> KeyEvent {
2502 KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)
2503 }
2504 fn backspace() -> KeyEvent {
2505 KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)
2506 }
2507
2508 #[test]
2509 fn test_history_empty_up_does_nothing() {
2510 let mut editor = Editor::new(EditorOptions::default());
2511 editor.handle_input(&up_key());
2512 assert_eq!(editor.get_text(), "");
2513 }
2514
2515 #[test]
2516 fn test_history_up_shows_most_recent() {
2517 let mut editor = Editor::new(EditorOptions::default());
2518 editor.add_to_history("first");
2519 editor.add_to_history("second");
2520 editor.handle_input(&up_key());
2521 assert_eq!(editor.get_text(), "second");
2522 }
2523
2524 #[test]
2525 fn test_history_cycles() {
2526 let mut editor = Editor::new(EditorOptions::default());
2527 editor.add_to_history("first");
2528 editor.add_to_history("second");
2529 editor.add_to_history("third");
2530 editor.handle_input(&up_key());
2531 assert_eq!(editor.get_text(), "third");
2532 editor.handle_input(&up_key());
2533 assert_eq!(editor.get_text(), "second");
2534 editor.handle_input(&up_key());
2535 assert_eq!(editor.get_text(), "first");
2536 editor.handle_input(&up_key()); assert_eq!(editor.get_text(), "first");
2538 }
2539
2540 #[test]
2541 fn test_history_exits_on_type() {
2542 let mut editor = Editor::new(EditorOptions::default());
2543 editor.add_to_history("old");
2544 editor.handle_input(&up_key());
2545 assert_eq!(editor.get_text(), "old");
2546 editor.handle_input(&char_key('x'));
2547 assert_eq!(editor.get_text(), "xold");
2548 }
2549
2550 #[test]
2551 fn test_backslash_enter_newline() {
2552 let mut editor = Editor::new(EditorOptions::default());
2553 editor.handle_input(&char_key('\\'));
2554 assert_eq!(editor.get_text(), "\\");
2555 editor.handle_input(&enter_key());
2556 assert_eq!(editor.get_text(), "\n");
2557 }
2558
2559 #[test]
2560 fn test_move_cursor_over_emoji() {
2561 let mut editor = Editor::new(EditorOptions::default());
2562 editor.set_text("a😀b");
2563 editor.cursor_col = 0;
2564 editor.move_right();
2565 assert_eq!(editor.cursor_col, 1);
2566 editor.move_right();
2567 assert_eq!(editor.cursor_col, 5);
2568 editor.move_right();
2569 assert_eq!(editor.cursor_col, 6);
2570 }
2571
2572 #[test]
2573 fn test_backspace_emoji() {
2574 let mut editor = Editor::new(EditorOptions::default());
2575 editor.set_text("a😀b");
2576 editor.cursor_col = 6;
2577 editor.backspace();
2578 assert_eq!(editor.get_text(), "a😀");
2579 editor.backspace();
2580 assert_eq!(editor.get_text(), "a");
2581 }
2582
2583 #[test]
2584 fn test_render_cursor_visible() {
2585 let mut editor = Editor::new(EditorOptions::default());
2586 editor.focused = true;
2587 editor.insert_character("x");
2588 let lines = editor.render(40);
2589 let content = &lines[1];
2590 assert!(content.contains("\x1b[7m"), "Cursor inverse not found");
2591 }
2592
2593 #[test]
2594 fn test_emits_cursor_marker_when_focused() {
2595 let mut editor = Editor::new(EditorOptions::default());
2596 editor.focused = true;
2597 editor.insert_character("hello");
2598 let lines = editor.render(40);
2599 let content = &lines[1];
2600 assert!(
2601 content.contains(CURSOR_MARKER),
2602 "Focused editor should emit cursor marker"
2603 );
2604 }
2605
2606 #[test]
2607 fn test_no_cursor_marker_when_not_focused() {
2608 let mut editor = Editor::new(EditorOptions::default());
2609 editor.focused = false;
2610 editor.insert_character("hello");
2611 let lines = editor.render(40);
2612 let content = &lines[1];
2613 assert!(
2614 !content.contains(CURSOR_MARKER),
2615 "Unfocused editor should not emit cursor marker"
2616 );
2617 }
2618
2619 #[test]
2620 fn test_render_borders_always_present() {
2621 let mut editor = Editor::new(EditorOptions::default());
2622 let lines = editor.render(80);
2623 assert_eq!(lines.len(), 3, "Empty editor should have 3 lines");
2624 assert!(lines[0].contains('─'), "Top border missing");
2625 assert!(lines[2].contains('─'), "Bottom border missing");
2626
2627 editor.insert_character("/");
2628 let lines = editor.render(80);
2629 assert_eq!(lines.len(), 3, "After typing / should still have 3 lines");
2630 assert!(lines[0].contains('─'), "Top border missing after /");
2631 assert!(lines[2].contains('─'), "Bottom border missing after /");
2632
2633 editor.set_text("hello world this is text");
2634 let lines = editor.render(40);
2635 assert!(lines.len() >= 3, "Wrapped text: {}", lines.len());
2636 assert!(lines[0].contains('─'), "Top border");
2637 assert!(lines.last().unwrap().contains('─'), "Bottom border");
2638 }
2639
2640 #[test]
2641 fn test_content_width_respected() {
2642 let mut editor = Editor::new(EditorOptions { padding_x: 1 });
2643 editor.set_text("hello world this is a test");
2644 let lines = editor.render(20);
2645 for line in &lines {
2646 let vw = crate::tui::util::visible_width(line);
2647 assert!(vw <= 20, "Width {} > 20: {:?}", vw, line);
2648 }
2649 }
2650
2651 #[test]
2654 fn test_no_duplicate_chunks_from_wrapping() {
2655 let texts = [
2659 "hello world this is a test of the wrapping system",
2660 "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",
2661 "short",
2662 "",
2663 "abc abc abc abc abc abc abc abc",
2664 " leading and trailing spaces ",
2665 "hello world extra spaces",
2666 ];
2667 for text in &texts {
2668 for width in [1, 2, 3, 5, 8, 12, 20, 40] {
2669 let wrapped = crate::tui::util::wrap_text_with_ansi(text, width);
2670
2671 let total_vis_wrapped: usize = wrapped.iter().map(|c| visible_width(c)).sum();
2673 let total_vis_original = visible_width(text);
2674 assert!(
2675 total_vis_wrapped <= total_vis_original,
2676 "Width={}: wrapped visible {} > original visible {} for {:?}",
2677 width,
2678 total_vis_wrapped,
2679 total_vis_original,
2680 text
2681 );
2682
2683 for a in &wrapped {
2686 if a.is_empty() {
2687 continue;
2688 }
2689 let count_in_wrapped = wrapped.iter().filter(|c| *c == a).count();
2690 let count_in_original = text.matches(a.as_str()).count();
2691 assert!(
2692 count_in_wrapped <= count_in_original || count_in_original == 0,
2693 "Width={}: chunk '{}' appears {}x in wrapped but {}x in original for {:?}",
2694 width,
2695 a,
2696 count_in_wrapped,
2697 count_in_original,
2698 text
2699 );
2700 }
2701 }
2702 }
2703 }
2704
2705 #[test]
2706 fn test_cursor_in_wrapped_text_first_chunk() {
2707 let mut editor = Editor::new(EditorOptions::default());
2709 let text = "hello world this is a test";
2710 editor.set_text(text);
2711 editor.cursor_col = 3;
2713 let vl = layout_text(&editor.lines, 10, editor.cursor_line, editor.cursor_col);
2714 assert!(vl.len() > 1, "Text should wrap into multiple visual lines");
2715 assert!(
2716 vl[0].has_cursor,
2717 "Cursor at col 3 should be in first visual line"
2718 );
2719 if let Some(pos) = vl[0].cursor_pos {
2720 assert_eq!(pos, 3, "Cursor byte offset in first chunk should be 3");
2721 }
2722 }
2723
2724 #[test]
2725 fn test_cursor_in_wrapped_text_middle_chunk() {
2726 let mut editor = Editor::new(EditorOptions::default());
2728 let text = "hello world this is a test";
2729 editor.set_text(text);
2730 editor.cursor_col = 16;
2734 let vl = layout_text(&editor.lines, 10, editor.cursor_line, editor.cursor_col);
2735 assert!(vl.len() > 1, "Text should wrap");
2736 let cursor_vl = vl.iter().position(|v| v.has_cursor);
2737 assert!(
2738 cursor_vl.is_some(),
2739 "Cursor should be found in some visual line"
2740 );
2741 }
2742
2743 #[test]
2744 fn test_cursor_last_chunk_on_boundary() {
2745 let mut editor = Editor::new(EditorOptions::default());
2747 let text = "hello world this is a test";
2748 editor.set_text(text);
2749 editor.cursor_col = text.len();
2750 let vl = layout_text(&editor.lines, 10, editor.cursor_line, editor.cursor_col);
2751 assert!(
2752 vl.last().is_some_and(|v| v.has_cursor),
2753 "Cursor at end should be in last visual line"
2754 );
2755 }
2756
2757 #[test]
2758 fn test_layout_text_each_chunk_unique() {
2759 let text = "hello world this is a test of the wrapping system";
2762 let vl = layout_text(&[text.to_string()], 12, 0, 0);
2763 let chunk_texts: Vec<&str> = vl.iter().map(|v| v.text.as_str()).collect();
2764 for i in 0..chunk_texts.len() {
2765 for j in (i + 1)..chunk_texts.len() {
2766 if chunk_texts[i] == chunk_texts[j] {
2767 if !chunk_texts[i].is_empty() {
2769 panic!(
2770 "Duplicate chunk text at positions {} and {}: '{}'",
2771 i, j, chunk_texts[i]
2772 );
2773 }
2774 }
2775 }
2776 }
2777 }
2778
2779 #[test]
2782 fn test_visual_col_to_byte_offset_ascii() {
2783 let text = "hello";
2784 assert_eq!(crate::tui::util::visual_col_to_byte_offset(text, 0), 0);
2785 assert_eq!(crate::tui::util::visual_col_to_byte_offset(text, 3), 3);
2786 assert_eq!(crate::tui::util::visual_col_to_byte_offset(text, 5), 5);
2787 }
2788
2789 #[test]
2790 fn test_visual_col_to_byte_offset_cjk() {
2791 let text = "世界hello";
2792 assert_eq!(crate::tui::util::visual_col_to_byte_offset(text, 0), 0);
2793 assert_eq!(crate::tui::util::visual_col_to_byte_offset(text, 2), 3);
2795 assert_eq!(crate::tui::util::visual_col_to_byte_offset(text, 4), 6);
2797 }
2798
2799 #[test]
2800 fn test_visual_col_to_byte_offset_ansi() {
2801 let text = "\x1b[31mhello\x1b[0m";
2803 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); }
2811
2812 #[test]
2813 fn test_visual_col_to_byte_offset_empty() {
2814 assert_eq!(crate::tui::util::visual_col_to_byte_offset("", 0), 0);
2815 assert_eq!(crate::tui::util::visual_col_to_byte_offset("", 5), 0);
2816 }
2817
2818 #[test]
2819 fn test_visual_col_to_byte_offset_zero_col() {
2820 assert_eq!(crate::tui::util::visual_col_to_byte_offset("abc", 0), 0);
2822 assert_eq!(
2824 crate::tui::util::visual_col_to_byte_offset("\x1b[31mabc", 0),
2825 5
2826 );
2827 }
2828
2829 #[test]
2832 fn test_large_paste_creates_marker() {
2833 let mut editor = Editor::new(EditorOptions::default());
2834 let large = "line1\nline2\nline3\nline4\nline5\nline6\nline7\nline8\nline9\nline10\nline11";
2835 editor.handle_paste(large);
2836 let text = editor.get_text();
2837 assert!(text.contains("[paste #"), "Should contain paste marker");
2838 assert!(
2839 !text.contains("line1"),
2840 "Should not contain original content"
2841 );
2842 assert_eq!(editor.pastes.len(), 1, "Should store one paste");
2843 }
2844
2845 #[test]
2846 fn test_small_paste_no_marker() {
2847 let mut editor = Editor::new(EditorOptions::default());
2848 editor.handle_paste("hello");
2849 let text = editor.get_text();
2850 assert!(
2851 !text.contains("[paste #"),
2852 "Small paste should not create marker"
2853 );
2854 assert_eq!(text, "hello");
2855 }
2856
2857 #[test]
2858 fn test_expand_paste_markers() {
2859 let mut editor = Editor::new(EditorOptions::default());
2860 editor.handle_paste(
2861 "line1\nline2\nline3\nline4\nline5\nline6\nline7\nline8\nline9\nline10\nline11",
2862 );
2863 let expanded = editor.get_expanded_text();
2864 assert!(
2865 expanded.contains("line1"),
2866 "Expanded text should contain original content"
2867 );
2868 assert!(
2869 !expanded.contains("[paste #"),
2870 "Expanded text should not contain markers"
2871 );
2872 }
2873
2874 #[test]
2875 fn test_submit_expands_markers() {
2876 let mut editor = Editor::new(EditorOptions::default());
2877 editor.handle_paste(
2878 "line1\nline2\nline3\nline4\nline5\nline6\nline7\nline8\nline9\nline10\nline11",
2879 );
2880 let large_content =
2881 "line1\nline2\nline3\nline4\nline5\nline6\nline7\nline8\nline9\nline10\nline11";
2882 let raw = editor.lines.join("\n");
2884 let expanded = editor.expand_paste_markers(&raw);
2885 assert_eq!(
2886 expanded, large_content,
2887 "Submit should expand to original content"
2888 );
2889 }
2890
2891 #[test]
2892 fn test_is_paste_marker() {
2893 assert!(Editor::is_paste_marker("[paste #1 +5 lines]"));
2894 assert!(Editor::is_paste_marker("[paste #123 456 chars]"));
2895 assert!(!Editor::is_paste_marker("normal text"));
2896 assert!(!Editor::is_paste_marker(""));
2897 }
2898
2899 #[test]
2900 fn test_get_expanded_text() {
2901 let mut editor = Editor::new(EditorOptions::default());
2902 editor.handle_paste(
2903 "line1\nline2\nline3\nline4\nline5\nline6\nline7\nline8\nline9\nline10\nline11",
2904 );
2905 let expanded = editor.get_expanded_text();
2906 assert!(
2907 expanded.contains("line1"),
2908 "get_expanded_text should expand markers"
2909 );
2910 assert!(
2911 expanded.starts_with("line1"),
2912 "Should start with original content"
2913 );
2914 }
2915
2916 #[test]
2919 fn test_multiline_render_no_duplicate_content() {
2920 let mut editor = Editor::new(EditorOptions::default());
2921 editor.set_text("hello");
2923 editor.add_newline();
2924 editor.insert_character("w");
2925 editor.insert_character("o");
2926 editor.insert_character("r");
2927 editor.insert_character("l");
2928 editor.insert_character("d");
2929 assert_eq!(editor.get_text(), "hello\nworld");
2930
2931 for width in [20, 40, 80] {
2933 let rendered = editor.render(width);
2934
2935 let content_lines: Vec<&str> = rendered
2937 .iter()
2938 .filter(|l| !l.contains('─'))
2939 .map(|l| l.trim())
2940 .collect();
2941
2942 assert!(
2944 content_lines.len() >= 2,
2945 "Width {}: expected >= 2 content lines, got {}: {:?}",
2946 width,
2947 content_lines.len(),
2948 rendered
2949 );
2950
2951 let mut seen = std::collections::HashSet::new();
2953 for line in &content_lines {
2954 if !line.is_empty() {
2955 let plain = line.replace("\x1b_pi:c\x07", "").to_string();
2956 if !seen.insert(plain.clone()) {
2957 panic!(
2958 "Width {}: duplicate content line '{}' in {:?}",
2959 width, line, rendered
2960 );
2961 }
2962 }
2963 }
2964 }
2965 }
2966
2967 #[test]
2968 fn test_editor_add_newline_adds_one_visual_line() {
2969 let mut editor = Editor::new(EditorOptions::default());
2970 editor.set_text("hello");
2971
2972 let before = editor.render(80).len();
2973 editor.add_newline();
2974 let after = editor.render(80).len();
2975
2976 assert_eq!(
2977 after,
2978 before + 1,
2979 "Adding newline should increase rendered line count by exactly 1. before={}, after={}",
2980 before,
2981 after
2982 );
2983 }
2984
2985 #[test]
2986 fn test_layout_text_no_extra_empty_visual_line() {
2987 let lines: Vec<String> = vec![String::new()];
2990 let vl = layout_text(&lines, 80, 0, 0);
2991 assert_eq!(vl.len(), 1, "Empty text should have 1 visual line");
2992 assert!(vl[0].has_cursor);
2993
2994 let lines = vec!["hello".to_string()];
2995 let vl = layout_text(&lines, 80, 0, 5);
2996 assert_eq!(vl.len(), 1, "Single line should have 1 visual line");
2997 assert!(vl[0].has_cursor);
2998
2999 let lines = vec!["hello".to_string(), "".to_string()];
3000 let vl = layout_text(&lines, 80, 0, 5);
3001 assert_eq!(
3002 vl.len(),
3003 2,
3004 "Two lines (one empty) should have 2 visual lines"
3005 );
3006 assert!(vl[0].has_cursor);
3008 assert!(!vl[1].has_cursor);
3009
3010 let lines = vec!["hello".to_string(), "".to_string()];
3011 let vl = layout_text(&lines, 80, 1, 0);
3012 assert_eq!(vl.len(), 2);
3013 assert!(!vl[0].has_cursor);
3015 assert!(vl[1].has_cursor);
3016
3017 let lines = vec!["".to_string(), "hello".to_string()];
3018 let vl = layout_text(&lines, 80, 1, 5);
3019 assert_eq!(
3020 vl.len(),
3021 2,
3022 "Two lines (one empty first) should have 2 visual lines"
3023 );
3024 assert!(!vl[0].has_cursor);
3025 assert!(vl[1].has_cursor);
3026 }
3027
3028 #[test]
3029 fn test_wrap_edge_cases_no_empty_lines() {
3030 let cases = vec![
3034 (" hello", 3, "leading spaces"),
3035 ("hello ", 3, "trailing spaces"),
3036 (" hello ", 3, "leading and trailing spaces"),
3037 ("abc def", 5, "double space in middle"),
3038 ("a b", 4, "triple space"),
3039 ("a b", 3, "double space at wrap boundary"),
3040 ];
3041 for (text, width, label) in &cases {
3042 let wrapped = crate::tui::util::wrap_text_with_ansi(text, *width);
3045 for chunk in &wrapped {
3046 if chunk.is_empty() {
3048 panic!(
3049 "Case '{}' (width {}): empty chunk found in wrapped: {:?}",
3050 label, width, wrapped
3051 );
3052 }
3053 let vis = crate::tui::util::visible_width(chunk);
3054 assert!(
3055 vis > 0,
3056 "Case '{}' (width {}): chunk with visible width 0: {:?} (wrapped: {:?})",
3057 label,
3058 width,
3059 chunk,
3060 wrapped
3061 );
3062 }
3063 }
3064 }
3065
3066 #[test]
3067 fn test_wrap_long_word_no_duplicate_chunks() {
3068 let long = "aaaaa bbbbb ccccc ddddd";
3070 for width in [5, 6, 7, 8, 10, 12] {
3071 let wrapped = crate::tui::util::wrap_text_with_ansi(long, width);
3072 let mut seen = std::collections::HashSet::new();
3074 for chunk in &wrapped {
3075 let trimmed = chunk.trim();
3076 if !trimmed.is_empty() && !seen.insert(trimmed.to_string()) {
3077 panic!(
3078 "Width {}: duplicate chunk '{}' in {:?}",
3079 width, chunk, wrapped
3080 );
3081 }
3082 }
3083 }
3084 }
3085
3086 #[test]
3087 fn test_wrap_typing_detailed_trace() {
3088 let mut editor = Editor::new(EditorOptions::default());
3091
3092 let sentence = "hello world";
3094 let width = 10;
3095
3096 for (i, ch) in sentence.chars().enumerate() {
3097 editor.handle_input(&char_key(ch));
3098
3099 let vl = layout_text(&editor.lines, width, editor.cursor_line, editor.cursor_col);
3101
3102 let mut seen = std::collections::HashSet::new();
3104 for vis in &vl {
3105 let trimmed = vis.text.trim();
3106 if !trimmed.is_empty() && !seen.insert(trimmed.to_string()) {
3107 panic!(
3108 "After char '{}' (pos {}): duplicate visual line '{}' in {:?}",
3109 ch, i, vis.text, vl
3110 );
3111 }
3112 }
3113
3114 let cursor_count = vl.iter().filter(|v| v.has_cursor).count();
3116 assert_eq!(
3117 cursor_count, 1,
3118 "After char '{}' (pos {}): expected exactly 1 cursor, got {}. vl: {:?}",
3119 ch, i, cursor_count, vl
3120 );
3121 }
3122 }
3123
3124 #[test]
3125 fn test_wrap_long_continuous_string_no_duplicates() {
3126 let mut editor = Editor::new(EditorOptions::default());
3129
3130 let url = "https://very-long-url-with-no-spaces.example.com/path/to/resource";
3132 for ch in url.chars() {
3133 editor.handle_input(&char_key(ch));
3134 }
3135
3136 for width in [5, 10, 15, 20, 30] {
3138 let rendered = editor.render(width);
3139 let content: Vec<&str> = rendered
3140 .iter()
3141 .filter(|l| !l.contains('─'))
3142 .map(|l| l.trim())
3143 .filter(|l| !l.is_empty())
3144 .collect();
3145
3146 let mut seen = std::collections::HashSet::new();
3147 for line in &content {
3148 let plain = line
3149 .replace("\x1b_pi:c\x07", "")
3150 .chars()
3151 .filter(|&c| c.is_ascii_graphic() || c == ' ')
3152 .collect::<String>()
3153 .trim()
3154 .to_string();
3155 if !plain.is_empty() && !seen.insert(plain.clone()) {
3156 panic!(
3157 "Width {}: duplicate content line '{}' (plain: '{}')\nFull render: {:?}",
3158 width, line, plain, rendered
3159 );
3160 }
3161 }
3162 }
3163 }
3164
3165 #[test]
3166 fn test_editor_typing_past_width_no_duplicate_render() {
3167 let mut editor = Editor::new(EditorOptions::default());
3170
3171 let input = "hello world this is a test of the emergency broadcast system";
3173 for ch in input.chars() {
3174 editor.handle_input(&char_key(ch));
3175 }
3176
3177 for width in [5, 8, 10, 12, 15, 20] {
3179 let rendered = editor.render(width);
3180
3181 let content: Vec<&str> = rendered
3183 .iter()
3184 .filter(|l| !l.contains('─'))
3185 .map(|l| l.trim())
3186 .filter(|l| !l.is_empty())
3187 .collect();
3188
3189 let mut seen = std::collections::HashSet::new();
3191 for line in &content {
3192 let plain = line
3194 .replace("\x1b_pi:c\x07", "")
3195 .chars()
3196 .filter(|&c| c.is_ascii_graphic() || c == ' ')
3197 .collect::<String>()
3198 .trim()
3199 .to_string();
3200 if !plain.is_empty() && !seen.insert(plain.clone()) {
3201 panic!(
3202 "Width {}: duplicate content line '{}' (plain: '{}')\nFull render: {:?}",
3203 width, line, plain, rendered
3204 );
3205 }
3206 }
3207
3208 let content_plain: String = content.join(" ");
3210 let content_plain = content_plain
3211 .replace("\x1b_pi:c\x07", "")
3212 .chars()
3213 .filter(|&c| c.is_ascii_graphic() || c == ' ')
3214 .collect::<String>();
3215 assert!(
3216 !content_plain.is_empty(),
3217 "Width {}: no visible content in render: {:?}",
3218 width,
3219 rendered
3220 );
3221 }
3222 }
3223}