1use 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, LspRenameOp, 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#[derive(Debug)]
42pub struct CompletionState {
43 pub items: Vec<LspCompletionItem>,
44 pub cursor: usize,
45 pub trigger_cursor: Position,
48 pub loading: bool,
50}
51
52#[derive(Debug)]
53pub struct HoverState {
54 pub text: String,
55}
56
57#[derive(Debug)]
63pub struct GoToLineState {
64 pub input: InputField,
65}
66
67impl Default for GoToLineState {
68 fn default() -> Self {
69 Self::new()
70 }
71}
72
73impl GoToLineState {
74 pub fn new() -> Self {
75 Self {
76 input: InputField::new("Line"),
77 }
78 }
79
80 pub fn line_number(&self) -> Option<usize> {
82 self.input.text().trim().parse::<usize>().ok().and_then(|n| n.checked_sub(1))
83 }
84}
85
86#[derive(Debug, Clone)]
88pub struct RenameState {
89 pub input: InputField,
90 pub cursor_row: u32,
93 pub cursor_col: u32,
94}
95
96impl RenameState {
97 pub fn new(current_name: &str, row: u32, col: u32) -> Self {
98 let mut input = InputField::new("New name");
99 for c in current_name.chars() {
101 input.apply(&crate::widgets::input_field::InputFieldOp::InsertChar(c));
102 }
103 Self { input, cursor_row: row, cursor_col: col }
104 }
105}
106
107#[derive(Debug, Clone, Copy, PartialEq)]
108pub enum SearchKind {
109 Find,
110 Replace,
111}
112
113#[derive(Debug, Clone, Copy, PartialEq, Default)]
114pub enum SearchMode {
115 #[default]
116 Inline,
117 Expanded,
118}
119
120const SEARCH_FOCUS_QUERY: &str = "search_query";
121const SEARCH_FOCUS_REPLACEMENT: &str = "search_replacement";
122const SEARCH_FOCUS_INCLUDE: &str = "search_include";
123const SEARCH_FOCUS_EXCLUDE: &str = "search_exclude";
124const SEARCH_FOCUS_TREE: &str = "search_tree";
126const SEARCH_FOCUS_FILES: &str = "search_files";
128const SEARCH_FOCUS_MATCHES: &str = "search_matches";
130
131#[derive(Debug, Clone)]
132pub struct SearchOptions {
133 pub ignore_case: bool,
134 pub regex: bool,
135 pub smart_case: bool,
136 pub include_glob: String,
138 pub exclude_glob: String,
140}
141
142impl Default for SearchOptions {
143 fn default() -> Self {
144 Self {
145 ignore_case: false,
146 regex: false,
147 smart_case: false,
148 include_glob: String::from("*"),
149 exclude_glob: String::new(),
150 }
151 }
152}
153
154#[derive(Debug)]
155pub struct FileMatch {
156 pub path: std::path::PathBuf,
157 pub matches: Vec<MatchSpan>,
158}
159
160#[derive(Debug)]
162enum SearchTreeRow {
163 File {
165 file_idx: usize,
166 expanded: bool,
167 },
168 Match {
170 file_idx: usize,
171 match_idx: usize,
172 },
173}
174
175fn build_search_tree(state: &SearchState) -> Vec<SearchTreeRow> {
178 let mut sorted_indices: Vec<usize> = (0..state.files.len()).collect();
179 sorted_indices.sort_by(|a, b| state.files[*a].path.cmp(&state.files[*b].path));
180
181 let mut rows = Vec::new();
182 for &fi in &sorted_indices {
183 let fm = &state.files[fi];
184 let expanded = state.expanded_files.contains(&fm.path);
185 rows.push(SearchTreeRow::File {
186 file_idx: fi,
187 expanded,
188 });
189 if expanded {
190 for mi in 0..fm.matches.len() {
191 rows.push(SearchTreeRow::Match {
192 file_idx: fi,
193 match_idx: mi,
194 });
195 }
196 }
197 }
198 rows
199}
200
201fn resolve_tree_cursor(rows: &[SearchTreeRow], files: &[FileMatch], cursor_path: &Option<std::path::PathBuf>, cursor_match: &Option<usize>) -> usize {
204 let Some(path) = cursor_path else { return 0 };
205 for (i, row) in rows.iter().enumerate() {
206 match row {
207 SearchTreeRow::File { file_idx, .. } => {
208 if cursor_match.is_none() && files[*file_idx].path == *path {
209 return i;
210 }
211 }
212 SearchTreeRow::Match { file_idx, match_idx } => {
213 if cursor_match == &Some(*match_idx) && files[*file_idx].path == *path {
214 return i;
215 }
216 }
217 }
218 }
219 0
220}
221
222fn set_tree_cursor(state: &mut SearchState, rows: &[SearchTreeRow], flat_idx: usize) {
224 if let Some(row) = rows.get(flat_idx) {
225 match row {
226 SearchTreeRow::File { file_idx, .. } => {
227 state.tree_cursor_path = Some(state.files[*file_idx].path.clone());
228 state.tree_cursor_match = None;
229 }
230 SearchTreeRow::Match { file_idx, match_idx } => {
231 state.tree_cursor_path = Some(state.files[*file_idx].path.clone());
232 state.tree_cursor_match = Some(*match_idx);
233 }
234 }
235 }
236}
237
238#[derive(Debug)]
239pub struct SearchState {
240 pub query: InputField,
241 pub replacement: InputField,
242 pub kind: SearchKind,
243 pub mode: SearchMode,
244 pub focus: crate::widgets::focus::FocusRing,
245 pub opts: SearchOptions,
246 pub matches: Vec<(usize, usize, usize)>,
248 pub current: usize,
250 pub files: Vec<FileMatch>,
252 pub file_path_index: HashMap<std::path::PathBuf, usize>,
254 pub selected_file: usize,
255 pub file_panel_scroll: usize,
257 pub match_panel_scroll: usize,
259 pub include_filter: InputField,
261 pub exclude_filter: InputField,
263 pub project_search_generation: u64,
267 pub expanded_files: HashSet<std::path::PathBuf>,
269 pub tree_cursor_path: Option<std::path::PathBuf>,
271 pub tree_cursor_match: Option<usize>,
274 pub tree_scroll: usize,
276 pub project_match_cursor: Option<usize>,
279}
280
281impl SearchState {
282 pub fn total_project_matches(&self) -> usize {
284 self.files.iter().map(|f| f.matches.len()).sum()
285 }
286
287 pub fn project_match_at(&self, flat: usize) -> Option<(usize, usize)> {
290 let mut remaining = flat;
291 for (fi, fm) in self.files.iter().enumerate() {
292 if remaining < fm.matches.len() {
293 return Some((fi, remaining));
294 }
295 remaining -= fm.matches.len();
296 }
297 None
298 }
299}
300
301fn find_matches(lines: &[String], query: &str, opts: &SearchOptions) -> Vec<(usize, usize, usize)> {
302 if query.is_empty() {
303 return vec![];
304 }
305 let ignore = opts.ignore_case || (opts.smart_case && !query.chars().any(|c| c.is_uppercase()));
306
307 let mut out = Vec::new();
308 if opts.regex {
309 if let Ok(re) = regex::RegexBuilder::new(query)
310 .case_insensitive(ignore)
311 .build()
312 {
313 for (row, line) in lines.iter().enumerate() {
314 for m in re.find_iter(line) {
315 out.push((row, m.start(), m.end()));
316 }
317 }
318 }
319 } else {
320 let q = if ignore {
321 query.to_lowercase()
322 } else {
323 query.to_owned()
324 };
325 for (row, line) in lines.iter().enumerate() {
326 let l = if ignore {
327 line.to_lowercase()
328 } else {
329 line.clone()
330 };
331 let mut start = 0;
332 while let Some(pos) = l[start..].find(&q) {
333 let abs = start + pos;
334 out.push((row, abs, abs + q.len()));
335 start = abs + 1;
336 }
337 }
338 }
339 out
340}
341
342#[derive(Debug)]
347pub(crate) struct ClipboardPickerState {
348 pub cursor: usize,
349}
350
351#[derive(Debug, Default)]
357struct ClickState {
358 last_col: u16,
360 last_row: u16,
362 last_time: Option<std::time::Instant>,
364 count: u8,
366 dragging: bool,
368 word_drag: bool,
370}
371
372const DOUBLE_CLICK_MS: u128 = 400;
374
375#[derive(Debug)]
380pub struct EditorView {
381 pub buffer: Buffer,
382 pub folds: FoldState,
383 pub highlighter: SyntaxHighlighter,
384 pub gutter_markers: Vec<GutterMarker>,
385 pub gutter_issue_texts: HashMap<usize, String>,
388 pub status_msg: Option<String>,
389 pub completion: Option<CompletionState>,
391 pub hover: Option<HoverState>,
392 pub lsp_version: i32,
394 pub deferred_ops: Vec<Operation>,
397 pub search: Option<SearchState>,
399 pub last_search: Option<SearchState>,
401 pub go_to_line: Option<GoToLineState>,
403 pub rename_prompt: Option<RenameState>,
405 pub(crate) clipboard_picker: Option<ClipboardPickerState>,
408 pub word_wrap: bool,
410 pub show_line_numbers: bool,
412 pub use_space: bool,
414 pub indentation_width: usize,
416 pub highlight_cache: HashMap<usize, Vec<StyledSpan>>,
419
420 pub pending_highlight_lines: HashSet<usize>,
422
423 pub stale_highlight_lines: HashSet<usize>,
426
427 pub highlight_task: Option<tokio::task::JoinHandle<()>>,
429
430 pub highlight_generation: u64,
433 pub(crate) prev_scroll: usize,
436 pub(crate) last_body_area: Rect,
439 pub(crate) last_search_bar_area: Rect,
441 pub(crate) last_file_panel_area: Rect,
443 pub(crate) last_match_panel_area: Rect,
445 click_state: std::cell::RefCell<ClickState>,
448 pub external_conflict_msg: Option<String>,
450 pub lang_id: Option<crate::language::LanguageId>,
452 pub issue_error_count: usize,
455 pub issue_warning_count: usize,
458 pub git_changed_lines: HashSet<usize>,
461}
462
463impl EditorView {
464 pub fn open(buffer: Buffer, folds: FoldState, settings: &crate::settings::Settings) -> Self {
465 use crate::settings::adapters;
466 let highlighter = buffer
467 .path
468 .as_deref()
469 .map(SyntaxHighlighter::for_path)
470 .unwrap_or_else(SyntaxHighlighter::plain);
471 let lang_id = buffer
472 .path
473 .as_deref()
474 .and_then(crate::language::detect_language);
475 Self {
476 buffer,
477 folds,
478 highlighter,
479 gutter_markers: Vec::new(),
480 gutter_issue_texts: HashMap::new(),
481 status_msg: None,
482 completion: None,
483 hover: None,
484 lsp_version: 1,
485 deferred_ops: Vec::new(),
486 search: None,
487 last_search: None,
488 go_to_line: None,
489 rename_prompt: None,
490 clipboard_picker: None,
491 word_wrap: *adapters::editor::word_wrap(settings),
492 show_line_numbers: *adapters::editor::show_line_numbers(settings),
493 use_space: *adapters::editor::use_space(settings),
494 indentation_width: *adapters::editor::indentation_width(settings) as usize,
495 highlight_cache: HashMap::new(),
496 pending_highlight_lines: HashSet::new(),
497 stale_highlight_lines: HashSet::new(),
498 highlight_task: None,
499 highlight_generation: 0,
500 prev_scroll: 0,
501 last_body_area: Rect::default(),
502 last_search_bar_area: Rect::default(),
503 last_file_panel_area: Rect::default(),
504 last_match_panel_area: Rect::default(),
505 click_state: std::cell::RefCell::new(ClickState::default()),
506 external_conflict_msg: None,
507 lang_id,
508 issue_error_count: 0,
509 issue_warning_count: 0,
510 git_changed_lines: HashSet::new(),
511 }
512 }
513
514 pub fn into_state(mut self) -> (Buffer, FoldState) {
515 self.invalidate_all_highlights();
518 (self.buffer, self.folds)
519 }
520
521 pub fn take_deferred_ops(&mut self) -> Vec<Operation> {
523 std::mem::take(&mut self.deferred_ops)
524 }
525
526 pub fn start_file_watching(&mut self) {
527 if !self.buffer.is_dirty() {
528 self.buffer.compute_and_store_file_hash();
529 }
530 }
531
532 pub fn stop_file_watching(&mut self) {
533 }
534
535 pub fn check_external_modification_for_paths(&mut self, changed_paths: &[std::path::PathBuf]) -> bool {
536 let path = match &self.buffer.path {
537 Some(p) => p.clone(),
538 None => return false,
539 };
540 for changed_path in changed_paths {
541 if changed_path == &path
542 && self.buffer.check_external_modification() {
543 let filename = path.file_name()
544 .map(|n| n.to_string_lossy().to_string())
545 .unwrap_or_else(|| path.to_string_lossy().to_string());
546 self.external_conflict_msg = Some(
547 format!("{} modified externally. Press Ctrl+Shift+R to reload or Ctrl+S to save.", filename),
548 );
549 return true;
550 }
551 }
552 false
553 }
554
555 pub fn reload_from_disk(&mut self) -> Result<()> {
556 self.buffer.reload_from_disk()?;
557 self.external_conflict_msg = None;
558 self.buffer.compute_and_store_file_hash();
559 self.invalidate_all_highlights();
560 Ok(())
561 }
562
563 pub(crate) fn invalidate_highlights_from(&mut self, from_line: usize) {
568 if let Some(task) = self.highlight_task.take() {
570 task.abort();
571 }
572 self.highlight_generation = self.highlight_generation.wrapping_add(1);
573 self.highlighter.invalidate_from(from_line);
574 for &k in self.highlight_cache.keys() {
576 if k >= from_line {
577 self.stale_highlight_lines.insert(k);
578 }
579 }
580 self.pending_highlight_lines.retain(|&k| k < from_line);
582 }
583
584 pub(crate) fn invalidate_all_highlights(&mut self) {
586 if let Some(task) = self.highlight_task.take() {
587 task.abort();
588 }
589 self.highlight_generation = self.highlight_generation.wrapping_add(1);
590 self.highlighter.clear_cache();
591 self.highlight_cache.clear();
592 self.pending_highlight_lines.clear();
593 self.stale_highlight_lines.clear();
594 }
595
596 pub fn clear_external_modification(&mut self) {
597 self.external_conflict_msg = None;
598 self.buffer.compute_and_store_file_hash();
599 }
600
601 pub(crate) fn path_matches(&self, path: &Option<std::path::PathBuf>) -> bool {
602 match (path, &self.buffer.path) {
603 (None, _) => true,
604 (Some(p), Some(bp)) => p == bp,
605 _ => false,
606 }
607 }
608
609 pub(crate) fn recompute_search_matches(&mut self) {
614 if let Some(s) = &mut self.search {
615 s.matches = find_matches(&self.buffer.lines(), s.query.text(), &s.opts);
616 s.current = s.current.min(s.matches.len().saturating_sub(1));
617 }
618 if let Some(s) = &mut self.last_search {
619 s.matches = find_matches(&self.buffer.lines(), s.query.text(), &s.opts);
620 s.current = s.current.min(s.matches.len().saturating_sub(1));
621 }
622 }
623
624 fn render_body(&mut self, frame: &mut Frame, area: Rect, theme: &crate::theme::Theme) {
625 let total_lines = self.buffer.lines().len();
626 let gutter_w = gutter_width(self.show_line_numbers, total_lines);
627 let content_w = (area.width as usize).saturating_sub(gutter_w);
628
629 if self.word_wrap {
630 self.buffer.scroll_x = 0;
631 self.buffer
632 .scroll_to_cursor_visual(area.height as usize, content_w, self.indentation_width);
633 } else {
634 self.buffer.scroll_to_cursor(area.height as usize);
635 self.buffer.scroll_x_to_cursor(content_w, self.indentation_width);
636 }
637
638 let (matches, current) = match self.search.as_ref().or(self.last_search.as_ref()) {
639 Some(s) if !s.matches.is_empty() => (s.matches.as_slice(), Some(s.current)),
640 _ => (&[] as &[(usize, usize, usize)], None),
641 };
642 let selection_spans: Vec<(usize, usize, usize)> = self
643 .buffer
644 .selection()
645 .map(|s| s.covered_spans())
646 .unwrap_or_default();
647 frame.render_widget(
648 EditorWidget {
649 buffer: &self.buffer,
650 folds: &self.folds,
651 highlight_cache: &self.highlight_cache,
652 gutter_markers: &self.gutter_markers,
653 git_changed_lines: &self.git_changed_lines,
654 search_matches: matches,
655 search_current: current,
656 selection_spans: &selection_spans,
657 scroll_x: self.buffer.scroll_x,
658 word_wrap: self.word_wrap,
659 show_line_numbers: self.show_line_numbers,
660 tab_width: self.indentation_width,
661 gutter_bg: theme.gutter_bg(),
662 },
663 area,
664 );
665 }
666
667 fn render_search_bar(&self, frame: &mut Frame, area: Rect) {
668 let s = self.search.as_ref().unwrap();
669 let bar_bg = Color::Rgb(30, 45, 70);
670 let active_bg = Color::Rgb(50, 70, 110);
671 let btn_on = Style::default()
672 .fg(Color::Black)
673 .bg(Color::Rgb(80, 170, 220));
674 let btn_off = Style::default()
675 .fg(Color::DarkGray)
676 .bg(Color::Rgb(40, 55, 80));
677
678 const BTN_W: u16 = 27;
681 let left_w = area.width.saturating_sub(BTN_W);
682
683 let btn_row = Line::from(vec![
684 Span::styled(" ", Style::default().bg(bar_bg)),
685 Span::styled(
686 " IgnCase ",
687 if s.opts.ignore_case { btn_on } else { btn_off },
688 ),
689 Span::styled(" ", Style::default().bg(bar_bg)),
690 Span::styled(" Regex ", if s.opts.regex { btn_on } else { btn_off }),
691 Span::styled(" ", Style::default().bg(bar_bg)),
692 Span::styled(" Smart ", if s.opts.smart_case { btn_on } else { btn_off }),
693 Span::styled(" ", Style::default().bg(bar_bg)),
694 ]);
695
696 let count_str = if s.matches.is_empty() {
697 " No matches".to_owned()
698 } else {
699 format!(" {}/{}", s.current + 1, s.matches.len())
700 };
701
702 let query_area = Rect { height: 1, ..area };
704 let [left_area, right_area] = Layout::default()
705 .direction(Direction::Horizontal)
706 .constraints([Constraint::Length(left_w), Constraint::Length(BTN_W)])
707 .split(query_area)[..]
708 else {
709 return;
710 };
711
712 let query_line = format!(" Find: {} {}", s.query.text(), count_str);
713 frame.render_widget(
714 Paragraph::new(query_line).style(Style::default().fg(Color::White).bg(
715 if s.focus.current() == SEARCH_FOCUS_QUERY {
716 active_bg
717 } else {
718 bar_bg
719 },
720 )),
721 left_area,
722 );
723 frame.render_widget(
724 Paragraph::new(btn_row.clone()).style(Style::default().bg(bar_bg)),
725 right_area,
726 );
727
728 if s.kind == SearchKind::Replace && area.height >= 2 {
730 let replace_area = Rect {
731 y: area.y + 1,
732 height: 1,
733 ..area
734 };
735 let [left_r, right_r] = Layout::default()
736 .direction(Direction::Horizontal)
737 .constraints([Constraint::Length(left_w), Constraint::Length(BTN_W)])
738 .split(replace_area)[..]
739 else {
740 return;
741 };
742
743 let replace_line = format!(" Replace: {}", s.replacement.text());
744 frame.render_widget(
745 Paragraph::new(replace_line).style(Style::default().fg(Color::White).bg(
746 if s.focus.current() == SEARCH_FOCUS_REPLACEMENT {
747 active_bg
748 } else {
749 bar_bg
750 },
751 )),
752 left_r,
753 );
754 let hint = Line::from(vec![Span::styled(
755 " Enter:replace Alt+A:all ",
756 Style::default().fg(Color::DarkGray).bg(bar_bg),
757 )]);
758 frame.render_widget(
759 Paragraph::new(hint).style(Style::default().bg(bar_bg)),
760 right_r,
761 );
762 }
763
764 if s.mode == SearchMode::Expanded && area.height >= 2 {
766 let filter_area = Rect {
767 y: area.y + 1,
768 height: 1,
769 ..area
770 };
771 let half = filter_area.width / 2;
772 let [incl_area, excl_area] = Layout::default()
773 .direction(Direction::Horizontal)
774 .constraints([Constraint::Length(half), Constraint::Min(1)])
775 .split(filter_area)[..]
776 else {
777 return;
778 };
779
780 let incl_focused = s.focus.current() == SEARCH_FOCUS_INCLUDE;
781 let excl_focused = s.focus.current() == SEARCH_FOCUS_EXCLUDE;
782
783 let incl_text = format!(" incl: {}", s.include_filter.text());
784 frame.render_widget(
785 Paragraph::new(incl_text).style(Style::default().fg(Color::White).bg(
786 if incl_focused { active_bg } else { bar_bg },
787 )),
788 incl_area,
789 );
790 let excl_text = format!(" excl: {}", s.exclude_filter.text());
791 frame.render_widget(
792 Paragraph::new(excl_text).style(Style::default().fg(Color::White).bg(
793 if excl_focused { active_bg } else { bar_bg },
794 )),
795 excl_area,
796 );
797 }
798 }
799
800
801
802
803
804 fn render_search_tree(&self, frame: &mut Frame, area: Rect) {
806 let s = match self.search.as_ref() {
807 Some(s) => s,
808 None => return,
809 };
810
811 let tree_bg = Color::Rgb(15, 22, 38);
812 let selected_bg = Color::Rgb(40, 60, 100);
813 let header_bg = Color::Rgb(25, 35, 55);
814 let hit_bg = Color::Rgb(100, 80, 0);
815 let hit_fg = Color::White;
816 let file_fg = Color::Rgb(200, 200, 220);
817 let match_fg = Color::Rgb(160, 160, 180);
818 let count_fg = Color::Rgb(120, 140, 170);
819 let divider_fg = Color::Rgb(60, 70, 90);
820
821 let on_tree = s.focus.current() == SEARCH_FOCUS_TREE;
822
823 if area.height < 1 { return; }
825 let total_matches: usize = s.files.iter().map(|f| f.matches.len()).sum();
826 let header_text = if s.files.is_empty() && s.project_search_generation > 0 {
827 " searching…".to_string()
828 } else {
829 format!(" {} files {} matches", s.files.len(), total_matches)
830 };
831 let header_style = if on_tree {
832 Style::default().fg(Color::White).bg(header_bg)
833 } else {
834 Style::default().fg(count_fg).bg(header_bg)
835 };
836 frame.render_widget(
837 Paragraph::new(header_text).style(header_style),
838 Rect { x: area.x, y: area.y, width: area.width.saturating_sub(1), height: 1 },
839 );
840 frame.render_widget(
842 Paragraph::new("│").style(Style::default().fg(divider_fg).bg(header_bg)),
843 Rect { x: area.x + area.width.saturating_sub(1), y: area.y, width: 1, height: 1 },
844 );
845
846 let list_height = (area.height as usize).saturating_sub(1);
847 if list_height == 0 { return; }
848 let list_y = area.y + 1;
849
850 let rows = build_search_tree(s);
851 let cursor_flat = resolve_tree_cursor(&rows, &s.files, &s.tree_cursor_path, &s.tree_cursor_match);
852
853 let scroll = {
855 let mut sc = s.tree_scroll;
856 if cursor_flat < sc {
857 sc = cursor_flat;
858 } else if cursor_flat >= sc + list_height {
859 sc = cursor_flat.saturating_sub(list_height - 1);
860 }
861 sc
862 };
863
864 let content_w = area.width.saturating_sub(1); for i in 0..list_height {
867 let row_y = list_y + i as u16;
868 let flat_idx = scroll + i;
869 if flat_idx >= rows.len() {
870 frame.render_widget(
872 Paragraph::new("").style(Style::default().bg(tree_bg)),
873 Rect { x: area.x, y: row_y, width: content_w, height: 1 },
874 );
875 } else {
876 let is_selected = flat_idx == cursor_flat && on_tree;
877 let row_bg = if is_selected { selected_bg } else { tree_bg };
878
879 match &rows[flat_idx] {
880 SearchTreeRow::File { file_idx, expanded } => {
881 let fm = &s.files[*file_idx];
882 let icon = if *expanded { "▾ " } else { "▸ " };
883 let display_path = {
885 let components: Vec<_> = fm.path.components().collect();
886 let n = components.len();
887 if n > 2 {
888 let parent = components[n-2].as_os_str().to_string_lossy();
889 let name = components[n-1].as_os_str().to_string_lossy();
890 format!("{}/{}", parent, name)
891 } else {
892 fm.path.file_name().map(|f| f.to_string_lossy().to_string())
893 .unwrap_or_default()
894 }
895 };
896 let count = format!(" ({})", fm.matches.len());
897 let spans = vec![
898 Span::styled(icon, Style::default().fg(count_fg).bg(row_bg)),
899 Span::styled(display_path, Style::default().fg(file_fg).bg(row_bg)),
900 Span::styled(count, Style::default().fg(count_fg).bg(row_bg)),
901 ];
902 let line = Line::from(spans);
903 frame.render_widget(
904 Paragraph::new(line),
905 Rect { x: area.x, y: row_y, width: content_w, height: 1 },
906 );
907 }
908 SearchTreeRow::Match { file_idx, match_idx } => {
909 let ms = &s.files[*file_idx].matches[*match_idx];
910 let indent = " ";
911 let line_num = format!("{}: ", ms.line + 1);
912 let text = ms.line_text.trim_start();
913 let trim_offset = ms.line_text.len() - text.len();
914
915 let mut spans = vec![
917 Span::styled(indent, Style::default().fg(match_fg).bg(row_bg)),
918 Span::styled(line_num, Style::default().fg(count_fg).bg(row_bg)),
919 ];
920 let bs = ms.byte_start.saturating_sub(trim_offset);
922 let be = ms.byte_end.saturating_sub(trim_offset);
923 if bs < text.len() && be <= text.len() && bs < be {
924 if bs > 0 {
925 spans.push(Span::styled(&text[..bs], Style::default().fg(match_fg).bg(row_bg)));
926 }
927 spans.push(Span::styled(&text[bs..be], Style::default().fg(hit_fg).bg(hit_bg)));
928 if be < text.len() {
929 spans.push(Span::styled(&text[be..], Style::default().fg(match_fg).bg(row_bg)));
930 }
931 } else {
932 spans.push(Span::styled(text, Style::default().fg(match_fg).bg(row_bg)));
933 }
934 let line = Line::from(spans);
935 frame.render_widget(
936 Paragraph::new(line),
937 Rect { x: area.x, y: row_y, width: content_w, height: 1 },
938 );
939 }
940 }
941 }
942 frame.render_widget(
944 Paragraph::new("│").style(Style::default().fg(divider_fg).bg(tree_bg)),
945 Rect { x: area.x + area.width.saturating_sub(1), y: row_y, width: 1, height: 1 },
946 );
947 }
948 }
949
950 fn render_go_to_line_bar(&self, frame: &mut Frame, area: Rect) {
951 let state = match &self.go_to_line {
952 Some(s) => s,
953 None => return,
954 };
955 let bar_bg = Color::Rgb(30, 45, 70);
956 let total = self.buffer.line_count();
957 let hint = format!(" of {} ", total);
958 let hint_w = hint.len() as u16;
959 let input_w = area.width.saturating_sub(hint_w);
960 let [input_area, hint_area] = Layout::default()
961 .direction(Direction::Horizontal)
962 .constraints([Constraint::Length(input_w), Constraint::Length(hint_w)])
963 .split(area)[..]
964 else {
965 return;
966 };
967 let line = format!(" Go to line: {}", state.input.text());
968 frame.render_widget(
969 Paragraph::new(line).style(Style::default().fg(Color::White).bg(Color::Rgb(50, 70, 110))),
970 input_area,
971 );
972 frame.render_widget(
973 Paragraph::new(hint).style(Style::default().fg(Color::DarkGray).bg(bar_bg)),
974 hint_area,
975 );
976 }
977
978 fn render_rename_bar(&self, frame: &mut Frame, area: Rect) {
979 let state = match &self.rename_prompt {
980 Some(s) => s,
981 None => return,
982 };
983 let hint = " [Enter] confirm [Esc] cancel ";
984 let hint_w = (hint.len() as u16).min(area.width / 2);
985 let input_w = area.width.saturating_sub(hint_w);
986 let [input_area, hint_area] = Layout::default()
987 .direction(Direction::Horizontal)
988 .constraints([Constraint::Length(input_w), Constraint::Length(hint_w)])
989 .split(area)[..]
990 else {
991 return;
992 };
993 let text = format!(" Rename: {}_", state.input.text());
994 frame.render_widget(
995 Paragraph::new(text).style(Style::default().fg(Color::White).bg(Color::Rgb(60, 30, 80))),
996 input_area,
997 );
998 frame.render_widget(
999 Paragraph::new(hint).style(Style::default().fg(Color::DarkGray).bg(Color::Rgb(40, 20, 60))),
1000 hint_area,
1001 );
1002 }
1003
1004 fn render_clipboard_picker(&self, frame: &mut Frame, registers: &Registers) {
1005 let picker = match &self.clipboard_picker {
1006 Some(p) => p,
1007 None => return,
1008 };
1009
1010 const MAX_ITEMS: usize = 10;
1011 let history_len = registers.len();
1012 if history_len == 0 {
1013 return;
1014 }
1015
1016 let term = frame.area();
1017 let visible_count = history_len.min(MAX_ITEMS);
1018 let inner_h = (visible_count as u16) + 1;
1020 let popup_h = inner_h + 2; let popup_w = 64u16.min(term.width.saturating_sub(4));
1022 let x = (term.width.saturating_sub(popup_w)) / 2;
1023 let y = (term.height.saturating_sub(popup_h)) / 2;
1024 let popup_area = Rect {
1025 x,
1026 y,
1027 width: popup_w,
1028 height: popup_h,
1029 };
1030
1031 frame.render_widget(Clear, popup_area);
1032
1033 let block = Block::default()
1034 .title(" Clipboard History ")
1035 .borders(Borders::ALL)
1036 .border_style(Style::default().fg(Color::Cyan));
1037 let inner = block.inner(popup_area);
1038 frame.render_widget(block, popup_area);
1039
1040 let hint_y = inner.y + inner.height.saturating_sub(1);
1042 frame.render_widget(
1043 Paragraph::new(Line::from(vec![
1044 Span::styled(" ↑↓ navigate", Style::default().fg(Color::DarkGray)),
1045 Span::styled(" Enter paste", Style::default().fg(Color::DarkGray)),
1046 Span::styled(" Esc close ", Style::default().fg(Color::DarkGray)),
1047 ])),
1048 Rect {
1049 x: inner.x,
1050 y: hint_y,
1051 width: inner.width,
1052 height: 1,
1053 },
1054 );
1055
1056 let items_h = inner.height.saturating_sub(1) as usize;
1058 let scroll = if picker.cursor >= items_h {
1059 picker.cursor + 1 - items_h
1060 } else {
1061 0
1062 };
1063
1064 for (display_idx, (ring_idx, text)) in registers
1065 .iter()
1066 .enumerate()
1067 .skip(scroll)
1068 .take(items_h)
1069 .enumerate()
1070 {
1071 let row_y = inner.y + display_idx as u16;
1072 if row_y >= hint_y {
1073 break;
1074 }
1075 let is_selected = ring_idx == picker.cursor;
1076 let bg = if is_selected {
1077 Color::DarkGray
1078 } else {
1079 Color::Reset
1080 };
1081 let fg = if is_selected {
1082 Color::White
1083 } else {
1084 Color::Gray
1085 };
1086
1087 let preview = text.lines().next().unwrap_or("");
1089 let label = format!("{:>2} {}", ring_idx + 1, preview);
1090 let width = inner.width as usize;
1091 let padded = format!("{:<width$}", label.chars().take(width).collect::<String>());
1092
1093 frame.render_widget(
1094 Paragraph::new(padded).style(Style::default().fg(fg).bg(bg)),
1095 Rect {
1096 x: inner.x,
1097 y: row_y,
1098 width: inner.width,
1099 height: 1,
1100 },
1101 );
1102 }
1103 }
1104
1105
1106
1107 #[allow(dead_code)]
1108 fn render_completion_overlay(
1109 &self,
1110 frame: &mut Frame,
1111 body_area: Rect,
1112 comp: &CompletionState,
1113 ) {
1114 let gutter_w = gutter_width(self.show_line_numbers, self.buffer.line_count());
1116 let scroll = self.buffer.scroll;
1117 let cursor = self.buffer.cursor();
1118
1119 if cursor.line < scroll || cursor.line - scroll >= body_area.height as usize {
1120 return;
1121 }
1122
1123 let anchor_x = body_area.x + gutter_w as u16 + cursor.column as u16;
1124 let anchor_y = body_area.y + (cursor.line - scroll) as u16;
1125
1126 let filter = self.completion_filter(comp);
1128
1129 let widget = CompletionWidget {
1130 items: &comp.items,
1131 cursor: comp.cursor,
1132 filter: &filter,
1133 anchor_x,
1134 anchor_y,
1135 terminal_area: frame.area(),
1136 loading: comp.loading,
1137 };
1138 frame.render_widget(widget, frame.area());
1139 }
1140
1141 #[allow(dead_code)]
1142 fn render_hover_overlay(&self, frame: &mut Frame, body_area: Rect, hover: &HoverState) {
1143 let gutter_w = gutter_width(self.show_line_numbers, self.buffer.line_count());
1144 let scroll = self.buffer.scroll;
1145 let cursor = self.buffer.cursor();
1146
1147 if cursor.line < scroll || cursor.line - scroll >= body_area.height as usize {
1148 return;
1149 }
1150
1151 let anchor_x = body_area.x + gutter_w as u16 + cursor.column as u16;
1152 let anchor_y = body_area.y + (cursor.line - scroll) as u16;
1153
1154 let widget = HoverWidget {
1155 text: &hover.text,
1156 anchor_x,
1157 anchor_y,
1158 terminal_area: frame.area(),
1159 };
1160 frame.render_widget(widget, frame.area());
1161 }
1162
1163 pub(crate) fn screen_to_cursor(&self, col: u16, row: u16) -> Option<Position> {
1172 let area = self.last_body_area;
1173 if area.width == 0 || area.height == 0 {
1174 return None;
1175 }
1176 if row < area.y || row >= area.y + area.height {
1177 return None;
1178 }
1179 if col < area.x {
1180 return None;
1181 }
1182
1183 let visual_row = (row - area.y) as usize;
1184
1185 let visible: Vec<usize> = self
1190 .folds
1191 .visible_lines(&self.buffer.lines())
1192 .into_iter()
1193 .filter(|(logical, _)| *logical >= self.buffer.scroll)
1194 .map(|(logical, _)| logical)
1195 .collect();
1196 let logical_row = *visible.get(visual_row)?;
1197
1198 let lines = self.buffer.lines();
1199 let line = lines.get(logical_row)?;
1200
1201 let gw = gutter_width(self.show_line_numbers, self.buffer.line_count());
1202 let content_x = area.x + gw as u16;
1203
1204 if col < content_x {
1205 return Some(Position::new(logical_row, 0));
1207 }
1208
1209 let visual_col = (col - content_x) as usize + self.buffer.scroll_x;
1212
1213 let char_col = visual_col.min(line.chars().count());
1216
1217 Some(Position::new(logical_row, char_col))
1218 }
1219
1220 fn completion_filter(&self, comp: &CompletionState) -> String {
1223 let cur = self.buffer.cursor();
1224 let trig = comp.trigger_cursor;
1225 if cur.line != trig.line || cur.column <= trig.column {
1226 return String::new();
1227 }
1228 if let Some(line) = self.buffer.lines().get(cur.line) {
1230 let bytes = line.as_bytes();
1231 let start = trig.column.min(bytes.len());
1232 let end = cur.column.min(bytes.len());
1233 if start <= end {
1234 return String::from_utf8_lossy(&bytes[start..end]).into_owned();
1235 }
1236 }
1237 String::new()
1238 }
1239}
1240
1241impl View for EditorView {
1246 const KIND: crate::views::ViewKind = crate::views::ViewKind::Primary;
1247
1248 fn save_state(&mut self, app: &mut crate::app_state::AppState) {
1249 crate::views::save_state::editor_pre_save(self, app);
1250 }
1251 fn handle_key(&self, key: KeyEvent) -> Vec<Operation> {
1253 let path = self.buffer.path.clone();
1254
1255 if self.clipboard_picker.is_some() {
1257 return match key.key {
1258 Key::ArrowUp => vec![Operation::ClipboardLocal(ClipOp::HistoryUp)],
1259 Key::ArrowDown => vec![Operation::ClipboardLocal(ClipOp::HistoryDown)],
1260 Key::Enter => vec![Operation::ClipboardLocal(ClipOp::HistoryConfirm)],
1261 _ => vec![],
1262 };
1263 }
1264
1265 if self.go_to_line.is_some() {
1267 return match key.key {
1268 Key::Enter => vec![Operation::GoToLineLocal(GoToLineOp::Confirm)],
1269 _ => {
1270 if let Some(field_op) = crate::widgets::input_field::key_to_op(key) {
1271 vec![Operation::GoToLineLocal(GoToLineOp::Input(field_op))]
1272 } else {
1273 vec![]
1274 }
1275 }
1276 };
1277 }
1278
1279 if self.rename_prompt.is_some() {
1281 return match key.key {
1282 Key::Enter => {
1283 let new_name = self.rename_prompt.as_ref()
1284 .map(|r| r.input.text().trim().to_string())
1285 .unwrap_or_default();
1286 let (row, col) = self.rename_prompt.as_ref()
1287 .map(|r| (r.cursor_row, r.cursor_col))
1288 .unwrap_or((0, 0));
1289 vec![
1291 Operation::LspRenameLocal(LspRenameOp::Dismiss),
1292 Operation::LspTriggerRenameWith { new_name, row, col },
1293 ]
1294 }
1295 _ => {
1296 if let Some(field_op) = crate::widgets::input_field::key_to_op(key) {
1297 vec![Operation::LspRenameLocal(LspRenameOp::Input(field_op))]
1298 } else {
1299 vec![]
1300 }
1301 }
1302 };
1303 }
1304
1305 if let Some(search) = &self.search {
1307 let on_replacement =
1308 search.kind == SearchKind::Replace && search.focus.current() == SEARCH_FOCUS_REPLACEMENT;
1309 let on_include =
1310 search.mode == SearchMode::Expanded && search.focus.current() == SEARCH_FOCUS_INCLUDE;
1311 let on_exclude =
1312 search.mode == SearchMode::Expanded && search.focus.current() == SEARCH_FOCUS_EXCLUDE;
1313 let on_tree =
1314 search.mode == SearchMode::Expanded && search.focus.current() == SEARCH_FOCUS_TREE;
1315 let on_files =
1317 search.mode == SearchMode::Expanded && search.focus.current() == SEARCH_FOCUS_FILES;
1318 let on_matches =
1319 search.mode == SearchMode::Expanded && search.focus.current() == SEARCH_FOCUS_MATCHES;
1320 let on_panel = on_tree || on_files || on_matches;
1321 return match (key.modifiers, key.key) {
1322 (_, Key::F(3)) if !key.modifiers.contains(Modifiers::SHIFT) => {
1323 vec![Operation::SearchLocal(SearchOp::NextMatch)]
1324 }
1325 (m, Key::F(3)) if m.contains(Modifiers::SHIFT) => {
1326 vec![Operation::SearchLocal(SearchOp::PrevMatch)]
1327 }
1328 (_, Key::Tab) if search.kind == SearchKind::Replace || search.mode == SearchMode::Expanded => {
1330 vec![Operation::Focus(FocusOp::Next)]
1331 }
1332 (_, Key::BackTab) if search.kind == SearchKind::Replace || search.mode == SearchMode::Expanded => {
1333 vec![Operation::Focus(FocusOp::Prev)]
1334 }
1335 (_, Key::ArrowUp) if on_tree => {
1337 vec![Operation::SearchLocal(SearchOp::TreeMoveUp)]
1338 }
1339 (_, Key::ArrowDown) if on_tree => {
1340 vec![Operation::SearchLocal(SearchOp::TreeMoveDown)]
1341 }
1342 (_, Key::ArrowRight) if on_tree => {
1344 vec![Operation::SearchLocal(SearchOp::TreeToggle)]
1345 }
1346 (_, Key::ArrowLeft) if on_tree => {
1348 vec![Operation::SearchLocal(SearchOp::TreeCollapse)]
1349 }
1350 (_, Key::ArrowUp) if on_files => {
1352 vec![Operation::SearchLocal(SearchOp::SelectFile(
1353 search.selected_file.saturating_sub(1),
1354 ))]
1355 }
1356 (_, Key::ArrowDown) if on_files => {
1357 vec![Operation::SearchLocal(SearchOp::SelectFile(
1358 search.selected_file + 1,
1359 ))]
1360 }
1361 (_, Key::ArrowUp) if on_matches => {
1362 vec![Operation::SearchLocal(SearchOp::ScrollMatchPanel(-1))]
1363 }
1364 (_, Key::ArrowDown) if on_matches => {
1365 vec![Operation::SearchLocal(SearchOp::ScrollMatchPanel(1))]
1366 }
1367 (_, Key::Enter) => {
1368 if on_replacement {
1369 vec![Operation::SearchLocal(SearchOp::ReplaceOne)]
1370 } else if on_include || on_exclude {
1371 vec![Operation::Focus(FocusOp::Next)]
1372 } else if on_tree {
1373 vec![Operation::SearchLocal(SearchOp::TreeToggle)]
1374 } else if on_panel {
1375 vec![]
1376 } else {
1377 vec![
1378 Operation::SearchLocal(SearchOp::NextMatch),
1379 Operation::SearchLocal(SearchOp::Close),
1380 ]
1381 }
1382 }
1383 (m, Key::Char('c')) if m.contains(Modifiers::ALT) => {
1384 vec![Operation::SearchLocal(SearchOp::ToggleIgnoreCase)]
1385 }
1386 (m, Key::Char('r')) if m.contains(Modifiers::ALT) => {
1387 vec![Operation::SearchLocal(SearchOp::ToggleRegex)]
1388 }
1389 (m, Key::Char('s')) if m.contains(Modifiers::ALT) => {
1390 vec![Operation::SearchLocal(SearchOp::ToggleSmartCase)]
1391 }
1392 (m, Key::Char('a')) if m.contains(Modifiers::ALT) && on_replacement => {
1393 vec![Operation::SearchLocal(SearchOp::ReplaceAll)]
1394 }
1395 _ => {
1396 if !on_panel
1398 && let Some(field_op) = crate::widgets::input_field::key_to_op(key) {
1399 let op = if on_replacement {
1400 SearchOp::ReplacementInput(field_op)
1401 } else if on_include {
1402 SearchOp::IncludeGlobInput(field_op)
1403 } else if on_exclude {
1404 SearchOp::ExcludeGlobInput(field_op)
1405 } else {
1406 SearchOp::QueryInput(field_op)
1407 };
1408 return vec![Operation::SearchLocal(op)];
1409 }
1410 vec![]
1411 }
1412 };
1413 }
1414
1415 if self.completion.is_some() {
1417 log::debug!("editor: completion is open, checking key: {:?}", key.key);
1418 match key.key {
1419 Key::ArrowUp => return vec![Operation::LspLocal(LspOp::CompletionMoveUp)],
1420 Key::ArrowDown => return vec![Operation::LspLocal(LspOp::CompletionMoveDown)],
1421 Key::Enter => return vec![Operation::LspLocal(LspOp::CompletionConfirm)],
1422 _ => {} }
1424 }
1425
1426 if self.hover.is_some() {
1428 match key.key {
1429 Key::ArrowUp
1430 | Key::ArrowDown
1431 | Key::ArrowLeft
1432 | Key::ArrowRight => {
1433 return vec![Operation::LspLocal(LspOp::HoverDismiss)];
1434 }
1435 _ => {}
1436 }
1437 }
1438
1439 match (key.modifiers, key.key) {
1440 (m, Key::ArrowLeft)
1442 if m.contains(Modifiers::CTRL) && m.contains(Modifiers::SHIFT) =>
1443 {
1444 vec![Operation::SelectionLocal(SelectionOp::Extend {
1445 head: self.buffer.word_boundary_prev(self.buffer.cursor()),
1446 })]
1447 }
1448 (m, Key::ArrowRight)
1449 if m.contains(Modifiers::CTRL) && m.contains(Modifiers::SHIFT) =>
1450 {
1451 vec![Operation::SelectionLocal(SelectionOp::Extend {
1452 head: self.buffer.word_boundary_next(self.buffer.cursor()),
1453 })]
1454 }
1455
1456 (m, Key::ArrowLeft) if m.contains(Modifiers::SHIFT) => {
1458 vec![Operation::SelectionLocal(SelectionOp::Extend {
1459 head: self.buffer.offset_left(self.buffer.cursor()),
1460 })]
1461 }
1462 (m, Key::ArrowRight) if m.contains(Modifiers::SHIFT) => {
1463 vec![Operation::SelectionLocal(SelectionOp::Extend {
1464 head: self.buffer.offset_right(self.buffer.cursor()),
1465 })]
1466 }
1467 (m, Key::ArrowUp) if m.contains(Modifiers::SHIFT) => {
1468 vec![Operation::SelectionLocal(SelectionOp::Extend {
1469 head: self.buffer.offset_up(self.buffer.cursor()),
1470 })]
1471 }
1472 (m, Key::ArrowDown) if m.contains(Modifiers::SHIFT) => {
1473 vec![Operation::SelectionLocal(SelectionOp::Extend {
1474 head: self.buffer.offset_down(self.buffer.cursor()),
1475 })]
1476 }
1477 (m, Key::Home) if m.contains(Modifiers::SHIFT) => {
1478 vec![Operation::SelectionLocal(SelectionOp::Extend {
1479 head: self.buffer.offset_line_start(self.buffer.cursor()),
1480 })]
1481 }
1482 (m, Key::End) if m.contains(Modifiers::SHIFT) => {
1483 vec![Operation::SelectionLocal(SelectionOp::Extend {
1484 head: self.buffer.offset_line_end(self.buffer.cursor()),
1485 })]
1486 }
1487
1488 (_, Key::ArrowUp) => vec![Operation::MoveCursor {
1489 path,
1490 cursor: self.buffer.offset_up(self.buffer.cursor()),
1491 }],
1492 (_, Key::ArrowDown) => vec![Operation::MoveCursor {
1493 path,
1494 cursor: self.buffer.offset_down(self.buffer.cursor()),
1495 }],
1496 (_, Key::ArrowLeft) => vec![Operation::MoveCursor {
1497 path,
1498 cursor: self.buffer.offset_left(self.buffer.cursor()),
1499 }],
1500 (_, Key::ArrowRight) => vec![Operation::MoveCursor {
1501 path,
1502 cursor: self.buffer.offset_right(self.buffer.cursor()),
1503 }],
1504 (_, Key::Home) => vec![Operation::MoveCursor {
1505 path,
1506 cursor: self.buffer.offset_line_start(self.buffer.cursor()),
1507 }],
1508 (_, Key::End) => vec![Operation::MoveCursor {
1509 path,
1510 cursor: self.buffer.offset_line_end(self.buffer.cursor()),
1511 }],
1512 (_, Key::PageUp) => vec![Operation::MoveCursor {
1513 path,
1514 cursor: self.buffer.offset_up_n(self.buffer.cursor(), 20),
1515 }],
1516 (_, Key::PageDown) => vec![Operation::MoveCursor {
1517 path,
1518 cursor: self.buffer.offset_down_n(self.buffer.cursor(), 20),
1519 }],
1520
1521 (_, Key::Enter) => vec![Operation::InsertText {
1522 path,
1523 cursor: self.buffer.cursor(),
1524 text: "\n".into(),
1525 }],
1526 (_, Key::Backspace) => vec![Operation::DeleteText {
1527 path,
1528 cursor: self.buffer.cursor(),
1529 len: 1,
1530 }],
1531 (m, Key::Tab) if !m.contains(Modifiers::CTRL) => {
1532 let col = self.buffer.cursor().column;
1533 let spaces = self.indentation_width - (col % self.indentation_width);
1534 vec![Operation::InsertText {
1535 path,
1536 cursor: self.buffer.cursor(),
1537 text: " ".repeat(spaces),
1538 }]
1539 }
1540 (_, Key::Delete) => vec![Operation::DeleteForward {
1541 path,
1542 cursor: self.buffer.cursor(),
1543 }],
1544
1545 (_, Key::Char(c))
1546 if !key.modifiers.contains(Modifiers::CTRL)
1547 || key.modifiers.contains(Modifiers::ALT) =>
1548 {
1549 let mut ops = vec![Operation::InsertText {
1550 path,
1551 cursor: self.buffer.cursor(),
1552 text: c.to_string(),
1553 }];
1554
1555 if is_trigger_character(&c, self.lang_id.as_ref().map(|l| l.as_str()), &[]) {
1559 log::debug!("editor: trigger character typed, triggering completion, lang_id={:?}", self.lang_id);
1560 ops.push(Operation::LspTriggerCompletion {
1561 path: self.buffer.path.clone(),
1562 trigger_char: Some(c.to_string()),
1563 });
1564 }
1565
1566 ops
1567 }
1568
1569 _ => vec![],
1570 }
1571 }
1572
1573 fn handle_mouse(&self, mouse: MouseEvent) -> Vec<Operation> {
1574 if let Some(search) = &self.search
1576 && search.mode == SearchMode::Expanded {
1577 if self.last_file_panel_area.width > 0
1579 && hit_test((mouse.column, mouse.row), self.last_file_panel_area)
1580 {
1581 let panel = self.last_file_panel_area;
1582 match mouse.kind {
1583 MouseEventKind::Down(MouseButton::Left) => {
1584 if mouse.row > panel.y {
1586 let rows = build_search_tree(search);
1587 let flat_idx = search.tree_scroll
1588 + (mouse.row - panel.y - 1) as usize;
1589 if flat_idx < rows.len() {
1590 let mut ops = Vec::new();
1592 match &rows[flat_idx] {
1596 SearchTreeRow::File { file_idx: _, .. } => {
1597 ops.push(Operation::SearchLocal(SearchOp::TreeMoveUp)); ops.push(Operation::SearchLocal(SearchOp::TreeToggle));
1600 }
1601 SearchTreeRow::Match { .. } => {
1602 ops.push(Operation::SearchLocal(SearchOp::TreeToggle));
1603 }
1604 }
1605 return vec![Operation::SearchLocal(SearchOp::SelectFile(flat_idx))];
1611 }
1612 }
1613 return vec![];
1614 }
1615 MouseEventKind::ScrollUp => {
1616 return vec![Operation::SearchLocal(SearchOp::TreeMoveUp)];
1617 }
1618 MouseEventKind::ScrollDown => {
1619 return vec![Operation::SearchLocal(SearchOp::TreeMoveDown)];
1620 }
1621 _ => return vec![],
1622 }
1623 }
1624 }
1625
1626 if self.search.is_some()
1628 && matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) {
1629 let search_area = self.last_search_bar_area;
1630 if search_area.width > 0 && hit_test((mouse.column, mouse.row), search_area) {
1631 const BTN_W: u16 = 27;
1634 let buttons_start = search_area.x + search_area.width.saturating_sub(BTN_W);
1635 let col = mouse.column;
1636
1637 if col >= buttons_start {
1638 let igncase_start = buttons_start + 1;
1641 let regex_start = buttons_start + 11;
1642 let smart_start = buttons_start + 19;
1643
1644 if col >= igncase_start && col < igncase_start + 9 {
1645 return vec![Operation::SearchLocal(SearchOp::ToggleIgnoreCase)];
1646 }
1647 if col >= regex_start && col < regex_start + 7 {
1648 return vec![Operation::SearchLocal(SearchOp::ToggleRegex)];
1649 }
1650 if col >= smart_start && col < smart_start + 7 {
1651 return vec![Operation::SearchLocal(SearchOp::ToggleSmartCase)];
1652 }
1653 }
1654 }
1655 }
1656
1657 match mouse.kind {
1658 MouseEventKind::Down(MouseButton::Left) => {
1660 let Some(click_pos) = self.screen_to_cursor(mouse.column, mouse.row) else {
1661 return vec![];
1662 };
1663
1664 let mut state = self.click_state.borrow_mut();
1665 let now = std::time::Instant::now();
1666
1667 let is_double = state.count >= 1
1669 && state.last_col == mouse.column
1670 && state.last_row == mouse.row
1671 && state
1672 .last_time
1673 .map(|t| now.duration_since(t).as_millis() < DOUBLE_CLICK_MS)
1674 .unwrap_or(false);
1675
1676 state.last_col = mouse.column;
1677 state.last_row = mouse.row;
1678 state.last_time = Some(now);
1679 state.count = if is_double { 2 } else { 1 };
1680 state.dragging = true;
1681 state.word_drag = is_double;
1682 drop(state);
1683
1684 if is_double {
1685 let (word_start_col, word_end_col) = self.buffer.word_range_at(click_pos);
1686 let word_start = Position::new(click_pos.line, word_start_col);
1687 let word_end = Position::new(click_pos.line, word_end_col);
1688 vec![
1689 Operation::MoveCursor { path: None, cursor: word_start },
1690 Operation::SelectionLocal(SelectionOp::Extend { head: word_end }),
1691 ]
1692 } else {
1693 vec![
1695 Operation::MoveCursor { path: None, cursor: click_pos },
1696 Operation::SelectionLocal(SelectionOp::Clear),
1697 ]
1698 }
1699 }
1700
1701 MouseEventKind::Drag(MouseButton::Left) => {
1703 let state = self.click_state.borrow();
1704 if !state.dragging {
1705 return vec![];
1706 }
1707 drop(state);
1708
1709 let Some(drag_pos) = self.screen_to_cursor(mouse.column, mouse.row) else {
1710 return vec![];
1711 };
1712 vec![Operation::SelectionLocal(SelectionOp::Extend { head: drag_pos })]
1714 }
1715
1716 MouseEventKind::Up(MouseButton::Left) => {
1718 self.click_state.borrow_mut().dragging = false;
1719 vec![]
1720 }
1721
1722 MouseEventKind::ScrollUp => vec![Operation::MoveCursor {
1724 path: None,
1725 cursor: self.buffer.offset_up_n(self.buffer.cursor(), 3),
1726 }],
1727 MouseEventKind::ScrollDown => vec![Operation::MoveCursor {
1728 path: None,
1729 cursor: self.buffer.offset_down_n(self.buffer.cursor(), 3),
1730 }],
1731 MouseEventKind::Down(MouseButton::Right) => {
1732 let has_selection = self.buffer.selection().is_some();
1733 vec![Operation::OpenContextMenu {
1734 items: vec![
1735 ("Cut".to_string(), crate::commands::CommandId::new_static("editor", "cut"), Some(has_selection)),
1736 ("Copy".to_string(), crate::commands::CommandId::new_static("editor", "copy"), Some(has_selection)),
1737 ("Paste".to_string(), crate::commands::CommandId::new_static("editor", "paste"), None),
1738 ("Find".to_string(), crate::commands::CommandId::new_static("editor", "find"), Some(true)),
1739 ("Replace".to_string(), crate::commands::CommandId::new_static("editor", "find_replace"), Some(true)),
1740 ("Go to Line".to_string(), crate::commands::CommandId::new_static("editor", "go_to_line"), Some(true)),
1741 ("Select All".to_string(), crate::commands::CommandId::new_static("editor", "select_all"), Some(true)),
1742 ("Comment/Uncomment".to_string(), crate::commands::CommandId::new_static("editor", "toggle_comment"), Some(true)),
1743 ("Format Document".to_string(), crate::commands::CommandId::new_static("editor", "format_document"), Some(true)),
1744 ],
1745 x: mouse.column,
1746 y: mouse.row,
1747 }]
1748 }
1749 _ => vec![],
1750 }
1751 }
1752
1753 fn handle_operation(&mut self, op: &Operation, _settings: &Settings) -> Option<Event> {
1755 match op {
1756 Operation::InsertText {
1757 path,
1758 cursor: _,
1759 text,
1760 } if self.path_matches(path) => {
1761 let edit_line = self.buffer.cursor().line;
1762 if text == "\n" {
1763 self.buffer.insert_newline_with_indent(self.use_space, self.indentation_width);
1764 } else {
1765 self.buffer.insert(text);
1766 }
1767 self.completion = None;
1768 self.lsp_version = self.lsp_version.wrapping_add(1);
1769 self.invalidate_highlights_from(edit_line);
1770 self.recompute_search_matches();
1771 Some(Event::applied("editor", op.clone()))
1772 }
1773
1774 Operation::DeleteText { path, .. } if self.path_matches(path) => {
1775 let edit_line = self.buffer.cursor().line.saturating_sub(
1777 if self.buffer.cursor().column == 0 { 1 } else { 0 }
1778 );
1779 self.buffer.delete_backward();
1780 self.completion = None;
1781 self.lsp_version = self.lsp_version.wrapping_add(1);
1782 self.invalidate_highlights_from(edit_line);
1783 self.recompute_search_matches();
1784 Some(Event::applied("editor", op.clone()))
1785 }
1786
1787 Operation::DeleteForward { path, .. } if self.path_matches(path) => {
1788 let edit_line = self.buffer.cursor().line;
1789 self.buffer.delete_forward();
1790 self.completion = None;
1791 self.lsp_version = self.lsp_version.wrapping_add(1);
1792 self.invalidate_highlights_from(edit_line);
1793 self.recompute_search_matches();
1794 Some(Event::applied("editor", op.clone()))
1795 }
1796
1797 Operation::DeleteWordBackward { path } if self.path_matches(path) => {
1798 let edit_line = self.buffer.cursor().line.saturating_sub(1);
1800 self.buffer.delete_word_backward();
1801 self.completion = None;
1802 self.lsp_version = self.lsp_version.wrapping_add(1);
1803 self.invalidate_highlights_from(edit_line);
1804 self.recompute_search_matches();
1805 Some(Event::applied("editor", op.clone()))
1806 }
1807
1808 Operation::DeleteWordForward { path } if self.path_matches(path) => {
1809 let edit_line = self.buffer.cursor().line;
1810 self.buffer.delete_word_forward();
1811 self.completion = None;
1812 self.lsp_version = self.lsp_version.wrapping_add(1);
1813 self.invalidate_highlights_from(edit_line);
1814 self.recompute_search_matches();
1815 Some(Event::applied("editor", op.clone()))
1816 }
1817
1818 Operation::DeleteLine { path } if self.path_matches(path) => {
1819 let edit_line = self.buffer.cursor().line;
1820 self.buffer.delete_line();
1821 self.completion = None;
1822 self.lsp_version = self.lsp_version.wrapping_add(1);
1823 self.invalidate_highlights_from(edit_line);
1824 self.recompute_search_matches();
1825 Some(Event::applied("editor", op.clone()))
1826 }
1827
1828 Operation::IndentLines { path } if self.path_matches(path) => {
1829 log::info!("indent");
1830 let (start_line, end_line) = if let Some(sel) = self.buffer.selection() {
1831 let min = sel.min();
1832 let max = sel.max();
1833 (min.line, max.line)
1834 } else {
1835 let line = self.buffer.cursor().line;
1836 (line, line)
1837 };
1838
1839 for line_idx in start_line..=end_line {
1840 if let Some(line_text) = self.buffer.line(line_idx) {
1841 let leading = line_text.len() - line_text.trim_start().len();
1842 let target = if leading % self.indentation_width == 0 {
1843 leading + self.indentation_width
1844 } else if (leading + 1) % self.indentation_width == 0 {
1845 leading.div_ceil(self.indentation_width) * self.indentation_width + self.indentation_width
1846 } else {
1847 leading.div_ceil(self.indentation_width) * self.indentation_width
1848 };
1849 let spaces_to_add = target - leading;
1850 let to_insert = if self.use_space {
1851 " ".repeat(spaces_to_add)
1852 } else {
1853 "\t".to_string()
1854 };
1855 self.buffer.replace_range(
1856 Position::new(line_idx, leading),
1857 Position::new(line_idx, leading),
1858 &to_insert,
1859 );
1860 }
1861 }
1862
1863 self.invalidate_highlights_from(start_line);
1864 self.recompute_search_matches();
1865 Some(Event::applied("editor", op.clone()))
1866 }
1867
1868 Operation::UnindentLines { path } if self.path_matches(path) => {
1869 log::info!("unindent");
1870
1871 let (start_line, end_line) = if let Some(sel) = self.buffer.selection() {
1872 let min = sel.min();
1873 let max = sel.max();
1874 (min.line, max.line)
1875 } else {
1876 let line = self.buffer.cursor().line;
1877 (line, line)
1878 };
1879
1880 for line_idx in start_line..=end_line {
1881 if let Some(line_text) = self.buffer.line(line_idx) {
1882 let leading = line_text.len() - line_text.trim_start().len();
1883 let spaces_to_remove = self.indentation_width.min(leading);
1884 if spaces_to_remove > 0 {
1885 self.buffer.replace_range(
1886 Position::new(line_idx, 0),
1887 Position::new(line_idx, spaces_to_remove),
1888 "",
1889 );
1890 }
1891 }
1892 }
1893
1894 self.invalidate_highlights_from(start_line);
1895 self.recompute_search_matches();
1896 Some(Event::applied("editor", op.clone()))
1897 }
1898
1899 Operation::ReplaceRange {
1900 path,
1901 start,
1902 end,
1903 text,
1904 } if self.path_matches(path) => {
1905 let current_cursor = self.buffer.cursor();
1909 if *start > *end || *start > current_cursor {
1910 self.completion = None;
1912 return Some(Event::applied("editor", op.clone()));
1913 }
1914 self.buffer.replace_range(*start, *end, text);
1915 self.completion = None;
1916 self.lsp_version = self.lsp_version.wrapping_add(1);
1917 self.invalidate_highlights_from(start.line);
1918 self.recompute_search_matches();
1919 Some(Event::applied("editor", op.clone()))
1920 }
1921
1922 Operation::MoveCursor { path, cursor } if self.path_matches(path) => {
1923 self.completion = None;
1926 self.buffer.set_cursor(*cursor);
1927 Some(Event::applied("editor", op.clone()))
1928 }
1929
1930 Operation::Undo { path } if self.path_matches(path) => {
1931 self.buffer.undo();
1932 self.completion = None;
1933 self.invalidate_all_highlights();
1934 Some(Event::applied("editor", op.clone()))
1935 }
1936
1937 Operation::Redo { path } if self.path_matches(path) => {
1938 self.buffer.redo();
1939 self.completion = None;
1940 self.invalidate_all_highlights();
1941 Some(Event::applied("editor", op.clone()))
1942 }
1943
1944 Operation::ToggleFold { path, line } if self.path_matches(path) => {
1945 self.folds.toggle(*line, &self.buffer.lines());
1946 Some(Event::applied("editor", op.clone()))
1947 }
1948
1949 Operation::ToggleMarker { path, line } if self.path_matches(path) => {
1950 let row = *line;
1951 if let Some(pos) = self.buffer.markers.iter().position(|m| m.line == row) {
1952 self.buffer.markers.remove(pos);
1953 } else {
1954 self.buffer.markers.push(crate::editor::buffer::Marker {
1955 line: row,
1956 label: "●".into(),
1957 });
1958 }
1959 Some(Event::applied("editor", op.clone()))
1960 }
1961
1962 Operation::ToggleWordWrap => {
1963 self.word_wrap = !self.word_wrap;
1964 self.buffer.scroll_x = 0;
1965 Some(Event::applied("editor", op.clone()))
1966 }
1967
1968 Operation::LspLocal(lsp_op) => {
1970 match lsp_op {
1971 LspOp::CompletionLoading { trigger } => {
1972 if self.buffer.cursor() != *trigger {
1975 return None;
1976 }
1977 self.completion = Some(CompletionState {
1978 items: vec![],
1979 cursor: 0,
1980 trigger_cursor: *trigger,
1981 loading: true,
1982 });
1983 Some(Event::applied("editor", op.clone()))
1984 }
1985
1986 LspOp::CompletionResponse { items, trigger, version } => {
1987 if let Some(v) = version
1988 && *v != self.lsp_version {
1989 return None;
1990 }
1991 let incoming_trigger = trigger.unwrap_or_else(|| self.buffer.cursor());
1992
1993 if let Some(ref existing) = self.completion
1997 && self.buffer.cursor() != existing.trigger_cursor {
1998 return None;
2000 }
2001
2002 self.completion = Some(CompletionState {
2003 items: items.clone(),
2004 cursor: 0,
2005 trigger_cursor: incoming_trigger,
2006 loading: false,
2007 });
2008 Some(Event::applied("editor", op.clone()))
2009 }
2010
2011 LspOp::HoverResponse(Some(text)) => {
2012 self.hover = Some(HoverState { text: text.clone() });
2013 Some(Event::applied("editor", op.clone()))
2014 }
2015
2016 LspOp::HoverResponse(None) => {
2017 self.hover = None;
2018 Some(Event::applied("editor", op.clone()))
2019 }
2020
2021 LspOp::CompletionMoveUp => {
2022 let visible_count = if let Some(c) = &self.completion {
2024 let f = self.completion_filter(c);
2025 c.items
2026 .iter()
2027 .filter(|item| {
2028 f.is_empty()
2029 || item.label.to_lowercase().contains(&f.to_lowercase())
2030 })
2031 .count()
2032 } else {
2033 0
2034 };
2035 if let Some(c) = &mut self.completion {
2036 if visible_count > 0 {
2038 c.cursor = c.cursor.min(visible_count - 1);
2039 }
2040 c.cursor = if c.cursor == 0 {
2042 visible_count.saturating_sub(1)
2043 } else {
2044 c.cursor - 1
2045 };
2046 }
2047 Some(Event::applied("editor", op.clone()))
2048 }
2049
2050 LspOp::CompletionMoveDown => {
2051 let visible_count = if let Some(c) = &self.completion {
2053 let f = self.completion_filter(c);
2054 c.items
2055 .iter()
2056 .filter(|item| {
2057 f.is_empty()
2058 || item.label.to_lowercase().contains(&f.to_lowercase())
2059 })
2060 .count()
2061 } else {
2062 0
2063 };
2064 if let Some(c) = &mut self.completion {
2065 if visible_count > 0 {
2067 c.cursor = c.cursor.min(visible_count - 1);
2068 }
2069 c.cursor = if visible_count == 0 || c.cursor + 1 >= visible_count {
2071 0
2072 } else {
2073 c.cursor + 1
2074 };
2075 }
2076 Some(Event::applied("editor", op.clone()))
2077 }
2078
2079 LspOp::CompletionConfirm => {
2080 let confirm = self.completion.as_ref().and_then(|c| {
2081 let filter = self.completion_filter(c);
2082 let filter_lower = filter.to_lowercase();
2083 c.items
2084 .iter()
2085 .filter(|item| {
2086 filter.is_empty()
2087 || item.label.to_lowercase().contains(&filter_lower)
2088 })
2089 .nth(c.cursor)
2090 .map(|item| {
2091 let text = item
2092 .insert_text
2093 .clone()
2094 .unwrap_or_else(|| item.label.clone());
2095 (c.trigger_cursor, text)
2096 })
2097 });
2098 self.completion = None;
2099 if let Some((trigger, text)) = confirm {
2100 let end_cursor = self.buffer.cursor();
2101 self.deferred_ops.push(Operation::ReplaceRange {
2105 path: self.buffer.path.clone(),
2106 start: trigger,
2107 end: end_cursor,
2108 text,
2109 });
2110 }
2111 Some(Event::applied("editor", op.clone()))
2112 }
2113
2114 LspOp::CompletionDismiss => {
2115 self.completion = None;
2116 Some(Event::applied("editor", op.clone()))
2117 }
2118
2119 LspOp::HoverDismiss => {
2120 self.hover = None;
2121 Some(Event::applied("editor", op.clone()))
2122 }
2123 }
2124 }
2125
2126 Operation::SearchLocal(sop) => {
2128 match sop {
2129 SearchOp::Open { replace } => {
2130 let kind = if *replace {
2131 SearchKind::Replace
2132 } else {
2133 SearchKind::Find
2134 };
2135 if self.search.is_none() {
2136 let (query_text, opts) = self
2138 .last_search
2139 .as_ref()
2140 .map(|s| (s.query.text().to_owned(), s.opts.clone()))
2141 .unwrap_or_default();
2142 let mut query = InputField::new("Find");
2143 if !query_text.is_empty() {
2144 query.set_text(query_text);
2145 }
2146 let focus = if kind == SearchKind::Replace {
2147 crate::widgets::focus::FocusRing::new(vec![
2148 SEARCH_FOCUS_QUERY, SEARCH_FOCUS_REPLACEMENT,
2149 ])
2150 } else {
2151 crate::widgets::focus::FocusRing::new(vec![
2152 SEARCH_FOCUS_QUERY,
2153 ])
2154 };
2155 self.search = Some(SearchState {
2156 query,
2157 replacement: InputField::new("Replace"),
2158 kind,
2159 mode: SearchMode::default(),
2160 focus,
2161 opts,
2162 matches: vec![],
2163 current: 0,
2164 files: Vec::new(),
2165 file_path_index: HashMap::new(),
2166 selected_file: 0,
2167 file_panel_scroll: 0,
2168 match_panel_scroll: 0,
2169 include_filter: InputField::new("incl").with_text("*"),
2170 exclude_filter: InputField::new("excl"),
2171 project_search_generation: 0,
2172 expanded_files: HashSet::new(),
2173 tree_cursor_path: None,
2174 tree_cursor_match: None,
2175 tree_scroll: 0,
2176 project_match_cursor: None,
2177 });
2178 self.recompute_search_matches();
2179 } else if let Some(s) = &mut self.search {
2180 if kind == SearchKind::Find && s.kind == SearchKind::Find {
2184 if s.mode == SearchMode::Inline {
2185 s.mode = SearchMode::Expanded;
2186 s.focus = crate::widgets::focus::FocusRing::new(vec![
2188 SEARCH_FOCUS_QUERY,
2189 SEARCH_FOCUS_INCLUDE,
2190 SEARCH_FOCUS_EXCLUDE,
2191 SEARCH_FOCUS_TREE,
2192 ]);
2193 } else {
2194 s.focus.set_focus(SEARCH_FOCUS_QUERY);
2196 }
2197 }
2198
2199 s.kind = kind;
2200 s.focus.set_focus(SEARCH_FOCUS_QUERY);
2201 if s.mode != SearchMode::Expanded {
2203 if kind == SearchKind::Replace {
2204 s.focus = crate::widgets::focus::FocusRing::new(vec![
2205 SEARCH_FOCUS_QUERY, SEARCH_FOCUS_REPLACEMENT,
2206 ]);
2207 } else {
2208 s.focus = crate::widgets::focus::FocusRing::new(vec![
2209 SEARCH_FOCUS_QUERY,
2210 ]);
2211 }
2212 }
2213 }
2214 }
2215 SearchOp::Close => {
2216 self.last_search = self.search.take();
2218 }
2219 SearchOp::QueryInput(field_op) => {
2220 if let Some(s) = &mut self.search {
2221 s.query.apply(field_op);
2222 }
2223 self.recompute_search_matches();
2224 }
2225 SearchOp::ReplacementInput(field_op) => {
2226 if let Some(s) = &mut self.search {
2227 s.replacement.apply(field_op);
2228 }
2229 }
2230 SearchOp::IncludeGlobInput(field_op) => {
2231 if let Some(s) = &mut self.search {
2232 s.include_filter.apply(field_op);
2233 s.opts.include_glob = s.include_filter.text().to_owned();
2234 }
2235 }
2236 SearchOp::ExcludeGlobInput(field_op) => {
2237 if let Some(s) = &mut self.search {
2238 s.exclude_filter.apply(field_op);
2239 s.opts.exclude_glob = s.exclude_filter.text().to_owned();
2240 }
2241 }
2242 SearchOp::ToggleIgnoreCase => {
2243 if let Some(s) = &mut self.search {
2244 s.opts.ignore_case ^= true;
2245 }
2246 self.recompute_search_matches();
2247 }
2248 SearchOp::ToggleRegex => {
2249 if let Some(s) = &mut self.search {
2250 s.opts.regex ^= true;
2251 }
2252 self.recompute_search_matches();
2253 }
2254 SearchOp::ToggleSmartCase => {
2255 if let Some(s) = &mut self.search {
2256 s.opts.smart_case ^= true;
2257 }
2258 self.recompute_search_matches();
2259 }
2260 SearchOp::FocusSwitch => {
2261 if let Some(s) = &mut self.search {
2262 s.focus.focus_next();
2263 }
2264 }
2265 SearchOp::NextMatch => {
2266 if self.search.as_ref().is_some_and(|s| s.mode == SearchMode::Expanded) {
2268 if let Some(s) = &mut self.search {
2269 let total = s.total_project_matches();
2270 if total > 0 {
2271 let next = s.project_match_cursor
2272 .map(|c| (c + 1) % total)
2273 .unwrap_or(0);
2274 s.project_match_cursor = Some(next);
2275 if let Some((fi, mi)) = s.project_match_at(next) {
2276 let fm = &s.files[fi];
2277 let ms = &fm.matches[mi];
2278 let col = ms.line_text[..ms.byte_start].chars().count();
2279 s.tree_cursor_path = Some(fm.path.clone());
2281 s.tree_cursor_match = Some(mi);
2282 s.expanded_files.insert(fm.path.clone());
2283 self.deferred_ops.push(Operation::GoToProjectMatch {
2284 file: fm.path.clone(),
2285 line: ms.line,
2286 col,
2287 });
2288 }
2289 }
2290 }
2291 } else {
2292 let bar_open = self.search.is_some();
2294 let cur = self.buffer.cursor();
2295 let new_pos = {
2296 let state = if bar_open {
2297 self.search.as_mut()
2298 } else {
2299 self.last_search.as_mut()
2300 };
2301 state.filter(|s| !s.matches.is_empty()).map(|s| {
2302 s.current = if bar_open {
2303 (s.current + 1) % s.matches.len()
2304 } else {
2305 s.matches
2306 .iter()
2307 .position(|&(r, c, _)| (r, c) > (cur.line, cur.column))
2308 .unwrap_or(0)
2309 };
2310 let (row, col, _) = s.matches[s.current];
2311 Position::new(row, col)
2312 })
2313 };
2314 if let Some(c) = new_pos {
2315 self.buffer.set_cursor(c);
2316 }
2317 }
2318 }
2319 SearchOp::PrevMatch => {
2320 if self.search.as_ref().is_some_and(|s| s.mode == SearchMode::Expanded) {
2322 if let Some(s) = &mut self.search {
2323 let total = s.total_project_matches();
2324 if total > 0 {
2325 let prev = s.project_match_cursor
2326 .map(|c| if c == 0 { total - 1 } else { c - 1 })
2327 .unwrap_or(total - 1);
2328 s.project_match_cursor = Some(prev);
2329 if let Some((fi, mi)) = s.project_match_at(prev) {
2330 let fm = &s.files[fi];
2331 let ms = &fm.matches[mi];
2332 let col = ms.line_text[..ms.byte_start].chars().count();
2333 s.tree_cursor_path = Some(fm.path.clone());
2334 s.tree_cursor_match = Some(mi);
2335 s.expanded_files.insert(fm.path.clone());
2336 self.deferred_ops.push(Operation::GoToProjectMatch {
2337 file: fm.path.clone(),
2338 line: ms.line,
2339 col,
2340 });
2341 }
2342 }
2343 }
2344 } else {
2345 let bar_open = self.search.is_some();
2347 let cur = self.buffer.cursor();
2348 let new_pos = {
2349 let state = if bar_open {
2350 self.search.as_mut()
2351 } else {
2352 self.last_search.as_mut()
2353 };
2354 state.filter(|s| !s.matches.is_empty()).map(|s| {
2355 s.current = if bar_open {
2356 s.current.checked_sub(1).unwrap_or(s.matches.len() - 1)
2357 } else {
2358 s.matches
2359 .iter()
2360 .rposition(|&(r, c, _)| (r, c) < (cur.line, cur.column))
2361 .unwrap_or(s.matches.len() - 1)
2362 };
2363 let (row, col, _) = s.matches[s.current];
2364 Position::new(row, col)
2365 })
2366 };
2367 if let Some(c) = new_pos {
2368 self.buffer.set_cursor(c);
2369 }
2370 }
2371 }
2372 SearchOp::ReplaceOne => {
2373 if let Some(s) = &self.search
2374 && !s.matches.is_empty()
2375 {
2376 let (row, start, end) = s.matches[s.current];
2377 let replacement = s.replacement.text().to_owned();
2378 let path = self.buffer.path.clone();
2379 self.deferred_ops.push(Operation::ReplaceRange {
2380 path,
2381 start: Position::new(row, start),
2382 end: Position::new(row, end),
2383 text: replacement,
2384 });
2385 }
2386 }
2387 SearchOp::ReplaceAll => {
2388 if let Some(s) = &self.search {
2389 let replacement = s.replacement.text().to_owned();
2390 let matches = s.matches.clone();
2391 for &(row, start, end) in matches.iter().rev() {
2392 self.buffer
2393 .replace_range(Position::new(row, start), Position::new(row, end), &replacement);
2394 }
2395 self.lsp_version = self.lsp_version.wrapping_add(1);
2396 }
2397 self.recompute_search_matches();
2398 }
2399 SearchOp::AddProjectResult { file, result, generation } => {
2400 if let Some(s) = &mut self.search {
2401 if *generation != s.project_search_generation {
2402 } else if let Some(&idx) = s.file_path_index.get(file) {
2404 s.files[idx].matches.push(result.clone());
2405 } else {
2406 let idx = s.files.len();
2407 s.file_path_index.insert(file.clone(), idx);
2408 s.files.push(FileMatch {
2409 path: file.clone(),
2410 matches: vec![result.clone()],
2411 });
2412 }
2413 }
2414 }
2415 SearchOp::ClearProjectResults { generation } => {
2416 if let Some(s) = &mut self.search {
2417 s.files.clear();
2418 s.file_path_index.clear();
2419 s.selected_file = 0;
2420 s.file_panel_scroll = 0;
2421 s.match_panel_scroll = 0;
2422 s.project_search_generation = *generation;
2423 s.expanded_files.clear();
2424 s.tree_cursor_path = None;
2425 s.tree_cursor_match = None;
2426 s.tree_scroll = 0;
2427 s.project_match_cursor = None;
2428 }
2429 }
2430 SearchOp::SelectFile(idx) => {
2431 if let Some(s) = &mut self.search {
2432 let rows = build_search_tree(s);
2435 let clamped = (*idx).min(rows.len().saturating_sub(1));
2436 set_tree_cursor(s, &rows, clamped);
2437 let legacy = (*idx).min(s.files.len().saturating_sub(1));
2439 s.selected_file = legacy;
2440 s.file_panel_scroll = legacy.saturating_sub(5);
2441 s.match_panel_scroll = 0;
2442 }
2443 }
2444 SearchOp::ScrollMatchPanel(delta) => {
2445 if let Some(s) = &mut self.search {
2446 let total = s.files.get(s.selected_file).map_or(0, |f| f.matches.len());
2447 if *delta > 0 {
2448 s.match_panel_scroll = (s.match_panel_scroll + *delta as usize).min(total.saturating_sub(1));
2449 } else {
2450 s.match_panel_scroll = s.match_panel_scroll.saturating_sub((-delta) as usize);
2451 }
2452 }
2453 }
2454 SearchOp::TreeMoveUp => {
2455 if let Some(s) = &mut self.search {
2456 let rows = build_search_tree(s);
2457 if rows.is_empty() { return Some(Event::applied("editor", op.clone())); }
2458 let cur = resolve_tree_cursor(&rows, &s.files, &s.tree_cursor_path, &s.tree_cursor_match);
2459 let new = cur.saturating_sub(1);
2460 set_tree_cursor(s, &rows, new);
2461 }
2462 }
2463 SearchOp::TreeMoveDown => {
2464 if let Some(s) = &mut self.search {
2465 let rows = build_search_tree(s);
2466 if rows.is_empty() { return Some(Event::applied("editor", op.clone())); }
2467 let cur = resolve_tree_cursor(&rows, &s.files, &s.tree_cursor_path, &s.tree_cursor_match);
2468 let new = (cur + 1).min(rows.len() - 1);
2469 set_tree_cursor(s, &rows, new);
2470 }
2471 }
2472 SearchOp::TreeToggle => {
2473 if let Some(s) = &mut self.search {
2474 let rows = build_search_tree(s);
2475 let cur = resolve_tree_cursor(&rows, &s.files, &s.tree_cursor_path, &s.tree_cursor_match);
2476 if let Some(row) = rows.get(cur) {
2477 match row {
2478 SearchTreeRow::File { file_idx, expanded } => {
2479 let path = s.files[*file_idx].path.clone();
2480 if *expanded {
2481 s.expanded_files.remove(&path);
2482 } else {
2483 s.expanded_files.insert(path);
2484 }
2485 }
2486 SearchTreeRow::Match { file_idx, match_idx } => {
2487 let fm = &s.files[*file_idx];
2488 let ms = &fm.matches[*match_idx];
2489 let col = ms.line_text[..ms.byte_start].chars().count();
2491 self.deferred_ops.push(Operation::GoToProjectMatch {
2492 file: fm.path.clone(),
2493 line: ms.line,
2494 col,
2495 });
2496 }
2497 }
2498 }
2499 }
2500 }
2501 SearchOp::TreeCollapse => {
2502 if let Some(s) = &mut self.search {
2503 let rows = build_search_tree(s);
2504 let cur = resolve_tree_cursor(&rows, &s.files, &s.tree_cursor_path, &s.tree_cursor_match);
2505 if let Some(row) = rows.get(cur) {
2506 match row {
2507 SearchTreeRow::File { file_idx, expanded } => {
2508 if *expanded {
2509 let path = s.files[*file_idx].path.clone();
2510 s.expanded_files.remove(&path);
2511 }
2512 }
2513 SearchTreeRow::Match { file_idx, .. } => {
2514 let path = s.files[*file_idx].path.clone();
2516 s.tree_cursor_path = Some(path);
2517 s.tree_cursor_match = None;
2518 }
2519 }
2520 }
2521 }
2522 }
2523 }
2524 Some(Event::applied("editor", op.clone()))
2525 }
2526
2527 Operation::GoToLineLocal(gop) => {
2529 match gop {
2530 GoToLineOp::Open => {
2531 if self.go_to_line.is_none() {
2532 self.go_to_line = Some(GoToLineState::new());
2533 }
2534 }
2535 GoToLineOp::Close => {
2536 self.go_to_line = None;
2537 }
2538 GoToLineOp::Confirm => {
2539 if let Some(state) = &self.go_to_line
2540 && let Some(line) = state.line_number() {
2541 let clamped = line.min(self.buffer.line_count().saturating_sub(1));
2542 self.buffer.set_cursor(Position::new(clamped, 0));
2543 }
2544 self.go_to_line = None;
2545 }
2546 GoToLineOp::Input(field_op) => {
2547 if let Some(state) = &mut self.go_to_line {
2548 if let crate::widgets::input_field::InputFieldOp::InsertChar(c) = field_op {
2550 if c.is_ascii_digit() {
2551 state.input.apply(field_op);
2552 }
2553 } else {
2554 state.input.apply(field_op);
2555 }
2556 if let Some(line) = state.line_number() {
2558 let clamped = line.min(self.buffer.line_count().saturating_sub(1));
2559 self.buffer.set_cursor(Position::new(clamped, 0));
2560 }
2561 }
2562 }
2563 GoToLineOp::JumpTo { line, column } => {
2564 let clamped_line = (*line).min(self.buffer.line_count().saturating_sub(1));
2565 self.buffer.set_cursor(Position::new(clamped_line, *column));
2566 }
2567 }
2568 Some(Event::applied("editor", op.clone()))
2569 }
2570
2571 Operation::LspRenameLocal(rename_op) => {
2573 match rename_op {
2574 LspRenameOp::OpenPrompt { current_name } => {
2575 let row = self.buffer.cursor().line as u32;
2576 let col = self.buffer.cursor().column as u32;
2577 self.rename_prompt = Some(RenameState::new(current_name, row, col));
2578 }
2579 LspRenameOp::Input(field_op) => {
2580 if let Some(state) = &mut self.rename_prompt {
2581 state.input.apply(field_op);
2582 }
2583 }
2584 LspRenameOp::Dismiss => {
2585 self.rename_prompt = None;
2586 }
2587 }
2588 Some(Event::applied("editor", op.clone()))
2589 }
2590
2591 Operation::SelectionLocal(sel_op) => {
2593 match sel_op {
2594 SelectionOp::Extend { head } => {
2595 let anchor = self.buffer.selection()
2596 .map(|s| s.anchor)
2597 .unwrap_or(self.buffer.cursor());
2598 self.buffer.set_selection(Some(Selection { anchor, active: *head }));
2599 }
2600 SelectionOp::SelectAll => {
2601 self.buffer.select_all();
2602 }
2603 SelectionOp::Clear => {
2604 self.buffer.set_selection(None);
2605 }
2606 }
2607 Some(Event::applied("editor", op.clone()))
2608 }
2609
2610 Operation::SetEditorGitChanges { path, changed_lines } => {
2612 if self.path_matches(&Some(path.clone())) {
2613 self.git_changed_lines = changed_lines.clone();
2614 }
2615 None
2616 }
2617
2618 Operation::SetEditorHighlights { path, version, start_line, generation, spans } => {
2620 if self.path_matches(path)
2621 && *version == self.buffer.version()
2622 && *generation == self.highlight_generation
2623 {
2624 for (i, line_spans) in spans.iter().enumerate() {
2625 let line = *start_line + i;
2626 self.highlight_cache.insert(line, line_spans.clone());
2627 self.pending_highlight_lines.remove(&line);
2628 self.stale_highlight_lines.remove(&line);
2629 }
2630 }
2631 None
2632 }
2633
2634 Operation::SetEditorHighlightsChunk { path, version, generation, spans } => {
2636 if self.path_matches(path)
2637 && *version == self.buffer.version()
2638 && *generation == self.highlight_generation
2639 {
2640 for (line, line_spans) in spans {
2641 self.highlight_cache.insert(*line, line_spans.clone());
2642 self.pending_highlight_lines.remove(line);
2643 self.stale_highlight_lines.remove(line);
2644 }
2645 }
2646 None
2647 }
2648
2649 Operation::Focus(focus_op) => {
2654 if let Some(s) = &mut self.search {
2655 match focus_op {
2656 FocusOp::Next => s.focus.focus_next(),
2657 FocusOp::Prev => s.focus.focus_prev(),
2658 _ => {}
2659 }
2660 }
2661 None
2662 }
2663
2664 _ => None,
2666 }
2667 }
2668
2669 fn render(&self, frame: &mut Frame, area: Rect, _theme: &crate::theme::Theme) {
2670 let _ = (frame, area);
2673 }
2674
2675 fn status_bar(
2676 &self,
2677 _state: &crate::app_state::AppState,
2678 bar: &mut crate::widgets::status_bar::StatusBarBuilder,
2679 ) {
2680 match &self.buffer.path {
2683 Some(p) => {
2684 bar.file_path(p.clone(), self.buffer.is_dirty());
2685 }
2686 None => {
2687 bar.label("(no file)");
2688 return; }
2690 }
2691
2692 let lang = self
2694 .lang_id
2695 .as_ref()
2696 .map(|l| l.as_str())
2697 .unwrap_or(self.highlighter.syntax_name.as_str())
2698 .to_owned();
2699 bar.label(lang);
2700
2701 let c = self.buffer.cursor();
2703 bar.label(format!("{}:{}", c.line + 1, c.column + 1));
2704
2705 if self.issue_error_count > 0 || self.issue_warning_count > 0 {
2707 let mut parts = Vec::new();
2708 if self.issue_error_count > 0 {
2709 parts.push(format!("● E:{}", self.issue_error_count));
2710 }
2711 if self.issue_warning_count > 0 {
2712 parts.push(format!("▲ W:{}", self.issue_warning_count));
2713 }
2714 bar.label(parts.join(" "));
2715 }
2716
2717 if let Some(msg) = self.external_conflict_msg.as_deref() {
2719 bar.label(format!("⚠ {}", msg));
2720 } else if let Some(msg) = self.status_msg.as_deref() {
2721 bar.label(msg.to_owned());
2722 }
2723 }
2724}
2725
2726impl EditorView {
2727 pub fn render_with_registers(&mut self, frame: &mut Frame, area: Rect, registers: &Registers, theme: &crate::theme::Theme) {
2730 let go_to_line_open = self.go_to_line.is_some();
2731 let rename_open = self.rename_prompt.is_some();
2732 let is_expanded = !go_to_line_open && !rename_open
2733 && self.search.as_ref().is_some_and(|s| s.mode == SearchMode::Expanded);
2734
2735 if is_expanded {
2737 const TREE_PANEL_W: u16 = 36;
2738 let v_chunks = Layout::default()
2739 .direction(Direction::Vertical)
2740 .constraints([Constraint::Length(2), Constraint::Min(1)])
2741 .split(area);
2742
2743 self.render_search_bar(frame, v_chunks[0]);
2744 self.last_search_bar_area = v_chunks[0];
2745
2746 let [panel_area, body_area] = Layout::default()
2747 .direction(Direction::Horizontal)
2748 .constraints([Constraint::Length(TREE_PANEL_W), Constraint::Min(1)])
2749 .split(v_chunks[1])[..]
2750 else {
2751 self.last_file_panel_area = Rect::default();
2752 self.last_match_panel_area = Rect::default();
2753 self.last_body_area = v_chunks[1];
2754 self.render_body(frame, v_chunks[1], theme);
2755 return;
2756 };
2757
2758 self.last_file_panel_area = panel_area;
2759 self.last_match_panel_area = Rect::default();
2760 self.render_search_tree(frame, panel_area);
2761 self.last_body_area = body_area;
2762 self.render_body(frame, body_area, theme);
2763 } else {
2764 self.last_file_panel_area = Rect::default();
2766 self.last_match_panel_area = Rect::default();
2767
2768 let search_h: u16 = if go_to_line_open || rename_open {
2769 1
2770 } else {
2771 match &self.search {
2772 None => 0,
2773 Some(s) if s.kind == SearchKind::Replace => 2,
2774 Some(_) => 1,
2775 }
2776 };
2777 let body_area = if search_h == 0 {
2778 self.last_search_bar_area = Rect::default();
2779 area
2780 } else {
2781 let chunks = Layout::default()
2782 .direction(Direction::Vertical)
2783 .constraints([Constraint::Length(search_h), Constraint::Min(1)])
2784 .split(area);
2785 if rename_open {
2786 self.render_rename_bar(frame, chunks[0]);
2787 self.last_search_bar_area = Rect::default();
2788 } else if go_to_line_open {
2789 self.render_go_to_line_bar(frame, chunks[0]);
2790 self.last_search_bar_area = Rect::default();
2791 } else {
2792 self.render_search_bar(frame, chunks[0]);
2793 self.last_search_bar_area = chunks[0];
2794 }
2795 chunks[1]
2796 };
2797 self.last_body_area = body_area;
2798 self.render_body(frame, body_area, theme);
2799 }
2800
2801 if let Some(comp) = &self.completion {
2802 let items = comp.items.clone();
2803 let cursor = comp.cursor;
2804 let trigger_cursor = comp.trigger_cursor;
2805 let filter = {
2806 let comp_ref = self.completion.as_ref().unwrap();
2807 self.completion_filter(comp_ref)
2808 };
2809 let gutter_w = gutter_width(self.show_line_numbers, self.buffer.line_count());
2810 let scroll = self.buffer.scroll;
2811 let buf_cursor = self.buffer.cursor();
2812 let body_area = self.last_body_area;
2813 if buf_cursor.line >= scroll && buf_cursor.line - scroll < body_area.height as usize {
2814 let anchor_x = body_area.x + gutter_w as u16 + buf_cursor.column as u16;
2815 let anchor_y = body_area.y + (buf_cursor.line - scroll) as u16;
2816 let _ = trigger_cursor;
2817 frame.render_widget(
2818 CompletionWidget {
2819 items: &items,
2820 cursor,
2821 filter: &filter,
2822 anchor_x,
2823 anchor_y,
2824 terminal_area: area,
2825 loading: false,
2826 },
2827 area,
2828 );
2829 }
2830 }
2831
2832 if let Some(hover) = &self.hover {
2833 let text = hover.text.clone();
2834 let gutter_w = gutter_width(self.show_line_numbers, self.buffer.line_count());
2835 let scroll = self.buffer.scroll;
2836 let buf_cursor = self.buffer.cursor();
2837 let body_area = self.last_body_area;
2838 if buf_cursor.line >= scroll && buf_cursor.line - scroll < body_area.height as usize {
2839 let anchor_x = body_area.x + gutter_w as u16 + buf_cursor.column as u16;
2840 let anchor_y = body_area.y + (buf_cursor.line - scroll) as u16;
2841 frame.render_widget(
2842 HoverWidget {
2843 text: &text,
2844 anchor_x,
2845 anchor_y,
2846 terminal_area: area,
2847 },
2848 area,
2849 );
2850 }
2851 } else if let Some(text) = self.gutter_issue_texts.get(&self.buffer.cursor().line).cloned() {
2852 let gutter_w = gutter_width(self.show_line_numbers, self.buffer.line_count());
2854 let scroll = self.buffer.scroll;
2855 let buf_cursor = self.buffer.cursor();
2856 let body_area = self.last_body_area;
2857 if buf_cursor.line >= scroll && buf_cursor.line - scroll < body_area.height as usize {
2858 let anchor_x = body_area.x + gutter_w as u16;
2859 let anchor_y = body_area.y + (buf_cursor.line - scroll) as u16;
2860 frame.render_widget(
2861 HoverWidget {
2862 text: &text,
2863 anchor_x,
2864 anchor_y,
2865 terminal_area: area,
2866 },
2867 area,
2868 );
2869 }
2870 }
2871
2872 self.render_clipboard_picker(frame, registers);
2873 }
2874}
2875
2876pub(crate) fn is_trigger_character(c: &char, lang_id: Option<&str>, _server_triggers: &[String]) -> bool {
2887 let common_triggers = ['.', ';', '{', '}', '[', ']', '(', ')', '<', '>', ',', ':', '"', '\''];
2893
2894 if common_triggers.contains(c) {
2895 match lang_id {
2896 Some("rust") | Some("python") | Some("javascript") | Some("typescript")
2897 | Some("cpp") | Some("c") | Some("go") | Some("java") | Some("json")
2898 | Some("yaml") | Some("toml") | Some("html") | Some("css") => true,
2899 Some(_) => true,
2900 None => *c == '.',
2901 }
2902 } else {
2903 false
2904 }
2905}
2906
2907#[cfg(test)]
2912mod tests {
2913 use super::*;
2914 use crate::editor::fold::FoldState;
2915
2916 fn make_editor() -> (EditorView, crate::settings::Settings) {
2917 let dir = tempfile::tempdir().unwrap();
2918 let path = dir.path().join("test.rs");
2919 std::fs::write(&path, "fn main() {}\n").unwrap();
2920 let buf = Buffer::open(&path).unwrap();
2921 let settings = crate::settings::Settings::new(dir.path().join("config.yaml").as_path())
2922 .expect("Settings::new failed in test");
2923 std::mem::forget(dir);
2925 (EditorView::open(buf, FoldState::default(), &settings), settings)
2926 }
2927
2928 #[test]
2929 fn completion_move_down_up() {
2930 let (mut ed, settings) = make_editor();
2931 ed.completion = Some(CompletionState {
2932 items: vec![
2933 crate::operation::LspCompletionItem {
2934 label: "foo".into(),
2935 kind: None,
2936 detail: None,
2937 insert_text: None,
2938 },
2939 crate::operation::LspCompletionItem {
2940 label: "bar".into(),
2941 kind: None,
2942 detail: None,
2943 insert_text: None,
2944 },
2945 ],
2946 cursor: 0,
2947 trigger_cursor: Position::default(),
2948 loading: false,
2949 });
2950
2951 ed.handle_operation(&Operation::LspLocal(LspOp::CompletionMoveDown), &settings);
2952 assert_eq!(ed.completion.as_ref().unwrap().cursor, 1);
2953
2954 ed.handle_operation(&Operation::LspLocal(LspOp::CompletionMoveDown), &settings);
2956 assert_eq!(ed.completion.as_ref().unwrap().cursor, 0);
2957
2958 ed.handle_operation(&Operation::LspLocal(LspOp::CompletionMoveDown), &settings);
2960 assert_eq!(ed.completion.as_ref().unwrap().cursor, 1);
2961
2962 ed.handle_operation(&Operation::LspLocal(LspOp::CompletionMoveUp), &settings);
2963 assert_eq!(ed.completion.as_ref().unwrap().cursor, 0);
2964
2965 ed.handle_operation(&Operation::LspLocal(LspOp::CompletionMoveUp), &settings);
2967 assert_eq!(ed.completion.as_ref().unwrap().cursor, 1);
2968 }
2969
2970 #[test]
2971 fn completion_dismiss() {
2972 let (mut ed, settings) = make_editor();
2973 ed.completion = Some(CompletionState {
2974 items: vec![],
2975 cursor: 0,
2976 trigger_cursor: Position::default(),
2977 loading: false,
2978 });
2979 ed.handle_operation(&Operation::LspLocal(LspOp::CompletionDismiss), &settings);
2980 assert!(ed.completion.is_none());
2981 }
2982
2983 #[test]
2984 fn hover_dismiss() {
2985 let (mut ed, settings) = make_editor();
2986 ed.hover = Some(HoverState {
2987 text: "docs".into(),
2988 });
2989 ed.handle_operation(&Operation::LspLocal(LspOp::HoverDismiss), &settings);
2990 assert!(ed.hover.is_none());
2991 }
2992
2993 #[test]
2994 fn completion_confirm_produces_deferred_op() {
2995 let (mut ed, settings) = make_editor();
2996 ed.completion = Some(CompletionState {
2997 items: vec![crate::operation::LspCompletionItem {
2998 label: "println".into(),
2999 kind: None,
3000 detail: None,
3001 insert_text: Some("println!($1)".into()),
3002 }],
3003 cursor: 0,
3004 trigger_cursor: Position::default(),
3005 loading: false,
3006 });
3007 ed.handle_operation(&Operation::LspLocal(LspOp::CompletionConfirm), &settings);
3008 assert!(ed.completion.is_none());
3009 let deferred = ed.take_deferred_ops();
3010 assert_eq!(deferred.len(), 1);
3011 assert!(
3012 matches!(&deferred[0], Operation::ReplaceRange { text, .. } if text == "println!($1)")
3013 );
3014 }
3015
3016 fn make_editor_with_lines(lines: &[&str]) -> EditorView {
3022 let dir = tempfile::tempdir().unwrap();
3023 let path = dir.path().join("test.txt");
3024 std::fs::write(&path, lines.join("\n")).unwrap();
3025 let buf = Buffer::open(&path).unwrap();
3026 let settings = crate::settings::Settings::new(dir.path().join("config.yaml").as_path())
3027 .expect("Settings::new failed");
3028 std::mem::forget(dir);
3029 let mut ed = EditorView::open(buf, FoldState::default(), &settings);
3030 ed.show_line_numbers = false;
3032 ed.last_body_area = Rect { x: 0, y: 0, width: 40, height: 20 };
3034 ed
3035 }
3036
3037 #[test]
3038 fn click_outside_body_returns_none() {
3039 let ed = make_editor_with_lines(&["hello"]);
3040 assert!(ed.screen_to_cursor(5, 20).is_none());
3042 let mut ed2 = make_editor_with_lines(&["hello"]);
3044 ed2.last_body_area = Rect { x: 5, y: 2, width: 40, height: 20 };
3045 assert!(ed2.screen_to_cursor(3, 2).is_none()); assert!(ed2.screen_to_cursor(5, 1).is_none()); }
3048
3049 #[test]
3050 fn click_in_gutter_returns_col_zero() {
3051 let ed = make_editor_with_lines(&["hello world"]);
3053 assert_eq!(ed.screen_to_cursor(2, 0), Some(Position::new(0, 0)));
3055 assert_eq!(ed.screen_to_cursor(0, 0), Some(Position::new(0, 0)));
3056 }
3057
3058 #[test]
3059 fn click_maps_to_correct_char() {
3060 let ed = make_editor_with_lines(&["hello"]);
3063 assert_eq!(ed.screen_to_cursor(4, 0), Some(Position::new(0, 0))); assert_eq!(ed.screen_to_cursor(5, 0), Some(Position::new(0, 1))); assert_eq!(ed.screen_to_cursor(8, 0), Some(Position::new(0, 4))); }
3067
3068 #[test]
3069 fn click_past_end_of_line_clamps_to_line_len() {
3070 let ed = make_editor_with_lines(&["hi"]);
3071 assert_eq!(ed.screen_to_cursor(14, 0), Some(Position::new(0, 2)));
3073 }
3074
3075 #[test]
3076 fn click_selects_correct_row() {
3077 let ed = make_editor_with_lines(&["line0", "line1", "line2"]);
3078 assert_eq!(ed.screen_to_cursor(4, 0), Some(Position::new(0, 0)));
3079 assert_eq!(ed.screen_to_cursor(4, 1), Some(Position::new(1, 0)));
3080 assert_eq!(ed.screen_to_cursor(4, 2), Some(Position::new(2, 0)));
3081 }
3082
3083 #[test]
3084 fn scroll_offsets_row_mapping() {
3085 let mut ed = make_editor_with_lines(&["a", "b", "c", "d", "e"]);
3086 ed.buffer.scroll = 2;
3088 assert_eq!(ed.screen_to_cursor(4, 0), Some(Position::new(2, 0)));
3089 assert_eq!(ed.screen_to_cursor(4, 1), Some(Position::new(3, 0)));
3090 }
3091
3092 #[test]
3093 fn scroll_x_offsets_col_mapping() {
3094 let mut ed = make_editor_with_lines(&["abcdef"]);
3095 ed.buffer.scroll_x = 2;
3097 assert_eq!(ed.screen_to_cursor(4, 0), Some(Position::new(0, 2)));
3098 assert_eq!(ed.screen_to_cursor(5, 0), Some(Position::new(0, 3)));
3099 }
3100
3101 #[test]
3102 fn multibyte_utf8_maps_by_char() {
3103 let ed = make_editor_with_lines(&["éàü"]);
3105 assert_eq!(ed.screen_to_cursor(4, 0), Some(Position::new(0, 0)));
3107 assert_eq!(ed.screen_to_cursor(5, 0), Some(Position::new(0, 1)));
3109 assert_eq!(ed.screen_to_cursor(6, 0), Some(Position::new(0, 2)));
3111 }
3112
3113 #[test]
3118 fn set_editor_git_changes_populates_changed_lines() {
3119 let (mut ed, settings) = make_editor();
3120 assert!(ed.git_changed_lines.is_empty(), "should start empty");
3121
3122 let path = ed.buffer.path.clone().unwrap();
3123 let mut changed = std::collections::HashSet::new();
3124 changed.insert(0usize);
3125 changed.insert(3usize);
3126
3127 ed.handle_operation(
3128 &Operation::SetEditorGitChanges { path, changed_lines: changed.clone() },
3129 &settings,
3130 );
3131
3132 assert_eq!(ed.git_changed_lines, changed);
3133 }
3134
3135 #[test]
3136 fn set_editor_git_changes_ignores_wrong_path() {
3137 let (mut ed, settings) = make_editor();
3138 let wrong_path = std::path::PathBuf::from("/totally/wrong/path.rs");
3139 let mut changed = std::collections::HashSet::new();
3140 changed.insert(5usize);
3141
3142 ed.handle_operation(
3143 &Operation::SetEditorGitChanges { path: wrong_path, changed_lines: changed },
3144 &settings,
3145 );
3146
3147 assert!(ed.git_changed_lines.is_empty(), "wrong path should not update changed lines");
3149 }
3150
3151 #[test]
3152 fn set_editor_git_changes_replaces_previous_set() {
3153 let (mut ed, settings) = make_editor();
3154 let path = ed.buffer.path.clone().unwrap();
3155
3156 let mut first = std::collections::HashSet::new();
3158 first.insert(1usize);
3159 first.insert(2usize);
3160 ed.handle_operation(
3161 &Operation::SetEditorGitChanges { path: path.clone(), changed_lines: first },
3162 &settings,
3163 );
3164 assert!(ed.git_changed_lines.contains(&1));
3165 assert!(ed.git_changed_lines.contains(&2));
3166
3167 let mut second = std::collections::HashSet::new();
3169 second.insert(7usize);
3170 ed.handle_operation(
3171 &Operation::SetEditorGitChanges { path: path.clone(), changed_lines: second },
3172 &settings,
3173 );
3174 assert!(!ed.git_changed_lines.contains(&1), "old line 1 should be gone");
3175 assert!(!ed.git_changed_lines.contains(&2), "old line 2 should be gone");
3176 assert!(ed.git_changed_lines.contains(&7));
3177 }
3178}