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