Skip to main content

oo_ide/views/
editor.rs

1//! Editor view — full-screen buffer editor.
2//!
3//! Layout:
4//!   ┌──────────────────────────────────────────────────────────────────┐
5//!   │  1 ⌄   fn main() {                                               │
6//!   │  2   │     let x = 1;                                            │
7//!   │  3   │ }                                                         │
8//!   │ src/main.rs [+]                                       1:1  Rust  │
9//!   └──────────────────────────────────────────────────────────────────┘
10//!
11
12use std::collections::{HashMap, HashSet};
13
14use ratatui::{
15    Frame,
16    layout::{Constraint, Direction, Layout, Rect},
17    style::{Color, Style},
18    text::{Line, Span},
19    widgets::{Block, Borders, Clear, Paragraph},
20};
21
22use crate::prelude::*;
23
24use editor::{
25    buffer::Buffer, position::Position, fold::FoldState, highlight::{StyledSpan, SyntaxHighlighter}, selection::Selection
26};
27use input::{Key, KeyEvent, Modifiers, MouseButton, MouseEvent, MouseEventKind};
28use interraction::hit_test;
29use operation::{ClipOp, Event, GoToLineOp, LspCompletionItem, LspOp, MatchSpan, Operation, SearchOp, SelectionOp};
30use registers::Registers;
31use settings::Settings;
32use views::View;
33use widgets::{completion::CompletionWidget, editor::{EditorWidget, GutterMarker, gutter_width}, hover::HoverWidget};
34use widgets::focusable::FocusOp;
35use widgets::input_field::InputField;
36
37// ---------------------------------------------------------------------------
38// LSP overlay state
39// ---------------------------------------------------------------------------
40
41#[derive(Debug)]
42pub struct CompletionState {
43    pub items: Vec<LspCompletionItem>,
44    pub cursor: usize,
45    /// Buffer cursor position at which completion was triggered.
46    /// Used to derive the typed filter by slicing the current buffer.
47    pub trigger_cursor: Position,
48}
49
50#[derive(Debug)]
51pub struct HoverState {
52    pub text: String,
53}
54
55// ---------------------------------------------------------------------------
56// Search state
57// ---------------------------------------------------------------------------
58
59/// State for the go-to-line input bar.
60#[derive(Debug)]
61pub struct GoToLineState {
62    pub input: InputField,
63}
64
65impl Default for GoToLineState {
66    fn default() -> Self {
67        Self::new()
68    }
69}
70
71impl GoToLineState {
72    pub fn new() -> Self {
73        Self {
74            input: InputField::new("Line"),
75        }
76    }
77
78    /// Parse the current input as a 1-based line number, returning a 0-based index.
79    pub fn line_number(&self) -> Option<usize> {
80        self.input.text().trim().parse::<usize>().ok().and_then(|n| n.checked_sub(1))
81    }
82}
83
84#[derive(Debug, Clone, Copy, PartialEq)]
85pub enum SearchKind {
86    Find,
87    Replace,
88}
89
90#[derive(Debug, Clone, Copy, PartialEq, Default)]
91pub enum SearchMode {
92    #[default]
93    Inline,
94    Expanded,
95}
96
97const SEARCH_FOCUS_QUERY: &str = "search_query";
98const SEARCH_FOCUS_REPLACEMENT: &str = "search_replacement";
99const SEARCH_FOCUS_INCLUDE: &str = "search_include";
100const SEARCH_FOCUS_EXCLUDE: &str = "search_exclude";
101/// File-list panel has keyboard focus (Expanded mode).
102const SEARCH_FOCUS_FILES: &str = "search_files";
103/// Match-results panel has keyboard focus (Expanded mode).
104const SEARCH_FOCUS_MATCHES: &str = "search_matches";
105
106#[derive(Debug, Clone)]
107pub struct SearchOptions {
108    pub ignore_case: bool,
109    pub regex: bool,
110    pub smart_case: bool,
111    /// Glob pattern for files to include (default `"*"` = all files).
112    pub include_glob: String,
113    /// Glob pattern for files to exclude (default `""` = exclude nothing).
114    pub exclude_glob: String,
115}
116
117impl Default for SearchOptions {
118    fn default() -> Self {
119        Self {
120            ignore_case: false,
121            regex: false,
122            smart_case: false,
123            include_glob: String::from("*"),
124            exclude_glob: String::new(),
125        }
126    }
127}
128
129#[derive(Debug)]
130pub struct FileMatch {
131    pub path: std::path::PathBuf,
132    pub matches: Vec<MatchSpan>,
133}
134
135#[derive(Debug)]
136pub struct SearchState {
137    pub query: InputField,
138    pub replacement: InputField,
139    pub kind: SearchKind,
140    pub mode: SearchMode,
141    pub focus: crate::widgets::focus::FocusRing,
142    pub opts: SearchOptions,
143    /// (row, byte_start, byte_end) for every match in the buffer.
144    pub matches: Vec<(usize, usize, usize)>,
145    /// Index of the "current" (highlighted) match.
146    pub current: usize,
147    // Project-wide file-grouped results (unused by inline search)
148    pub files: Vec<FileMatch>,
149    pub selected_file: usize,
150    /// Scroll offset of the file-list panel in Expanded mode.
151    pub file_panel_scroll: usize,
152    /// Scroll offset of the match-results panel in Expanded mode.
153    pub match_panel_scroll: usize,
154    /// Include-glob filter input (Expanded mode only).
155    pub include_filter: InputField,
156    /// Exclude-glob filter input (Expanded mode only).
157    pub exclude_filter: InputField,
158}
159
160fn find_matches(lines: &[String], query: &str, opts: &SearchOptions) -> Vec<(usize, usize, usize)> {
161    if query.is_empty() {
162        return vec![];
163    }
164    let ignore = opts.ignore_case || (opts.smart_case && !query.chars().any(|c| c.is_uppercase()));
165
166    let mut out = Vec::new();
167    if opts.regex {
168        if let Ok(re) = regex::RegexBuilder::new(query)
169            .case_insensitive(ignore)
170            .build()
171        {
172            for (row, line) in lines.iter().enumerate() {
173                for m in re.find_iter(line) {
174                    out.push((row, m.start(), m.end()));
175                }
176            }
177        }
178    } else {
179        let q = if ignore {
180            query.to_lowercase()
181        } else {
182            query.to_owned()
183        };
184        for (row, line) in lines.iter().enumerate() {
185            let l = if ignore {
186                line.to_lowercase()
187            } else {
188                line.clone()
189            };
190            let mut start = 0;
191            while let Some(pos) = l[start..].find(&q) {
192                let abs = start + pos;
193                out.push((row, abs, abs + q.len()));
194                start = abs + 1;
195            }
196        }
197    }
198    out
199}
200
201// ---------------------------------------------------------------------------
202// Clipboard history picker state
203// ---------------------------------------------------------------------------
204
205#[derive(Debug)]
206pub(crate) struct ClipboardPickerState {
207    pub cursor: usize,
208}
209
210// ---------------------------------------------------------------------------
211// Mouse interaction state
212// ---------------------------------------------------------------------------
213
214/// Tracks double-click detection and drag state for the editor.
215#[derive(Debug, Default)]
216struct ClickState {
217    /// Screen column of the last Left-Down event.
218    last_col: u16,
219    /// Screen row of the last Left-Down event.
220    last_row: u16,
221    /// Time of the last Left-Down event.
222    last_time: Option<std::time::Instant>,
223    /// Consecutive click count at the same position (1 = single, 2 = double).
224    count: u8,
225    /// Whether the left button is currently held (drag in progress).
226    dragging: bool,
227    /// True if the current drag started with a double-click (word-drag mode).
228    word_drag: bool,
229}
230
231/// Maximum gap between two clicks to count as a double-click.
232const DOUBLE_CLICK_MS: u128 = 400;
233
234// ---------------------------------------------------------------------------
235// EditorView
236// ---------------------------------------------------------------------------
237
238#[derive(Debug)]
239pub struct EditorView {
240    pub buffer: Buffer,
241    pub folds: FoldState,
242    pub highlighter: SyntaxHighlighter,
243    pub gutter_markers: Vec<GutterMarker>,
244    pub status_msg: Option<String>,
245    // LSP
246    pub completion: Option<CompletionState>,
247    pub hover: Option<HoverState>,
248    /// Incremented on every buffer change; sent with textDocument/didChange.
249    pub lsp_version: i32,
250    /// Secondary ops produced by completion-confirm that the view needs to
251    /// emit but cannot return from handle_operation.  Drained by app.rs.
252    pub deferred_ops: Vec<Operation>,
253    /// Active search/replace state; `None` when bar is closed.
254    pub search: Option<SearchState>,
255    /// Last committed search; kept alive after the bar closes so F3 still works.
256    pub last_search: Option<SearchState>,
257    /// Active go-to-line bar state; `None` when closed.
258    pub go_to_line: Option<GoToLineState>,
259    /// Clipboard history picker state; `None` when closed.
260    /// Managed by `apply_clipboard_op` in `app.rs` (needs register access).
261    pub(crate) clipboard_picker: Option<ClipboardPickerState>,
262    /// When true, long lines are soft-wrapped instead of clipped horizontally.
263    pub word_wrap: bool,
264    /// When true, line numbers are shown in the gutter.
265    pub show_line_numbers: bool,
266    /// When true, use spaces for indentation; otherwise use tabs.
267    pub use_space: bool,
268    /// Number of spaces (or tab width) for indentation.
269    pub indentation_width: usize,
270    /// Fully computed highlight cache keyed by line number.
271    /// Lines are never erased on edits — only updated when recomputed.
272    pub highlight_cache: HashMap<usize, Vec<StyledSpan>>,
273
274    /// Lines currently being computed in background (avoid redundant tasks).
275    pub pending_highlight_lines: HashSet<usize>,
276
277    /// Lines whose cached spans are stale (content changed) but are kept in
278    /// `highlight_cache` for flicker-free rendering until fresh results arrive.
279    pub stale_highlight_lines: HashSet<usize>,
280
281    /// Handle for the in-flight background highlight task.
282    pub highlight_task: Option<tokio::task::JoinHandle<()>>,
283
284    /// Generation counter for highlight tasks. Incremented on each edit
285    /// to detect and discard stale completions.
286    pub highlight_generation: u64,
287    /// Body area from the last render pass — used to map mouse coordinates to
288    /// buffer positions in `handle_mouse`.
289    pub(crate) last_body_area: Rect,
290    /// Search bar area from the last render pass — used to map mouse clicks to buttons.
291    pub(crate) last_search_bar_area: Rect,
292    /// File-list panel area from the last render pass (Expanded mode only).
293    pub(crate) last_file_panel_area: Rect,
294    /// Match-results panel area from the last render pass (Expanded mode only).
295    pub(crate) last_match_panel_area: Rect,
296    /// Click/drag state for mouse interaction.  Uses interior mutability because
297    /// `handle_mouse` takes `&self`.
298    click_state: std::cell::RefCell<ClickState>,
299    /// Message shown when external modification is detected.
300    pub external_conflict_msg: Option<String>,
301    /// Detected language of the open file (e.g. `"rust"`, `"python"`).
302    pub lang_id: Option<crate::language::LanguageId>,
303}
304
305impl EditorView {
306    pub fn open(buffer: Buffer, folds: FoldState, settings: &crate::settings::Settings) -> Self {
307        use crate::settings::adapters;
308        let highlighter = buffer
309            .path
310            .as_deref()
311            .map(SyntaxHighlighter::for_path)
312            .unwrap_or_else(SyntaxHighlighter::plain);
313        let lang_id = buffer
314            .path
315            .as_deref()
316            .and_then(crate::language::detect_language);
317        Self {
318            buffer,
319            folds,
320            highlighter,
321            gutter_markers: Vec::new(),
322            status_msg: None,
323            completion: None,
324            hover: None,
325            lsp_version: 1,
326            deferred_ops: Vec::new(),
327            search: None,
328            last_search: None,
329            go_to_line: None,
330            clipboard_picker: None,
331            word_wrap: *adapters::editor::word_wrap(settings),
332            show_line_numbers: *adapters::editor::show_line_numbers(settings),
333            use_space: *adapters::editor::use_space(settings),
334            indentation_width: *adapters::editor::indentation_width(settings) as usize,
335            highlight_cache: HashMap::new(),
336            pending_highlight_lines: HashSet::new(),
337            stale_highlight_lines: HashSet::new(),
338            highlight_task: None,
339            highlight_generation: 0,
340            last_body_area: Rect::default(),
341            last_search_bar_area: Rect::default(),
342            last_file_panel_area: Rect::default(),
343            last_match_panel_area: Rect::default(),
344            click_state: std::cell::RefCell::new(ClickState::default()),
345            external_conflict_msg: None,
346            lang_id,
347        }
348    }
349
350    pub fn into_state(self) -> (Buffer, FoldState) {
351        (self.buffer, self.folds)
352    }
353
354    /// Drain any secondary ops produced during the last handle_operation call.
355    pub fn take_deferred_ops(&mut self) -> Vec<Operation> {
356        std::mem::take(&mut self.deferred_ops)
357    }
358
359    pub fn start_file_watching(&mut self) {
360        if !self.buffer.is_dirty() {
361            self.buffer.compute_and_store_file_hash();
362        }
363    }
364
365    pub fn stop_file_watching(&mut self) {
366    }
367
368    pub fn check_external_modification_for_paths(&mut self, changed_paths: &[std::path::PathBuf]) -> bool {
369        let path = match &self.buffer.path {
370            Some(p) => p.clone(),
371            None => return false,
372        };
373        for changed_path in changed_paths {
374            if changed_path == &path
375                && self.buffer.check_external_modification() {
376                    let filename = path.file_name()
377                        .map(|n| n.to_string_lossy().to_string())
378                        .unwrap_or_else(|| path.to_string_lossy().to_string());
379                    self.external_conflict_msg = Some(
380                        format!("{} modified externally. Press Ctrl+Shift+R to reload or Ctrl+S to save.", filename),
381                    );
382                    return true;
383                }
384        }
385        false
386    }
387
388    pub fn reload_from_disk(&mut self) -> Result<()> {
389        self.buffer.reload_from_disk()?;
390        self.external_conflict_msg = None;
391        self.buffer.compute_and_store_file_hash();
392        self.invalidate_all_highlights();
393        Ok(())
394    }
395
396    /// Invalidate highlights for all lines at and after `from_line`, preserving
397    /// earlier cached spans so they remain visible without flashing.
398    /// Lines at and after `from_line` are kept in the cache as **stale** so they
399    /// continue rendering with their old colors until the worker delivers fresh results.
400    pub(crate) fn invalidate_highlights_from(&mut self, from_line: usize) {
401        // Abort any running task — it may be computing lines that are now stale.
402        if let Some(task) = self.highlight_task.take() {
403            task.abort();
404        }
405        self.highlight_generation = self.highlight_generation.wrapping_add(1);
406        self.highlighter.invalidate_from(from_line);
407        // Mark cached lines >= from_line as stale (keep them for rendering).
408        for &k in self.highlight_cache.keys() {
409            if k >= from_line {
410                self.stale_highlight_lines.insert(k);
411            }
412        }
413        // Cancel any pending requests for stale lines — they'll be re-queued next frame.
414        self.pending_highlight_lines.retain(|&k| k < from_line);
415    }
416
417    /// Full highlight cache reset (use for undo/redo or whole-file changes).
418    pub(crate) fn invalidate_all_highlights(&mut self) {
419        if let Some(task) = self.highlight_task.take() {
420            task.abort();
421        }
422        self.highlight_generation = self.highlight_generation.wrapping_add(1);
423        self.highlighter.clear_cache();
424        self.highlight_cache.clear();
425        self.pending_highlight_lines.clear();
426        self.stale_highlight_lines.clear();
427    }
428
429    pub fn clear_external_modification(&mut self) {
430        self.external_conflict_msg = None;
431        self.buffer.compute_and_store_file_hash();
432    }
433
434    pub(crate) fn path_matches(&self, path: &Option<std::path::PathBuf>) -> bool {
435        match (path, &self.buffer.path) {
436            (None, _) => true,
437            (Some(p), Some(bp)) => p == bp,
438            _ => false,
439        }
440    }
441
442    // -----------------------------------------------------------------------
443    // Rendering helpers
444    // -----------------------------------------------------------------------
445
446    pub(crate) fn recompute_search_matches(&mut self) {
447        if let Some(s) = &mut self.search {
448            s.matches = find_matches(&self.buffer.lines(), s.query.text(), &s.opts);
449            s.current = s.current.min(s.matches.len().saturating_sub(1));
450        }
451        if let Some(s) = &mut self.last_search {
452            s.matches = find_matches(&self.buffer.lines(), s.query.text(), &s.opts);
453            s.current = s.current.min(s.matches.len().saturating_sub(1));
454        }
455    }
456
457    fn render_body(&mut self, frame: &mut Frame, area: Rect) {
458        let total_lines = self.buffer.lines().len();
459        let gutter_w = gutter_width(self.show_line_numbers, total_lines);
460        let content_w = (area.width as usize).saturating_sub(gutter_w);
461
462        if self.word_wrap {
463            self.buffer.scroll_x = 0;
464            self.buffer
465                .scroll_to_cursor_visual(area.height as usize, content_w, self.indentation_width);
466        } else {
467            self.buffer.scroll_to_cursor(area.height as usize);
468            self.buffer.scroll_x_to_cursor(content_w, self.indentation_width);
469        }
470
471        let (matches, current) = match self.search.as_ref().or(self.last_search.as_ref()) {
472            Some(s) if !s.matches.is_empty() => (s.matches.as_slice(), Some(s.current)),
473            _ => (&[] as &[(usize, usize, usize)], None),
474        };
475        let selection_spans: Vec<(usize, usize, usize)> = self
476            .buffer
477            .selection()
478            .map(|s| s.covered_spans())
479            .unwrap_or_default();
480        frame.render_widget(
481            EditorWidget {
482                buffer: &self.buffer,
483                folds: &self.folds,
484                highlight_cache: &self.highlight_cache,
485                gutter_markers: &self.gutter_markers,
486                search_matches: matches,
487                search_current: current,
488                selection_spans: &selection_spans,
489                scroll_x: self.buffer.scroll_x,
490                word_wrap: self.word_wrap,
491                show_line_numbers: self.show_line_numbers,
492                tab_width: self.indentation_width,
493            },
494            area,
495        );
496    }
497
498    fn render_search_bar(&self, frame: &mut Frame, area: Rect) {
499        let s = self.search.as_ref().unwrap();
500        let bar_bg = Color::Rgb(30, 45, 70);
501        let active_bg = Color::Rgb(50, 70, 110);
502        let btn_on = Style::default()
503            .fg(Color::Black)
504            .bg(Color::Rgb(80, 170, 220));
505        let btn_off = Style::default()
506            .fg(Color::DarkGray)
507            .bg(Color::Rgb(40, 55, 80));
508
509        // Buttons: " IgnCase  Regex  Smart "
510        // Fixed width so they always sit flush at the right edge.
511        const BTN_W: u16 = 27;
512        let left_w = area.width.saturating_sub(BTN_W);
513
514        let btn_row = Line::from(vec![
515            Span::styled(" ", Style::default().bg(bar_bg)),
516            Span::styled(
517                " IgnCase ",
518                if s.opts.ignore_case { btn_on } else { btn_off },
519            ),
520            Span::styled(" ", Style::default().bg(bar_bg)),
521            Span::styled(" Regex ", if s.opts.regex { btn_on } else { btn_off }),
522            Span::styled(" ", Style::default().bg(bar_bg)),
523            Span::styled(" Smart ", if s.opts.smart_case { btn_on } else { btn_off }),
524            Span::styled(" ", Style::default().bg(bar_bg)),
525        ]);
526
527        let count_str = if s.matches.is_empty() {
528            " No matches".to_owned()
529        } else {
530            format!(" {}/{}", s.current + 1, s.matches.len())
531        };
532
533        // ── Query row ───────────────────────────────────────────────────────
534        let query_area = Rect { height: 1, ..area };
535        let [left_area, right_area] = Layout::default()
536            .direction(Direction::Horizontal)
537            .constraints([Constraint::Length(left_w), Constraint::Length(BTN_W)])
538            .split(query_area)[..]
539        else {
540            return;
541        };
542
543        let query_line = format!(" Find: {}  {}", s.query.text(), count_str);
544        frame.render_widget(
545            Paragraph::new(query_line).style(Style::default().fg(Color::White).bg(
546                if s.focus.current() == SEARCH_FOCUS_QUERY {
547                    active_bg
548                } else {
549                    bar_bg
550                },
551            )),
552            left_area,
553        );
554        frame.render_widget(
555            Paragraph::new(btn_row.clone()).style(Style::default().bg(bar_bg)),
556            right_area,
557        );
558
559        // ── Replace row ─────────────────────────────────────────────────────
560        if s.kind == SearchKind::Replace && area.height >= 2 {
561            let replace_area = Rect {
562                y: area.y + 1,
563                height: 1,
564                ..area
565            };
566            let [left_r, right_r] = Layout::default()
567                .direction(Direction::Horizontal)
568                .constraints([Constraint::Length(left_w), Constraint::Length(BTN_W)])
569                .split(replace_area)[..]
570            else {
571                return;
572            };
573
574            let replace_line = format!(" Replace: {}", s.replacement.text());
575            frame.render_widget(
576                Paragraph::new(replace_line).style(Style::default().fg(Color::White).bg(
577                    if s.focus.current() == SEARCH_FOCUS_REPLACEMENT {
578                        active_bg
579                    } else {
580                        bar_bg
581                    },
582                )),
583                left_r,
584            );
585            let hint = Line::from(vec![Span::styled(
586                "  Enter:replace  Alt+A:all  ",
587                Style::default().fg(Color::DarkGray).bg(bar_bg),
588            )]);
589            frame.render_widget(
590                Paragraph::new(hint).style(Style::default().bg(bar_bg)),
591                right_r,
592            );
593        }
594
595        // ── Filter row (Expanded mode only) ─────────────────────────────────
596        if s.mode == SearchMode::Expanded && area.height >= 2 {
597            let filter_area = Rect {
598                y: area.y + 1,
599                height: 1,
600                ..area
601            };
602            let half = filter_area.width / 2;
603            let [incl_area, excl_area] = Layout::default()
604                .direction(Direction::Horizontal)
605                .constraints([Constraint::Length(half), Constraint::Min(1)])
606                .split(filter_area)[..]
607            else {
608                return;
609            };
610
611            let incl_focused = s.focus.current() == SEARCH_FOCUS_INCLUDE;
612            let excl_focused = s.focus.current() == SEARCH_FOCUS_EXCLUDE;
613
614            let incl_text = format!(" incl: {}", s.include_filter.text());
615            frame.render_widget(
616                Paragraph::new(incl_text).style(Style::default().fg(Color::White).bg(
617                    if incl_focused { active_bg } else { bar_bg },
618                )),
619                incl_area,
620            );
621            let excl_text = format!(" excl: {}", s.exclude_filter.text());
622            frame.render_widget(
623                Paragraph::new(excl_text).style(Style::default().fg(Color::White).bg(
624                    if excl_focused { active_bg } else { bar_bg },
625                )),
626                excl_area,
627            );
628        }
629    }
630
631    /// Render the file-list panel used in Expanded search mode.
632    ///
633    /// Layout (within `area`):
634    ///   Row 0: header  "  N files"
635    ///   Row 1+: one row per file — `<name>  (<count>)`
636    ///
637    /// The selected file is highlighted. Scroll is taken from
638    /// `search.file_panel_scroll`.
639    fn render_file_panel(&self, frame: &mut Frame, area: Rect) {
640        let s = match &self.search {
641            Some(s) => s,
642            None => return,
643        };
644        if area.height == 0 {
645            return;
646        }
647
648        let panel_bg = Color::Rgb(22, 32, 52);
649        let selected_bg = Color::Rgb(50, 70, 110);
650        let header_fg = Color::Rgb(100, 145, 210);
651        let count_fg = Color::Rgb(100, 120, 150);
652        let divider_fg = Color::Rgb(40, 55, 80);
653        let focused_header_bg = Color::Rgb(40, 65, 115);
654
655        let panel_focused = s.focus.current() == SEARCH_FOCUS_FILES;
656
657        // Right border: draw a 1-column wide divider on the rightmost cell.
658        let inner_w = area.width.saturating_sub(1);
659
660        // Header row
661        let header_text = if s.files.is_empty() {
662            "  searching…".to_owned()
663        } else {
664            format!("  {} file{}", s.files.len(), if s.files.len() == 1 { "" } else { "s" })
665        };
666        let header_area = Rect { height: 1, width: inner_w, ..area };
667        frame.render_widget(
668            Paragraph::new(header_text).style(Style::default().fg(header_fg).bg(
669                if panel_focused { focused_header_bg } else { panel_bg },
670            )),
671            header_area,
672        );
673        // Divider cell on header row
674        frame.render_widget(
675            Paragraph::new("│").style(Style::default().fg(divider_fg).bg(panel_bg)),
676            Rect { x: area.x + inner_w, y: area.y, width: 1, height: 1 },
677        );
678
679        if area.height <= 1 {
680            return;
681        }
682
683        let list_area = Rect {
684            y: area.y + 1,
685            height: area.height - 1,
686            ..area
687        };
688        let visible = list_area.height as usize;
689        let scroll = s.file_panel_scroll;
690
691        for (display_idx, (file_idx, fm)) in s
692            .files
693            .iter()
694            .enumerate()
695            .skip(scroll)
696            .take(visible)
697            .enumerate()
698        {
699            let row_y = list_area.y + display_idx as u16;
700            let is_selected = file_idx == s.selected_file;
701            let bg = if is_selected { selected_bg } else { panel_bg };
702
703            // Build a short display name: show last two path components.
704            let display_name = {
705                let mut comps = fm.path.components().rev();
706                let file = comps.next().map(|c| c.as_os_str().to_string_lossy().into_owned()).unwrap_or_default();
707                let parent = comps.next().map(|c| c.as_os_str().to_string_lossy().into_owned());
708                match parent {
709                    Some(p) => format!("{p}/{file}"),
710                    None => file,
711                }
712            };
713
714            let count_label = format!("({}) ", fm.matches.len());
715            let max_name_w = (inner_w as usize).saturating_sub(count_label.len() + 2);
716            let name_display = if display_name.len() > max_name_w {
717                format!("…{}", &display_name[display_name.len().saturating_sub(max_name_w.saturating_sub(1))..])
718            } else {
719                display_name
720            };
721
722            let row = Line::from(vec![
723                Span::styled(" ", Style::default().bg(bg)),
724                Span::styled(
725                    format!("{:<width$}", name_display, width = max_name_w),
726                    Style::default().fg(Color::White).bg(bg),
727                ),
728                Span::styled(" ", Style::default().bg(bg)),
729                Span::styled(&count_label, Style::default().fg(count_fg).bg(bg)),
730            ]);
731            frame.render_widget(
732                Paragraph::new(row),
733                Rect { x: list_area.x, y: row_y, width: inner_w, height: 1 },
734            );
735            // Divider
736            frame.render_widget(
737                Paragraph::new("│").style(Style::default().fg(divider_fg).bg(panel_bg)),
738                Rect { x: area.x + inner_w, y: row_y, width: 1, height: 1 },
739            );
740        }
741
742        // Fill remaining rows with background + divider
743        let filled = s.files.len().saturating_sub(scroll).min(visible);
744        for i in filled..visible {
745            let row_y = list_area.y + i as u16;
746            frame.render_widget(
747                Paragraph::new("").style(Style::default().bg(panel_bg)),
748                Rect { x: list_area.x, y: row_y, width: inner_w, height: 1 },
749            );
750            frame.render_widget(
751                Paragraph::new("│").style(Style::default().fg(divider_fg).bg(panel_bg)),
752                Rect { x: area.x + inner_w, y: row_y, width: 1, height: 1 },
753            );
754        }
755    }
756
757    /// Render the match-results panel (right side of Expanded search layout).
758    ///
759    /// Shows all `MatchSpan` entries for the currently selected file, with the
760    /// matched byte range highlighted using the same amber colours as inline search.
761    ///
762    /// Layout (within `area`):
763    ///   Row 0: header  "  N matches — <filename>"
764    ///   Row 1+: one row per match — `  <line_num>  <line_text>`
765    fn render_match_panel(&mut self, frame: &mut Frame, area: Rect) {
766        const MATCH_BG: Color = Color::Rgb(15, 22, 38);
767        const HEADER_BG: Color = Color::Rgb(22, 32, 52);
768        const HEADER_FG: Color = Color::Rgb(100, 145, 210);
769        const LNUM_FG: Color = Color::Rgb(80, 100, 130);
770        const HIT_BG: Color = Color::Rgb(100, 80, 0);
771        const HIT_FG: Color = Color::White;
772        const EMPTY_FG: Color = Color::Rgb(60, 80, 110);
773
774        if area.height == 0 {
775            return;
776        }
777
778        let s = match &self.search {
779            Some(s) => s,
780            None => {
781                self.last_match_panel_area = Rect::default();
782                return;
783            }
784        };
785        self.last_match_panel_area = area;
786
787        // ── Header ───────────────────────────────────────────────────────────
788        let (file_name, match_count) = match s.files.get(s.selected_file) {
789            Some(fm) => {
790                let name = fm.path.file_name()
791                    .map(|n| n.to_string_lossy().into_owned())
792                    .unwrap_or_else(|| fm.path.display().to_string());
793                (name, fm.matches.len())
794            }
795            None => ("".to_owned(), 0),
796        };
797
798        let panel_focused = s.focus.current() == SEARCH_FOCUS_MATCHES;
799        let focused_header_bg = Color::Rgb(35, 55, 90);
800
801        let header_text = if s.files.is_empty() {
802            "  type to search across project…".to_owned()
803        } else {
804            format!("  {} match{} — {}", match_count, if match_count == 1 { "" } else { "es" }, file_name)
805        };
806        frame.render_widget(
807            Paragraph::new(header_text).style(Style::default().fg(HEADER_FG).bg(
808                if panel_focused { focused_header_bg } else { HEADER_BG },
809            )),
810            Rect { height: 1, ..area },
811        );
812
813        if area.height <= 1 {
814            return;
815        }
816
817        let list_area = Rect { y: area.y + 1, height: area.height - 1, ..area };
818        let visible = list_area.height as usize;
819
820        let fm = match s.files.get(s.selected_file) {
821            Some(fm) => fm,
822            None => {
823                // Empty panel — fill with background
824                frame.render_widget(
825                    Paragraph::new(if s.files.is_empty() {
826                        "  Enter a query to search the project."
827                    } else {
828                        ""
829                    })
830                    .style(Style::default().fg(EMPTY_FG).bg(MATCH_BG)),
831                    list_area,
832                );
833                return;
834            }
835        };
836
837        let scroll = s.match_panel_scroll;
838        // Pre-compute the gutter width for line numbers (based on max line number in this file)
839        let max_line = fm.matches.iter().map(|m| m.line).max().unwrap_or(0);
840        let gutter_w = (max_line + 1).to_string().len().max(3) + 2; // padding on both sides
841
842        for (display_idx, span) in fm.matches.iter().skip(scroll).take(visible).enumerate() {
843            let row_y = list_area.y + display_idx as u16;
844            let line_num_str = format!("{:>width$} ", span.line + 1, width = gutter_w - 1);
845
846            // Build the match row as styled spans.
847            // We highlight [byte_start..byte_end] in the line_text.
848            let text = &span.line_text;
849            let start = span.byte_start.min(text.len());
850            let end = span.byte_end.min(text.len()).max(start);
851            let before = text[..start].to_owned();
852            let matched = text[start..end].to_owned();
853            let after = text[end..].to_owned();
854
855            // Truncate 'before' to leave room for gutter + match + at least a bit of after
856            let avail = (area.width as usize).saturating_sub(gutter_w + 1);
857
858            let row = if area.width as usize > gutter_w + 1 {
859                Line::from(vec![
860                    Span::styled(line_num_str, Style::default().fg(LNUM_FG).bg(MATCH_BG)),
861                    Span::styled(
862                        truncate_start(&before, avail.saturating_sub(matched.len() + after.len().min(10))),
863                        Style::default().fg(Color::Gray).bg(MATCH_BG),
864                    ),
865                    Span::styled(matched, Style::default().fg(HIT_FG).bg(HIT_BG)),
866                    Span::styled(
867                        truncate_end(&after, 10.min(avail)),
868                        Style::default().fg(Color::Gray).bg(MATCH_BG),
869                    ),
870                ])
871            } else {
872                Line::from(Span::styled(line_num_str, Style::default().fg(LNUM_FG).bg(MATCH_BG)))
873            };
874
875            frame.render_widget(
876                Paragraph::new(row),
877                Rect { x: list_area.x, y: row_y, width: area.width, height: 1 },
878            );
879        }
880
881        // Fill remaining rows
882        let filled = fm.matches.len().saturating_sub(scroll).min(visible);
883        for i in filled..visible {
884            frame.render_widget(
885                Paragraph::new("").style(Style::default().bg(MATCH_BG)),
886                Rect { x: list_area.x, y: list_area.y + i as u16, width: area.width, height: 1 },
887            );
888        }
889    }
890
891    fn render_go_to_line_bar(&self, frame: &mut Frame, area: Rect) {
892        let state = match &self.go_to_line {
893            Some(s) => s,
894            None => return,
895        };
896        let bar_bg = Color::Rgb(30, 45, 70);
897        let total = self.buffer.line_count();
898        let hint = format!(" of {} ", total);
899        let hint_w = hint.len() as u16;
900        let input_w = area.width.saturating_sub(hint_w);
901        let [input_area, hint_area] = Layout::default()
902            .direction(Direction::Horizontal)
903            .constraints([Constraint::Length(input_w), Constraint::Length(hint_w)])
904            .split(area)[..]
905        else {
906            return;
907        };
908        let line = format!(" Go to line: {}", state.input.text());
909        frame.render_widget(
910            Paragraph::new(line).style(Style::default().fg(Color::White).bg(Color::Rgb(50, 70, 110))),
911            input_area,
912        );
913        frame.render_widget(
914            Paragraph::new(hint).style(Style::default().fg(Color::DarkGray).bg(bar_bg)),
915            hint_area,
916        );
917    }
918
919    fn render_clipboard_picker(&self, frame: &mut Frame, registers: &Registers) {
920        let picker = match &self.clipboard_picker {
921            Some(p) => p,
922            None => return,
923        };
924
925        const MAX_ITEMS: usize = 10;
926        let history_len = registers.len();
927        if history_len == 0 {
928            return;
929        }
930
931        let term = frame.area();
932        let visible_count = history_len.min(MAX_ITEMS);
933        // inner rows = items + 1 hint line
934        let inner_h = (visible_count as u16) + 1;
935        let popup_h = inner_h + 2; // +2 for block borders
936        let popup_w = 64u16.min(term.width.saturating_sub(4));
937        let x = (term.width.saturating_sub(popup_w)) / 2;
938        let y = (term.height.saturating_sub(popup_h)) / 2;
939        let popup_area = Rect {
940            x,
941            y,
942            width: popup_w,
943            height: popup_h,
944        };
945
946        frame.render_widget(Clear, popup_area);
947
948        let block = Block::default()
949            .title(" Clipboard History ")
950            .borders(Borders::ALL)
951            .border_style(Style::default().fg(Color::Cyan));
952        let inner = block.inner(popup_area);
953        frame.render_widget(block, popup_area);
954
955        // Hint line (last row of inner area)
956        let hint_y = inner.y + inner.height.saturating_sub(1);
957        frame.render_widget(
958            Paragraph::new(Line::from(vec![
959                Span::styled(" ↑↓ navigate", Style::default().fg(Color::DarkGray)),
960                Span::styled("  Enter paste", Style::default().fg(Color::DarkGray)),
961                Span::styled("  Esc close ", Style::default().fg(Color::DarkGray)),
962            ])),
963            Rect {
964                x: inner.x,
965                y: hint_y,
966                width: inner.width,
967                height: 1,
968            },
969        );
970
971        // Item rows (all rows above the hint line)
972        let items_h = inner.height.saturating_sub(1) as usize;
973        let scroll = if picker.cursor >= items_h {
974            picker.cursor + 1 - items_h
975        } else {
976            0
977        };
978
979        for (display_idx, (ring_idx, text)) in registers
980            .iter()
981            .enumerate()
982            .skip(scroll)
983            .take(items_h)
984            .enumerate()
985        {
986            let row_y = inner.y + display_idx as u16;
987            if row_y >= hint_y {
988                break;
989            }
990            let is_selected = ring_idx == picker.cursor;
991            let bg = if is_selected {
992                Color::DarkGray
993            } else {
994                Color::Reset
995            };
996            let fg = if is_selected {
997                Color::White
998            } else {
999                Color::Gray
1000            };
1001
1002            // Show only the first line of multi-line entries as preview.
1003            let preview = text.lines().next().unwrap_or("");
1004            let label = format!("{:>2}  {}", ring_idx + 1, preview);
1005            let width = inner.width as usize;
1006            let padded = format!("{:<width$}", label.chars().take(width).collect::<String>());
1007
1008            frame.render_widget(
1009                Paragraph::new(padded).style(Style::default().fg(fg).bg(bg)),
1010                Rect {
1011                    x: inner.x,
1012                    y: row_y,
1013                    width: inner.width,
1014                    height: 1,
1015                },
1016            );
1017        }
1018    }
1019
1020
1021
1022    #[allow(dead_code)]
1023    fn render_completion_overlay(
1024        &self,
1025        frame: &mut Frame,
1026        body_area: Rect,
1027        comp: &CompletionState,
1028    ) {
1029        // Compute screen coordinates of the cursor.
1030        let gutter_w = gutter_width(self.show_line_numbers, self.buffer.line_count());
1031        let scroll = self.buffer.scroll;
1032        let cursor = self.buffer.cursor();
1033
1034        if cursor.line < scroll || cursor.line - scroll >= body_area.height as usize {
1035            return;
1036        }
1037
1038        let anchor_x = body_area.x + gutter_w as u16 + cursor.column as u16;
1039        let anchor_y = body_area.y + (cursor.line - scroll) as u16;
1040
1041        // Derive filter: text typed since trigger point.
1042        let filter = self.completion_filter(comp);
1043
1044        let widget = CompletionWidget {
1045            items: &comp.items,
1046            cursor: comp.cursor,
1047            filter: &filter,
1048            anchor_x,
1049            anchor_y,
1050            terminal_area: frame.area(),
1051        };
1052        frame.render_widget(widget, frame.area());
1053    }
1054
1055    #[allow(dead_code)]
1056    fn render_hover_overlay(&self, frame: &mut Frame, body_area: Rect, hover: &HoverState) {
1057        let gutter_w = gutter_width(self.show_line_numbers, self.buffer.line_count());
1058        let scroll = self.buffer.scroll;
1059        let cursor = self.buffer.cursor();
1060
1061        if cursor.line < scroll || cursor.line - scroll >= body_area.height as usize {
1062            return;
1063        }
1064
1065        let anchor_x = body_area.x + gutter_w as u16 + cursor.column as u16;
1066        let anchor_y = body_area.y + (cursor.line - scroll) as u16;
1067
1068        let widget = HoverWidget {
1069            text: &hover.text,
1070            anchor_x,
1071            anchor_y,
1072            terminal_area: frame.area(),
1073        };
1074        frame.render_widget(widget, frame.area());
1075    }
1076
1077    // -----------------------------------------------------------------------
1078    // Mouse helpers
1079    // -----------------------------------------------------------------------
1080
1081    /// Map screen coordinates `(col, row)` to a buffer `Cursor`.
1082    ///
1083    /// Returns `None` when the click is outside the body area.  Clicks inside
1084    /// the gutter are mapped to column 0 of the logical line.
1085    pub(crate) fn screen_to_cursor(&self, col: u16, row: u16) -> Option<Position> {
1086        let area = self.last_body_area;
1087        if area.width == 0 || area.height == 0 {
1088            return None;
1089        }
1090        if row < area.y || row >= area.y + area.height {
1091            return None;
1092        }
1093        if col < area.x {
1094            return None;
1095        }
1096
1097        let visual_row = (row - area.y) as usize;
1098
1099        // Resolve visual_row → logical line index (accounting for folds).
1100        // EditorWidget iterates visible_lines, skips those with logical < scroll,
1101        // then takes up to height — so visual_row maps to visible[visual_row] after
1102        // filtering out pre-scroll lines.
1103        let visible: Vec<usize> = self
1104            .folds
1105            .visible_lines(&self.buffer.lines())
1106            .into_iter()
1107            .filter(|(logical, _)| *logical >= self.buffer.scroll)
1108            .map(|(logical, _)| logical)
1109            .collect();
1110        let logical_row = *visible.get(visual_row)?;
1111
1112        let lines = self.buffer.lines();
1113        let line = lines.get(logical_row)?;
1114
1115        let gw = gutter_width(self.show_line_numbers, self.buffer.line_count());
1116        let content_x = area.x + gw as u16;
1117
1118        if col < content_x {
1119            // Click in the gutter → jump to start of line.
1120            return Some(Position::new(logical_row, 0));
1121        }
1122
1123        // visual_col is a character-column offset within the line (no wide-char
1124        // handling; source code is almost always ASCII).
1125        let visual_col = (col - content_x) as usize + self.buffer.scroll_x;
1126
1127        // Position.column uses character indices, not byte offsets.
1128        // Clamp to the actual line length in characters.
1129        let char_col = visual_col.min(line.chars().count());
1130
1131        Some(Position::new(logical_row, char_col))
1132    }
1133
1134    /// Derive the completion filter by extracting buffer text from trigger_cursor
1135    /// to the current cursor on the same line.
1136    fn completion_filter(&self, comp: &CompletionState) -> String {
1137        let cur = self.buffer.cursor();
1138        let trig = comp.trigger_cursor;
1139        if cur.line != trig.line || cur.column <= trig.column {
1140            return String::new();
1141        }
1142        // Get the current line text and slice from trigger col to current col.
1143        if let Some(line) = self.buffer.lines().get(cur.line) {
1144            let bytes = line.as_bytes();
1145            let start = trig.column.min(bytes.len());
1146            let end = cur.column.min(bytes.len());
1147            if start <= end {
1148                return String::from_utf8_lossy(&bytes[start..end]).into_owned();
1149            }
1150        }
1151        String::new()
1152    }
1153}
1154
1155// ---------------------------------------------------------------------------
1156// View trait impl
1157// ---------------------------------------------------------------------------
1158
1159impl View for EditorView {
1160    const KIND: crate::views::ViewKind = crate::views::ViewKind::Primary;
1161
1162    fn save_state(&mut self, app: &mut crate::app_state::AppState) {
1163        crate::views::save_state::editor_pre_save(self, app);
1164    }
1165    /// Translate key input into operations — no mutation of self.
1166    fn handle_key(&self, key: KeyEvent) -> Vec<Operation> {
1167        let path = self.buffer.path.clone();
1168
1169        // If clipboard history picker is open, capture navigation keys.
1170        if self.clipboard_picker.is_some() {
1171            return match key.key {
1172                Key::ArrowUp => vec![Operation::ClipboardLocal(ClipOp::HistoryUp)],
1173                Key::ArrowDown => vec![Operation::ClipboardLocal(ClipOp::HistoryDown)],
1174                Key::Enter => vec![Operation::ClipboardLocal(ClipOp::HistoryConfirm)],
1175                Key::Escape => vec![Operation::ClipboardLocal(ClipOp::HistoryClose)],
1176                _ => vec![],
1177            };
1178        }
1179
1180        // If go-to-line bar is open, capture all keypresses for it.
1181        if self.go_to_line.is_some() {
1182            return match key.key {
1183                Key::Escape => vec![Operation::GoToLineLocal(GoToLineOp::Close)],
1184                Key::Enter => vec![Operation::GoToLineLocal(GoToLineOp::Confirm)],
1185                _ => {
1186                    if let Some(field_op) = crate::widgets::input_field::key_to_op(key) {
1187                        vec![Operation::GoToLineLocal(GoToLineOp::Input(field_op))]
1188                    } else {
1189                        vec![]
1190                    }
1191                }
1192            };
1193        }
1194
1195        // If search bar is open, capture all keypresses for it.
1196        if let Some(search) = &self.search {
1197            let on_replacement =
1198                search.kind == SearchKind::Replace && search.focus.current() == SEARCH_FOCUS_REPLACEMENT;
1199            let on_include =
1200                search.mode == SearchMode::Expanded && search.focus.current() == SEARCH_FOCUS_INCLUDE;
1201            let on_exclude =
1202                search.mode == SearchMode::Expanded && search.focus.current() == SEARCH_FOCUS_EXCLUDE;
1203            let on_files =
1204                search.mode == SearchMode::Expanded && search.focus.current() == SEARCH_FOCUS_FILES;
1205            let on_matches =
1206                search.mode == SearchMode::Expanded && search.focus.current() == SEARCH_FOCUS_MATCHES;
1207            return match (key.modifiers, key.key) {
1208                (_, Key::Escape) => vec![Operation::SearchLocal(SearchOp::Close)],
1209                (_, Key::F(3)) if !key.modifiers.contains(Modifiers::SHIFT) => {
1210                    vec![Operation::SearchLocal(SearchOp::NextMatch)]
1211                }
1212                (m, Key::F(3)) if m.contains(Modifiers::SHIFT) => {
1213                    vec![Operation::SearchLocal(SearchOp::PrevMatch)]
1214                }
1215                // Tab / Shift+Tab cycle focus in Replace and Expanded modes
1216                (_, Key::Tab) if search.kind == SearchKind::Replace || search.mode == SearchMode::Expanded => {
1217                    vec![Operation::Focus(FocusOp::Next)]
1218                }
1219                (_, Key::BackTab) if search.kind == SearchKind::Replace || search.mode == SearchMode::Expanded => {
1220                    vec![Operation::Focus(FocusOp::Prev)]
1221                }
1222                // Up/Down navigate the file list when it has focus
1223                (_, Key::ArrowUp) if on_files => {
1224                    vec![Operation::SearchLocal(SearchOp::SelectFile(
1225                        search.selected_file.saturating_sub(1),
1226                    ))]
1227                }
1228                (_, Key::ArrowDown) if on_files => {
1229                    vec![Operation::SearchLocal(SearchOp::SelectFile(
1230                        search.selected_file + 1,
1231                    ))]
1232                }
1233                // Up/Down scroll the match panel when it has focus
1234                (_, Key::ArrowUp) if on_matches => {
1235                    vec![Operation::SearchLocal(SearchOp::ScrollMatchPanel(-1))]
1236                }
1237                (_, Key::ArrowDown) if on_matches => {
1238                    vec![Operation::SearchLocal(SearchOp::ScrollMatchPanel(1))]
1239                }
1240                (_, Key::Enter) => {
1241                    if on_replacement {
1242                        vec![Operation::SearchLocal(SearchOp::ReplaceOne)]
1243                    } else if on_include || on_exclude {
1244                        // Move focus to next field on Enter in filter fields
1245                        vec![Operation::Focus(FocusOp::Next)]
1246                    } else if on_files || on_matches {
1247                        vec![] // no-op for panel focus
1248                    } else {
1249                        vec![
1250                            Operation::SearchLocal(SearchOp::NextMatch),
1251                            Operation::SearchLocal(SearchOp::Close),
1252                        ]
1253                    }
1254                }
1255                (m, Key::Char('c')) if m.contains(Modifiers::ALT) => {
1256                    vec![Operation::SearchLocal(SearchOp::ToggleIgnoreCase)]
1257                }
1258                (m, Key::Char('r')) if m.contains(Modifiers::ALT) => {
1259                    vec![Operation::SearchLocal(SearchOp::ToggleRegex)]
1260                }
1261                (m, Key::Char('s')) if m.contains(Modifiers::ALT) => {
1262                    vec![Operation::SearchLocal(SearchOp::ToggleSmartCase)]
1263                }
1264                (m, Key::Char('a')) if m.contains(Modifiers::ALT) && on_replacement => {
1265                    vec![Operation::SearchLocal(SearchOp::ReplaceAll)]
1266                }
1267                _ => {
1268                    // Only route to text fields; ignore when a panel has focus
1269                    if !on_files && !on_matches
1270                        && let Some(field_op) = crate::widgets::input_field::key_to_op(key) {
1271                            let op = if on_replacement {
1272                                SearchOp::ReplacementInput(field_op)
1273                            } else if on_include {
1274                                SearchOp::IncludeGlobInput(field_op)
1275                            } else if on_exclude {
1276                                SearchOp::ExcludeGlobInput(field_op)
1277                            } else {
1278                                SearchOp::QueryInput(field_op)
1279                            };
1280                            return vec![Operation::SearchLocal(op)];
1281                        }
1282                    vec![]
1283                }
1284            };
1285        }
1286
1287        // If completion dropdown is open, intercept navigation keys.
1288        if self.completion.is_some() {
1289            match key.key {
1290                Key::ArrowUp => return vec![Operation::LspLocal(LspOp::CompletionMoveUp)],
1291                Key::ArrowDown => return vec![Operation::LspLocal(LspOp::CompletionMoveDown)],
1292                Key::Enter => return vec![Operation::LspLocal(LspOp::CompletionConfirm)],
1293                Key::Escape => return vec![Operation::LspLocal(LspOp::CompletionDismiss)],
1294                _ => {} // Fall through to normal key handling
1295            }
1296        }
1297
1298        // If hover box is open, motion keys / Esc dismiss it.
1299        if self.hover.is_some() {
1300            match key.key {
1301                Key::Escape
1302                | Key::ArrowUp
1303                | Key::ArrowDown
1304                | Key::ArrowLeft
1305                | Key::ArrowRight => {
1306                    return vec![Operation::LspLocal(LspOp::HoverDismiss)];
1307                }
1308                _ => {}
1309            }
1310        }
1311
1312        match (key.modifiers, key.key) {
1313            (_, Key::Escape) if self.buffer.selection().is_some() => {
1314                vec![Operation::SelectionLocal(SelectionOp::Clear)]
1315            }
1316
1317            // Ctrl+Shift+arrow: extend selection by word.
1318            (m, Key::ArrowLeft)
1319                if m.contains(Modifiers::CTRL) && m.contains(Modifiers::SHIFT) =>
1320            {
1321                vec![Operation::SelectionLocal(SelectionOp::Extend {
1322                    head: self.buffer.word_boundary_prev(self.buffer.cursor()),
1323                })]
1324            }
1325            (m, Key::ArrowRight)
1326                if m.contains(Modifiers::CTRL) && m.contains(Modifiers::SHIFT) =>
1327            {
1328                vec![Operation::SelectionLocal(SelectionOp::Extend {
1329                    head: self.buffer.word_boundary_next(self.buffer.cursor()),
1330                })]
1331            }
1332
1333            // Shift+arrow: extend active selection.
1334            (m, Key::ArrowLeft) if m.contains(Modifiers::SHIFT) => {
1335                vec![Operation::SelectionLocal(SelectionOp::Extend {
1336                    head: self.buffer.offset_left(self.buffer.cursor()),
1337                })]
1338            }
1339            (m, Key::ArrowRight) if m.contains(Modifiers::SHIFT) => {
1340                vec![Operation::SelectionLocal(SelectionOp::Extend {
1341                    head: self.buffer.offset_right(self.buffer.cursor()),
1342                })]
1343            }
1344            (m, Key::ArrowUp) if m.contains(Modifiers::SHIFT) => {
1345                vec![Operation::SelectionLocal(SelectionOp::Extend {
1346                    head: self.buffer.offset_up(self.buffer.cursor()),
1347                })]
1348            }
1349            (m, Key::ArrowDown) if m.contains(Modifiers::SHIFT) => {
1350                vec![Operation::SelectionLocal(SelectionOp::Extend {
1351                    head: self.buffer.offset_down(self.buffer.cursor()),
1352                })]
1353            }
1354            (m, Key::Home) if m.contains(Modifiers::SHIFT) => {
1355                vec![Operation::SelectionLocal(SelectionOp::Extend {
1356                    head: self.buffer.offset_line_start(self.buffer.cursor()),
1357                })]
1358            }
1359            (m, Key::End) if m.contains(Modifiers::SHIFT) => {
1360                vec![Operation::SelectionLocal(SelectionOp::Extend {
1361                    head: self.buffer.offset_line_end(self.buffer.cursor()),
1362                })]
1363            }
1364
1365            (_, Key::ArrowUp) => vec![Operation::MoveCursor {
1366                path,
1367                cursor: self.buffer.offset_up(self.buffer.cursor()),
1368            }],
1369            (_, Key::ArrowDown) => vec![Operation::MoveCursor {
1370                path,
1371                cursor: self.buffer.offset_down(self.buffer.cursor()),
1372            }],
1373            (_, Key::ArrowLeft) => vec![Operation::MoveCursor {
1374                path,
1375                cursor: self.buffer.offset_left(self.buffer.cursor()),
1376            }],
1377            (_, Key::ArrowRight) => vec![Operation::MoveCursor {
1378                path,
1379                cursor: self.buffer.offset_right(self.buffer.cursor()),
1380            }],
1381            (_, Key::Home) => vec![Operation::MoveCursor {
1382                path,
1383                cursor: self.buffer.offset_line_start(self.buffer.cursor()),
1384            }],
1385            (_, Key::End) => vec![Operation::MoveCursor {
1386                path,
1387                cursor: self.buffer.offset_line_end(self.buffer.cursor()),
1388            }],
1389            (_, Key::PageUp) => vec![Operation::MoveCursor {
1390                path,
1391                cursor: self.buffer.offset_up_n(self.buffer.cursor(), 20),
1392            }],
1393            (_, Key::PageDown) => vec![Operation::MoveCursor {
1394                path,
1395                cursor: self.buffer.offset_down_n(self.buffer.cursor(), 20),
1396            }],
1397
1398            (_, Key::Enter) => vec![Operation::InsertText {
1399                path,
1400                cursor: self.buffer.cursor(),
1401                text: "\n".into(),
1402            }],
1403            (_, Key::Backspace) => vec![Operation::DeleteText {
1404                path,
1405                cursor: self.buffer.cursor(),
1406                len: 1,
1407            }],
1408            (m, Key::Tab) if !m.contains(Modifiers::CTRL) => {
1409                let col = self.buffer.cursor().column;
1410                let spaces = self.indentation_width - (col % self.indentation_width);
1411                vec![Operation::InsertText {
1412                    path,
1413                    cursor: self.buffer.cursor(),
1414                    text: " ".repeat(spaces),
1415                }]
1416            }
1417            (_, Key::Delete) => vec![Operation::DeleteForward {
1418                path,
1419                cursor: self.buffer.cursor(),
1420            }],
1421
1422            (_, Key::Char(c))
1423                if !key.modifiers.contains(Modifiers::CTRL)
1424                    || key.modifiers.contains(Modifiers::ALT) =>
1425            {
1426                vec![Operation::InsertText {
1427                    path,
1428                    cursor: self.buffer.cursor(),
1429                    text: c.to_string(),
1430                }]
1431            }
1432
1433            _ => vec![],
1434        }
1435    }
1436
1437    fn handle_mouse(&self, mouse: MouseEvent) -> Vec<Operation> {
1438        // ── Expanded mode: file panel and match panel mouse handling ─────────
1439        if let Some(search) = &self.search
1440            && search.mode == SearchMode::Expanded {
1441                // File panel left-click → SelectFile
1442                if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left))
1443                    && self.last_file_panel_area.width > 0
1444                    && hit_test((mouse.column, mouse.row), self.last_file_panel_area)
1445                {
1446                    let panel = self.last_file_panel_area;
1447                    if mouse.row > panel.y {
1448                        let file_idx = search.file_panel_scroll
1449                            + (mouse.row - panel.y - 1) as usize;
1450                        if file_idx < search.files.len() {
1451                            return vec![Operation::SearchLocal(SearchOp::SelectFile(file_idx))];
1452                        }
1453                    }
1454                    return vec![];
1455                }
1456
1457                // Match panel scroll
1458                if self.last_match_panel_area.width > 0
1459                    && hit_test((mouse.column, mouse.row), self.last_match_panel_area)
1460                {
1461                    return match mouse.kind {
1462                        MouseEventKind::ScrollUp => {
1463                            vec![Operation::SearchLocal(SearchOp::ScrollMatchPanel(-3))]
1464                        }
1465                        MouseEventKind::ScrollDown => {
1466                            vec![Operation::SearchLocal(SearchOp::ScrollMatchPanel(3))]
1467                        }
1468                        _ => vec![],
1469                    };
1470                }
1471            }
1472
1473        // Handle search bar toggle buttons if search is active
1474        if self.search.is_some()
1475            && matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) {
1476                let search_area = self.last_search_bar_area;
1477                if search_area.width > 0 && hit_test((mouse.column, mouse.row), search_area) {
1478                    // Search bar buttons are at the right side: " IgnCase  Regex  Smart "
1479                    // Layout: space(1) + " IgnCase "(9) + space(1) + " Regex "(7) + space(1) + " Smart "(7) + space(1) = 27
1480                    const BTN_W: u16 = 27;
1481                    let buttons_start = search_area.x + search_area.width.saturating_sub(BTN_W);
1482                    let col = mouse.column;
1483
1484                    if col >= buttons_start {
1485                        // Button positions (accounting for leading space before each)
1486                        // " IgnCase " starts at +1, " Regex " at +11, " Smart " at +19
1487                        let igncase_start = buttons_start + 1;
1488                        let regex_start = buttons_start + 11;
1489                        let smart_start = buttons_start + 19;
1490
1491                        if col >= igncase_start && col < igncase_start + 9 {
1492                            return vec![Operation::SearchLocal(SearchOp::ToggleIgnoreCase)];
1493                        }
1494                        if col >= regex_start && col < regex_start + 7 {
1495                            return vec![Operation::SearchLocal(SearchOp::ToggleRegex)];
1496                        }
1497                        if col >= smart_start && col < smart_start + 7 {
1498                            return vec![Operation::SearchLocal(SearchOp::ToggleSmartCase)];
1499                        }
1500                    }
1501                }
1502            }
1503
1504        match mouse.kind {
1505            // ── Left button down: single click or double click ───────────────
1506            MouseEventKind::Down(MouseButton::Left) => {
1507                let Some(click_pos) = self.screen_to_cursor(mouse.column, mouse.row) else {
1508                    return vec![];
1509                };
1510
1511                let mut state = self.click_state.borrow_mut();
1512                let now = std::time::Instant::now();
1513
1514                // Double-click: same screen cell within threshold.
1515                let is_double = state.count >= 1
1516                    && state.last_col == mouse.column
1517                    && state.last_row == mouse.row
1518                    && state
1519                        .last_time
1520                        .map(|t| now.duration_since(t).as_millis() < DOUBLE_CLICK_MS)
1521                        .unwrap_or(false);
1522
1523                state.last_col = mouse.column;
1524                state.last_row = mouse.row;
1525                state.last_time = Some(now);
1526                state.count = if is_double { 2 } else { 1 };
1527                state.dragging = true;
1528                state.word_drag = is_double;
1529                drop(state);
1530
1531                if is_double {
1532                    let (word_start_col, word_end_col) = self.buffer.word_range_at(click_pos);
1533                    let word_start = Position::new(click_pos.line, word_start_col);
1534                    let word_end = Position::new(click_pos.line, word_end_col);
1535                    vec![
1536                        Operation::MoveCursor { path: None, cursor: word_start },
1537                        Operation::SelectionLocal(SelectionOp::Extend { head: word_end }),
1538                    ]
1539                } else {
1540                    // Single click: place cursor, clear selection.
1541                    vec![
1542                        Operation::MoveCursor { path: None, cursor: click_pos },
1543                        Operation::SelectionLocal(SelectionOp::Clear),
1544                    ]
1545                }
1546            }
1547
1548            // ── Drag: extend selection from anchor ───────────────────────────
1549            MouseEventKind::Drag(MouseButton::Left) => {
1550                let state = self.click_state.borrow();
1551                if !state.dragging {
1552                    return vec![];
1553                }
1554                drop(state);
1555
1556                let Some(drag_pos) = self.screen_to_cursor(mouse.column, mouse.row) else {
1557                    return vec![];
1558                };
1559                // Extend uses self.buffer.selection.anchor (set on Down) or cursor.
1560                vec![Operation::SelectionLocal(SelectionOp::Extend { head: drag_pos })]
1561            }
1562
1563            // ── Left button up: end drag ─────────────────────────────────────
1564            MouseEventKind::Up(MouseButton::Left) => {
1565                self.click_state.borrow_mut().dragging = false;
1566                vec![]
1567            }
1568
1569            // ── Scroll ───────────────────────────────────────────────────────
1570            MouseEventKind::ScrollUp => vec![Operation::MoveCursor {
1571                path: None,
1572                cursor: self.buffer.offset_up_n(self.buffer.cursor(), 3),
1573            }],
1574            MouseEventKind::ScrollDown => vec![Operation::MoveCursor {
1575                path: None,
1576                cursor: self.buffer.offset_down_n(self.buffer.cursor(), 3),
1577            }],
1578            MouseEventKind::Down(MouseButton::Right) => {
1579                let has_selection = self.buffer.selection().is_some();
1580                vec![Operation::OpenContextMenu {
1581                    items: vec![
1582                        ("Cut".to_string(),   crate::commands::CommandId::new_static("editor", "cut"), Some(has_selection)),
1583                        ("Copy".to_string(),  crate::commands::CommandId::new_static("editor", "copy"), Some(has_selection)),
1584                        ("Paste".to_string(), crate::commands::CommandId::new_static("editor", "paste"), None),
1585                        ("Find".to_string(),  crate::commands::CommandId::new_static("editor", "find"), Some(true)),
1586                        ("Replace".to_string(), crate::commands::CommandId::new_static("editor", "find_replace"), Some(true)),
1587                        ("Go to Line".to_string(), crate::commands::CommandId::new_static("editor", "go_to_line"), Some(true)),
1588                        ("Select All".to_string(), crate::commands::CommandId::new_static("editor", "select_all"), Some(true)),
1589                        ("Comment/Uncomment".to_string(), crate::commands::CommandId::new_static("editor", "toggle_comment"), Some(true)),
1590                        ("Format Document".to_string(), crate::commands::CommandId::new_static("editor", "format_document"), Some(true)),
1591                    ],
1592                    x: mouse.column,
1593                    y: mouse.row,
1594                }]
1595            }
1596            _ => vec![],
1597        }
1598    }
1599
1600    /// Apply operations this view owns.  Ignores ops that belong elsewhere.
1601    fn handle_operation(&mut self, op: &Operation, _settings: &Settings) -> Option<Event> {
1602        match op {
1603            Operation::InsertText {
1604                path,
1605                cursor: _,
1606                text,
1607            } if self.path_matches(path) => {
1608                let edit_line = self.buffer.cursor().line;
1609                if text == "\n" {
1610                    self.buffer.insert_newline_with_indent(self.use_space, self.indentation_width);
1611                } else {
1612                    self.buffer.insert(text);
1613                }
1614                self.completion = None;
1615                self.lsp_version = self.lsp_version.wrapping_add(1);
1616                self.invalidate_highlights_from(edit_line);
1617                self.recompute_search_matches();
1618                Some(Event::applied("editor", op.clone()))
1619            }
1620
1621            Operation::DeleteText { path, .. } if self.path_matches(path) => {
1622                // Backspace at column 0 merges two lines — invalidate from previous line.
1623                let edit_line = self.buffer.cursor().line.saturating_sub(
1624                    if self.buffer.cursor().column == 0 { 1 } else { 0 }
1625                );
1626                self.buffer.delete_backward();
1627                self.completion = None;
1628                self.lsp_version = self.lsp_version.wrapping_add(1);
1629                self.invalidate_highlights_from(edit_line);
1630                self.recompute_search_matches();
1631                Some(Event::applied("editor", op.clone()))
1632            }
1633
1634            Operation::DeleteForward { path, .. } if self.path_matches(path) => {
1635                let edit_line = self.buffer.cursor().line;
1636                self.buffer.delete_forward();
1637                self.completion = None;
1638                self.lsp_version = self.lsp_version.wrapping_add(1);
1639                self.invalidate_highlights_from(edit_line);
1640                self.recompute_search_matches();
1641                Some(Event::applied("editor", op.clone()))
1642            }
1643
1644            Operation::DeleteWordBackward { path } if self.path_matches(path) => {
1645                // Word-backward delete may cross a line boundary.
1646                let edit_line = self.buffer.cursor().line.saturating_sub(1);
1647                self.buffer.delete_word_backward();
1648                self.completion = None;
1649                self.lsp_version = self.lsp_version.wrapping_add(1);
1650                self.invalidate_highlights_from(edit_line);
1651                self.recompute_search_matches();
1652                Some(Event::applied("editor", op.clone()))
1653            }
1654
1655            Operation::DeleteWordForward { path } if self.path_matches(path) => {
1656                let edit_line = self.buffer.cursor().line;
1657                self.buffer.delete_word_forward();
1658                self.completion = None;
1659                self.lsp_version = self.lsp_version.wrapping_add(1);
1660                self.invalidate_highlights_from(edit_line);
1661                self.recompute_search_matches();
1662                Some(Event::applied("editor", op.clone()))
1663            }
1664
1665            Operation::DeleteLine { path } if self.path_matches(path) => {
1666                let edit_line = self.buffer.cursor().line;
1667                self.buffer.delete_line();
1668                self.completion = None;
1669                self.lsp_version = self.lsp_version.wrapping_add(1);
1670                self.invalidate_highlights_from(edit_line);
1671                self.recompute_search_matches();
1672                Some(Event::applied("editor", op.clone()))
1673            }
1674
1675            Operation::IndentLines { path } if self.path_matches(path) => {
1676    log::info!("indent");
1677                let (start_line, end_line) = if let Some(sel) = self.buffer.selection() {
1678                    let min = sel.min();
1679                    let max = sel.max();
1680                    (min.line, max.line)
1681                } else {
1682                    let line = self.buffer.cursor().line;
1683                    (line, line)
1684                };
1685
1686                for line_idx in start_line..=end_line {
1687                    if let Some(line_text) = self.buffer.line(line_idx) {
1688                        let leading = line_text.len() - line_text.trim_start().len();
1689                        let target = if leading % self.indentation_width == 0 {
1690                            leading + self.indentation_width
1691                        } else if (leading + 1) % self.indentation_width == 0 {
1692                            leading.div_ceil(self.indentation_width) * self.indentation_width + self.indentation_width
1693                        } else {
1694                            leading.div_ceil(self.indentation_width) * self.indentation_width
1695                        };
1696                        let spaces_to_add = target - leading;
1697                        let to_insert = if self.use_space {
1698                            " ".repeat(spaces_to_add)
1699                        } else {
1700                            "\t".to_string()
1701                        };
1702                        self.buffer.replace_range(
1703                            Position::new(line_idx, leading),
1704                            Position::new(line_idx, leading),
1705                            &to_insert,
1706                        );
1707                    }
1708                }
1709
1710                self.invalidate_highlights_from(start_line);
1711                self.recompute_search_matches();
1712                Some(Event::applied("editor", op.clone()))
1713            }
1714
1715            Operation::UnindentLines { path } if self.path_matches(path) => {
1716    log::info!("unindent");
1717
1718                    let (start_line, end_line) = if let Some(sel) = self.buffer.selection() {
1719                    let min = sel.min();
1720                    let max = sel.max();
1721                    (min.line, max.line)
1722                } else {
1723                    let line = self.buffer.cursor().line;
1724                    (line, line)
1725                };
1726
1727                for line_idx in start_line..=end_line {
1728                    if let Some(line_text) = self.buffer.line(line_idx) {
1729                        let leading = line_text.len() - line_text.trim_start().len();
1730                        let spaces_to_remove = self.indentation_width.min(leading);
1731                        if spaces_to_remove > 0 {
1732                            self.buffer.replace_range(
1733                                Position::new(line_idx, 0),
1734                                Position::new(line_idx, spaces_to_remove),
1735                                "",
1736                            );
1737                        }
1738                    }
1739                }
1740
1741                self.invalidate_highlights_from(start_line);
1742                self.recompute_search_matches();
1743                Some(Event::applied("editor", op.clone()))
1744            }
1745
1746            Operation::ReplaceRange {
1747                path,
1748                start,
1749                end,
1750                text,
1751            } if self.path_matches(path) => {
1752                self.buffer.replace_range(*start, *end, text);
1753                self.completion = None;
1754                self.lsp_version = self.lsp_version.wrapping_add(1);
1755                self.invalidate_highlights_from(start.line);
1756                self.recompute_search_matches();
1757                Some(Event::applied("editor", op.clone()))
1758            }
1759
1760            Operation::MoveCursor { path, cursor } if self.path_matches(path) => {
1761                self.buffer.set_cursor(*cursor);
1762                self.completion = None;
1763                Some(Event::applied("editor", op.clone()))
1764            }
1765
1766            Operation::Undo { path } if self.path_matches(path) => {
1767                self.buffer.undo();
1768                self.completion = None;
1769                self.invalidate_all_highlights();
1770                Some(Event::applied("editor", op.clone()))
1771            }
1772
1773            Operation::Redo { path } if self.path_matches(path) => {
1774                self.buffer.redo();
1775                self.completion = None;
1776                self.invalidate_all_highlights();
1777                Some(Event::applied("editor", op.clone()))
1778            }
1779
1780            Operation::ToggleFold { path, line } if self.path_matches(path) => {
1781                self.folds.toggle(*line, &self.buffer.lines());
1782                Some(Event::applied("editor", op.clone()))
1783            }
1784
1785            Operation::ToggleMarker { path, line } if self.path_matches(path) => {
1786                let row = *line;
1787                if let Some(pos) = self.buffer.markers.iter().position(|m| m.line == row) {
1788                    self.buffer.markers.remove(pos);
1789                } else {
1790                    self.buffer.markers.push(crate::editor::buffer::Marker {
1791                        line: row,
1792                        label: "●".into(),
1793                    });
1794                }
1795                Some(Event::applied("editor", op.clone()))
1796            }
1797
1798            Operation::ToggleWordWrap => {
1799                self.word_wrap = !self.word_wrap;
1800                self.buffer.scroll_x = 0;
1801                Some(Event::applied("editor", op.clone()))
1802            }
1803
1804            // --- LSP local ops ---
1805            Operation::LspLocal(lsp_op) => {
1806                match lsp_op {
1807                    LspOp::CompletionResponse { items, trigger, version } => {
1808                        if let Some(v) = version
1809                            && *v != self.lsp_version {
1810                                return None;
1811                            }
1812                        let trigger_cursor = trigger.unwrap_or_else(|| self.buffer.cursor());
1813                        self.completion = Some(CompletionState {
1814                            items: items.clone(),
1815                            cursor: 0,
1816                            trigger_cursor,
1817                        });
1818                        Some(Event::applied("editor", op.clone()))
1819                    }
1820
1821                    LspOp::HoverResponse(Some(text)) => {
1822                        self.hover = Some(HoverState { text: text.clone() });
1823                        Some(Event::applied("editor", op.clone()))
1824                    }
1825
1826                    LspOp::HoverResponse(None) => {
1827                        self.hover = None;
1828                        Some(Event::applied("editor", op.clone()))
1829                    }
1830
1831                    LspOp::CompletionMoveUp => {
1832                        // Compute visible count first (immutable borrow), then mutate.
1833                        let visible_count = if let Some(c) = &self.completion {
1834                            let f = self.completion_filter(c);
1835                            c.items
1836                                .iter()
1837                                .filter(|item| {
1838                                    f.is_empty()
1839                                        || item.label.to_lowercase().contains(&f.to_lowercase())
1840                                })
1841                                .count()
1842                        } else {
1843                            0
1844                        };
1845                        if let Some(c) = &mut self.completion {
1846                            // Clamp first in case filter shrunk the list.
1847                            if visible_count > 0 {
1848                                c.cursor = c.cursor.min(visible_count - 1);
1849                            }
1850                            // Wrap-around: going up from 0 wraps to bottom.
1851                            c.cursor = if c.cursor == 0 {
1852                                visible_count.saturating_sub(1)
1853                            } else {
1854                                c.cursor - 1
1855                            };
1856                        }
1857                        Some(Event::applied("editor", op.clone()))
1858                    }
1859
1860                    LspOp::CompletionMoveDown => {
1861                        // Compute filter and visible count before mutably borrowing.
1862                        let visible_count = if let Some(c) = &self.completion {
1863                            let f = self.completion_filter(c);
1864                            c.items
1865                                .iter()
1866                                .filter(|item| {
1867                                    f.is_empty()
1868                                        || item.label.to_lowercase().contains(&f.to_lowercase())
1869                                })
1870                                .count()
1871                        } else {
1872                            0
1873                        };
1874                        if let Some(c) = &mut self.completion {
1875                            // Clamp first in case filter shrunk the list.
1876                            if visible_count > 0 {
1877                                c.cursor = c.cursor.min(visible_count - 1);
1878                            }
1879                            // Wrap-around: going down from last wraps to top.
1880                            c.cursor = if visible_count == 0 || c.cursor + 1 >= visible_count {
1881                                0
1882                            } else {
1883                                c.cursor + 1
1884                            };
1885                        }
1886                        Some(Event::applied("editor", op.clone()))
1887                    }
1888
1889                    LspOp::CompletionConfirm => {
1890                        let confirm = self.completion.as_ref().and_then(|c| {
1891                            let filter = self.completion_filter(c);
1892                            let filter_lower = filter.to_lowercase();
1893                            c.items
1894                                .iter()
1895                                .filter(|item| {
1896                                    filter.is_empty()
1897                                        || item.label.to_lowercase().contains(&filter_lower)
1898                                })
1899                                .nth(c.cursor)
1900                                .map(|item| {
1901                                    let text = item
1902                                        .insert_text
1903                                        .clone()
1904                                        .unwrap_or_else(|| item.label.clone());
1905                                    (c.trigger_cursor, text)
1906                                })
1907                        });
1908                        self.completion = None;
1909                        if let Some((trigger, text)) = confirm {
1910                            let end_cursor = self.buffer.cursor();
1911                            // ReplaceRange deletes the filter prefix and inserts the
1912                            // completion text in one operation, so `pri` + confirm `println`
1913                            // yields `println` not `priprintln`.
1914                            self.deferred_ops.push(Operation::ReplaceRange {
1915                                path: self.buffer.path.clone(),
1916                                start: trigger,
1917                                end: end_cursor,
1918                                text,
1919                            });
1920                        }
1921                        Some(Event::applied("editor", op.clone()))
1922                    }
1923
1924                    LspOp::CompletionDismiss => {
1925                        self.completion = None;
1926                        Some(Event::applied("editor", op.clone()))
1927                    }
1928
1929                    LspOp::HoverDismiss => {
1930                        self.hover = None;
1931                        Some(Event::applied("editor", op.clone()))
1932                    }
1933                }
1934            }
1935
1936            // --- Search local ops ---
1937            Operation::SearchLocal(sop) => {
1938                match sop {
1939                    SearchOp::Open { replace } => {
1940                        let kind = if *replace {
1941                            SearchKind::Replace
1942                        } else {
1943                            SearchKind::Find
1944                        };
1945                        if self.search.is_none() {
1946                            // Restore last query/opts so the bar opens pre-filled.
1947                            let (query_text, opts) = self
1948                                .last_search
1949                                .as_ref()
1950                                .map(|s| (s.query.text().to_owned(), s.opts.clone()))
1951                                .unwrap_or_default();
1952                            let mut query = InputField::new("Find");
1953                            if !query_text.is_empty() {
1954                                query.set_text(query_text);
1955                            }
1956                            let focus = if kind == SearchKind::Replace {
1957                                crate::widgets::focus::FocusRing::new(vec![
1958                                    SEARCH_FOCUS_QUERY, SEARCH_FOCUS_REPLACEMENT,
1959                                ])
1960                            } else {
1961                                crate::widgets::focus::FocusRing::new(vec![
1962                                    SEARCH_FOCUS_QUERY,
1963                                ])
1964                            };
1965                            self.search = Some(SearchState {
1966                                query,
1967                                replacement: InputField::new("Replace"),
1968                                kind,
1969                                mode: SearchMode::default(),
1970                                focus,
1971                                opts,
1972                                matches: vec![],
1973                                current: 0,
1974                                files: Vec::new(),
1975                                selected_file: 0,
1976                                file_panel_scroll: 0,
1977                                match_panel_scroll: 0,
1978                                include_filter: InputField::new("incl").with_text("*"),
1979                                exclude_filter: InputField::new("excl"),
1980                            });
1981                            self.recompute_search_matches();
1982                        } else if let Some(s) = &mut self.search {
1983                            // If the search bar already exists and the request is a Find
1984                            // open, toggle to Expanded mode on repeated Ctrl+F presses when
1985                            // currently Inline. Otherwise preserve existing behavior.
1986                            if kind == SearchKind::Find && s.kind == SearchKind::Find {
1987                                if s.mode == SearchMode::Inline {
1988                                    s.mode = SearchMode::Expanded;
1989                                    // Expand focus ring to include filter fields and panels.
1990                                    s.focus = crate::widgets::focus::FocusRing::new(vec![
1991                                        SEARCH_FOCUS_QUERY,
1992                                        SEARCH_FOCUS_INCLUDE,
1993                                        SEARCH_FOCUS_EXCLUDE,
1994                                        SEARCH_FOCUS_FILES,
1995                                        SEARCH_FOCUS_MATCHES,
1996                                    ]);
1997                                } else {
1998                                    // Already expanded: refocus the query input.
1999                                    s.focus.set_focus(SEARCH_FOCUS_QUERY);
2000                                }
2001                            }
2002
2003                            s.kind = kind;
2004                            s.focus.set_focus(SEARCH_FOCUS_QUERY);
2005                            // Rebuild focus ring for new kind (only when not already Expanded).
2006                            if s.mode != SearchMode::Expanded {
2007                                if kind == SearchKind::Replace {
2008                                    s.focus = crate::widgets::focus::FocusRing::new(vec![
2009                                        SEARCH_FOCUS_QUERY, SEARCH_FOCUS_REPLACEMENT,
2010                                    ]);
2011                                } else {
2012                                    s.focus = crate::widgets::focus::FocusRing::new(vec![
2013                                        SEARCH_FOCUS_QUERY,
2014                                    ]);
2015                                }
2016                            }
2017                        }
2018                    }
2019                    SearchOp::Close => {
2020                        // Move to last_search so F3 still works after closing.
2021                        self.last_search = self.search.take();
2022                    }
2023                    SearchOp::QueryInput(field_op) => {
2024                        if let Some(s) = &mut self.search {
2025                            s.query.apply(field_op);
2026                        }
2027                        self.recompute_search_matches();
2028                    }
2029                    SearchOp::ReplacementInput(field_op) => {
2030                        if let Some(s) = &mut self.search {
2031                            s.replacement.apply(field_op);
2032                        }
2033                    }
2034                    SearchOp::IncludeGlobInput(field_op) => {
2035                        if let Some(s) = &mut self.search {
2036                            s.include_filter.apply(field_op);
2037                            s.opts.include_glob = s.include_filter.text().to_owned();
2038                        }
2039                    }
2040                    SearchOp::ExcludeGlobInput(field_op) => {
2041                        if let Some(s) = &mut self.search {
2042                            s.exclude_filter.apply(field_op);
2043                            s.opts.exclude_glob = s.exclude_filter.text().to_owned();
2044                        }
2045                    }
2046                    SearchOp::ToggleIgnoreCase => {
2047                        if let Some(s) = &mut self.search {
2048                            s.opts.ignore_case ^= true;
2049                        }
2050                        self.recompute_search_matches();
2051                    }
2052                    SearchOp::ToggleRegex => {
2053                        if let Some(s) = &mut self.search {
2054                            s.opts.regex ^= true;
2055                        }
2056                        self.recompute_search_matches();
2057                    }
2058                    SearchOp::ToggleSmartCase => {
2059                        if let Some(s) = &mut self.search {
2060                            s.opts.smart_case ^= true;
2061                        }
2062                        self.recompute_search_matches();
2063                    }
2064                    SearchOp::FocusSwitch => {
2065                        if let Some(s) = &mut self.search {
2066                            s.focus.focus_next();
2067                        }
2068                    }
2069                    SearchOp::NextMatch => {
2070                        let bar_open = self.search.is_some();
2071                        let cur = self.buffer.cursor();
2072                        let new_pos = {
2073                            let state = if bar_open {
2074                                self.search.as_mut()
2075                            } else {
2076                                self.last_search.as_mut()
2077                            };
2078                            state.filter(|s| !s.matches.is_empty()).map(|s| {
2079                                s.current = if bar_open {
2080                                    (s.current + 1) % s.matches.len()
2081                                } else {
2082                                    // Find the first match strictly after the cursor
2083                                    // so F3 advances from wherever the user is.
2084                                    s.matches
2085                                        .iter()
2086                                        .position(|&(r, c, _)| (r, c) > (cur.line, cur.column))
2087                                        .unwrap_or(0)
2088                                };
2089                                let (row, col, _) = s.matches[s.current];
2090                                Position::new(row, col)
2091                            })
2092                        };
2093                        if let Some(c) = new_pos {
2094                            self.buffer.set_cursor(c);
2095                        }
2096                    }
2097                    SearchOp::PrevMatch => {
2098                        let bar_open = self.search.is_some();
2099                        let cur = self.buffer.cursor();
2100                        let new_pos = {
2101                            let state = if bar_open {
2102                                self.search.as_mut()
2103                            } else {
2104                                self.last_search.as_mut()
2105                            };
2106                            state.filter(|s| !s.matches.is_empty()).map(|s| {
2107                                s.current = if bar_open {
2108                                    s.current.checked_sub(1).unwrap_or(s.matches.len() - 1)
2109                                } else {
2110                                    s.matches
2111                                        .iter()
2112                                        .rposition(|&(r, c, _)| (r, c) < (cur.line, cur.column))
2113                                        .unwrap_or(s.matches.len() - 1)
2114                                };
2115                                let (row, col, _) = s.matches[s.current];
2116                                Position::new(row, col)
2117                            })
2118                        };
2119                        if let Some(c) = new_pos {
2120                            self.buffer.set_cursor(c);
2121                        }
2122                    }
2123                    SearchOp::ReplaceOne => {
2124                        if let Some(s) = &self.search
2125                            && !s.matches.is_empty()
2126                        {
2127                            let (row, start, end) = s.matches[s.current];
2128                            let replacement = s.replacement.text().to_owned();
2129                            let path = self.buffer.path.clone();
2130                            self.deferred_ops.push(Operation::ReplaceRange {
2131                                path,
2132                                start: Position::new(row, start),
2133                                end: Position::new(row, end),
2134                                text: replacement,
2135                            });
2136                        }
2137                    }
2138                    SearchOp::ReplaceAll => {
2139                        if let Some(s) = &self.search {
2140                            let replacement = s.replacement.text().to_owned();
2141                            let matches = s.matches.clone();
2142                            for &(row, start, end) in matches.iter().rev() {
2143                                self.buffer
2144                                    .replace_range(Position::new(row, start), Position::new(row, end), &replacement);
2145                            }
2146                            self.lsp_version = self.lsp_version.wrapping_add(1);
2147                        }
2148                        self.recompute_search_matches();
2149                    }
2150                    SearchOp::AddProjectResult { file, result } => {
2151                        if let Some(s) = &mut self.search {
2152                            if let Some(fm) = s.files.iter_mut().find(|fm| fm.path == *file) {
2153                                fm.matches.push(result.clone());
2154                            } else {
2155                                s.files.push(FileMatch {
2156                                    path: file.clone(),
2157                                    matches: vec![result.clone()],
2158                                });
2159                            }
2160                        }
2161                    }
2162                    SearchOp::ClearProjectResults => {
2163                        if let Some(s) = &mut self.search {
2164                            s.files.clear();
2165                            s.selected_file = 0;
2166                            s.file_panel_scroll = 0;
2167                            s.match_panel_scroll = 0;
2168                        }
2169                    }
2170                    SearchOp::SelectFile(idx) => {
2171                        if let Some(s) = &mut self.search {
2172                            let clamped = (*idx).min(s.files.len().saturating_sub(1));
2173                            s.selected_file = clamped;
2174                            // Keep selected row within the panel view.
2175                            // We don't know the rendered height here, so use a fixed
2176                            // lookahead of 5 rows above the selection as the scroll origin.
2177                            s.file_panel_scroll = clamped.saturating_sub(5);
2178                            s.match_panel_scroll = 0;
2179                        }
2180                    }
2181                    SearchOp::ScrollMatchPanel(delta) => {
2182                        if let Some(s) = &mut self.search {
2183                            let total = s.files.get(s.selected_file).map_or(0, |f| f.matches.len());
2184                            if *delta > 0 {
2185                                s.match_panel_scroll = (s.match_panel_scroll + *delta as usize).min(total.saturating_sub(1));
2186                            } else {
2187                                s.match_panel_scroll = s.match_panel_scroll.saturating_sub((-delta) as usize);
2188                            }
2189                        }
2190                    }
2191                }
2192                Some(Event::applied("editor", op.clone()))
2193            }
2194
2195            // --- Go-to-line bar ops ---
2196            Operation::GoToLineLocal(gop) => {
2197                match gop {
2198                    GoToLineOp::Open => {
2199                        if self.go_to_line.is_none() {
2200                            self.go_to_line = Some(GoToLineState::new());
2201                        }
2202                    }
2203                    GoToLineOp::Close => {
2204                        self.go_to_line = None;
2205                    }
2206                    GoToLineOp::Confirm => {
2207                        if let Some(state) = &self.go_to_line
2208                            && let Some(line) = state.line_number() {
2209                                let clamped = line.min(self.buffer.line_count().saturating_sub(1));
2210                                self.buffer.set_cursor(Position::new(clamped, 0));
2211                            }
2212                        self.go_to_line = None;
2213                    }
2214                    GoToLineOp::Input(field_op) => {
2215                        if let Some(state) = &mut self.go_to_line {
2216                            // Only allow digit characters; discard non-numeric input.
2217                            if let crate::widgets::input_field::InputFieldOp::InsertChar(c) = field_op {
2218                                if c.is_ascii_digit() {
2219                                    state.input.apply(field_op);
2220                                }
2221                            } else {
2222                                state.input.apply(field_op);
2223                            }
2224                            // Preview: jump the cursor live as the user types.
2225                            if let Some(line) = state.line_number() {
2226                                let clamped = line.min(self.buffer.line_count().saturating_sub(1));
2227                                self.buffer.set_cursor(Position::new(clamped, 0));
2228                            }
2229                        }
2230                    }
2231                }
2232                Some(Event::applied("editor", op.clone()))
2233            }
2234
2235            // --- Selection local ops ---
2236            Operation::SelectionLocal(sel_op) => {
2237                match sel_op {
2238                    SelectionOp::Extend { head } => {
2239                        let anchor = self.buffer.selection()
2240                            .map(|s| s.anchor)
2241                            .unwrap_or(self.buffer.cursor());
2242                        self.buffer.set_selection(Some(Selection { anchor, active: *head }));
2243                    }
2244                    SelectionOp::SelectAll => {
2245                        self.buffer.select_all();
2246                    }
2247                    SelectionOp::Clear => {
2248                        self.buffer.set_selection(None);
2249                    }
2250                }
2251                Some(Event::applied("editor", op.clone()))
2252            }
2253
2254            // --- Async highlight result (legacy, for backward compat) ---
2255            Operation::SetEditorHighlights { path, version, start_line, generation, spans } => {
2256                if self.path_matches(path)
2257                    && *version == self.buffer.version()
2258                    && *generation == self.highlight_generation
2259                {
2260                    for (i, line_spans) in spans.iter().enumerate() {
2261                        let line = *start_line + i;
2262                        self.highlight_cache.insert(line, line_spans.clone());
2263                        self.pending_highlight_lines.remove(&line);
2264                        self.stale_highlight_lines.remove(&line);
2265                    }
2266                }
2267                None
2268            }
2269
2270            // --- Incremental highlight chunk update ---
2271            Operation::SetEditorHighlightsChunk { path, version, generation, spans } => {
2272                if self.path_matches(path)
2273                    && *version == self.buffer.version()
2274                    && *generation == self.highlight_generation
2275                {
2276                    for (line, line_spans) in spans {
2277                        self.highlight_cache.insert(*line, line_spans.clone());
2278                        self.pending_highlight_lines.remove(line);
2279                        self.stale_highlight_lines.remove(line);
2280                    }
2281                }
2282                None
2283            }
2284
2285            // ClipboardLocal ops are handled in app::apply_clipboard_op which
2286            // has access to AppState.registers and AppState.clipboard.
2287
2288            // Focus ops for search bar.
2289            Operation::Focus(focus_op) => {
2290                if let Some(s) = &mut self.search {
2291                    match focus_op {
2292                        FocusOp::Next => s.focus.focus_next(),
2293                        FocusOp::Prev => s.focus.focus_prev(),
2294                        _ => {}
2295                    }
2296                }
2297                None
2298            }
2299
2300            // Not this view's operation.
2301            _ => None,
2302        }
2303    }
2304
2305    fn render(&self, frame: &mut Frame, area: Rect, _theme: &crate::theme::Theme) {
2306        // EditorView is always rendered via render_with_registers in app::render.
2307        // This stub satisfies the View trait; it is never called from app.rs.
2308        let _ = (frame, area);
2309    }
2310
2311    fn status_bar(
2312        &self,
2313        _state: &crate::app_state::AppState,
2314        bar: &mut crate::widgets::status_bar::StatusBarBuilder,
2315    ) {
2316        // Filename + dirty indicator (always shown).
2317        match &self.buffer.path {
2318            Some(p) => {
2319                let dirty = if self.buffer.is_dirty() { " [+]" } else { "" };
2320                bar.label(format!("{}{}", p.display(), dirty));
2321            }
2322            None => {
2323                bar.label("(no file)");
2324                return; // omit language and cursor when there is no file
2325            }
2326        }
2327
2328        // Language (short, stable identifier).
2329        let lang = self
2330            .lang_id
2331            .as_ref()
2332            .map(|l| l.as_str())
2333            .unwrap_or(self.highlighter.syntax_name.as_str())
2334            .to_owned();
2335        bar.label(lang);
2336
2337        // Cursor position (1-indexed line:column).
2338        let c = self.buffer.cursor();
2339        bar.label(format!("{}:{}", c.line + 1, c.column + 1));
2340
2341        // Transient editor messages (conflict warning takes priority).
2342        if let Some(msg) = self.external_conflict_msg.as_deref() {
2343            bar.label(format!("⚠ {}", msg));
2344        } else if let Some(msg) = self.status_msg.as_deref() {
2345            bar.label(msg.to_owned());
2346        }
2347    }
2348}
2349
2350impl EditorView {
2351    /// Full render path used by `app::render` — passes the yank ring so the
2352    /// clipboard history picker reads from the authoritative source.
2353    pub fn render_with_registers(&mut self, frame: &mut Frame, area: Rect, registers: &Registers, _theme: &crate::theme::Theme) {
2354        let go_to_line_open = self.go_to_line.is_some();
2355        let is_expanded = !go_to_line_open
2356            && self.search.as_ref().is_some_and(|s| s.mode == SearchMode::Expanded);
2357
2358        // ── Expanded mode: query + filter bar on top, file panel + body split below ──
2359        if is_expanded {
2360            const FILE_PANEL_W: u16 = 36;
2361            let v_chunks = Layout::default()
2362                .direction(Direction::Vertical)
2363                .constraints([Constraint::Length(2), Constraint::Min(1)])
2364                .split(area);
2365
2366            self.render_search_bar(frame, v_chunks[0]);
2367            self.last_search_bar_area = v_chunks[0];
2368
2369            let [panel_area, body_area] = Layout::default()
2370                .direction(Direction::Horizontal)
2371                .constraints([Constraint::Length(FILE_PANEL_W), Constraint::Min(1)])
2372                .split(v_chunks[1])[..]
2373            else {
2374                self.last_file_panel_area = Rect::default();
2375                self.last_match_panel_area = Rect::default();
2376                self.last_body_area = v_chunks[1];
2377                self.render_body(frame, v_chunks[1]);
2378                return;
2379            };
2380
2381            self.last_file_panel_area = panel_area;
2382            self.render_file_panel(frame, panel_area);
2383            self.last_body_area = body_area;
2384            self.render_match_panel(frame, body_area);
2385        } else {
2386            // ── Inline / closed mode: existing behaviour ─────────────────────
2387            self.last_file_panel_area = Rect::default();
2388            self.last_match_panel_area = Rect::default();
2389
2390            let search_h: u16 = if go_to_line_open {
2391                1
2392            } else {
2393                match &self.search {
2394                    None => 0,
2395                    Some(s) if s.kind == SearchKind::Replace => 2,
2396                    Some(_) => 1,
2397                }
2398            };
2399            let body_area = if search_h == 0 {
2400                self.last_search_bar_area = Rect::default();
2401                area
2402            } else {
2403                let chunks = Layout::default()
2404                    .direction(Direction::Vertical)
2405                    .constraints([Constraint::Length(search_h), Constraint::Min(1)])
2406                    .split(area);
2407                if go_to_line_open {
2408                    self.render_go_to_line_bar(frame, chunks[0]);
2409                    self.last_search_bar_area = Rect::default();
2410                } else {
2411                    self.render_search_bar(frame, chunks[0]);
2412                    self.last_search_bar_area = chunks[0];
2413                }
2414                chunks[1]
2415            };
2416            self.last_body_area = body_area;
2417            self.render_body(frame, body_area);
2418        }
2419
2420        if let Some(comp) = &self.completion {
2421            let items = comp.items.clone();
2422            let cursor = comp.cursor;
2423            let trigger_cursor = comp.trigger_cursor;
2424            let filter = {
2425                let comp_ref = self.completion.as_ref().unwrap();
2426                self.completion_filter(comp_ref)
2427            };
2428            let gutter_w = gutter_width(self.show_line_numbers, self.buffer.line_count());
2429            let scroll = self.buffer.scroll;
2430            let buf_cursor = self.buffer.cursor();
2431            let body_area = self.last_body_area;
2432            if buf_cursor.line >= scroll && buf_cursor.line - scroll < body_area.height as usize {
2433                let anchor_x = body_area.x + gutter_w as u16 + buf_cursor.column as u16;
2434                let anchor_y = body_area.y + (buf_cursor.line - scroll) as u16;
2435                let _ = trigger_cursor;
2436                frame.render_widget(
2437                    CompletionWidget {
2438                        items: &items,
2439                        cursor,
2440                        filter: &filter,
2441                        anchor_x,
2442                        anchor_y,
2443                        terminal_area: area,
2444                    },
2445                    area,
2446                );
2447            }
2448        }
2449
2450        if let Some(hover) = &self.hover {
2451            let text = hover.text.clone();
2452            let gutter_w = gutter_width(self.show_line_numbers, self.buffer.line_count());
2453            let scroll = self.buffer.scroll;
2454            let buf_cursor = self.buffer.cursor();
2455            let body_area = self.last_body_area;
2456            if buf_cursor.line >= scroll && buf_cursor.line - scroll < body_area.height as usize {
2457                let anchor_x = body_area.x + gutter_w as u16 + buf_cursor.column as u16;
2458                let anchor_y = body_area.y + (buf_cursor.line - scroll) as u16;
2459                frame.render_widget(
2460                    HoverWidget {
2461                        text: &text,
2462                        anchor_x,
2463                        anchor_y,
2464                        terminal_area: area,
2465                    },
2466                    area,
2467                );
2468            }
2469        }
2470
2471        self.render_clipboard_picker(frame, registers);
2472    }
2473}
2474
2475// ---------------------------------------------------------------------------
2476// Helpers
2477// ---------------------------------------------------------------------------
2478
2479/// Truncate a string from the **left** so its byte length is at most `max_bytes`.
2480/// Prepends "…" when truncation occurs.
2481fn truncate_start(s: &str, max_bytes: usize) -> String {
2482    if s.len() <= max_bytes {
2483        return s.to_owned();
2484    }
2485    // Walk forward until we can fit "…" + remaining
2486    let budget = max_bytes.saturating_sub(3); // "…" is 3 UTF-8 bytes
2487    let cut = s.len() - budget;
2488    // Round up to a valid char boundary
2489    let cut = (cut..=s.len()).find(|&i| s.is_char_boundary(i)).unwrap_or(s.len());
2490    format!("…{}", &s[cut..])
2491}
2492
2493/// Truncate a string from the **right** so its byte length is at most `max_bytes`.
2494/// Appends "…" when truncation occurs.
2495fn truncate_end(s: &str, max_bytes: usize) -> String {
2496    if s.len() <= max_bytes {
2497        return s.to_owned();
2498    }
2499    let budget = max_bytes.saturating_sub(3);
2500    let cut = (0..=budget).rev().find(|&i| s.is_char_boundary(i)).unwrap_or(0);
2501    format!("{}…", &s[..cut])
2502}
2503
2504// ---------------------------------------------------------------------------
2505// Tests
2506// ---------------------------------------------------------------------------
2507
2508#[cfg(test)]
2509mod tests {
2510    use super::*;
2511    use crate::editor::fold::FoldState;
2512
2513    fn make_editor() -> (EditorView, crate::settings::Settings) {
2514        let dir = tempfile::tempdir().unwrap();
2515        let path = dir.path().join("test.rs");
2516        std::fs::write(&path, "fn main() {}\n").unwrap();
2517        let buf = Buffer::open(&path).unwrap();
2518        let settings = crate::settings::Settings::new(dir.path().join("config.yaml").as_path())
2519            .expect("Settings::new failed in test");
2520        // Keep dir alive by leaking — acceptable in tests
2521        std::mem::forget(dir);
2522        (EditorView::open(buf, FoldState::default(), &settings), settings)
2523    }
2524
2525    #[test]
2526    fn completion_move_down_up() {
2527        let (mut ed, settings) = make_editor();
2528        ed.completion = Some(CompletionState {
2529            items: vec![
2530                crate::operation::LspCompletionItem {
2531                    label: "foo".into(),
2532                    kind: None,
2533                    detail: None,
2534                    insert_text: None,
2535                },
2536                crate::operation::LspCompletionItem {
2537                    label: "bar".into(),
2538                    kind: None,
2539                    detail: None,
2540                    insert_text: None,
2541                },
2542            ],
2543            cursor: 0,
2544            trigger_cursor: Position::default(),
2545        });
2546
2547        ed.handle_operation(&Operation::LspLocal(LspOp::CompletionMoveDown), &settings);
2548        assert_eq!(ed.completion.as_ref().unwrap().cursor, 1);
2549
2550        // Past end wraps to top.
2551        ed.handle_operation(&Operation::LspLocal(LspOp::CompletionMoveDown), &settings);
2552        assert_eq!(ed.completion.as_ref().unwrap().cursor, 0);
2553
2554        // Move down again to get back to 1.
2555        ed.handle_operation(&Operation::LspLocal(LspOp::CompletionMoveDown), &settings);
2556        assert_eq!(ed.completion.as_ref().unwrap().cursor, 1);
2557
2558        ed.handle_operation(&Operation::LspLocal(LspOp::CompletionMoveUp), &settings);
2559        assert_eq!(ed.completion.as_ref().unwrap().cursor, 0);
2560
2561        // Past top wraps to bottom.
2562        ed.handle_operation(&Operation::LspLocal(LspOp::CompletionMoveUp), &settings);
2563        assert_eq!(ed.completion.as_ref().unwrap().cursor, 1);
2564    }
2565
2566    #[test]
2567    fn completion_dismiss() {
2568        let (mut ed, settings) = make_editor();
2569        ed.completion = Some(CompletionState {
2570            items: vec![],
2571            cursor: 0,
2572            trigger_cursor: Position::default(),
2573        });
2574        ed.handle_operation(&Operation::LspLocal(LspOp::CompletionDismiss), &settings);
2575        assert!(ed.completion.is_none());
2576    }
2577
2578    #[test]
2579    fn hover_dismiss() {
2580        let (mut ed, settings) = make_editor();
2581        ed.hover = Some(HoverState {
2582            text: "docs".into(),
2583        });
2584        ed.handle_operation(&Operation::LspLocal(LspOp::HoverDismiss), &settings);
2585        assert!(ed.hover.is_none());
2586    }
2587
2588    #[test]
2589    fn completion_confirm_produces_deferred_op() {
2590        let (mut ed, settings) = make_editor();
2591        ed.completion = Some(CompletionState {
2592            items: vec![crate::operation::LspCompletionItem {
2593                label: "println".into(),
2594                kind: None,
2595                detail: None,
2596                insert_text: Some("println!($1)".into()),
2597            }],
2598            cursor: 0,
2599            trigger_cursor: Position::default(),
2600        });
2601        ed.handle_operation(&Operation::LspLocal(LspOp::CompletionConfirm), &settings);
2602        assert!(ed.completion.is_none());
2603        let deferred = ed.take_deferred_ops();
2604        assert_eq!(deferred.len(), 1);
2605        assert!(
2606            matches!(&deferred[0], Operation::ReplaceRange { text, .. } if text == "println!($1)")
2607        );
2608    }
2609
2610    // -----------------------------------------------------------------------
2611    // screen_to_cursor tests
2612    // -----------------------------------------------------------------------
2613
2614    /// Build a minimal EditorView whose body area and buffer are fully controlled.
2615    fn make_editor_with_lines(lines: &[&str]) -> EditorView {
2616        let dir = tempfile::tempdir().unwrap();
2617        let path = dir.path().join("test.txt");
2618        std::fs::write(&path, lines.join("\n")).unwrap();
2619        let buf = Buffer::open(&path).unwrap();
2620        let settings = crate::settings::Settings::new(dir.path().join("config.yaml").as_path())
2621            .expect("Settings::new failed");
2622        std::mem::forget(dir);
2623        let mut ed = EditorView::open(buf, FoldState::default(), &settings);
2624        // Disable line numbers so gutter_w = 4 (constant, easier to reason about).
2625        ed.show_line_numbers = false;
2626        // Set a fixed body area: x=0, y=0, width=40, height=20.
2627        ed.last_body_area = Rect { x: 0, y: 0, width: 40, height: 20 };
2628        ed
2629    }
2630
2631    #[test]
2632    fn click_outside_body_returns_none() {
2633        let ed = make_editor_with_lines(&["hello"]);
2634        // Row below body area (height=20, so row 20 is out).
2635        assert!(ed.screen_to_cursor(5, 20).is_none());
2636        // Col before area.x=0 is impossible (u16), but area with offset:
2637        let mut ed2 = make_editor_with_lines(&["hello"]);
2638        ed2.last_body_area = Rect { x: 5, y: 2, width: 40, height: 20 };
2639        assert!(ed2.screen_to_cursor(3, 2).is_none()); // col < area.x
2640        assert!(ed2.screen_to_cursor(5, 1).is_none()); // row < area.y
2641    }
2642
2643    #[test]
2644    fn click_in_gutter_returns_col_zero() {
2645        // gutter_w = 4 (no line numbers), content starts at col 4.
2646        let ed = make_editor_with_lines(&["hello world"]);
2647        // Click at col=2 (inside gutter) → col 0 of line 0.
2648        assert_eq!(ed.screen_to_cursor(2, 0), Some(Position::new(0, 0)));
2649        assert_eq!(ed.screen_to_cursor(0, 0), Some(Position::new(0, 0)));
2650    }
2651
2652    #[test]
2653    fn click_maps_to_correct_char() {
2654        // gutter_w=4 → content starts at col 4.
2655        // Col 4 → char 0, col 5 → char 1, ...
2656        let ed = make_editor_with_lines(&["hello"]);
2657        assert_eq!(ed.screen_to_cursor(4, 0), Some(Position::new(0, 0))); // 'h'
2658        assert_eq!(ed.screen_to_cursor(5, 0), Some(Position::new(0, 1))); // 'e'
2659        assert_eq!(ed.screen_to_cursor(8, 0), Some(Position::new(0, 4))); // 'o'
2660    }
2661
2662    #[test]
2663    fn click_past_end_of_line_clamps_to_line_len() {
2664        let ed = make_editor_with_lines(&["hi"]);
2665        // "hi" is 2 bytes; clicking at col 4+10 = char 10 → clamps to len=2.
2666        assert_eq!(ed.screen_to_cursor(14, 0), Some(Position::new(0, 2)));
2667    }
2668
2669    #[test]
2670    fn click_selects_correct_row() {
2671        let ed = make_editor_with_lines(&["line0", "line1", "line2"]);
2672        assert_eq!(ed.screen_to_cursor(4, 0), Some(Position::new(0, 0)));
2673        assert_eq!(ed.screen_to_cursor(4, 1), Some(Position::new(1, 0)));
2674        assert_eq!(ed.screen_to_cursor(4, 2), Some(Position::new(2, 0)));
2675    }
2676
2677    #[test]
2678    fn scroll_offsets_row_mapping() {
2679        let mut ed = make_editor_with_lines(&["a", "b", "c", "d", "e"]);
2680        // Scroll down by 2: visual row 0 → logical row 2.
2681        ed.buffer.scroll = 2;
2682        assert_eq!(ed.screen_to_cursor(4, 0), Some(Position::new(2, 0)));
2683        assert_eq!(ed.screen_to_cursor(4, 1), Some(Position::new(3, 0)));
2684    }
2685
2686    #[test]
2687    fn scroll_x_offsets_col_mapping() {
2688        let mut ed = make_editor_with_lines(&["abcdef"]);
2689        // Horizontal scroll by 2: col 4 on screen → char index 0+2=2 in line → 'c'.
2690        ed.buffer.scroll_x = 2;
2691        assert_eq!(ed.screen_to_cursor(4, 0), Some(Position::new(0, 2)));
2692        assert_eq!(ed.screen_to_cursor(5, 0), Some(Position::new(0, 3)));
2693    }
2694
2695    #[test]
2696    fn multibyte_utf8_maps_by_char() {
2697        // "éàü" — each is 2 bytes in UTF-8 but 1 character each.
2698        let ed = make_editor_with_lines(&["éàü"]);
2699        // col=4 → char 0
2700        assert_eq!(ed.screen_to_cursor(4, 0), Some(Position::new(0, 0)));
2701        // col=5 → char 1 (é is at screen col 5)
2702        assert_eq!(ed.screen_to_cursor(5, 0), Some(Position::new(0, 1)));
2703        // col=6 → char 2
2704        assert_eq!(ed.screen_to_cursor(6, 0), Some(Position::new(0, 2)));
2705    }
2706}