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