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