Skip to main content

rab/tui/components/
editor.rs

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/// Direction for character jump mode (pi-style).
33#[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
49// ── Editor ─────────────────────────────────────────────────────────
50
51pub 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    // Character jump mode (pi-style: await next printable char to jump to)
73    jump_mode: Option<JumpDirection>,
74
75    // Pi-style autocomplete provider (handles slash commands, file paths, etc.)
76    autocomplete_provider: Option<Box<dyn AutocompleteProvider>>,
77
78    // Pi-style paste markers (large pastes stored, marker inserted in place)
79    pastes: HashMap<u32, String>,
80    paste_counter: u32,
81
82    /// True after submit() is called, reset when checked.
83    pub just_submitted: bool,
84    /// Text that was submitted in the last submit() call, captured before clearing.
85    pub last_submitted_text: String,
86
87    // Pi-style autocomplete state (uses SelectList)
88    /// Terminal height for dynamic max-visible-lines (pi: 30% of rows, min 5).
89    terminal_rows: usize,
90    autocomplete_max_visible: usize,
91    autocomplete_list: Option<SelectList>,
92    pub autocomplete_active: bool,
93    /// The prefix from the provider's last get_suggestions call.
94    /// Used instead of recomputing at selection time to avoid mismatches
95    /// (e.g. `@src/au` → provider strips `@`, returns prefix `src/au`).
96    autocomplete_prefix: String,
97    /// Debounce: minimum time between autocomplete provider calls for @/# triggers.
98    /// Pi uses 20ms for attachment autocomplete, 0ms for slash commands.
99    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    // ── Public API ──
147
148    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    /// Update the terminal height so render can compute max visible lines
161    /// dynamically (pi: 30% of rows, min 5).
162    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    /// Internal: set text without undo/autocomplete (used by history navigation).
175    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        // Pi: cancel autocomplete, push undo if content differs, then fire onChange
189        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        // Skip consecutive duplicates (pi-style)
205        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    // ── Autocomplete (pi-style: uses SelectList) ──
224
225    /// Set the autocomplete provider (handles slash commands, file paths, etc.).
226    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    /// Set autocomplete items with an optional custom layout.
240    /// Pi-style: slash commands use a special layout with wider primary column.
241    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        // Pi-style: pre-select the best matching item (exact match > prefix match)
265        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    /// Find the best autocomplete item index for the current prefix.
273    /// Returns 0 if no match (same as pi's default).
274    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; // Exact match always wins
283            }
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    /// Apply completion using a pre-selected value.
298    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    /// After cursor movement, re-query autocomplete if active (pi-style).
326    /// Keeps the picker in sync with the new cursor position - closes when
327    /// the new position yields no suggestions, refreshes otherwise.
328    fn update_autocomplete_if_active(&mut self) {
329        if self.autocomplete_active {
330            self.try_trigger_autocomplete();
331        }
332    }
333
334    /// Pi-style: after backspace/delete that dismissed autocomplete,
335    /// re-trigger if cursor is still in a completable context.
336    fn retrigger_autocomplete_dismissed(&mut self) {
337        if self.autocomplete_active {
338            return; // not dismissed
339        }
340        // Pi: check slash command context first (only line 0, starts with /)
341        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        // Check @/# is at token start
352        if before.contains('@') || before.contains('#') {
353            self.try_trigger_autocomplete();
354        }
355    }
356
357    pub fn autocomplete_selected_value(&self) -> Option<String> {
358        self.autocomplete_list
359            .as_ref()
360            .and_then(|l| l.selected_item())
361            .map(|item| item.value.clone())
362    }
363
364    pub fn autocomplete_is_empty(&self) -> bool {
365        self.autocomplete_list
366            .as_ref()
367            .is_none_or(|l| l.items().is_empty())
368    }
369
370    // ── Undo ──
371
372    // Pi fish-style undo coalescing:
373    // - Consecutive word chars coalesce into one undo unit
374    // - Space captures state before itself (undo removes space + following word)
375    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    // ── Cursor ──
404
405    fn set_cursor_col(&mut self, col: usize) {
406        self.cursor_col = col;
407        self.preferred_col = None;
408    }
409
410    // ── Text insertion ──
411
412    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 = &current_line[..self.cursor_col.min(current_line.len())];
420        let after = &current_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        // Pi-style autocomplete: trigger or update after character insertion
447        self.update_autocomplete(ch);
448    }
449
450    /// Check if the just-typed character should trigger or update autocomplete.
451    /// Pi behavior: / at start of line, @ and # at token boundaries,
452    /// and letters when already in a slash command context.
453    /// When autocomplete is already active, re-triggers to update suggestions.
454    /// Pi: slash menu only allowed on the first line of the editor.
455    fn is_slash_menu_allowed(&self) -> bool {
456        self.cursor_line == 0
457    }
458
459    /// Pi: check if cursor is at start of message (for slash command detection).
460    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    /// Pi: check if cursor is in a slash command context (starts with /, slash menu allowed).
475    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 autocomplete is already active, always re-trigger to update
490        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 = &current_line[..self.cursor_col.min(current_line.len())];
496
497        // / at start of message (pi: checks isAtStartOfMessage)
498        if ch == "/" && self.is_at_start_of_message() {
499            self.try_trigger_autocomplete();
500            return;
501        }
502
503        // @ and # at token boundaries
504        if ch == "@" || ch == "#" {
505            let before_char = text_before.chars().nth_back(1);
506            if text_before.len() == 1
507                || before_char.is_none_or(|c| c.is_whitespace() || c == ' ' || c == '\t')
508            {
509                self.try_trigger_autocomplete();
510                return;
511            }
512        }
513
514        // Provider trigger characters (e.g. custom providers that use +, :, etc.)
515        if let Some(ref provider) = self.autocomplete_provider {
516            for tc in provider.trigger_characters() {
517                if ch.len() == 1 && ch == tc.to_string() && tc != &'/' && tc != &'@' && tc != &'#'
518                // already handled above
519                {
520                    let before_char = text_before.chars().nth_back(1);
521                    if text_before.len() == 1
522                        || before_char.is_none_or(|c| c.is_whitespace() || c == ' ' || c == '\t')
523                    {
524                        self.try_trigger_autocomplete();
525                        return;
526                    }
527                }
528            }
529        }
530
531        // Letters when in a slash command context (pi: only on first line)
532        if ch.len() == 1
533            && ch
534                .chars()
535                .next()
536                .is_some_and(|c| c.is_alphanumeric() || c == '-' || c == '_')
537        {
538            if self.is_in_slash_command_context() && !text_before.trim_start().contains(' ') {
539                self.try_trigger_autocomplete();
540                return;
541            }
542            // Also trigger for @ and # contexts
543            if text_before.contains('@') || text_before.contains('#') {
544                self.try_trigger_autocomplete();
545            }
546        }
547    }
548
549    /// Get the autocomplete prefix for the current cursor position.
550    fn get_autocomplete_prefix(&self) -> String {
551        let line = self
552            .lines
553            .get(self.cursor_line)
554            .map(|l| l.as_str())
555            .unwrap_or("");
556        let before = &line[..self.cursor_col.min(line.len())];
557        // Find the last token boundary
558        if before.starts_with('/') && !before.contains(' ') {
559            before.to_string()
560        } else if let Some(pos) = before.rfind(['@', '#']) {
561            before[pos..].to_string()
562        } else if let Some(pos) = before.rfind(|c: char| c.is_whitespace()) {
563            before[pos + 1..].to_string()
564        } else {
565            before.to_string()
566        }
567    }
568
569    /// Trigger autocomplete.
570    ///
571    /// When `force` is true (Tab key):
572    /// - 1 match → complete immediately (no selector)
573    /// - Otherwise → open the selector
574    ///
575    /// When `force` is false (automatic on typing), always opens the selector.
576    fn trigger_autocomplete(&mut self, force: bool) {
577        let Some(ref provider) = self.autocomplete_provider else {
578            return;
579        };
580
581        // Debounce: for non-slash, non-force triggers (attachment @, #),
582        // skip if called within 20ms of the last call. Pi uses 20ms for
583        // attachment autocomplete to avoid flickering during rapid typing.
584        if !force {
585            let line = self
586                .lines
587                .get(self.cursor_line)
588                .map(|l| l.as_str())
589                .unwrap_or("");
590            let before = &line[..self.cursor_col.min(line.len())];
591            let is_slash = before.starts_with('/');
592            if !is_slash && !before.is_empty() {
593                let elapsed = self.last_autocomplete_trigger.elapsed();
594                if elapsed < std::time::Duration::from_millis(20) {
595                    return;
596                }
597            }
598        }
599        self.last_autocomplete_trigger = std::time::Instant::now();
600
601        let Some(suggestions) =
602            provider.get_suggestions(&self.lines, self.cursor_line, self.cursor_col, force)
603        else {
604            self.clear_autocomplete();
605            return;
606        };
607
608        let items = suggestions.items;
609        let prefix = suggestions.prefix;
610
611        if items.is_empty() {
612            self.clear_autocomplete();
613            return;
614        }
615
616        // Pi behavior: on Tab (force), single match → complete immediately with no selector
617        if force && items.len() == 1 {
618            let (new_lines, new_line, new_col) = provider.apply_completion(
619                &self.lines,
620                self.cursor_line,
621                self.cursor_col,
622                &items[0],
623                &prefix,
624            );
625            self.lines = new_lines;
626            self.cursor_line = new_line;
627            self.cursor_col = new_col;
628            self.clear_autocomplete();
629            return;
630        }
631
632        // ── Open the selector with all matches ──
633        let select_items: Vec<SelectItem> = items
634            .into_iter()
635            .map(|item| {
636                let mut si = SelectItem::new(item.value, item.label);
637                if let Some(desc) = item.description {
638                    si = si.with_description(desc);
639                }
640                si
641            })
642            .collect();
643        // Pi-style: slash commands use a wider primary column layout
644        let layout = if prefix.starts_with('/') {
645            Some(
646                crate::tui::components::select_list::SelectListLayoutOptions {
647                    min_primary_column_width: Some(12),
648                    max_primary_column_width: Some(32),
649                    truncate_primary: None,
650                },
651            )
652        } else {
653            None
654        };
655        self.set_autocomplete_with_layout(select_items, layout);
656        self.autocomplete_prefix = prefix;
657    }
658
659    pub fn try_trigger_autocomplete(&mut self) {
660        self.trigger_autocomplete(false);
661    }
662
663    /// Force-trigger autocomplete (for Tab key).
664    fn try_trigger_autocomplete_force(&mut self) {
665        self.trigger_autocomplete(true);
666    }
667
668    fn add_newline(&mut self) {
669        self.exit_history();
670        self.last_action = None;
671        self.push_undo();
672        let line = self.lines[self.cursor_line].clone();
673        let before = &line[..self.cursor_col.min(line.len())];
674        let after = &line[self.cursor_col.min(line.len())..];
675        self.lines[self.cursor_line] = before.to_string();
676        self.lines.insert(self.cursor_line + 1, after.to_string());
677        self.cursor_line += 1;
678        self.set_cursor_col(0);
679        self.notify_change();
680    }
681
682    // ── Delete ──
683
684    /// Find the segment before cursor, treating paste markers as atomic units.
685    /// Returns (start, len) of the segment to delete.
686    fn grapheme_or_paste_before(&self, line: &str, cursor: usize) -> Option<(usize, usize)> {
687        // Check if cursor is at the end of a paste marker
688        for &(start, end) in &Self::find_paste_marker_spans(line) {
689            if cursor >= end && cursor < end + 10 {
690                // The grapheme at end could be the start of the next marker.
691                // If cursor lands exactly at a marker start, the previous
692                // atomic unit is that marker itself.
693                if cursor == end {
694                    return Some((start, end - start));
695                }
696            }
697        }
698        // Also check if cursor is inside a marker - snap to start
699        for &(start, end) in &Self::find_paste_marker_spans(line) {
700            if cursor > start && cursor < end {
701                return Some((start, end - start));
702            }
703        }
704        // Default: last grapheme
705        let graphemes: Vec<(usize, &str)> = line[..cursor].grapheme_indices(true).collect();
706        graphemes.last().map(|&(idx, g)| (idx, g.len()))
707    }
708
709    /// Find the segment after cursor, treating paste markers as atomic units.
710    fn grapheme_or_paste_after(&self, line: &str, cursor: usize) -> Option<(usize, usize)> {
711        // Check if cursor is at the start of a paste marker
712        for &(start, end) in &Self::find_paste_marker_spans(line) {
713            if cursor == start {
714                return Some((start, end - start));
715            }
716        }
717        // Default: first grapheme
718        let graphemes: Vec<(usize, &str)> = line[cursor..].grapheme_indices(true).collect();
719        graphemes.first().map(|&(i, g)| (cursor + i, g.len()))
720    }
721
722    fn backspace(&mut self) {
723        self.exit_history();
724        self.last_action = None;
725        if self.cursor_col > 0 {
726            self.push_undo();
727            let line = self.lines[self.cursor_line].clone();
728            if let Some((idx, len)) = self.grapheme_or_paste_before(&line, self.cursor_col) {
729                self.lines[self.cursor_line].drain(idx..idx + len);
730                self.set_cursor_col(idx);
731            }
732        } else if self.cursor_line > 0 {
733            self.push_undo();
734            let current = self.lines.remove(self.cursor_line);
735            self.cursor_line -= 1;
736            let prev_len = self.lines[self.cursor_line].len();
737            self.lines[self.cursor_line].push_str(&current);
738            self.set_cursor_col(prev_len);
739        }
740        self.notify_change();
741    }
742
743    fn delete_forward(&mut self) {
744        self.exit_history();
745        self.last_action = None;
746        let line = self.lines[self.cursor_line].clone();
747        if self.cursor_col < line.len() {
748            self.push_undo();
749            if let Some((idx, len)) = self.grapheme_or_paste_after(&line, self.cursor_col) {
750                self.lines[self.cursor_line].drain(idx..idx + len);
751            }
752        } else if self.cursor_line + 1 < self.lines.len() {
753            self.push_undo();
754            let next = self.lines.remove(self.cursor_line + 1);
755            self.lines[self.cursor_line].push_str(&next);
756        }
757        self.notify_change();
758
759        // Pi: re-trigger autocomplete after forward delete if in context
760        self.retrigger_autocomplete_dismissed();
761    }
762
763    // ── Kill operations ──
764
765    fn delete_to_line_start(&mut self) {
766        self.exit_history();
767        let line = self.lines[self.cursor_line].clone();
768        if self.cursor_col > 0 {
769            self.push_undo();
770            let deleted = line[..self.cursor_col].to_string();
771            let accumulate = self.last_action.as_deref() == Some("kill");
772            self.kill_ring.push(&deleted, true, accumulate);
773            self.last_action = Some("kill".into());
774            self.lines[self.cursor_line] = line[self.cursor_col..].to_string();
775            self.set_cursor_col(0);
776        } else if self.cursor_line > 0 {
777            self.push_undo();
778            let accumulate = self.last_action.as_deref() == Some("kill");
779            self.kill_ring.push("\n", true, accumulate);
780            self.last_action = Some("kill".into());
781            let current = self.lines.remove(self.cursor_line);
782            self.cursor_line -= 1;
783            let prev_len = self.lines[self.cursor_line].len();
784            self.lines[self.cursor_line].push_str(&current);
785            self.set_cursor_col(prev_len);
786        }
787        self.notify_change();
788    }
789
790    fn delete_to_line_end(&mut self) {
791        self.exit_history();
792        let line = self.lines[self.cursor_line].clone();
793        if self.cursor_col < line.len() {
794            self.push_undo();
795            let deleted = line[self.cursor_col..].to_string();
796            let accumulate = self.last_action.as_deref() == Some("kill");
797            self.kill_ring.push(&deleted, false, accumulate);
798            self.last_action = Some("kill".into());
799            self.lines[self.cursor_line] = line[..self.cursor_col].to_string();
800        } else if self.cursor_line + 1 < self.lines.len() {
801            self.push_undo();
802            let accumulate = self.last_action.as_deref() == Some("kill");
803            self.kill_ring.push("\n", false, accumulate);
804            self.last_action = Some("kill".into());
805            let next = self.lines.remove(self.cursor_line + 1);
806            self.lines[self.cursor_line].push_str(&next);
807        }
808        self.notify_change();
809    }
810
811    fn delete_word_backward(&mut self) {
812        self.exit_history();
813        let line = self.lines[self.cursor_line].clone();
814        if self.cursor_col == 0 {
815            return;
816        }
817        let opts = WordNavigationOptions {
818            segment: None,
819            is_atomic_segment: Some(&|s: &str| s.starts_with("[paste #") && s.ends_with(']')),
820        };
821        let new_col = find_word_backward_with(&line, self.cursor_col, &opts);
822        if new_col < self.cursor_col {
823            self.push_undo();
824            let deleted = line[new_col..self.cursor_col].to_string();
825            let accumulate = self.last_action.as_deref() == Some("kill");
826            self.kill_ring.push(&deleted, true, accumulate);
827            self.last_action = Some("kill".into());
828            self.lines[self.cursor_line].drain(new_col..self.cursor_col);
829            self.set_cursor_col(new_col);
830            self.notify_change();
831        }
832    }
833
834    fn delete_word_forward(&mut self) {
835        self.exit_history();
836        let line = self.lines[self.cursor_line].clone();
837        if self.cursor_col >= line.len() {
838            return;
839        }
840        let opts = WordNavigationOptions {
841            segment: None,
842            is_atomic_segment: Some(&|s: &str| s.starts_with("[paste #") && s.ends_with(']')),
843        };
844        let new_col = find_word_forward_with(&line, self.cursor_col, &opts);
845        if new_col > self.cursor_col {
846            self.push_undo();
847            let deleted = line[self.cursor_col..new_col].to_string();
848            let accumulate = self.last_action.as_deref() == Some("kill");
849            self.kill_ring.push(&deleted, false, accumulate);
850            self.last_action = Some("kill".into());
851            self.lines[self.cursor_line].drain(self.cursor_col..new_col);
852            self.notify_change();
853        }
854    }
855
856    // ── Yank ──
857
858    fn yank(&mut self) {
859        self.exit_history();
860        let text = self.kill_ring.peek().map(|s| s.to_string());
861        if let Some(text) = text {
862            self.push_undo();
863            self.cursor_col += text.len();
864            self.lines[self.cursor_line].insert_str(self.cursor_col - text.len(), &text);
865            self.last_action = Some("yank".into());
866            self.notify_change();
867        }
868    }
869
870    fn yank_pop(&mut self) {
871        // Must be called after yank() - check via last_action
872        if self.last_action.as_deref() != Some("yank") || self.kill_ring.len() <= 1 {
873            return;
874        }
875        // Save current state before modifying (pi-style: pushUndoSnapshot first)
876        self.push_undo();
877
878        // Delete the previously yanked text (still at end of ring before rotation)
879        let prev = self.kill_ring.peek().map(|s| s.to_string());
880        if let Some(ref prev_text) = prev {
881            let line = &self.lines[self.cursor_line].clone();
882            if self.cursor_col >= prev_text.len() {
883                let before = &line[..self.cursor_col - prev_text.len()];
884                let after = &line[self.cursor_col..];
885                self.lines[self.cursor_line] = format!("{}{}", before, after);
886                self.cursor_col -= prev_text.len();
887            }
888        }
889
890        // Rotate the ring: move end to front
891        self.kill_ring.rotate();
892
893        // Insert the new most recent entry (now at end after rotation)
894        let text = self.kill_ring.peek().map(|s| s.to_string());
895        if let Some(ref new_text) = text {
896            self.cursor_col += new_text.len();
897            self.lines[self.cursor_line].insert_str(self.cursor_col - new_text.len(), new_text);
898        }
899
900        self.last_action = Some("yank".into());
901        self.notify_change();
902    }
903
904    // ── Cursor movement ──
905
906    fn move_left(&mut self) {
907        self.last_action = None;
908        if self.cursor_col > 0 {
909            let line = &self.lines[self.cursor_line].clone();
910            let graphemes: Vec<(usize, &str)> =
911                line[..self.cursor_col].grapheme_indices(true).collect();
912            if let Some(&(idx, _g)) = graphemes.last() {
913                let raw = idx;
914                // Snap to paste marker start if inside one
915                self.set_cursor_col(Self::snap_paste_marker(line, raw, true));
916            }
917        } else if self.cursor_line > 0 {
918            self.cursor_line -= 1;
919            self.set_cursor_col(self.lines[self.cursor_line].len());
920        }
921    }
922
923    fn move_right(&mut self) {
924        self.last_action = None;
925        let line = &self.lines[self.cursor_line].clone();
926        if self.cursor_col < line.len() {
927            let mut it = line[self.cursor_col..].grapheme_indices(true);
928            if let Some((idx, g)) = it.next() {
929                let raw = self.cursor_col + idx + g.len();
930                // Snap to paste marker end if inside one
931                self.set_cursor_col(Self::snap_paste_marker(line, raw, false));
932            }
933        } else if self.cursor_line + 1 < self.lines.len() {
934            self.cursor_line += 1;
935            self.set_cursor_col(0);
936        }
937    }
938
939    fn move_up(&mut self) {
940        self.move_vertical(-1);
941    }
942
943    fn move_down(&mut self) {
944        self.move_vertical(1);
945    }
946
947    fn move_to_line_start(&mut self) {
948        self.last_action = None;
949        self.set_cursor_col(0);
950    }
951
952    fn move_to_line_end(&mut self) {
953        self.last_action = None;
954        let len = self.lines[self.cursor_line].len();
955        self.set_cursor_col(len);
956    }
957
958    /// Build visual line spans: (logical_line, start_byte_in_logical, length_in_bytes).
959    fn build_visual_line_spans(&self, width: usize) -> Vec<(usize, usize, usize)> {
960        let mut spans = Vec::new();
961        for (i, line) in self.lines.iter().enumerate() {
962            let line_w = visible_width(line);
963            if line.is_empty() {
964                spans.push((i, 0, 0));
965            } else if line_w <= width {
966                spans.push((i, 0, line.len()));
967            } else {
968                let chunks = crate::tui::util::wrap_text_with_ansi(line, width);
969                let mut byte_pos = 0;
970                for chunk in &chunks {
971                    let chunk_len = chunk.len();
972                    spans.push((i, byte_pos, chunk_len));
973                    byte_pos += chunk_len;
974                }
975            }
976        }
977        spans
978    }
979
980    /// Find the visual line index for the current cursor position.
981    fn find_current_visual_line(&self, spans: &[(usize, usize, usize)]) -> usize {
982        for (i, &(li, start, len)) in spans.iter().enumerate() {
983            if li != self.cursor_line {
984                continue;
985            }
986            let offset = self.cursor_col.saturating_sub(start);
987            let is_last = i + 1 >= spans.len() || spans[i + 1].0 != li;
988            if offset <= len || (is_last && offset == len) {
989                return i;
990            }
991        }
992        spans.len().saturating_sub(1)
993    }
994
995    /// Move cursor to a target visual line with sticky column logic.
996    /// Mirrors pi's moveToVisualLine() + computeVerticalMoveColumn().
997    fn move_to_visual_line(
998        &mut self,
999        spans: &[(usize, usize, usize)],
1000        current_vis: usize,
1001        target_vis: usize,
1002    ) {
1003        let (cur_li, _cur_start, cur_len) = spans[current_vis];
1004        let (tgt_li, tgt_start, tgt_len) = spans[target_vis];
1005        let cur_vis_col = self.cursor_col;
1006
1007        let is_last_source = current_vis + 1 >= spans.len() || spans[current_vis + 1].0 != cur_li;
1008        let src_max = if is_last_source {
1009            cur_len
1010        } else {
1011            cur_len.saturating_sub(1)
1012        };
1013
1014        let is_last_target = target_vis + 1 >= spans.len() || spans[target_vis + 1].0 != tgt_li;
1015        let tgt_max = if is_last_target {
1016            tgt_len
1017        } else {
1018            tgt_len.saturating_sub(1)
1019        };
1020
1021        // Decision table (matches pi)
1022        let has_pref = self.preferred_col.is_some();
1023        let cursor_in_middle = cur_vis_col < src_max;
1024        let target_too_short = tgt_max < cur_vis_col;
1025
1026        let move_to_col = if !has_pref || cursor_in_middle {
1027            if target_too_short {
1028                self.preferred_col = Some(cur_vis_col);
1029                tgt_max
1030            } else {
1031                self.preferred_col = None;
1032                cur_vis_col
1033            }
1034        } else {
1035            let pref = self.preferred_col.unwrap_or(0);
1036            let target_cant_fit_pref = tgt_max < pref;
1037            if target_too_short || target_cant_fit_pref {
1038                tgt_max
1039            } else {
1040                self.preferred_col = None;
1041                pref
1042            }
1043        };
1044
1045        self.cursor_line = tgt_li;
1046        let raw_col = tgt_start + move_to_col;
1047        let line = &self.lines[tgt_li].clone();
1048        self.cursor_col = raw_col.min(line.len());
1049        // Snapping uses `delta < 0` for moving-up context
1050        // (we don't have delta here, but snap-to-start is the safe choice
1051        //  since the marker boundary determination is same regardless of direction)
1052        // Actually, snap to start when moving up, end when moving down.
1053        // Infer direction from target_vis vs current_vis.
1054        let moving_up = target_vis < current_vis;
1055        self.cursor_col = Self::snap_paste_marker(line, self.cursor_col, moving_up);
1056    }
1057
1058    fn move_vertical(&mut self, delta: isize) {
1059        let width = self.last_width.get();
1060        let spans = self.build_visual_line_spans(width);
1061        let current_vis = self.find_current_visual_line(&spans);
1062
1063        let target_vis = if delta < 0 {
1064            if current_vis == 0 {
1065                return;
1066            }
1067            current_vis - 1
1068        } else if current_vis + 1 >= spans.len() {
1069            return;
1070        } else {
1071            current_vis + 1
1072        };
1073
1074        self.move_to_visual_line(&spans, current_vis, target_vis);
1075    }
1076
1077    // ── Character jump (pi-style) ──
1078
1079    /// Jump to the first occurrence of a character in the specified direction.
1080    /// Multi-line search (pi-style). Case-sensitive. Skips current cursor position.
1081    fn jump_to_char(&mut self, ch: char, dir: JumpDirection) {
1082        let is_forward = dir == JumpDirection::Forward;
1083        let lines = &self.lines;
1084
1085        let start_line = self.cursor_line as isize;
1086        let end = if is_forward { lines.len() as isize } else { -1 };
1087        let step: isize = if is_forward { 1 } else { -1 };
1088
1089        let mut line_idx = start_line;
1090        while line_idx != end {
1091            let line = &lines[line_idx as usize];
1092            let is_current = line_idx == start_line;
1093            let search_from = if is_current {
1094                if is_forward {
1095                    self.cursor_col + 1
1096                } else {
1097                    self.cursor_col.saturating_sub(1)
1098                }
1099            } else if is_forward {
1100                0
1101            } else {
1102                line.len()
1103            };
1104
1105            let idx = if is_forward {
1106                line[search_from..].find(ch).map(|i| search_from + i)
1107            } else if search_from > 0 {
1108                line[..search_from].rfind(ch)
1109            } else {
1110                None
1111            };
1112
1113            if let Some(pos) = idx {
1114                self.cursor_line = line_idx as usize;
1115                self.set_cursor_col(pos);
1116                return;
1117            }
1118            line_idx += step;
1119        }
1120        // No match - cursor stays
1121    }
1122
1123    // ── History ──
1124
1125    fn exit_history(&mut self) {
1126        self.history_index = -1;
1127        self.history_draft = None;
1128        self.last_action = None;
1129    }
1130
1131    fn recall_older(&mut self) {
1132        if self.history.is_empty() {
1133            return;
1134        }
1135        // Pi: newest at front (index 0), Up increases index (goes older)
1136        let idx = if self.history_index < 0 {
1137            0
1138        } else {
1139            self.history_index + 1
1140        };
1141        if idx >= self.history.len() as i32 {
1142            return; // already at oldest
1143        }
1144
1145        // Pi: save draft when first entering history browsing
1146        if self.history_index < 0 && idx >= 0 {
1147            self.history_draft = Some(EditorSnapshot {
1148                lines: self.lines.clone(),
1149                cursor_line: self.cursor_line,
1150                cursor_col: self.cursor_col,
1151            });
1152        }
1153
1154        let text = self.history[idx as usize].clone();
1155        self.set_text_internal(&text);
1156        self.cursor_col = 0; // pi: cursor at start when going older
1157        self.history_index = idx;
1158    }
1159
1160    fn recall_newer(&mut self) {
1161        if self.history_index < 0 {
1162            return;
1163        }
1164        // Pi: Down decreases index (goes newer). history_index > 0 means browsing older entries.
1165        let idx = self.history_index - 1;
1166        if idx < 0 {
1167            // Pi: restore draft instead of clearing to empty
1168            if let Some(draft) = self.history_draft.take() {
1169                self.lines = draft.lines;
1170                self.cursor_line = draft.cursor_line;
1171                self.cursor_col = draft.cursor_col;
1172                self.preferred_col = None;
1173            } else {
1174                self.set_text_internal("");
1175            }
1176            self.history_index = -1;
1177        } else {
1178            let text = self.history[idx as usize].clone();
1179            self.set_text_internal(&text);
1180            self.history_index = idx;
1181        }
1182    }
1183
1184    // ── Paste markers (pi-style) ──
1185
1186    /// CSI-u decode: terminals with extended keys (e.g. tmux popups with
1187    /// `extended-keys-format=csi-u`) re-encode control bytes inside bracketed
1188    /// paste as `\x1b[<codepoint>;5u`. Decode those back to the literal byte.
1189    fn decode_csi_u_in_paste(&self, text: &str) -> String {
1190        // Pattern: ESC [ digits ; 5 u  - Ctrl+<letter> encoded as CSI-u
1191        let re = regex::Regex::new(r"\x1b\[(\d+);5u").unwrap();
1192        re.replace_all(text, |caps: &regex::Captures| {
1193            let cp: u32 = caps[1].parse().unwrap_or(0);
1194            if (97..=122).contains(&cp) {
1195                // Ctrl+A..Ctrl+Z
1196                char::from_u32(cp - 96)
1197                    .map(|c| c.to_string())
1198                    .unwrap_or_default()
1199            } else if (65..=90).contains(&cp) {
1200                // Ctrl+Shift+A..Ctrl+Shift+Z
1201                char::from_u32(cp - 64)
1202                    .map(|c| c.to_string())
1203                    .unwrap_or_default()
1204            } else {
1205                caps[0].to_string()
1206            }
1207        })
1208        .to_string()
1209    }
1210
1211    // ── Paste marker atomic segment helpers (pi-style) ──
1212
1213    /// Find all paste marker spans `[paste #N ...]` in a line.
1214    /// Returns (start, end) byte positions.
1215    fn find_paste_marker_spans(line: &str) -> Vec<(usize, usize)> {
1216        let mut spans = Vec::new();
1217        let mut pos = 0;
1218        while let Some(start) = line[pos..].find("[paste #") {
1219            let abs_start = pos + start;
1220            if let Some(end) = line[abs_start..].find(']') {
1221                let abs_end = abs_start + end + 1;
1222                spans.push((abs_start, abs_end));
1223                pos = abs_end;
1224            } else {
1225                break;
1226            }
1227        }
1228        spans
1229    }
1230
1231    /// If cursor is inside a paste marker, snap to the nearest boundary:
1232    /// start of marker when moving left, end when moving right.
1233    fn snap_paste_marker(line: &str, cursor: usize, moving_left: bool) -> usize {
1234        for &(start, end) in &Self::find_paste_marker_spans(line) {
1235            if cursor > start && cursor < end {
1236                return if moving_left { start } else { end };
1237            }
1238        }
1239        cursor
1240    }
1241
1242    /// Handle a paste: normalizes line endings, filters non-printable chars,
1243    /// CSI-u decodes control bytes, and for large pastes (>10 lines or >1000 chars)
1244    /// stores the content with a marker like "\[paste #1 +123 lines\]".
1245    /// Matches pi's Editor.handlePaste().
1246    pub fn handle_paste(&mut self, text: &str) {
1247        self.clear_autocomplete();
1248        self.exit_history();
1249        self.last_action = None;
1250        self.push_undo();
1251
1252        // 1. CSI-u decode control bytes that tmux/etc may have re-encoded
1253        let decoded = self.decode_csi_u_in_paste(text);
1254
1255        // 2. Normalize line endings and tabs (same as insert_text_internal)
1256        let normalized = decoded
1257            .replace("\r\n", "\n")
1258            .replace('\r', "\n")
1259            .replace('\t', "    ");
1260
1261        // 3. Filter non-printable chars except newlines
1262        let filtered: String = normalized
1263            .chars()
1264            .filter(|&c| c == '\n' || c == ' ' || c as u32 >= 32)
1265            .collect();
1266
1267        // 4. If pasting a file path (starts with /, ~, or .) and char before
1268        //    cursor is a word char, prepend a space (pi-style)
1269        let current_line = self.lines[self.cursor_line].clone();
1270        let space_prefix = if filtered.starts_with('/')
1271            || filtered.starts_with('~')
1272            || filtered.starts_with('.')
1273        {
1274            if self.cursor_col > 0 {
1275                let prev = current_line
1276                    .as_bytes()
1277                    .get(self.cursor_col - 1)
1278                    .copied()
1279                    .unwrap_or(b' ');
1280                if prev.is_ascii_alphanumeric() || prev == b'_' {
1281                    " "
1282                } else {
1283                    ""
1284                }
1285            } else {
1286                ""
1287            }
1288        } else {
1289            ""
1290        };
1291        let prepared = format!("{}{}", space_prefix, filtered);
1292
1293        let total_chars = prepared.len();
1294        let is_large = prepared.lines().count().max(1) > 10 || total_chars > 1000;
1295
1296        if is_large {
1297            let line_count = prepared.lines().count();
1298            self.paste_counter += 1;
1299            let paste_id = self.paste_counter;
1300            self.pastes.insert(paste_id, prepared);
1301
1302            let marker = if line_count > 10 {
1303                format!("[paste #{} +{} lines]", paste_id, line_count)
1304            } else {
1305                format!("[paste #{} {} chars]", paste_id, total_chars)
1306            };
1307            self.insert_text_internal(&marker);
1308        } else {
1309            self.insert_text_internal(&prepared);
1310        }
1311    }
1312
1313    /// Expand paste markers in text back to their full content.
1314    pub fn expand_paste_markers(&self, text: &str) -> String {
1315        let mut result = text.to_string();
1316        // Replace markers from highest ID to lowest to avoid ID conflicts
1317        let mut ids: Vec<u32> = self.pastes.keys().copied().collect();
1318        ids.sort_unstable_by(|a, b| b.cmp(a)); // descending
1319        for paste_id in ids {
1320            if let Some(content) = self.pastes.get(&paste_id) {
1321                // Simple replacement - find any marker with this ID
1322                let marker1 = format!("[paste #{} ", paste_id);
1323                loop {
1324                    let start = result.find(&marker1);
1325                    match start {
1326                        Some(pos) => {
1327                            let end = result[pos..]
1328                                .find(']')
1329                                .map(|e| pos + e + 1)
1330                                .unwrap_or(result.len());
1331                            result.replace_range(pos..end, content);
1332                        }
1333                        None => break,
1334                    }
1335                }
1336            }
1337        }
1338        result
1339    }
1340
1341    /// Get text with paste markers expanded.
1342    /// Use this when you need the full content (e.g., for external editor).
1343    pub fn get_expanded_text(&self) -> String {
1344        self.expand_paste_markers(&self.lines.join("\n"))
1345    }
1346
1347    /// Check if a string is a paste marker.
1348    pub fn is_paste_marker(segment: &str) -> bool {
1349        segment.starts_with("[paste #") && segment.ends_with(']')
1350    }
1351
1352    // ── Page scroll ──
1353
1354    fn page_size(&self) -> usize {
1355        std::cmp::max(5, (self.terminal_rows as f64 * 0.3) as usize)
1356    }
1357
1358    fn page_up(&mut self) {
1359        let size = self.page_size();
1360        self.scroll_offset = self.scroll_offset.saturating_sub(size);
1361    }
1362
1363    fn page_down(&mut self) {
1364        let size = self.page_size();
1365        self.scroll_offset += size;
1366    }
1367
1368    // ── Submit ──
1369
1370    fn submit(&mut self) {
1371        // Pi: expand paste markers before submitting
1372        let raw = self.lines.join("\n");
1373        let result = self.expand_paste_markers(&raw);
1374        self.last_submitted_text = result.clone();
1375        self.lines = vec![String::new()];
1376        self.cursor_line = 0;
1377        self.cursor_col = 0;
1378        self.scroll_offset = 0;
1379        self.pastes.clear();
1380        self.paste_counter = 0;
1381        self.undo_stack.clear();
1382        self.last_action = None;
1383        self.preferred_col = None;
1384        self.just_submitted = true;
1385        self.exit_history();
1386        if let Some(ref mut cb) = self.on_submit {
1387            cb(result);
1388        }
1389        self.notify_change();
1390    }
1391
1392    // ── Helpers ──
1393
1394    fn notify_change(&mut self) {
1395        let text = self.get_text();
1396        if let Some(ref mut cb) = self.on_change {
1397            cb(&text);
1398        }
1399        // After any text change, update autocomplete if active (pi-style)
1400        if self.autocomplete_active {
1401            self.try_trigger_autocomplete();
1402        }
1403    }
1404
1405    fn is_empty(&self) -> bool {
1406        self.lines.is_empty() || (self.lines.len() == 1 && self.lines[0].is_empty())
1407    }
1408
1409    fn is_first_visual_line(&self) -> bool {
1410        let width = self.last_width.get();
1411        let visual_lines = layout_text(&self.lines, width, self.cursor_line, self.cursor_col);
1412        let current = visual_lines
1413            .iter()
1414            .position(|vl| vl.has_cursor)
1415            .unwrap_or(0);
1416        current == 0
1417    }
1418
1419    fn is_last_visual_line(&self) -> bool {
1420        let width = self.last_width.get();
1421        let visual_lines = layout_text(&self.lines, width, self.cursor_line, self.cursor_col);
1422        let current = visual_lines
1423            .iter()
1424            .position(|vl| vl.has_cursor)
1425            .unwrap_or(0);
1426        current >= visual_lines.len().saturating_sub(1)
1427    }
1428}
1429
1430// ── Component impl ─────────────────────────────────────────────────
1431
1432impl Component for Editor {
1433    fn render(&mut self, width: usize) -> Vec<String> {
1434        let max_padding = if width > 1 { (width - 1) / 2 } else { 0 };
1435        let pad_x = self.padding_x.min(max_padding);
1436        let content_width = if width > pad_x * 2 {
1437            width - pad_x * 2
1438        } else {
1439            1
1440        };
1441        // Pi: with padding, cursor can overflow into it; without, reserve 1 col for cursor.
1442        let layout_width = content_width
1443            .max(1)
1444            .saturating_sub(if pad_x > 0 { 0 } else { 1 });
1445        self.last_width.set(layout_width);
1446
1447        let horizontal = "─";
1448        let left_pad = " ".repeat(pad_x);
1449        let right_pad = " ".repeat(pad_x);
1450        let mut result: Vec<String> = Vec::new();
1451
1452        // ── Layout text into visual lines, tracking cursor ──
1453        let visual_lines =
1454            layout_text(&self.lines, layout_width, self.cursor_line, self.cursor_col);
1455        let total_visual = visual_lines.len().max(1);
1456
1457        // Find cursor visual line index
1458        let cursor_vis = visual_lines
1459            .iter()
1460            .position(|vl| vl.has_cursor)
1461            .unwrap_or(0);
1462
1463        // Adjust scroll to keep cursor visible
1464        // Pi: max visible lines is 30% of terminal height, minimum 5.
1465        let max_vis = std::cmp::max(5, (self.terminal_rows as f64 * 0.3) as usize).max(1);
1466        let mut scroll = self.scroll_offset;
1467        if cursor_vis < scroll {
1468            scroll = cursor_vis;
1469        } else if cursor_vis >= scroll + max_vis {
1470            scroll = cursor_vis - max_vis + 1;
1471        }
1472        let max_scroll = total_visual.saturating_sub(max_vis);
1473        scroll = scroll.min(max_scroll);
1474
1475        let visible_end = (scroll + max_vis).min(total_visual);
1476
1477        // ── Top border ──
1478        if scroll > 0 {
1479            let indicator = format!("─── ↑ {} more ", scroll);
1480            let indicator_w = visible_width(&indicator);
1481            let fill = if indicator_w < width {
1482                horizontal.repeat(width - indicator_w)
1483            } else {
1484                String::new()
1485            };
1486            result.push(self.border_color.apply(&format!("{}{}", indicator, fill)));
1487        } else {
1488            result.push(self.border_color.apply(&horizontal.repeat(width)));
1489        }
1490
1491        // ── Content lines ──
1492        for vl in visual_lines.iter().skip(scroll).take(visible_end - scroll) {
1493            let text = &vl.text;
1494            let (display, line_width) = if vl.has_cursor {
1495                let cursor_pos = vl.cursor_pos.unwrap_or(0);
1496                let before = &text[..cursor_pos.min(text.len())];
1497                let after = &text[cursor_pos.min(text.len())..];
1498
1499                let marker = if self.focused {
1500                    CURSOR_MARKER.to_string()
1501                } else {
1502                    String::new()
1503                };
1504
1505                if !after.is_empty() {
1506                    let after_graphemes: Vec<&str> = after.graphemes(true).collect();
1507                    let first_g = after_graphemes.first().copied().unwrap_or(" ");
1508                    let rest = &after[first_g.len()..];
1509                    let cursor = format!("\x1b[7m{}\x1b[0m", first_g);
1510                    (
1511                        format!("{}{}{}{}", before, marker, cursor, rest),
1512                        visible_width(text),
1513                    )
1514                } else if !before.is_empty() {
1515                    // Cursor at end: show a block cursor after the last character.
1516                    let cursor_block = "\x1b[7m \x1b[0m";
1517                    (
1518                        format!("{}{}{}", before, cursor_block, marker),
1519                        visible_width(text) + 1,
1520                    )
1521                } else {
1522                    // Empty text: show block cursor
1523                    let cursor = "\x1b[7m \x1b[0m";
1524                    (
1525                        format!("{}{}{}", before, marker, cursor),
1526                        visible_width(text) + 1,
1527                    )
1528                }
1529            } else {
1530                (text.clone(), visible_width(text))
1531            };
1532
1533            // Pi-style: cursor can overflow into right padding when at end of line
1534            let cursor_in_padding = line_width > content_width && pad_x > 0;
1535            let padding = if line_width < content_width {
1536                " ".repeat(content_width - line_width)
1537            } else {
1538                String::new()
1539            };
1540            let right_pad_used = if cursor_in_padding {
1541                &right_pad[1..]
1542            } else {
1543                &right_pad
1544            };
1545            result.push(format!(
1546                "{}{}{}{}",
1547                left_pad, display, padding, right_pad_used
1548            ));
1549        }
1550
1551        // ── Bottom border ──
1552        let below = total_visual.saturating_sub(visible_end);
1553        if below > 0 {
1554            let indicator = format!("─── ↓ {} more ", below);
1555            let indicator_w = visible_width(&indicator);
1556            let fill = if indicator_w < width {
1557                horizontal.repeat(width - indicator_w)
1558            } else {
1559                String::new()
1560            };
1561            result.push(self.border_color.apply(&format!("{}{}", indicator, fill)));
1562        } else {
1563            result.push(self.border_color.apply(&horizontal.repeat(width)));
1564        }
1565
1566        // ── Autocomplete dropdown (pi-style: renders SelectList below bottom border) ──
1567        if self.autocomplete_active
1568            && let Some(ref mut list) = self.autocomplete_list
1569        {
1570            let list_lines = list.render(width);
1571            result.extend(list_lines);
1572        }
1573
1574        result
1575    }
1576
1577    fn handle_input(&mut self, key: &KeyEvent) -> bool {
1578        let kb = get_keybindings();
1579
1580        // ── Character jump mode: await next printable char ──
1581        if let Some(dir) = self.jump_mode {
1582            // Cancel on jump hotkey again
1583            if kb.matches(key, ACTION_EDITOR_JUMP_FORWARD)
1584                || kb.matches(key, ACTION_EDITOR_JUMP_BACKWARD)
1585            {
1586                self.jump_mode = None;
1587                return true;
1588            }
1589            if is_printable_plain(key)
1590                && let Some(s) = key_event_to_string(key)
1591            {
1592                let ch = s.chars().next().unwrap_or(' ');
1593                self.jump_mode = None;
1594                self.jump_to_char(ch, dir);
1595                return true;
1596            }
1597            // Non-printable cancels jump mode
1598            self.jump_mode = None;
1599        }
1600
1601        // ── Autocomplete: route to SelectList (pi-style) ──
1602        // Pi behavior: only Escape dismisses, Enter/Tab confirms, Up/Down navigates.
1603        // All other keys (including printable chars and backspace) fall through
1604        // to the normal handler so the character is inserted/deleted first, then
1605        // autocomplete is re-queried via update_autocomplete().
1606        //
1607        // We capture state inside the borrow (list) and apply completion outside
1608        // to avoid borrow checker conflicts with self methods.
1609        let mut ac_selected: Option<String> = None;
1610        let mut ac_complete_and_return = false;
1611
1612        if let Some(ref mut list) = self.autocomplete_list {
1613            if kb.matches(key, ACTION_SELECT_CANCEL) {
1614                self.autocomplete_active = false;
1615                self.autocomplete_list = None;
1616                self.autocomplete_prefix.clear();
1617                return true;
1618            }
1619
1620            if kb.matches(key, ACTION_INPUT_TAB) {
1621                ac_selected = list.selected_item().map(|i| i.value.clone());
1622                ac_complete_and_return = true;
1623            } else if kb.matches(key, ACTION_SELECT_CONFIRM) {
1624                ac_selected = list.selected_item().map(|i| i.value.clone());
1625                let is_slash = self.autocomplete_prefix.starts_with('/');
1626                if !is_slash {
1627                    ac_complete_and_return = true;
1628                }
1629                // Slash command: apply completion and fall through to submit
1630            } else if kb.matches(key, ACTION_SELECT_UP) || kb.matches(key, ACTION_SELECT_DOWN) {
1631                list.handle_input(key);
1632                return true;
1633            }
1634            // For all other keys, fall through to normal handling.
1635            // autocomplete will be updated after the key is processed.
1636        }
1637
1638        // Handle deferred completion (outside the borrow on self.autocomplete_list)
1639        if let Some(val) = ac_selected {
1640            self.apply_autocomplete_completion_value(&val);
1641            self.clear_autocomplete();
1642            if ac_complete_and_return {
1643                return true;
1644            }
1645            // ac_complete_and_submit: fall through to submit handler below
1646        }
1647
1648        // ── Tab: trigger autocomplete via provider (pi-style) ──
1649        if kb.matches(key, ACTION_INPUT_TAB) && self.autocomplete_provider.is_some() {
1650            self.try_trigger_autocomplete_force();
1651            return true;
1652        }
1653
1654        // ── Enter / Submit ──
1655        if kb.matches(key, ACTION_INPUT_SUBMIT) {
1656            if self.disable_submit {
1657                self.add_newline();
1658                return true;
1659            }
1660            let line = &self.lines[self.cursor_line];
1661            if self.cursor_col > 0 && line.as_bytes().get(self.cursor_col - 1) == Some(&b'\\') {
1662                self.backspace();
1663                self.add_newline();
1664                return true;
1665            }
1666            self.submit();
1667            return true;
1668        }
1669
1670        // ── Character jump triggers ──
1671        if kb.matches(key, ACTION_EDITOR_JUMP_FORWARD) {
1672            self.jump_mode = Some(JumpDirection::Forward);
1673            return true;
1674        }
1675        if kb.matches(key, ACTION_EDITOR_JUMP_BACKWARD) {
1676            self.jump_mode = Some(JumpDirection::Backward);
1677            return true;
1678        }
1679
1680        // ── Printable character ──
1681        if is_printable_plain(key)
1682            && let Some(s) = key_event_to_string(key)
1683        {
1684            self.insert_character(&s);
1685            return true;
1686        }
1687
1688        // ── Basic movement ──
1689        if kb.matches(key, ACTION_EDITOR_CURSOR_LEFT) {
1690            self.move_left();
1691            self.update_autocomplete_if_active();
1692            return true;
1693        }
1694        if kb.matches(key, ACTION_EDITOR_CURSOR_RIGHT) {
1695            self.move_right();
1696            self.update_autocomplete_if_active();
1697            return true;
1698        }
1699        if kb.matches(key, ACTION_EDITOR_CURSOR_LINE_START) {
1700            self.move_to_line_start();
1701            self.update_autocomplete_if_active();
1702            return true;
1703        }
1704        if kb.matches(key, ACTION_EDITOR_CURSOR_LINE_END) {
1705            self.move_to_line_end();
1706            self.update_autocomplete_if_active();
1707            return true;
1708        }
1709
1710        // ── Up/Down with history ──
1711        if kb.matches(key, ACTION_EDITOR_CURSOR_UP) {
1712            if self.is_first_visual_line()
1713                && (self.is_empty() || self.history_index >= 0 || self.cursor_col == 0)
1714            {
1715                self.recall_older();
1716            } else if self.is_first_visual_line() {
1717                self.move_to_line_start();
1718            } else {
1719                self.move_up();
1720            }
1721            self.update_autocomplete_if_active();
1722            return true;
1723        }
1724        if kb.matches(key, ACTION_EDITOR_CURSOR_DOWN) {
1725            if self.history_index >= 0 && self.is_last_visual_line() {
1726                self.recall_newer();
1727            } else if self.is_last_visual_line() {
1728                self.move_to_line_end();
1729            } else {
1730                self.move_down();
1731            }
1732            self.update_autocomplete_if_active();
1733            return true;
1734        }
1735
1736        // ── Page scroll ──
1737        if kb.matches(key, ACTION_EDITOR_PAGE_UP) {
1738            self.page_up();
1739            self.update_autocomplete_if_active();
1740            return true;
1741        }
1742        if kb.matches(key, ACTION_EDITOR_PAGE_DOWN) {
1743            self.page_down();
1744            self.update_autocomplete_if_active();
1745            return true;
1746        }
1747
1748        // ── Word movement ──
1749        if kb.matches(key, ACTION_EDITOR_CURSOR_WORD_LEFT) {
1750            let line = &self.lines[self.cursor_line].clone();
1751            if self.cursor_col > 0 {
1752                let opts = WordNavigationOptions {
1753                    segment: None,
1754                    is_atomic_segment: Some(&|s: &str| {
1755                        s.starts_with("[paste #") && s.ends_with(']')
1756                    }),
1757                };
1758                let c = find_word_backward_with(line, self.cursor_col, &opts);
1759                self.set_cursor_col(c);
1760            }
1761            self.update_autocomplete_if_active();
1762            return true;
1763        }
1764        if kb.matches(key, ACTION_EDITOR_CURSOR_WORD_RIGHT) {
1765            let line = &self.lines[self.cursor_line].clone();
1766            if self.cursor_col < line.len() {
1767                let opts = WordNavigationOptions {
1768                    segment: None,
1769                    is_atomic_segment: Some(&|s: &str| {
1770                        s.starts_with("[paste #") && s.ends_with(']')
1771                    }),
1772                };
1773                let c = find_word_forward_with(line, self.cursor_col, &opts);
1774                self.set_cursor_col(c);
1775            }
1776            self.update_autocomplete_if_active();
1777            return true;
1778        }
1779
1780        // ── Deletion ──
1781        if kb.matches(key, ACTION_EDITOR_DELETE_CHAR_BACKWARD) {
1782            self.backspace();
1783            // notify_change handles autocomplete update
1784            return true;
1785        }
1786        if kb.matches(key, ACTION_EDITOR_DELETE_CHAR_FORWARD) {
1787            self.delete_forward();
1788            // notify_change handles autocomplete update
1789            return true;
1790        }
1791
1792        // ── Kill operations ──
1793        if kb.matches(key, ACTION_EDITOR_DELETE_WORD_BACKWARD) {
1794            self.delete_word_backward();
1795            // notify_change handles autocomplete update
1796            return true;
1797        }
1798        if kb.matches(key, ACTION_EDITOR_DELETE_WORD_FORWARD) {
1799            self.delete_word_forward();
1800            // notify_change handles autocomplete update
1801            return true;
1802        }
1803        if kb.matches(key, ACTION_EDITOR_DELETE_TO_LINE_START) {
1804            self.delete_to_line_start();
1805            // notify_change handles autocomplete update
1806            return true;
1807        }
1808        if kb.matches(key, ACTION_EDITOR_DELETE_TO_LINE_END) {
1809            self.delete_to_line_end();
1810            // notify_change handles autocomplete update
1811            return true;
1812        }
1813
1814        // ── Yank ──
1815        if kb.matches(key, ACTION_EDITOR_YANK) {
1816            self.yank();
1817            return true;
1818        }
1819        if kb.matches(key, ACTION_EDITOR_YANK_POP) {
1820            self.yank_pop();
1821            return true;
1822        }
1823
1824        // ── Undo ──
1825        if kb.matches(key, ACTION_EDITOR_UNDO) {
1826            self.last_action = None;
1827            self.undo();
1828            self.notify_change();
1829            return true;
1830        }
1831
1832        // ── Ctrl+J = newline ──
1833        if kb.matches(key, ACTION_INPUT_NEW_LINE) {
1834            self.add_newline();
1835            return true;
1836        }
1837
1838        // ── Escape - let parent handle ──
1839        if kb.matches(key, ACTION_SELECT_CANCEL) {
1840            return false;
1841        }
1842
1843        false
1844    }
1845
1846    fn handle_paste(&mut self, text: &str) {
1847        Editor::handle_paste(self, text);
1848    }
1849
1850    fn is_focusable(&self) -> bool {
1851        true
1852    }
1853}
1854
1855impl Focusable for Editor {
1856    fn set_focused(&mut self, focused: bool) {
1857        self.focused = focused;
1858    }
1859
1860    fn focused(&self) -> bool {
1861        self.focused
1862    }
1863}
1864
1865// ── Visual layout ──────────────────────────────────────────────────
1866
1867#[derive(Debug)]
1868struct VisualLine {
1869    text: String,
1870    has_cursor: bool,
1871    cursor_pos: Option<usize>,
1872}
1873
1874/// Layout text into visual lines, marking which line contains the cursor.
1875fn layout_text(
1876    lines: &[String],
1877    max_width: usize,
1878    cursor_line: usize,
1879    cursor_col: usize,
1880) -> Vec<VisualLine> {
1881    let mut result: Vec<VisualLine> = Vec::new();
1882
1883    if lines.is_empty() || (lines.len() == 1 && lines[0].is_empty()) {
1884        result.push(VisualLine {
1885            text: String::new(),
1886            has_cursor: true,
1887            cursor_pos: Some(0),
1888        });
1889        return result;
1890    }
1891
1892    let mut _col_offset = 0;
1893
1894    for (line_idx, line) in lines.iter().enumerate() {
1895        let is_cursor_line = line_idx == cursor_line;
1896        let line_w = visible_width(line);
1897        _col_offset = 0;
1898
1899        if line_w <= max_width {
1900            // Line fits entirely
1901            result.push(VisualLine {
1902                text: line.clone(),
1903                has_cursor: is_cursor_line,
1904                cursor_pos: if is_cursor_line {
1905                    Some(cursor_col.min(line.len()))
1906                } else {
1907                    None
1908                },
1909            });
1910        } else {
1911            // Word-wrap the line, tracking cursor position by visual column.
1912            // We cannot use byte-pos accumulation because wrap_text_with_ansi may
1913            // trim trailing whitespace or add ANSI codes, making chunk byte lengths
1914            // diverge from the original line's substrings.
1915            let wrapped = wrap_text_with_ansi(line, max_width);
1916
1917            // Compute cursor's visual column position in the original line.
1918            let cursor_vis = if is_cursor_line {
1919                visible_width(&line[..cursor_col.min(line.len())])
1920            } else {
1921                0
1922            };
1923
1924            let mut vis_offset: usize = 0;
1925            for (chunk_idx, chunk) in wrapped.iter().enumerate() {
1926                let chunk_vis = visible_width(chunk);
1927                let chunk_vis_end = vis_offset + chunk_vis;
1928
1929                let cursor_in_chunk = is_cursor_line
1930                    && cursor_vis >= vis_offset
1931                    && (cursor_vis < chunk_vis_end || chunk_idx == wrapped.len() - 1);
1932
1933                let cursor_pos = if cursor_in_chunk {
1934                    let local_vis = cursor_vis.saturating_sub(vis_offset);
1935                    // Convert visual offset within chunk to byte offset
1936                    Some(visual_col_to_byte_offset(chunk, local_vis))
1937                } else {
1938                    None
1939                };
1940
1941                result.push(VisualLine {
1942                    text: chunk.clone(),
1943                    has_cursor: cursor_in_chunk && cursor_pos.is_some(),
1944                    cursor_pos,
1945                });
1946
1947                vis_offset = chunk_vis_end;
1948            }
1949        }
1950    }
1951
1952    result
1953}
1954
1955fn is_printable_plain(key: &KeyEvent) -> bool {
1956    matches!(key.code, KeyCode::Char(_))
1957        && !key.modifiers.contains(KeyModifiers::CONTROL)
1958        && !key.modifiers.contains(KeyModifiers::ALT)
1959        && key.code != KeyCode::Enter
1960        && key.code != KeyCode::Tab
1961        && key.code != KeyCode::Backspace
1962        && key.code != KeyCode::Delete
1963        && key.code != KeyCode::Esc
1964}
1965
1966#[cfg(test)]
1967mod tests {
1968    use super::*;
1969    use crate::tui::autocomplete::{
1970        AutocompleteItem, AutocompleteProvider, AutocompleteSuggestions, SlashCommand,
1971    };
1972
1973    // ── Mock autocomplete provider for testing ──
1974
1975    struct MockSlashProvider {
1976        commands: Vec<SlashCommand>,
1977    }
1978
1979    impl MockSlashProvider {
1980        fn new(commands: Vec<&str>) -> Self {
1981            Self {
1982                commands: commands
1983                    .into_iter()
1984                    .map(|name| SlashCommand {
1985                        name: name.to_string(),
1986                        description: Some(format!("The {} command", name)),
1987                        argument_hint: None,
1988                        argument_completions: None,
1989                        get_argument_completions: None,
1990                    })
1991                    .collect(),
1992            }
1993        }
1994    }
1995
1996    impl AutocompleteProvider for MockSlashProvider {
1997        fn trigger_characters(&self) -> &[char] {
1998            &['/', '@', '#']
1999        }
2000
2001        fn get_suggestions(
2002            &self,
2003            lines: &[String],
2004            cursor_line: usize,
2005            cursor_col: usize,
2006            _force: bool,
2007        ) -> Option<AutocompleteSuggestions> {
2008            let line = lines.get(cursor_line)?;
2009            let before = &line[..cursor_col.min(line.len())];
2010
2011            // Slash command: text starts with / and has no space
2012            if before.starts_with('/') && !before.contains(' ') {
2013                let query = &before[1..].to_lowercase();
2014                let matching: Vec<AutocompleteItem> = self
2015                    .commands
2016                    .iter()
2017                    .filter(|cmd| cmd.name.to_lowercase().starts_with(query))
2018                    .map(|cmd| AutocompleteItem {
2019                        value: cmd.name.clone(),
2020                        label: format!("/{}", cmd.name),
2021                        description: cmd.description.clone(),
2022                    })
2023                    .collect();
2024                if matching.is_empty() {
2025                    return None;
2026                }
2027                return Some(AutocompleteSuggestions {
2028                    items: matching,
2029                    prefix: before.to_string(),
2030                });
2031            }
2032            None
2033        }
2034
2035        fn apply_completion(
2036            &self,
2037            lines: &[String],
2038            cursor_line: usize,
2039            cursor_col: usize,
2040            item: &AutocompleteItem,
2041            prefix: &str,
2042        ) -> (Vec<String>, usize, usize) {
2043            let current_line = lines[cursor_line].clone();
2044            let prefix_start = cursor_col.saturating_sub(prefix.len());
2045            let before = &current_line[..prefix_start];
2046            let after = &current_line[cursor_col..];
2047            (
2048                vec![format!("{}/{} {}", before, item.value, after)],
2049                cursor_line,
2050                before.len() + 1 + item.value.len() + 1,
2051            )
2052        }
2053
2054        fn should_trigger_file_completion(
2055            &self,
2056            lines: &[String],
2057            cursor_line: usize,
2058            cursor_col: usize,
2059        ) -> bool {
2060            let current_line = lines.get(cursor_line);
2061            match current_line {
2062                Some(text) => {
2063                    let before = &text[..cursor_col.min(text.len())];
2064                    if before.starts_with('/') && !before.contains(' ') {
2065                        return false;
2066                    }
2067                    true
2068                }
2069                None => false,
2070            }
2071        }
2072    }
2073
2074    // ── Autocomplete tests ──
2075
2076    fn make_editor_with_slash_provider(commands: Vec<&str>) -> Editor {
2077        let mut editor = Editor::new(EditorOptions::default());
2078        let provider = Box::new(MockSlashProvider::new(commands));
2079        editor.set_autocomplete_provider(provider);
2080        editor
2081    }
2082
2083    #[test]
2084    fn autocomplete_triggers_on_slash() {
2085        let mut editor = make_editor_with_slash_provider(vec!["help", "history"]);
2086        editor.handle_input(&char_key('/'));
2087        assert!(
2088            editor.autocomplete_active,
2089            "autocomplete should activate after typing /"
2090        );
2091        let selected = editor.autocomplete_selected_value();
2092        assert_eq!(
2093            selected.as_deref(),
2094            Some("help"),
2095            "first item should be help"
2096        );
2097    }
2098
2099    #[test]
2100    fn autocomplete_filters_as_user_types() {
2101        let mut editor = make_editor_with_slash_provider(vec!["help", "history", "model"]);
2102        // Type /
2103        editor.handle_input(&char_key('/'));
2104        assert!(editor.autocomplete_active);
2105
2106        // Type 'h' - should filter to help, history
2107        editor.handle_input(&char_key('h'));
2108        assert!(
2109            editor.autocomplete_active,
2110            "autocomplete should stay active after typing more letters"
2111        );
2112        // Should still have items (no flicker on footer)
2113
2114        // Type 'e' - should filter to help only
2115        editor.handle_input(&char_key('e'));
2116        assert!(editor.autocomplete_active);
2117        let selected = editor.autocomplete_selected_value();
2118        assert_eq!(selected.as_deref(), Some("help"));
2119    }
2120
2121    #[test]
2122    fn autocomplete_stays_active_on_printable_chars() {
2123        // Regression: typing a letter should NOT dismiss autocomplete first
2124        let mut editor = make_editor_with_slash_provider(vec!["help", "history"]);
2125        editor.handle_input(&char_key('/'));
2126        assert!(editor.autocomplete_active);
2127
2128        editor.handle_input(&char_key('h'));
2129        assert!(
2130            editor.autocomplete_active,
2131            "typing 'h' after '/' must keep autocomplete visible"
2132        );
2133
2134        editor.handle_input(&char_key('e'));
2135        assert!(
2136            editor.autocomplete_active,
2137            "typing 'e' after '/h' must keep autocomplete visible"
2138        );
2139
2140        let lines = editor.render(80);
2141        // Should have at least 3 border lines + some suggestion lines
2142        assert!(lines.len() > 3, "autocomplete lines should be rendered");
2143    }
2144
2145    #[test]
2146    fn escape_dismisses_autocomplete() {
2147        let mut editor = make_editor_with_slash_provider(vec!["help", "history"]);
2148        editor.handle_input(&char_key('/'));
2149        assert!(editor.autocomplete_active);
2150
2151        editor.handle_input(&escape());
2152        assert!(
2153            !editor.autocomplete_active,
2154            "escape should dismiss autocomplete"
2155        );
2156
2157        // Text should remain (Escape only dismisses autocomplete, not clear text)
2158        assert_eq!(editor.get_text(), "/");
2159    }
2160
2161    #[test]
2162    fn backspace_removing_slash_dismisses_autocomplete() {
2163        let mut editor = make_editor_with_slash_provider(vec!["help", "history"]);
2164        editor.handle_input(&char_key('/'));
2165        assert!(editor.autocomplete_active, "after /");
2166
2167        editor.handle_input(&backspace());
2168        assert!(
2169            !editor.autocomplete_active,
2170            "backspace removing / should dismiss autocomplete"
2171        );
2172        assert_eq!(editor.get_text(), "", "text should be empty");
2173    }
2174
2175    #[test]
2176    fn autocomplete_updates_after_backspace_char() {
2177        let mut editor = make_editor_with_slash_provider(vec!["help", "history"]);
2178        // Type /he
2179        editor.handle_input(&char_key('/'));
2180        editor.handle_input(&char_key('h'));
2181        editor.handle_input(&char_key('e'));
2182        assert!(editor.autocomplete_active);
2183        let val1 = editor.autocomplete_selected_value();
2184        assert_eq!(val1.as_deref(), Some("help"));
2185
2186        // Backspace the 'e' - should re-filter to show help, history
2187        editor.handle_input(&backspace());
2188        assert!(
2189            editor.autocomplete_active,
2190            "backspace should re-filter, not dismiss"
2191        );
2192        // Should now have 2 matching items (help, history)
2193        assert!(!editor.autocomplete_is_empty());
2194        assert_eq!(editor.get_text(), "/h");
2195    }
2196
2197    #[test]
2198    fn autocomplete_updates_on_cursor_movement() {
2199        let mut editor = make_editor_with_slash_provider(vec!["help", "history"]);
2200        // Type /help (autocomplete shows)
2201        editor.handle_input(&char_key('/'));
2202        editor.handle_input(&char_key('h'));
2203        editor.handle_input(&char_key('e'));
2204        editor.handle_input(&char_key('l'));
2205        editor.handle_input(&char_key('p'));
2206        assert!(editor.autocomplete_active);
2207
2208        // Now type a space after /help - autocomplete should dismiss because
2209        // the context changes (/command with space = file completion, not slash)
2210        editor.handle_input(&char_key(' '));
2211        assert!(
2212            !editor.autocomplete_active,
2213            "space after /cmd should dismiss slash autocomplete"
2214        );
2215
2216        // Move cursor left back into /help - should re-trigger autocomplete via update_autocomplete_if_active
2217        editor.handle_input(&left_key());
2218        // Actually, moving left won't trigger autocomplete since the provider doesn't
2219        // re-trigger from cursor movement alone when autocomplete was dismissed.
2220        // The key change is that when autocomplete IS active, cursor movement updates it.
2221    }
2222
2223    #[test]
2224    fn autocomplete_clears_when_provider_returns_none() {
2225        // Provider returns None for unknown commands, which should clear autocomplete
2226        let mut editor = make_editor_with_slash_provider(vec!["help"]);
2227        editor.handle_input(&char_key('/'));
2228        assert!(editor.autocomplete_active);
2229
2230        // Type 'z' - no command starts with /z, provider returns None
2231        editor.handle_input(&char_key('z'));
2232        assert!(
2233            !editor.autocomplete_active,
2234            "typing /z with no matching command should dismiss autocomplete"
2235        );
2236    }
2237
2238    #[test]
2239    fn autocomplete_does_not_interfere_with_normal_typing() {
2240        // Without a slash prefix, autocomplete should not trigger
2241        let mut editor = make_editor_with_slash_provider(vec!["help", "history"]);
2242        editor.handle_input(&char_key('h'));
2243        editor.handle_input(&char_key('e'));
2244        editor.handle_input(&char_key('l'));
2245        editor.handle_input(&char_key('l'));
2246        editor.handle_input(&char_key('o'));
2247        assert!(!editor.autocomplete_active, "no slash = no autocomplete");
2248        assert_eq!(editor.get_text(), "hello");
2249    }
2250
2251    #[test]
2252    fn autocomplete_renders_lines_below_editor() {
2253        let mut editor = make_editor_with_slash_provider(vec!["help", "history", "model"]);
2254        editor.handle_input(&char_key('/'));
2255        assert!(editor.autocomplete_active);
2256
2257        let lines = editor.render(80);
2258        // Lines should include: top border, content (/), bottom border, autocomplete items
2259        assert!(
2260            lines.len() >= 5,
2261            "should have border lines + autocomplete items"
2262        );
2263        // Bottom border should be present
2264        assert!(lines[2].contains('─'), "line 2 should be bottom border");
2265        // Autocomplete items should follow
2266        let after_border = &lines[3..];
2267        let all_have_content = after_border.iter().any(|l| !l.trim().is_empty());
2268        assert!(all_have_content, "autocomplete lines should have content");
2269    }
2270
2271    #[test]
2272    fn autocomplete_stable_rendering_no_flash_on_extra_char() {
2273        // Verify that typing an extra character doesn't change the total
2274        // line count drastically (no dismiss + re-show bounce).
2275        let mut editor = make_editor_with_slash_provider(vec!["help", "history", "model"]);
2276        editor.handle_input(&char_key('/'));
2277        let lines_after_slash = editor.render(80).len();
2278
2279        editor.handle_input(&char_key('h'));
2280        let lines_after_h = editor.render(80).len();
2281
2282        // Both renders should have autocomplete, so line counts should be similar
2283        // (items may differ: 3 vs 2, so at most 1 line difference)
2284        let diff = lines_after_slash.abs_diff(lines_after_h);
2285        assert!(
2286            diff <= 1,
2287            "line count should not change dramatically: {} -> {} (diff {})",
2288            lines_after_slash,
2289            lines_after_h,
2290            diff
2291        );
2292    }
2293
2294    #[test]
2295    fn autocomplete_dismissed_on_submit() {
2296        let mut editor = make_editor_with_slash_provider(vec!["help"]);
2297        editor.handle_input(&char_key('/'));
2298        assert!(editor.autocomplete_active);
2299
2300        // Submit (Enter) - should apply completion or dismiss
2301        editor.handle_input(&enter_key());
2302        // After submit, autocomplete is cleared
2303    }
2304
2305    #[test]
2306    fn tab_force_triggers_autocomplete() {
2307        let mut editor = make_editor_with_slash_provider(vec!["help", "history"]);
2308        // Type nothing - Tab should trigger file completion (not slash)
2309        // Type / and then Tab
2310        editor.handle_input(&char_key('/'));
2311        // insert_character should have triggered autocomplete already
2312        assert!(editor.autocomplete_active);
2313    }
2314
2315    #[test]
2316    fn autocomplete_persists_across_multiple_chars() {
2317        // Real-world flow: type /help and see autocomplete stay visible throughout
2318        let mut editor = make_editor_with_slash_provider(vec!["help", "history", "hello", "heavy"]);
2319
2320        for ch in "/hel".chars() {
2321            editor.handle_input(&char_key(ch));
2322            assert!(
2323                editor.autocomplete_active,
2324                "autocomplete should stay active after '{}'",
2325                ch
2326            );
2327        }
2328
2329        // Should show items starting with /hel
2330        assert!(
2331            !editor.autocomplete_is_empty(),
2332            "should have matching items"
2333        );
2334        assert_eq!(editor.get_text(), "/hel");
2335    }
2336
2337    #[test]
2338    fn test_new_editor() {
2339        let editor = Editor::new(EditorOptions::default());
2340        assert_eq!(editor.get_text(), "");
2341    }
2342
2343    #[test]
2344    fn test_set_text() {
2345        let mut editor = Editor::new(EditorOptions::default());
2346        editor.set_text("hello world");
2347        assert_eq!(editor.get_text(), "hello world");
2348    }
2349
2350    #[test]
2351    fn test_insert_and_move() {
2352        let mut editor = Editor::new(EditorOptions::default());
2353        editor.insert_character("h");
2354        editor.insert_character("i");
2355        assert_eq!(editor.get_text(), "hi");
2356        editor.move_left();
2357        assert_eq!(editor.cursor_col, 1);
2358        editor.move_right();
2359        assert_eq!(editor.cursor_col, 2);
2360    }
2361
2362    #[test]
2363    fn test_backspace() {
2364        let mut editor = Editor::new(EditorOptions::default());
2365        editor.set_text("hello");
2366        editor.backspace();
2367        assert_eq!(editor.get_text(), "hell");
2368    }
2369
2370    #[test]
2371    fn test_multiline() {
2372        let mut editor = Editor::new(EditorOptions::default());
2373        editor.set_text("line1\nline2");
2374        assert_eq!(editor.get_lines().len(), 2);
2375    }
2376
2377    #[test]
2378    fn test_undo() {
2379        let mut editor = Editor::new(EditorOptions::default());
2380        editor.push_undo();
2381        editor.insert_text_internal("a");
2382        editor.push_undo();
2383        editor.insert_text_internal("b");
2384        assert_eq!(editor.get_text(), "ab");
2385        editor.undo();
2386        assert_eq!(editor.get_text(), "a");
2387        editor.undo();
2388        assert_eq!(editor.get_text(), "");
2389    }
2390
2391    #[test]
2392    fn test_submit_clears() {
2393        let mut editor = Editor::new(EditorOptions::default());
2394        editor.set_text("hello");
2395        let result = editor.lines.join("\n");
2396        editor.lines = vec![String::new()];
2397        editor.cursor_line = 0;
2398        editor.cursor_col = 0;
2399        assert_eq!(result, "hello");
2400        assert_eq!(editor.get_text(), "");
2401    }
2402
2403    #[test]
2404    fn test_render_borders() {
2405        let mut editor = Editor::new(EditorOptions::default());
2406        let lines = editor.render(80);
2407        assert!(lines.len() >= 3);
2408        assert!(lines[0].contains('─'));
2409        assert!(lines.last().unwrap().contains('─'));
2410    }
2411
2412    #[test]
2413    fn test_scroll_indicator() {
2414        let mut editor = Editor::new(EditorOptions { padding_x: 1 });
2415        // Set terminal_rows=6 → max_vis = max(5, 1) = 5.
2416        // With 6 content lines and cursor at the bottom, scroll offset of 2
2417        // should produce an up-arrow indicator at the top.
2418        editor.set_terminal_rows(6);
2419        editor.set_text("line1\nline2\nline3\nline4\nline5\nline6");
2420        editor.cursor_line = 5;
2421        editor.cursor_col = 5;
2422        editor.scroll_offset = 2;
2423        let lines = editor.render(80);
2424        assert!(
2425            lines[0].contains("↑"),
2426            "Expected scroll-up indicator, got: {:?}",
2427            lines[0]
2428        );
2429    }
2430
2431    #[test]
2432    fn test_newline() {
2433        let mut editor = Editor::new(EditorOptions::default());
2434        editor.set_text("hello");
2435        editor.add_newline();
2436        assert_eq!(editor.get_text(), "hello\n");
2437        editor.insert_character("w");
2438        assert_eq!(editor.get_text(), "hello\nw");
2439    }
2440
2441    #[test]
2442    fn test_cursor_in_layout() {
2443        let editor = Editor::new(EditorOptions::default());
2444        // Empty editor - cursor should be in visual line 0
2445        let vl = layout_text(&editor.lines, 80, editor.cursor_line, editor.cursor_col);
2446        assert!(vl[0].has_cursor);
2447        assert_eq!(vl[0].cursor_pos, Some(0));
2448    }
2449
2450    #[test]
2451    fn test_cursor_in_layout_with_text() {
2452        let mut editor = Editor::new(EditorOptions::default());
2453        editor.set_text("abc");
2454        editor.cursor_col = 1;
2455        let vl = layout_text(&editor.lines, 80, editor.cursor_line, editor.cursor_col);
2456        assert!(vl[0].has_cursor);
2457        assert_eq!(vl[0].cursor_pos, Some(1));
2458    }
2459
2460    // ── Ported from pi-tui editor.test.ts ──
2461
2462    fn up_key() -> KeyEvent {
2463        KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)
2464    }
2465    fn left_key() -> KeyEvent {
2466        KeyEvent::new(KeyCode::Left, KeyModifiers::NONE)
2467    }
2468    fn char_key(c: char) -> KeyEvent {
2469        KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE)
2470    }
2471    fn enter_key() -> KeyEvent {
2472        KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)
2473    }
2474    fn escape() -> KeyEvent {
2475        KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)
2476    }
2477    fn backspace() -> KeyEvent {
2478        KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)
2479    }
2480
2481    #[test]
2482    fn test_history_empty_up_does_nothing() {
2483        let mut editor = Editor::new(EditorOptions::default());
2484        editor.handle_input(&up_key());
2485        assert_eq!(editor.get_text(), "");
2486    }
2487
2488    #[test]
2489    fn test_history_up_shows_most_recent() {
2490        let mut editor = Editor::new(EditorOptions::default());
2491        editor.add_to_history("first");
2492        editor.add_to_history("second");
2493        editor.handle_input(&up_key());
2494        assert_eq!(editor.get_text(), "second");
2495    }
2496
2497    #[test]
2498    fn test_history_cycles() {
2499        let mut editor = Editor::new(EditorOptions::default());
2500        editor.add_to_history("first");
2501        editor.add_to_history("second");
2502        editor.add_to_history("third");
2503        editor.handle_input(&up_key());
2504        assert_eq!(editor.get_text(), "third");
2505        editor.handle_input(&up_key());
2506        assert_eq!(editor.get_text(), "second");
2507        editor.handle_input(&up_key());
2508        assert_eq!(editor.get_text(), "first");
2509        editor.handle_input(&up_key()); // stays at oldest
2510        assert_eq!(editor.get_text(), "first");
2511    }
2512
2513    #[test]
2514    fn test_history_exits_on_type() {
2515        let mut editor = Editor::new(EditorOptions::default());
2516        editor.add_to_history("old");
2517        editor.handle_input(&up_key());
2518        assert_eq!(editor.get_text(), "old");
2519        editor.handle_input(&char_key('x'));
2520        assert_eq!(editor.get_text(), "xold");
2521    }
2522
2523    #[test]
2524    fn test_backslash_enter_newline() {
2525        let mut editor = Editor::new(EditorOptions::default());
2526        editor.handle_input(&char_key('\\'));
2527        assert_eq!(editor.get_text(), "\\");
2528        editor.handle_input(&enter_key());
2529        assert_eq!(editor.get_text(), "\n");
2530    }
2531
2532    #[test]
2533    fn test_move_cursor_over_emoji() {
2534        let mut editor = Editor::new(EditorOptions::default());
2535        editor.set_text("a😀b");
2536        editor.cursor_col = 0;
2537        editor.move_right();
2538        assert_eq!(editor.cursor_col, 1);
2539        editor.move_right();
2540        assert_eq!(editor.cursor_col, 5);
2541        editor.move_right();
2542        assert_eq!(editor.cursor_col, 6);
2543    }
2544
2545    #[test]
2546    fn test_backspace_emoji() {
2547        let mut editor = Editor::new(EditorOptions::default());
2548        editor.set_text("a😀b");
2549        editor.cursor_col = 6;
2550        editor.backspace();
2551        assert_eq!(editor.get_text(), "a😀");
2552        editor.backspace();
2553        assert_eq!(editor.get_text(), "a");
2554    }
2555
2556    #[test]
2557    fn test_render_cursor_visible() {
2558        let mut editor = Editor::new(EditorOptions::default());
2559        editor.focused = true;
2560        editor.insert_character("x");
2561        let lines = editor.render(40);
2562        let content = &lines[1];
2563        assert!(content.contains("\x1b[7m"), "Cursor inverse not found");
2564    }
2565
2566    #[test]
2567    fn test_emits_cursor_marker_when_focused() {
2568        let mut editor = Editor::new(EditorOptions::default());
2569        editor.focused = true;
2570        editor.insert_character("hello");
2571        let lines = editor.render(40);
2572        let content = &lines[1];
2573        assert!(
2574            content.contains(CURSOR_MARKER),
2575            "Focused editor should emit cursor marker"
2576        );
2577    }
2578
2579    #[test]
2580    fn test_no_cursor_marker_when_not_focused() {
2581        let mut editor = Editor::new(EditorOptions::default());
2582        editor.focused = false;
2583        editor.insert_character("hello");
2584        let lines = editor.render(40);
2585        let content = &lines[1];
2586        assert!(
2587            !content.contains(CURSOR_MARKER),
2588            "Unfocused editor should not emit cursor marker"
2589        );
2590    }
2591
2592    #[test]
2593    fn test_render_borders_always_present() {
2594        let mut editor = Editor::new(EditorOptions::default());
2595        let lines = editor.render(80);
2596        assert_eq!(lines.len(), 3, "Empty editor should have 3 lines");
2597        assert!(lines[0].contains('─'), "Top border missing");
2598        assert!(lines[2].contains('─'), "Bottom border missing");
2599
2600        editor.insert_character("/");
2601        let lines = editor.render(80);
2602        assert_eq!(lines.len(), 3, "After typing / should still have 3 lines");
2603        assert!(lines[0].contains('─'), "Top border missing after /");
2604        assert!(lines[2].contains('─'), "Bottom border missing after /");
2605
2606        editor.set_text("hello world this is text");
2607        let lines = editor.render(40);
2608        assert!(lines.len() >= 3, "Wrapped text: {}", lines.len());
2609        assert!(lines[0].contains('─'), "Top border");
2610        assert!(lines.last().unwrap().contains('─'), "Bottom border");
2611    }
2612
2613    #[test]
2614    fn test_content_width_respected() {
2615        let mut editor = Editor::new(EditorOptions { padding_x: 1 });
2616        editor.set_text("hello world this is a test");
2617        let lines = editor.render(20);
2618        for line in &lines {
2619            let vw = crate::tui::util::visible_width(line);
2620            assert!(vw <= 20, "Width {} > 20: {:?}", vw, line);
2621        }
2622    }
2623
2624    // ── Wrap/duplication tests ───────────────────────────────────
2625
2626    #[test]
2627    fn test_no_duplicate_chunks_from_wrapping() {
2628        // wrap_text_with_ansi must not produce duplicate chunks from a single input.
2629        // The same content may appear in multiple chunks if the source has it
2630        // multiple times, but it must not appear MORE times than in the original.
2631        let texts = [
2632            "hello world this is a test of the wrapping system",
2633            "a b c d e f g h i j k l m n o p q r s t u v w x y z",
2634            "short",
2635            "",
2636            "abc abc abc abc abc abc abc abc",
2637            "  leading and trailing spaces  ",
2638            "hello   world   extra   spaces",
2639        ];
2640        for text in &texts {
2641            for width in [1, 2, 3, 5, 8, 12, 20, 40] {
2642                let wrapped = crate::tui::util::wrap_text_with_ansi(text, width);
2643
2644                // Total visible content of wrapped chunks must not exceed original
2645                let total_vis_wrapped: usize = wrapped.iter().map(|c| visible_width(c)).sum();
2646                let total_vis_original = visible_width(text);
2647                assert!(
2648                    total_vis_wrapped <= total_vis_original,
2649                    "Width={}: wrapped visible {} > original visible {} for {:?}",
2650                    width,
2651                    total_vis_wrapped,
2652                    total_vis_original,
2653                    text
2654                );
2655
2656                // No non-empty chunk should appear more times than it occurs
2657                // as a substring in the original text.
2658                for a in &wrapped {
2659                    if a.is_empty() {
2660                        continue;
2661                    }
2662                    let count_in_wrapped = wrapped.iter().filter(|c| *c == a).count();
2663                    let count_in_original = text.matches(a.as_str()).count();
2664                    assert!(
2665                        count_in_wrapped <= count_in_original || count_in_original == 0,
2666                        "Width={}: chunk '{}' appears {}x in wrapped but {}x in original for {:?}",
2667                        width,
2668                        a,
2669                        count_in_wrapped,
2670                        count_in_original,
2671                        text
2672                    );
2673                }
2674            }
2675        }
2676    }
2677
2678    #[test]
2679    fn test_cursor_in_wrapped_text_first_chunk() {
2680        // Cursor at position within first wrapped chunk
2681        let mut editor = Editor::new(EditorOptions::default());
2682        let text = "hello world this is a test";
2683        editor.set_text(text);
2684        // cursor at position 3 ('l' in "hello")
2685        editor.cursor_col = 3;
2686        let vl = layout_text(&editor.lines, 10, editor.cursor_line, editor.cursor_col);
2687        assert!(vl.len() > 1, "Text should wrap into multiple visual lines");
2688        assert!(
2689            vl[0].has_cursor,
2690            "Cursor at col 3 should be in first visual line"
2691        );
2692        if let Some(pos) = vl[0].cursor_pos {
2693            assert_eq!(pos, 3, "Cursor byte offset in first chunk should be 3");
2694        }
2695    }
2696
2697    #[test]
2698    fn test_cursor_in_wrapped_text_middle_chunk() {
2699        // Cursor at position within the middle chunk
2700        let mut editor = Editor::new(EditorOptions::default());
2701        let text = "hello world this is a test";
2702        editor.set_text(text);
2703        // "hello world this" = 16 chars, cursor at col 16 = end of "hello world this"
2704        // which should be the last byte of chunk 0 ("hello worl" at width 10)
2705        // Actually at width 10, chunk 0 might be "hello worl", chunk 1 "d this is", chunk 2 " a test"
2706        editor.cursor_col = 16;
2707        let vl = layout_text(&editor.lines, 10, editor.cursor_line, editor.cursor_col);
2708        assert!(vl.len() > 1, "Text should wrap");
2709        let cursor_vl = vl.iter().position(|v| v.has_cursor);
2710        assert!(
2711            cursor_vl.is_some(),
2712            "Cursor should be found in some visual line"
2713        );
2714    }
2715
2716    #[test]
2717    fn test_cursor_last_chunk_on_boundary() {
2718        // Cursor at last byte of text - should be in the last visual line
2719        let mut editor = Editor::new(EditorOptions::default());
2720        let text = "hello world this is a test";
2721        editor.set_text(text);
2722        editor.cursor_col = text.len();
2723        let vl = layout_text(&editor.lines, 10, editor.cursor_line, editor.cursor_col);
2724        assert!(
2725            vl.last().is_some_and(|v| v.has_cursor),
2726            "Cursor at end should be in last visual line"
2727        );
2728    }
2729
2730    #[test]
2731    fn test_layout_text_each_chunk_unique() {
2732        // layout_text should never produce VisualLines with identical text
2733        // from a single logical line's wrapping.
2734        let text = "hello world this is a test of the wrapping system";
2735        let vl = layout_text(&[text.to_string()], 12, 0, 0);
2736        let chunk_texts: Vec<&str> = vl.iter().map(|v| v.text.as_str()).collect();
2737        for i in 0..chunk_texts.len() {
2738            for j in (i + 1)..chunk_texts.len() {
2739                if chunk_texts[i] == chunk_texts[j] {
2740                    // Same text is OK if the text is empty (edge case)
2741                    if !chunk_texts[i].is_empty() {
2742                        panic!(
2743                            "Duplicate chunk text at positions {} and {}: '{}'",
2744                            i, j, chunk_texts[i]
2745                        );
2746                    }
2747                }
2748            }
2749        }
2750    }
2751
2752    // ── visual_col_to_byte_offset tests ──────────────────────────
2753
2754    #[test]
2755    fn test_visual_col_to_byte_offset_ascii() {
2756        let text = "hello";
2757        assert_eq!(crate::tui::util::visual_col_to_byte_offset(text, 0), 0);
2758        assert_eq!(crate::tui::util::visual_col_to_byte_offset(text, 3), 3);
2759        assert_eq!(crate::tui::util::visual_col_to_byte_offset(text, 5), 5);
2760    }
2761
2762    #[test]
2763    fn test_visual_col_to_byte_offset_cjk() {
2764        let text = "世界hello";
2765        assert_eq!(crate::tui::util::visual_col_to_byte_offset(text, 0), 0);
2766        // "世" is width 2, 2 bytes
2767        assert_eq!(crate::tui::util::visual_col_to_byte_offset(text, 2), 3);
2768        // "世界" is width 4, 6 bytes
2769        assert_eq!(crate::tui::util::visual_col_to_byte_offset(text, 4), 6);
2770    }
2771
2772    #[test]
2773    fn test_visual_col_to_byte_offset_ansi() {
2774        // "\x1b[31m" = 5 bytes, "hello" = 5 bytes, "\x1b[0m" = 4 bytes = 14 total
2775        let text = "\x1b[31mhello\x1b[0m";
2776        // visible width is 5 ("hello"), ANSI codes are invisible
2777        assert_eq!(crate::tui::util::visual_col_to_byte_offset(text, 0), 5); // "h" at byte 5
2778        assert_eq!(crate::tui::util::visual_col_to_byte_offset(text, 1), 6); // "e" at byte 6
2779        assert_eq!(crate::tui::util::visual_col_to_byte_offset(text, 2), 7); // first "l" at byte 7
2780        assert_eq!(crate::tui::util::visual_col_to_byte_offset(text, 3), 8); // second "l" at byte 8
2781        assert_eq!(crate::tui::util::visual_col_to_byte_offset(text, 4), 9); // "o" at byte 9
2782        assert_eq!(crate::tui::util::visual_col_to_byte_offset(text, 5), 14); // end at byte 14
2783    }
2784
2785    #[test]
2786    fn test_visual_col_to_byte_offset_empty() {
2787        assert_eq!(crate::tui::util::visual_col_to_byte_offset("", 0), 0);
2788        assert_eq!(crate::tui::util::visual_col_to_byte_offset("", 5), 0);
2789    }
2790
2791    #[test]
2792    fn test_visual_col_to_byte_offset_zero_col() {
2793        // Plain ASCII: first visible char is at byte 0
2794        assert_eq!(crate::tui::util::visual_col_to_byte_offset("abc", 0), 0);
2795        // ANSI-prefixed: first visible char is after the ANSI code
2796        assert_eq!(
2797            crate::tui::util::visual_col_to_byte_offset("\x1b[31mabc", 0),
2798            5
2799        );
2800    }
2801
2802    // ── Paste marker tests ──
2803
2804    #[test]
2805    fn test_large_paste_creates_marker() {
2806        let mut editor = Editor::new(EditorOptions::default());
2807        let large = "line1\nline2\nline3\nline4\nline5\nline6\nline7\nline8\nline9\nline10\nline11";
2808        editor.handle_paste(large);
2809        let text = editor.get_text();
2810        assert!(text.contains("[paste #"), "Should contain paste marker");
2811        assert!(
2812            !text.contains("line1"),
2813            "Should not contain original content"
2814        );
2815        assert_eq!(editor.pastes.len(), 1, "Should store one paste");
2816    }
2817
2818    #[test]
2819    fn test_small_paste_no_marker() {
2820        let mut editor = Editor::new(EditorOptions::default());
2821        editor.handle_paste("hello");
2822        let text = editor.get_text();
2823        assert!(
2824            !text.contains("[paste #"),
2825            "Small paste should not create marker"
2826        );
2827        assert_eq!(text, "hello");
2828    }
2829
2830    #[test]
2831    fn test_expand_paste_markers() {
2832        let mut editor = Editor::new(EditorOptions::default());
2833        editor.handle_paste(
2834            "line1\nline2\nline3\nline4\nline5\nline6\nline7\nline8\nline9\nline10\nline11",
2835        );
2836        let expanded = editor.get_expanded_text();
2837        assert!(
2838            expanded.contains("line1"),
2839            "Expanded text should contain original content"
2840        );
2841        assert!(
2842            !expanded.contains("[paste #"),
2843            "Expanded text should not contain markers"
2844        );
2845    }
2846
2847    #[test]
2848    fn test_submit_expands_markers() {
2849        let mut editor = Editor::new(EditorOptions::default());
2850        editor.handle_paste(
2851            "line1\nline2\nline3\nline4\nline5\nline6\nline7\nline8\nline9\nline10\nline11",
2852        );
2853        let large_content =
2854            "line1\nline2\nline3\nline4\nline5\nline6\nline7\nline8\nline9\nline10\nline11";
2855        // Manually call the submit logic to verify expansion
2856        let raw = editor.lines.join("\n");
2857        let expanded = editor.expand_paste_markers(&raw);
2858        assert_eq!(
2859            expanded, large_content,
2860            "Submit should expand to original content"
2861        );
2862    }
2863
2864    #[test]
2865    fn test_is_paste_marker() {
2866        assert!(Editor::is_paste_marker("[paste #1 +5 lines]"));
2867        assert!(Editor::is_paste_marker("[paste #123 456 chars]"));
2868        assert!(!Editor::is_paste_marker("normal text"));
2869        assert!(!Editor::is_paste_marker(""));
2870    }
2871
2872    #[test]
2873    fn test_get_expanded_text() {
2874        let mut editor = Editor::new(EditorOptions::default());
2875        editor.handle_paste(
2876            "line1\nline2\nline3\nline4\nline5\nline6\nline7\nline8\nline9\nline10\nline11",
2877        );
2878        let expanded = editor.get_expanded_text();
2879        assert!(
2880            expanded.contains("line1"),
2881            "get_expanded_text should expand markers"
2882        );
2883        assert!(
2884            expanded.starts_with("line1"),
2885            "Should start with original content"
2886        );
2887    }
2888
2889    // ── Render duplication tests ──
2890
2891    #[test]
2892    fn test_multiline_render_no_duplicate_content() {
2893        let mut editor = Editor::new(EditorOptions::default());
2894        // Simulate: type "hello", add newline, type "world"
2895        editor.set_text("hello");
2896        editor.add_newline();
2897        editor.insert_character("w");
2898        editor.insert_character("o");
2899        editor.insert_character("r");
2900        editor.insert_character("l");
2901        editor.insert_character("d");
2902        assert_eq!(editor.get_text(), "hello\nworld");
2903
2904        // Render at various widths
2905        for width in [20, 40, 80] {
2906            let rendered = editor.render(width);
2907
2908            // Collect content lines (skip border lines)
2909            let content_lines: Vec<&str> = rendered
2910                .iter()
2911                .filter(|l| !l.contains('─'))
2912                .map(|l| l.trim())
2913                .collect();
2914
2915            // Check total content lines count matches expected (2: "hello" + "world")
2916            assert!(
2917                content_lines.len() >= 2,
2918                "Width {}: expected >= 2 content lines, got {}: {:?}",
2919                width,
2920                content_lines.len(),
2921                rendered
2922            );
2923
2924            // Check no duplicates among non-empty content lines
2925            let mut seen = std::collections::HashSet::new();
2926            for line in &content_lines {
2927                if !line.is_empty() {
2928                    let plain = line.replace("\x1b_pi:c\x07", "").to_string();
2929                    if !seen.insert(plain.clone()) {
2930                        panic!(
2931                            "Width {}: duplicate content line '{}' in {:?}",
2932                            width, line, rendered
2933                        );
2934                    }
2935                }
2936            }
2937        }
2938    }
2939
2940    #[test]
2941    fn test_editor_add_newline_adds_one_visual_line() {
2942        let mut editor = Editor::new(EditorOptions::default());
2943        editor.set_text("hello");
2944
2945        let before = editor.render(80).len();
2946        editor.add_newline();
2947        let after = editor.render(80).len();
2948
2949        assert_eq!(
2950            after,
2951            before + 1,
2952            "Adding newline should increase rendered line count by exactly 1. before={}, after={}",
2953            before,
2954            after
2955        );
2956    }
2957
2958    #[test]
2959    fn test_layout_text_no_extra_empty_visual_line() {
2960        // layout_text should not produce an extra empty visual line
2961        // when transitioning from empty to single-line content.
2962        let lines: Vec<String> = vec![String::new()];
2963        let vl = layout_text(&lines, 80, 0, 0);
2964        assert_eq!(vl.len(), 1, "Empty text should have 1 visual line");
2965        assert!(vl[0].has_cursor);
2966
2967        let lines = vec!["hello".to_string()];
2968        let vl = layout_text(&lines, 80, 0, 5);
2969        assert_eq!(vl.len(), 1, "Single line should have 1 visual line");
2970        assert!(vl[0].has_cursor);
2971
2972        let lines = vec!["hello".to_string(), "".to_string()];
2973        let vl = layout_text(&lines, 80, 0, 5);
2974        assert_eq!(
2975            vl.len(),
2976            2,
2977            "Two lines (one empty) should have 2 visual lines"
2978        );
2979        // Cursor is on line 0 ("hello"), so first visual line has cursor
2980        assert!(vl[0].has_cursor);
2981        assert!(!vl[1].has_cursor);
2982
2983        let lines = vec!["hello".to_string(), "".to_string()];
2984        let vl = layout_text(&lines, 80, 1, 0);
2985        assert_eq!(vl.len(), 2);
2986        // Cursor is on line 1 (empty), so second visual line has cursor
2987        assert!(!vl[0].has_cursor);
2988        assert!(vl[1].has_cursor);
2989
2990        let lines = vec!["".to_string(), "hello".to_string()];
2991        let vl = layout_text(&lines, 80, 1, 5);
2992        assert_eq!(
2993            vl.len(),
2994            2,
2995            "Two lines (one empty first) should have 2 visual lines"
2996        );
2997        assert!(!vl[0].has_cursor);
2998        assert!(vl[1].has_cursor);
2999    }
3000
3001    #[test]
3002    fn test_wrap_edge_cases_no_empty_lines() {
3003        // Various edge cases that should NOT produce empty lines.
3004        // Empty strings in wrapped output cause visual artifacts
3005        // (blank lines appearing in the editor).
3006        let cases = vec![
3007            ("  hello", 3, "leading spaces"),
3008            ("hello  ", 3, "trailing spaces"),
3009            ("  hello  ", 3, "leading and trailing spaces"),
3010            ("abc  def", 5, "double space in middle"),
3011            ("a   b", 4, "triple space"),
3012            ("a  b", 3, "double space at wrap boundary"),
3013        ];
3014        for (text, width, label) in &cases {
3015            // Debug: print the actual tokens produced by split_into_tokens
3016            // to verify our understanding of tokenization.
3017            let wrapped = crate::tui::util::wrap_text_with_ansi(text, *width);
3018            for chunk in &wrapped {
3019                // A non-empty input should never produce empty chunks
3020                if chunk.is_empty() {
3021                    panic!(
3022                        "Case '{}' (width {}): empty chunk found in wrapped: {:?}",
3023                        label, width, wrapped
3024                    );
3025                }
3026                let vis = crate::tui::util::visible_width(chunk);
3027                assert!(
3028                    vis > 0,
3029                    "Case '{}' (width {}): chunk with visible width 0: {:?} (wrapped: {:?})",
3030                    label,
3031                    width,
3032                    chunk,
3033                    wrapped
3034                );
3035            }
3036        }
3037    }
3038
3039    #[test]
3040    fn test_wrap_long_word_no_duplicate_chunks() {
3041        // A long continuous word (no spaces) past width should not duplicate
3042        let long = "aaaaa bbbbb ccccc ddddd";
3043        for width in [5, 6, 7, 8, 10, 12] {
3044            let wrapped = crate::tui::util::wrap_text_with_ansi(long, width);
3045            // Count visible content and check for duplicates
3046            let mut seen = std::collections::HashSet::new();
3047            for chunk in &wrapped {
3048                let trimmed = chunk.trim();
3049                if !trimmed.is_empty() && !seen.insert(trimmed.to_string()) {
3050                    panic!(
3051                        "Width {}: duplicate chunk '{}' in {:?}",
3052                        width, chunk, wrapped
3053                    );
3054                }
3055            }
3056        }
3057    }
3058
3059    #[test]
3060    fn test_wrap_typing_detailed_trace() {
3061        // Simulate typing character by character into the editor,
3062        // checking the visual line layout after each character.
3063        let mut editor = Editor::new(EditorOptions::default());
3064
3065        // Type a sentence that exceeds width 10, checking after each char
3066        let sentence = "hello world";
3067        let width = 10;
3068
3069        for (i, ch) in sentence.chars().enumerate() {
3070            editor.handle_input(&char_key(ch));
3071
3072            // Get visual lines via layout_text (simulating what render does)
3073            let vl = layout_text(&editor.lines, width, editor.cursor_line, editor.cursor_col);
3074
3075            // Check no duplicate visual lines or empty lines
3076            let mut seen = std::collections::HashSet::new();
3077            for vis in &vl {
3078                let trimmed = vis.text.trim();
3079                if !trimmed.is_empty() && !seen.insert(trimmed.to_string()) {
3080                    panic!(
3081                        "After char '{}' (pos {}): duplicate visual line '{}' in {:?}",
3082                        ch, i, vis.text, vl
3083                    );
3084                }
3085            }
3086
3087            // Check exactly one cursor
3088            let cursor_count = vl.iter().filter(|v| v.has_cursor).count();
3089            assert_eq!(
3090                cursor_count, 1,
3091                "After char '{}' (pos {}): expected exactly 1 cursor, got {}. vl: {:?}",
3092                ch, i, cursor_count, vl
3093            );
3094        }
3095    }
3096
3097    #[test]
3098    fn test_wrap_long_continuous_string_no_duplicates() {
3099        // A very long continuous string (like a URL or path) with no spaces.
3100        // Must not produce duplicate chunks when word-broken across lines.
3101        let mut editor = Editor::new(EditorOptions::default());
3102
3103        // Simulate typing a long URL character by character
3104        let url = "https://very-long-url-with-no-spaces.example.com/path/to/resource";
3105        for ch in url.chars() {
3106            editor.handle_input(&char_key(ch));
3107        }
3108
3109        // Test at various narrow widths
3110        for width in [5, 10, 15, 20, 30] {
3111            let rendered = editor.render(width);
3112            let content: Vec<&str> = rendered
3113                .iter()
3114                .filter(|l| !l.contains('─'))
3115                .map(|l| l.trim())
3116                .filter(|l| !l.is_empty())
3117                .collect();
3118
3119            let mut seen = std::collections::HashSet::new();
3120            for line in &content {
3121                let plain = line
3122                    .replace("\x1b_pi:c\x07", "")
3123                    .chars()
3124                    .filter(|&c| c.is_ascii_graphic() || c == ' ')
3125                    .collect::<String>()
3126                    .trim()
3127                    .to_string();
3128                if !plain.is_empty() && !seen.insert(plain.clone()) {
3129                    panic!(
3130                        "Width {}: duplicate content line '{}' (plain: '{}')\nFull render: {:?}",
3131                        width, line, plain, rendered
3132                    );
3133                }
3134            }
3135        }
3136    }
3137
3138    #[test]
3139    fn test_editor_typing_past_width_no_duplicate_render() {
3140        // Simulate typing characters one at a time until the line exceeds the width.
3141        // The rendered output must never show the same content line twice.
3142        let mut editor = Editor::new(EditorOptions::default());
3143
3144        // Type characters to build up a line longer than the render width
3145        let input = "hello world this is a test of the emergency broadcast system";
3146        for ch in input.chars() {
3147            editor.handle_input(&char_key(ch));
3148        }
3149
3150        // Render at a narrow width so wrapping occurs
3151        for width in [5, 8, 10, 12, 15, 20] {
3152            let rendered = editor.render(width);
3153
3154            // Collect visible content (skip border/scroll indicator lines)
3155            let content: Vec<&str> = rendered
3156                .iter()
3157                .filter(|l| !l.contains('─'))
3158                .map(|l| l.trim())
3159                .filter(|l| !l.is_empty())
3160                .collect();
3161
3162            // Check for duplicates among content lines
3163            let mut seen = std::collections::HashSet::new();
3164            for line in &content {
3165                // Strip cursor marker and ansi codes for comparison
3166                let plain = line
3167                    .replace("\x1b_pi:c\x07", "")
3168                    .chars()
3169                    .filter(|&c| c.is_ascii_graphic() || c == ' ')
3170                    .collect::<String>()
3171                    .trim()
3172                    .to_string();
3173                if !plain.is_empty() && !seen.insert(plain.clone()) {
3174                    panic!(
3175                        "Width {}: duplicate content line '{}' (plain: '{}')\nFull render: {:?}",
3176                        width, line, plain, rendered
3177                    );
3178                }
3179            }
3180
3181            // Also check that the total content roughly matches input (accounting for wrapping)
3182            let content_plain: String = content.join(" ");
3183            let content_plain = content_plain
3184                .replace("\x1b_pi:c\x07", "")
3185                .chars()
3186                .filter(|&c| c.is_ascii_graphic() || c == ' ')
3187                .collect::<String>();
3188            assert!(
3189                !content_plain.is_empty(),
3190                "Width {}: no visible content in render: {:?}",
3191                width,
3192                rendered
3193            );
3194        }
3195    }
3196}