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