1use super::*;
2use crate::model::event::LeafId;
3use crate::services::plugins::hooks::HookArgs;
4use anyhow::Result as AnyhowResult;
5use rust_i18n::t;
6impl Editor {
7 pub fn get_key_context(&self) -> crate::input::keybindings::KeyContext {
9 use crate::input::keybindings::KeyContext;
10
11 if self.settings_state.as_ref().is_some_and(|s| s.visible) {
13 KeyContext::Settings
14 } else if self.menu_state.active_menu.is_some() {
15 KeyContext::Menu
16 } else if self.is_prompting() {
17 KeyContext::Prompt
18 } else if self.active_state().popups.is_visible() {
19 KeyContext::Popup
20 } else if self.is_composite_buffer(self.active_buffer()) {
21 KeyContext::CompositeBuffer
22 } else {
23 self.key_context.clone()
25 }
26 }
27
28 pub fn handle_key(
31 &mut self,
32 code: crossterm::event::KeyCode,
33 modifiers: crossterm::event::KeyModifiers,
34 ) -> AnyhowResult<()> {
35 use crate::input::keybindings::Action;
36
37 let _t_total = std::time::Instant::now();
38
39 tracing::trace!(
40 "Editor.handle_key: code={:?}, modifiers={:?}",
41 code,
42 modifiers
43 );
44
45 let key_event = crossterm::event::KeyEvent::new(code, modifiers);
47
48 if self.is_event_debug_active() {
52 self.handle_event_debug_input(&key_event);
53 return Ok(());
54 }
55
56 if self.dispatch_terminal_input(&key_event).is_some() {
58 return Ok(());
59 }
60
61 let active_split = self.effective_active_split();
70 if let Some(view_state) = self.split_view_states.get_mut(&active_split) {
71 view_state.viewport.clear_skip_ensure_visible();
72 }
73
74 if self.theme_info_popup.is_some() {
76 self.theme_info_popup = None;
77 }
78
79 let mut context = self.get_key_context();
81
82 if matches!(context, crate::input::keybindings::KeyContext::Popup) {
85 let (is_transient_popup, has_selection) = {
87 let popup = self.active_state().popups.top();
88 (
89 popup.is_some_and(|p| p.transient),
90 popup.is_some_and(|p| p.has_selection()),
91 )
92 };
93
94 let is_copy_key = key_event.code == crossterm::event::KeyCode::Char('c')
96 && key_event
97 .modifiers
98 .contains(crossterm::event::KeyModifiers::CONTROL);
99
100 if is_transient_popup && !(has_selection && is_copy_key) {
101 self.hide_popup();
103 tracing::debug!("Dismissed transient popup on key press");
104 context = self.get_key_context();
106 }
107 }
108
109 if self.dispatch_modal_input(&key_event).is_some() {
111 return Ok(());
112 }
113
114 if context != self.get_key_context() {
117 context = self.get_key_context();
118 }
119
120 let should_check_mode_bindings =
124 matches!(context, crate::input::keybindings::KeyContext::Normal);
125
126 if should_check_mode_bindings {
127 let effective_mode = self.effective_mode().map(|s| s.to_owned());
130
131 if let Some(ref mode_name) = effective_mode {
132 let mode_ctx = crate::input::keybindings::KeyContext::Mode(mode_name.to_string());
133 let key_event = crossterm::event::KeyEvent::new(code, modifiers);
134
135 let (chord_result, resolved_action) = {
137 let keybindings = self.keybindings.read().unwrap();
138 let chord_result =
139 keybindings.resolve_chord(&self.chord_state, &key_event, mode_ctx.clone());
140 let resolved = keybindings.resolve(&key_event, mode_ctx);
141 (chord_result, resolved)
142 };
143 match chord_result {
144 crate::input::keybindings::ChordResolution::Complete(action) => {
145 tracing::debug!("Mode chord resolved to action: {:?}", action);
146 self.chord_state.clear();
147 return self.handle_action(action);
148 }
149 crate::input::keybindings::ChordResolution::Partial => {
150 tracing::debug!("Potential chord prefix in mode '{}'", mode_name);
151 self.chord_state.push((code, modifiers));
152 return Ok(());
153 }
154 crate::input::keybindings::ChordResolution::NoMatch => {
155 if !self.chord_state.is_empty() {
156 tracing::debug!("Chord sequence abandoned in mode, clearing state");
157 self.chord_state.clear();
158 }
159 }
160 }
161
162 if resolved_action != Action::None {
164 return self.handle_action(resolved_action);
165 }
166 }
167
168 if let Some(ref mode_name) = effective_mode {
180 if self.mode_registry.allows_text_input(mode_name) {
181 if let KeyCode::Char(c) = code {
182 let ch = if modifiers.contains(KeyModifiers::SHIFT) {
183 c.to_uppercase().next().unwrap_or(c)
184 } else {
185 c
186 };
187 if !modifiers.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) {
188 let action_name = format!("mode_text_input:{}", ch);
189 return self.handle_action(Action::PluginAction(action_name));
190 }
191 }
192 tracing::debug!("Blocking unbound key in text-input mode '{}'", mode_name);
193 return Ok(());
194 }
195 }
196 if let Some(ref mode_name) = self.editor_mode {
197 if self.mode_registry.is_read_only(mode_name) {
198 tracing::debug!("Ignoring unbound key in read-only mode '{}'", mode_name);
199 return Ok(());
200 }
201 tracing::debug!(
202 "Mode '{}' is not read-only, allowing key through",
203 mode_name
204 );
205 }
206 }
207
208 {
215 let active_buf = self.active_buffer();
216 let active_split = self.effective_active_split();
217 if self.is_composite_buffer(active_buf) {
218 if let Some(handled) =
219 self.try_route_composite_key(active_split, active_buf, &key_event)
220 {
221 return handled;
222 }
223 }
224 }
225
226 let key_event = crossterm::event::KeyEvent::new(code, modifiers);
228 let (chord_result, action) = {
229 let keybindings = self.keybindings.read().unwrap();
230 let chord_result =
231 keybindings.resolve_chord(&self.chord_state, &key_event, context.clone());
232 let action = keybindings.resolve(&key_event, context.clone());
233 (chord_result, action)
234 };
235
236 match chord_result {
237 crate::input::keybindings::ChordResolution::Complete(action) => {
238 tracing::debug!("Complete chord match -> Action: {:?}", action);
240 self.chord_state.clear();
241 return self.handle_action(action);
242 }
243 crate::input::keybindings::ChordResolution::Partial => {
244 tracing::debug!("Partial chord match - waiting for next key");
246 self.chord_state.push((code, modifiers));
247 return Ok(());
248 }
249 crate::input::keybindings::ChordResolution::NoMatch => {
250 if !self.chord_state.is_empty() {
252 tracing::debug!("Chord sequence abandoned, clearing state");
253 self.chord_state.clear();
254 }
255 }
256 }
257
258 tracing::trace!("Context: {:?} -> Action: {:?}", context, action);
260
261 match action {
264 Action::LspCompletion
265 | Action::LspGotoDefinition
266 | Action::LspReferences
267 | Action::LspHover
268 | Action::None => {
269 }
271 _ => {
272 self.cancel_pending_lsp_requests();
274 }
275 }
276
277 self.handle_action(action)
281 }
282
283 pub(crate) fn handle_action(&mut self, action: Action) -> AnyhowResult<()> {
286 use crate::input::keybindings::Action;
287
288 self.record_macro_action(&action);
290
291 if !matches!(action, Action::DabbrevExpand) {
293 self.reset_dabbrev_state();
294 }
295
296 match action {
297 Action::Quit => self.quit(),
298 Action::ForceQuit => {
299 self.should_quit = true;
300 }
301 Action::Detach => {
302 self.should_detach = true;
303 }
304 Action::Save => {
305 if self.active_state().buffer.file_path().is_none() {
307 self.start_prompt_with_initial_text(
308 t!("file.save_as_prompt").to_string(),
309 PromptType::SaveFileAs,
310 String::new(),
311 );
312 self.init_file_open_state();
313 } else if self.check_save_conflict().is_some() {
314 self.start_prompt(
316 t!("file.file_changed_prompt").to_string(),
317 PromptType::ConfirmSaveConflict,
318 );
319 } else if let Err(e) = self.save() {
320 let msg = format!("{}", e);
321 self.status_message = Some(t!("file.save_failed", error = &msg).to_string());
322 }
323 }
324 Action::SaveAs => {
325 let current_path = self
327 .active_state()
328 .buffer
329 .file_path()
330 .map(|p| {
331 p.strip_prefix(&self.working_dir)
333 .unwrap_or(p)
334 .to_string_lossy()
335 .to_string()
336 })
337 .unwrap_or_default();
338 self.start_prompt_with_initial_text(
339 t!("file.save_as_prompt").to_string(),
340 PromptType::SaveFileAs,
341 current_path,
342 );
343 self.init_file_open_state();
344 }
345 Action::Open => {
346 self.start_prompt(t!("file.open_prompt").to_string(), PromptType::OpenFile);
347 self.prefill_open_file_prompt();
348 self.init_file_open_state();
349 }
350 Action::SwitchProject => {
351 self.start_prompt(
352 t!("file.switch_project_prompt").to_string(),
353 PromptType::SwitchProject,
354 );
355 self.init_folder_open_state();
356 }
357 Action::GotoLine => {
358 let has_line_index = self
359 .buffers
360 .get(&self.active_buffer())
361 .is_none_or(|s| s.buffer.line_count().is_some());
362 if has_line_index {
363 self.start_prompt(
364 t!("file.goto_line_prompt").to_string(),
365 PromptType::GotoLine,
366 );
367 } else {
368 self.start_prompt(
369 t!("goto.scan_confirm_prompt", yes = "y", no = "N").to_string(),
370 PromptType::GotoLineScanConfirm,
371 );
372 }
373 }
374 Action::ScanLineIndex => {
375 self.start_incremental_line_scan(false);
376 }
377 Action::New => {
378 self.new_buffer();
379 }
380 Action::Close | Action::CloseTab => {
381 self.close_tab();
386 }
387 Action::Revert => {
388 if self.active_state().buffer.is_modified() {
390 let revert_key = t!("prompt.key.revert").to_string();
391 let cancel_key = t!("prompt.key.cancel").to_string();
392 self.start_prompt(
393 t!(
394 "prompt.revert_confirm",
395 revert_key = revert_key,
396 cancel_key = cancel_key
397 )
398 .to_string(),
399 PromptType::ConfirmRevert,
400 );
401 } else {
402 if let Err(e) = self.revert_file() {
404 self.set_status_message(
405 t!("error.failed_to_revert", error = e.to_string()).to_string(),
406 );
407 }
408 }
409 }
410 Action::ToggleAutoRevert => {
411 self.toggle_auto_revert();
412 }
413 Action::FormatBuffer => {
414 if let Err(e) = self.format_buffer() {
415 self.set_status_message(
416 t!("error.format_failed", error = e.to_string()).to_string(),
417 );
418 }
419 }
420 Action::TrimTrailingWhitespace => match self.trim_trailing_whitespace() {
421 Ok(true) => {
422 self.set_status_message(t!("whitespace.trimmed").to_string());
423 }
424 Ok(false) => {
425 self.set_status_message(t!("whitespace.no_trailing").to_string());
426 }
427 Err(e) => {
428 self.set_status_message(
429 t!("error.trim_whitespace_failed", error = e).to_string(),
430 );
431 }
432 },
433 Action::EnsureFinalNewline => match self.ensure_final_newline() {
434 Ok(true) => {
435 self.set_status_message(t!("whitespace.newline_added").to_string());
436 }
437 Ok(false) => {
438 self.set_status_message(t!("whitespace.already_has_newline").to_string());
439 }
440 Err(e) => {
441 self.set_status_message(
442 t!("error.ensure_newline_failed", error = e).to_string(),
443 );
444 }
445 },
446 Action::Copy => {
447 let state = self.active_state();
449 if let Some(popup) = state.popups.top() {
450 if popup.has_selection() {
451 if let Some(text) = popup.get_selected_text() {
452 self.clipboard.copy(text);
453 self.set_status_message(t!("clipboard.copied").to_string());
454 return Ok(());
455 }
456 }
457 }
458 let buffer_id = self.active_buffer();
460 if self.is_composite_buffer(buffer_id) {
461 if let Some(_handled) = self.handle_composite_action(buffer_id, &Action::Copy) {
462 return Ok(());
463 }
464 }
465 self.copy_selection()
466 }
467 Action::CopyWithTheme(theme) => self.copy_selection_with_theme(&theme),
468 Action::Cut => {
469 if self.is_editing_disabled() {
470 self.set_status_message(t!("buffer.editing_disabled").to_string());
471 return Ok(());
472 }
473 self.cut_selection()
474 }
475 Action::Paste => {
476 if self.is_editing_disabled() {
477 self.set_status_message(t!("buffer.editing_disabled").to_string());
478 return Ok(());
479 }
480 self.paste()
481 }
482 Action::YankWordForward => self.yank_word_forward(),
483 Action::YankWordBackward => self.yank_word_backward(),
484 Action::YankToLineEnd => self.yank_to_line_end(),
485 Action::YankToLineStart => self.yank_to_line_start(),
486 Action::YankViWordEnd => self.yank_vi_word_end(),
487 Action::Undo => {
488 self.handle_undo();
489 }
490 Action::Redo => {
491 self.handle_redo();
492 }
493 Action::ShowHelp => {
494 self.open_help_manual();
495 }
496 Action::ShowKeyboardShortcuts => {
497 self.open_keyboard_shortcuts();
498 }
499 Action::ShowWarnings => {
500 self.show_warnings_popup();
501 }
502 Action::ShowStatusLog => {
503 self.open_status_log();
504 }
505 Action::ShowLspStatus => {
506 self.show_lsp_status_popup();
507 }
508 Action::ClearWarnings => {
509 self.clear_warnings();
510 }
511 Action::CommandPalette => {
512 if let Some(prompt) = &self.prompt {
515 if prompt.prompt_type == PromptType::QuickOpen {
516 self.cancel_prompt();
517 return Ok(());
518 }
519 }
520 self.start_quick_open();
521 }
522 Action::QuickOpen => {
523 if let Some(prompt) = &self.prompt {
525 if prompt.prompt_type == PromptType::QuickOpen {
526 self.cancel_prompt();
527 return Ok(());
528 }
529 }
530
531 self.start_quick_open();
533 }
534 Action::ToggleLineWrap => {
535 self.config.editor.line_wrap = !self.config.editor.line_wrap;
536
537 let leaf_ids: Vec<_> = self.split_view_states.keys().copied().collect();
540 for leaf_id in leaf_ids {
541 let buffer_id = self
542 .split_manager
543 .get_buffer_id(leaf_id.into())
544 .unwrap_or(BufferId(0));
545 let effective_wrap = self.resolve_line_wrap_for_buffer(buffer_id);
546 let wrap_column = self.resolve_wrap_column_for_buffer(buffer_id);
547 if let Some(view_state) = self.split_view_states.get_mut(&leaf_id) {
548 view_state.viewport.line_wrap_enabled = effective_wrap;
549 view_state.viewport.wrap_indent = self.config.editor.wrap_indent;
550 view_state.viewport.wrap_column = wrap_column;
551 }
552 }
553
554 let state = if self.config.editor.line_wrap {
555 t!("view.state_enabled").to_string()
556 } else {
557 t!("view.state_disabled").to_string()
558 };
559 self.set_status_message(t!("view.line_wrap_state", state = state).to_string());
560 }
561 Action::ToggleCurrentLineHighlight => {
562 self.config.editor.highlight_current_line =
563 !self.config.editor.highlight_current_line;
564
565 let leaf_ids: Vec<_> = self.split_view_states.keys().copied().collect();
567 for leaf_id in leaf_ids {
568 if let Some(view_state) = self.split_view_states.get_mut(&leaf_id) {
569 view_state.highlight_current_line =
570 self.config.editor.highlight_current_line;
571 }
572 }
573
574 let state = if self.config.editor.highlight_current_line {
575 t!("view.state_enabled").to_string()
576 } else {
577 t!("view.state_disabled").to_string()
578 };
579 self.set_status_message(
580 t!("view.current_line_highlight_state", state = state).to_string(),
581 );
582 }
583 Action::ToggleReadOnly => {
584 let buffer_id = self.active_buffer();
585 let is_now_read_only = self
586 .buffer_metadata
587 .get(&buffer_id)
588 .map(|m| !m.read_only)
589 .unwrap_or(false);
590 self.mark_buffer_read_only(buffer_id, is_now_read_only);
591
592 let state_str = if is_now_read_only {
593 t!("view.state_enabled").to_string()
594 } else {
595 t!("view.state_disabled").to_string()
596 };
597 self.set_status_message(t!("view.read_only_state", state = state_str).to_string());
598 }
599 Action::TogglePageView => {
600 self.handle_toggle_page_view();
601 }
602 Action::SetPageWidth => {
603 let active_split = self.split_manager.active_split();
604 let current = self
605 .split_view_states
606 .get(&active_split)
607 .and_then(|v| v.compose_width.map(|w| w.to_string()))
608 .unwrap_or_default();
609 self.start_prompt_with_initial_text(
610 "Page width (empty = viewport): ".to_string(),
611 PromptType::SetPageWidth,
612 current,
613 );
614 }
615 Action::SetBackground => {
616 let default_path = self
617 .ansi_background_path
618 .as_ref()
619 .and_then(|p| {
620 p.strip_prefix(&self.working_dir)
621 .ok()
622 .map(|rel| rel.to_string_lossy().to_string())
623 })
624 .unwrap_or_else(|| DEFAULT_BACKGROUND_FILE.to_string());
625
626 self.start_prompt_with_initial_text(
627 "Background file: ".to_string(),
628 PromptType::SetBackgroundFile,
629 default_path,
630 );
631 }
632 Action::SetBackgroundBlend => {
633 let default_amount = format!("{:.2}", self.background_fade);
634 self.start_prompt_with_initial_text(
635 "Background blend (0-1): ".to_string(),
636 PromptType::SetBackgroundBlend,
637 default_amount,
638 );
639 }
640 Action::LspCompletion => {
641 self.request_completion();
642 }
643 Action::DabbrevExpand => {
644 self.dabbrev_expand();
645 }
646 Action::LspGotoDefinition => {
647 self.request_goto_definition()?;
648 }
649 Action::LspRename => {
650 self.start_rename()?;
651 }
652 Action::LspHover => {
653 self.request_hover()?;
654 }
655 Action::LspReferences => {
656 self.request_references()?;
657 }
658 Action::LspSignatureHelp => {
659 self.request_signature_help();
660 }
661 Action::LspCodeActions => {
662 self.request_code_actions()?;
663 }
664 Action::LspRestart => {
665 self.handle_lsp_restart();
666 }
667 Action::LspStop => {
668 self.handle_lsp_stop();
669 }
670 Action::LspToggleForBuffer => {
671 self.handle_lsp_toggle_for_buffer();
672 }
673 Action::ToggleInlayHints => {
674 self.toggle_inlay_hints();
675 }
676 Action::DumpConfig => {
677 self.dump_config();
678 }
679 Action::SelectTheme => {
680 self.start_select_theme_prompt();
681 }
682 Action::InspectThemeAtCursor => {
683 self.inspect_theme_at_cursor();
684 }
685 Action::SelectKeybindingMap => {
686 self.start_select_keybinding_map_prompt();
687 }
688 Action::SelectCursorStyle => {
689 self.start_select_cursor_style_prompt();
690 }
691 Action::SelectLocale => {
692 self.start_select_locale_prompt();
693 }
694 Action::Search => {
695 let is_search_prompt = self.prompt.as_ref().is_some_and(|p| {
697 matches!(
698 p.prompt_type,
699 PromptType::Search
700 | PromptType::ReplaceSearch
701 | PromptType::QueryReplaceSearch
702 )
703 });
704
705 if is_search_prompt {
706 self.confirm_prompt();
707 } else {
708 self.start_search_prompt(
709 t!("file.search_prompt").to_string(),
710 PromptType::Search,
711 false,
712 );
713 }
714 }
715 Action::Replace => {
716 self.start_search_prompt(
718 t!("file.replace_prompt").to_string(),
719 PromptType::ReplaceSearch,
720 false,
721 );
722 }
723 Action::QueryReplace => {
724 self.search_confirm_each = true;
726 self.start_search_prompt(
727 "Query replace: ".to_string(),
728 PromptType::QueryReplaceSearch,
729 false,
730 );
731 }
732 Action::FindInSelection => {
733 self.start_search_prompt(
734 t!("file.search_prompt").to_string(),
735 PromptType::Search,
736 true,
737 );
738 }
739 Action::FindNext => {
740 self.find_next();
741 }
742 Action::FindPrevious => {
743 self.find_previous();
744 }
745 Action::FindSelectionNext => {
746 self.find_selection_next();
747 }
748 Action::FindSelectionPrevious => {
749 self.find_selection_previous();
750 }
751 Action::AddCursorNextMatch => self.add_cursor_at_next_match(),
752 Action::AddCursorAbove => self.add_cursor_above(),
753 Action::AddCursorBelow => self.add_cursor_below(),
754 Action::NextBuffer => self.next_buffer(),
755 Action::PrevBuffer => self.prev_buffer(),
756 Action::SwitchToPreviousTab => self.switch_to_previous_tab(),
757 Action::SwitchToTabByName => self.start_switch_to_tab_prompt(),
758
759 Action::ScrollTabsLeft => {
761 let active_split_id = self.split_manager.active_split();
762 if let Some(view_state) = self.split_view_states.get_mut(&active_split_id) {
763 view_state.tab_scroll_offset = view_state.tab_scroll_offset.saturating_sub(5);
764 self.set_status_message(t!("status.scrolled_tabs_left").to_string());
765 }
766 }
767 Action::ScrollTabsRight => {
768 let active_split_id = self.split_manager.active_split();
769 if let Some(view_state) = self.split_view_states.get_mut(&active_split_id) {
770 view_state.tab_scroll_offset = view_state.tab_scroll_offset.saturating_add(5);
771 self.set_status_message(t!("status.scrolled_tabs_right").to_string());
772 }
773 }
774 Action::NavigateBack => self.navigate_back(),
775 Action::NavigateForward => self.navigate_forward(),
776 Action::SplitHorizontal => self.split_pane_horizontal(),
777 Action::SplitVertical => self.split_pane_vertical(),
778 Action::CloseSplit => self.close_active_split(),
779 Action::NextSplit => self.next_split(),
780 Action::PrevSplit => self.prev_split(),
781 Action::IncreaseSplitSize => self.adjust_split_size(0.05),
782 Action::DecreaseSplitSize => self.adjust_split_size(-0.05),
783 Action::ToggleMaximizeSplit => self.toggle_maximize_split(),
784 Action::ToggleFileExplorer => self.toggle_file_explorer(),
785 Action::ToggleMenuBar => self.toggle_menu_bar(),
786 Action::ToggleTabBar => self.toggle_tab_bar(),
787 Action::ToggleStatusBar => self.toggle_status_bar(),
788 Action::TogglePromptLine => self.toggle_prompt_line(),
789 Action::ToggleVerticalScrollbar => self.toggle_vertical_scrollbar(),
790 Action::ToggleHorizontalScrollbar => self.toggle_horizontal_scrollbar(),
791 Action::ToggleLineNumbers => self.toggle_line_numbers(),
792 Action::ToggleScrollSync => self.toggle_scroll_sync(),
793 Action::ToggleMouseCapture => self.toggle_mouse_capture(),
794 Action::ToggleMouseHover => self.toggle_mouse_hover(),
795 Action::ToggleDebugHighlights => self.toggle_debug_highlights(),
796 Action::AddRuler => {
798 self.start_prompt(t!("rulers.add_prompt").to_string(), PromptType::AddRuler);
799 }
800 Action::RemoveRuler => {
801 self.start_remove_ruler_prompt();
802 }
803 Action::SetTabSize => {
805 let current = self
806 .buffers
807 .get(&self.active_buffer())
808 .map(|s| s.buffer_settings.tab_size.to_string())
809 .unwrap_or_else(|| "4".to_string());
810 self.start_prompt_with_initial_text(
811 "Tab size: ".to_string(),
812 PromptType::SetTabSize,
813 current,
814 );
815 }
816 Action::SetLineEnding => {
817 self.start_set_line_ending_prompt();
818 }
819 Action::SetEncoding => {
820 self.start_set_encoding_prompt();
821 }
822 Action::ReloadWithEncoding => {
823 self.start_reload_with_encoding_prompt();
824 }
825 Action::SetLanguage => {
826 self.start_set_language_prompt();
827 }
828 Action::ToggleIndentationStyle => {
829 if let Some(state) = self.buffers.get_mut(&self.active_buffer()) {
830 state.buffer_settings.use_tabs = !state.buffer_settings.use_tabs;
831 let status = if state.buffer_settings.use_tabs {
832 "Indentation: Tabs"
833 } else {
834 "Indentation: Spaces"
835 };
836 self.set_status_message(status.to_string());
837 }
838 }
839 Action::ToggleTabIndicators | Action::ToggleWhitespaceIndicators => {
840 if let Some(state) = self.buffers.get_mut(&self.active_buffer()) {
841 state.buffer_settings.whitespace.toggle_all();
842 let status = if state.buffer_settings.whitespace.any_visible() {
843 t!("toggle.whitespace_indicators_shown")
844 } else {
845 t!("toggle.whitespace_indicators_hidden")
846 };
847 self.set_status_message(status.to_string());
848 }
849 }
850 Action::ResetBufferSettings => self.reset_buffer_settings(),
851 Action::FocusFileExplorer => self.focus_file_explorer(),
852 Action::FocusEditor => self.focus_editor(),
853 Action::FileExplorerUp => self.file_explorer_navigate_up(),
854 Action::FileExplorerDown => self.file_explorer_navigate_down(),
855 Action::FileExplorerPageUp => self.file_explorer_page_up(),
856 Action::FileExplorerPageDown => self.file_explorer_page_down(),
857 Action::FileExplorerExpand => self.file_explorer_toggle_expand(),
858 Action::FileExplorerCollapse => self.file_explorer_collapse(),
859 Action::FileExplorerOpen => self.file_explorer_open_file()?,
860 Action::FileExplorerRefresh => self.file_explorer_refresh(),
861 Action::FileExplorerNewFile => self.file_explorer_new_file(),
862 Action::FileExplorerNewDirectory => self.file_explorer_new_directory(),
863 Action::FileExplorerDelete => self.file_explorer_delete(),
864 Action::FileExplorerRename => self.file_explorer_rename(),
865 Action::FileExplorerToggleHidden => self.file_explorer_toggle_hidden(),
866 Action::FileExplorerToggleGitignored => self.file_explorer_toggle_gitignored(),
867 Action::FileExplorerSearchClear => self.file_explorer_search_clear(),
868 Action::FileExplorerSearchBackspace => self.file_explorer_search_pop_char(),
869 Action::RemoveSecondaryCursors => {
870 if let Some(events) = self.action_to_events(Action::RemoveSecondaryCursors) {
872 let batch = Event::Batch {
874 events: events.clone(),
875 description: "Remove secondary cursors".to_string(),
876 };
877 self.active_event_log_mut().append(batch.clone());
878 self.apply_event_to_active_buffer(&batch);
879
880 let active_split = self.split_manager.active_split();
882 let active_buffer = self.active_buffer();
883 if let Some(view_state) = self.split_view_states.get_mut(&active_split) {
884 let state = self.buffers.get_mut(&active_buffer).unwrap();
885 view_state.ensure_cursor_visible(&mut state.buffer, &state.marker_list);
886 }
887 }
888 }
889
890 Action::MenuActivate => {
892 self.handle_menu_activate();
893 }
894 Action::MenuClose => {
895 self.handle_menu_close();
896 }
897 Action::MenuLeft => {
898 self.handle_menu_left();
899 }
900 Action::MenuRight => {
901 self.handle_menu_right();
902 }
903 Action::MenuUp => {
904 self.handle_menu_up();
905 }
906 Action::MenuDown => {
907 self.handle_menu_down();
908 }
909 Action::MenuExecute => {
910 if let Some(action) = self.handle_menu_execute() {
911 return self.handle_action(action);
912 }
913 }
914 Action::MenuOpen(menu_name) => {
915 if self.config.editor.menu_bar_mnemonics {
916 self.handle_menu_open(&menu_name);
917 }
918 }
919
920 Action::SwitchKeybindingMap(map_name) => {
921 let is_builtin =
923 matches!(map_name.as_str(), "default" | "emacs" | "vscode" | "macos");
924 let is_user_defined = self.config.keybinding_maps.contains_key(&map_name);
925
926 if is_builtin || is_user_defined {
927 self.config.active_keybinding_map = map_name.clone().into();
929
930 *self.keybindings.write().unwrap() =
932 crate::input::keybindings::KeybindingResolver::new(&self.config);
933
934 self.set_status_message(
935 t!("view.keybindings_switched", map = map_name).to_string(),
936 );
937 } else {
938 self.set_status_message(
939 t!("view.keybindings_unknown", map = map_name).to_string(),
940 );
941 }
942 }
943
944 Action::SmartHome => {
945 let buffer_id = self.active_buffer();
947 if self.is_composite_buffer(buffer_id) {
948 if let Some(_handled) =
949 self.handle_composite_action(buffer_id, &Action::SmartHome)
950 {
951 return Ok(());
952 }
953 }
954 self.smart_home();
955 }
956 Action::ToggleComment => {
957 self.toggle_comment();
958 }
959 Action::ToggleFold => {
960 self.toggle_fold_at_cursor();
961 }
962 Action::GoToMatchingBracket => {
963 self.goto_matching_bracket();
964 }
965 Action::JumpToNextError => {
966 self.jump_to_next_error();
967 }
968 Action::JumpToPreviousError => {
969 self.jump_to_previous_error();
970 }
971 Action::SetBookmark(key) => {
972 self.set_bookmark(key);
973 }
974 Action::JumpToBookmark(key) => {
975 self.jump_to_bookmark(key);
976 }
977 Action::ClearBookmark(key) => {
978 self.clear_bookmark(key);
979 }
980 Action::ListBookmarks => {
981 self.list_bookmarks();
982 }
983 Action::ToggleSearchCaseSensitive => {
984 self.search_case_sensitive = !self.search_case_sensitive;
985 let state = if self.search_case_sensitive {
986 "enabled"
987 } else {
988 "disabled"
989 };
990 self.set_status_message(
991 t!("search.case_sensitive_state", state = state).to_string(),
992 );
993 if let Some(prompt) = &self.prompt {
996 if matches!(
997 prompt.prompt_type,
998 PromptType::Search
999 | PromptType::ReplaceSearch
1000 | PromptType::QueryReplaceSearch
1001 ) {
1002 let query = prompt.input.clone();
1003 self.update_search_highlights(&query);
1004 }
1005 } else if let Some(search_state) = &self.search_state {
1006 let query = search_state.query.clone();
1007 self.perform_search(&query);
1008 }
1009 }
1010 Action::ToggleSearchWholeWord => {
1011 self.search_whole_word = !self.search_whole_word;
1012 let state = if self.search_whole_word {
1013 "enabled"
1014 } else {
1015 "disabled"
1016 };
1017 self.set_status_message(t!("search.whole_word_state", state = state).to_string());
1018 if let Some(prompt) = &self.prompt {
1021 if matches!(
1022 prompt.prompt_type,
1023 PromptType::Search
1024 | PromptType::ReplaceSearch
1025 | PromptType::QueryReplaceSearch
1026 ) {
1027 let query = prompt.input.clone();
1028 self.update_search_highlights(&query);
1029 }
1030 } else if let Some(search_state) = &self.search_state {
1031 let query = search_state.query.clone();
1032 self.perform_search(&query);
1033 }
1034 }
1035 Action::ToggleSearchRegex => {
1036 self.search_use_regex = !self.search_use_regex;
1037 let state = if self.search_use_regex {
1038 "enabled"
1039 } else {
1040 "disabled"
1041 };
1042 self.set_status_message(t!("search.regex_state", state = state).to_string());
1043 if let Some(prompt) = &self.prompt {
1046 if matches!(
1047 prompt.prompt_type,
1048 PromptType::Search
1049 | PromptType::ReplaceSearch
1050 | PromptType::QueryReplaceSearch
1051 ) {
1052 let query = prompt.input.clone();
1053 self.update_search_highlights(&query);
1054 }
1055 } else if let Some(search_state) = &self.search_state {
1056 let query = search_state.query.clone();
1057 self.perform_search(&query);
1058 }
1059 }
1060 Action::ToggleSearchConfirmEach => {
1061 self.search_confirm_each = !self.search_confirm_each;
1062 let state = if self.search_confirm_each {
1063 "enabled"
1064 } else {
1065 "disabled"
1066 };
1067 self.set_status_message(t!("search.confirm_each_state", state = state).to_string());
1068 }
1069 Action::FileBrowserToggleHidden => {
1070 self.file_open_toggle_hidden();
1072 }
1073 Action::StartMacroRecording => {
1074 self.set_status_message(
1076 "Use Ctrl+Shift+R to start recording (will prompt for register)".to_string(),
1077 );
1078 }
1079 Action::StopMacroRecording => {
1080 self.stop_macro_recording();
1081 }
1082 Action::PlayMacro(key) => {
1083 self.play_macro(key);
1084 }
1085 Action::ToggleMacroRecording(key) => {
1086 self.toggle_macro_recording(key);
1087 }
1088 Action::ShowMacro(key) => {
1089 self.show_macro_in_buffer(key);
1090 }
1091 Action::ListMacros => {
1092 self.list_macros_in_buffer();
1093 }
1094 Action::PromptRecordMacro => {
1095 self.start_prompt("Record macro (0-9): ".to_string(), PromptType::RecordMacro);
1096 }
1097 Action::PromptPlayMacro => {
1098 self.start_prompt("Play macro (0-9): ".to_string(), PromptType::PlayMacro);
1099 }
1100 Action::PlayLastMacro => {
1101 if let Some(key) = self.last_macro_register {
1102 self.play_macro(key);
1103 } else {
1104 self.set_status_message(t!("status.no_macro_recorded").to_string());
1105 }
1106 }
1107 Action::PromptSetBookmark => {
1108 self.start_prompt("Set bookmark (0-9): ".to_string(), PromptType::SetBookmark);
1109 }
1110 Action::PromptJumpToBookmark => {
1111 self.start_prompt(
1112 "Jump to bookmark (0-9): ".to_string(),
1113 PromptType::JumpToBookmark,
1114 );
1115 }
1116 Action::CompositeNextHunk => {
1117 let buf = self.active_buffer();
1118 self.composite_next_hunk_active(buf);
1119 }
1120 Action::CompositePrevHunk => {
1121 let buf = self.active_buffer();
1122 self.composite_prev_hunk_active(buf);
1123 }
1124 Action::None => {}
1125 Action::DeleteBackward => {
1126 if self.is_editing_disabled() {
1127 self.set_status_message(t!("buffer.editing_disabled").to_string());
1128 return Ok(());
1129 }
1130 if let Some(events) = self.action_to_events(Action::DeleteBackward) {
1132 if events.len() > 1 {
1133 let description = "Delete backward".to_string();
1135 if let Some(bulk_edit) = self.apply_events_as_bulk_edit(events, description)
1136 {
1137 self.active_event_log_mut().append(bulk_edit);
1138 }
1139 } else {
1140 for event in events {
1141 self.active_event_log_mut().append(event.clone());
1142 self.apply_event_to_active_buffer(&event);
1143 }
1144 }
1145 }
1146 }
1147 Action::PluginAction(action_name) => {
1148 tracing::debug!("handle_action: PluginAction('{}')", action_name);
1149 #[cfg(feature = "plugins")]
1152 if let Some(result) = self.plugin_manager.execute_action_async(&action_name) {
1153 match result {
1154 Ok(receiver) => {
1155 self.pending_plugin_actions
1157 .push((action_name.clone(), receiver));
1158 }
1159 Err(e) => {
1160 self.set_status_message(
1161 t!("view.plugin_error", error = e.to_string()).to_string(),
1162 );
1163 tracing::error!("Plugin action error: {}", e);
1164 }
1165 }
1166 } else {
1167 self.set_status_message(t!("status.plugin_manager_unavailable").to_string());
1168 }
1169 #[cfg(not(feature = "plugins"))]
1170 {
1171 let _ = action_name;
1172 self.set_status_message(
1173 "Plugins not available (compiled without plugin support)".to_string(),
1174 );
1175 }
1176 }
1177 Action::LoadPluginFromBuffer => {
1178 #[cfg(feature = "plugins")]
1179 {
1180 let buffer_id = self.active_buffer();
1181 let state = self.active_state();
1182 let buffer = &state.buffer;
1183 let total = buffer.total_bytes();
1184 let content =
1185 String::from_utf8_lossy(&buffer.slice_bytes(0..total)).to_string();
1186
1187 let is_ts = buffer
1189 .file_path()
1190 .and_then(|p| p.extension())
1191 .and_then(|e| e.to_str())
1192 .map(|e| e == "ts" || e == "tsx")
1193 .unwrap_or(true);
1194
1195 let name = buffer
1197 .file_path()
1198 .and_then(|p| p.file_name())
1199 .and_then(|s| s.to_str())
1200 .map(|s| s.to_string())
1201 .unwrap_or_else(|| "buffer-plugin".to_string());
1202
1203 match self
1204 .plugin_manager
1205 .load_plugin_from_source(&content, &name, is_ts)
1206 {
1207 Ok(()) => {
1208 self.set_status_message(format!(
1209 "Plugin '{}' loaded from buffer",
1210 name
1211 ));
1212 }
1213 Err(e) => {
1214 self.set_status_message(format!("Failed to load plugin: {}", e));
1215 tracing::error!("LoadPluginFromBuffer error: {}", e);
1216 }
1217 }
1218
1219 self.setup_plugin_dev_lsp(buffer_id, &content);
1221 }
1222 #[cfg(not(feature = "plugins"))]
1223 {
1224 self.set_status_message(
1225 "Plugins not available (compiled without plugin support)".to_string(),
1226 );
1227 }
1228 }
1229 Action::OpenTerminal => {
1230 self.open_terminal();
1231 }
1232 Action::CloseTerminal => {
1233 self.close_terminal();
1234 }
1235 Action::FocusTerminal => {
1236 if self.is_terminal_buffer(self.active_buffer()) {
1238 self.terminal_mode = true;
1239 self.key_context = KeyContext::Terminal;
1240 self.set_status_message(t!("status.terminal_mode_enabled").to_string());
1241 }
1242 }
1243 Action::TerminalEscape => {
1244 if self.terminal_mode {
1246 self.terminal_mode = false;
1247 self.key_context = KeyContext::Normal;
1248 self.set_status_message(t!("status.terminal_mode_disabled").to_string());
1249 }
1250 }
1251 Action::ToggleKeyboardCapture => {
1252 if self.terminal_mode {
1254 self.keyboard_capture = !self.keyboard_capture;
1255 if self.keyboard_capture {
1256 self.set_status_message(
1257 "Keyboard capture ON - all keys go to terminal (F9 to toggle)"
1258 .to_string(),
1259 );
1260 } else {
1261 self.set_status_message(
1262 "Keyboard capture OFF - UI bindings active (F9 to toggle)".to_string(),
1263 );
1264 }
1265 }
1266 }
1267 Action::TerminalPaste => {
1268 if self.terminal_mode {
1270 if let Some(text) = self.clipboard.paste() {
1271 self.send_terminal_input(text.as_bytes());
1272 }
1273 }
1274 }
1275 Action::ShellCommand => {
1276 self.start_shell_command_prompt(false);
1278 }
1279 Action::ShellCommandReplace => {
1280 self.start_shell_command_prompt(true);
1282 }
1283 Action::OpenSettings => {
1284 self.open_settings();
1285 }
1286 Action::CloseSettings => {
1287 let has_changes = self
1289 .settings_state
1290 .as_ref()
1291 .is_some_and(|s| s.has_changes());
1292 if has_changes {
1293 if let Some(ref mut state) = self.settings_state {
1295 state.show_confirm_dialog();
1296 }
1297 } else {
1298 self.close_settings(false);
1299 }
1300 }
1301 Action::SettingsSave => {
1302 self.save_settings();
1303 }
1304 Action::SettingsReset => {
1305 if let Some(ref mut state) = self.settings_state {
1306 state.reset_current_to_default();
1307 }
1308 }
1309 Action::SettingsInherit => {
1310 if let Some(ref mut state) = self.settings_state {
1311 state.set_current_to_null();
1312 }
1313 }
1314 Action::SettingsToggleFocus => {
1315 if let Some(ref mut state) = self.settings_state {
1316 state.toggle_focus();
1317 }
1318 }
1319 Action::SettingsActivate => {
1320 self.settings_activate_current();
1321 }
1322 Action::SettingsSearch => {
1323 if let Some(ref mut state) = self.settings_state {
1324 state.start_search();
1325 }
1326 }
1327 Action::SettingsHelp => {
1328 if let Some(ref mut state) = self.settings_state {
1329 state.toggle_help();
1330 }
1331 }
1332 Action::SettingsIncrement => {
1333 self.settings_increment_current();
1334 }
1335 Action::SettingsDecrement => {
1336 self.settings_decrement_current();
1337 }
1338 Action::CalibrateInput => {
1339 self.open_calibration_wizard();
1340 }
1341 Action::EventDebug => {
1342 self.open_event_debug();
1343 }
1344 Action::OpenKeybindingEditor => {
1345 self.open_keybinding_editor();
1346 }
1347 Action::PromptConfirm => {
1348 if let Some((input, prompt_type, selected_index)) = self.confirm_prompt() {
1349 use super::prompt_actions::PromptResult;
1350 match self.handle_prompt_confirm_input(input, prompt_type, selected_index) {
1351 PromptResult::ExecuteAction(action) => {
1352 return self.handle_action(action);
1353 }
1354 PromptResult::EarlyReturn => {
1355 return Ok(());
1356 }
1357 PromptResult::Done => {}
1358 }
1359 }
1360 }
1361 Action::PromptConfirmWithText(ref text) => {
1362 if let Some(ref mut prompt) = self.prompt {
1364 prompt.set_input(text.clone());
1365 self.update_prompt_suggestions();
1366 }
1367 if let Some((input, prompt_type, selected_index)) = self.confirm_prompt() {
1368 use super::prompt_actions::PromptResult;
1369 match self.handle_prompt_confirm_input(input, prompt_type, selected_index) {
1370 PromptResult::ExecuteAction(action) => {
1371 return self.handle_action(action);
1372 }
1373 PromptResult::EarlyReturn => {
1374 return Ok(());
1375 }
1376 PromptResult::Done => {}
1377 }
1378 }
1379 }
1380 Action::PopupConfirm => {
1381 use super::popup_actions::PopupConfirmResult;
1382 if let PopupConfirmResult::EarlyReturn = self.handle_popup_confirm() {
1383 return Ok(());
1384 }
1385 }
1386 Action::PopupCancel => {
1387 self.handle_popup_cancel();
1388 }
1389 Action::InsertChar(c) => {
1390 if self.is_prompting() {
1391 return self.handle_insert_char_prompt(c);
1392 } else if self.key_context == KeyContext::FileExplorer {
1393 self.file_explorer_search_push_char(c);
1394 } else {
1395 self.handle_insert_char_editor(c)?;
1396 }
1397 }
1398 Action::PromptCopy => {
1400 if let Some(prompt) = &self.prompt {
1401 let text = prompt.selected_text().unwrap_or_else(|| prompt.get_text());
1402 if !text.is_empty() {
1403 self.clipboard.copy(text);
1404 self.set_status_message(t!("clipboard.copied").to_string());
1405 }
1406 }
1407 }
1408 Action::PromptCut => {
1409 if let Some(prompt) = &self.prompt {
1410 let text = prompt.selected_text().unwrap_or_else(|| prompt.get_text());
1411 if !text.is_empty() {
1412 self.clipboard.copy(text);
1413 }
1414 }
1415 if let Some(prompt) = self.prompt.as_mut() {
1416 if prompt.has_selection() {
1417 prompt.delete_selection();
1418 } else {
1419 prompt.clear();
1420 }
1421 }
1422 self.set_status_message(t!("clipboard.cut").to_string());
1423 self.update_prompt_suggestions();
1424 }
1425 Action::PromptPaste => {
1426 if let Some(text) = self.clipboard.paste() {
1427 if let Some(prompt) = self.prompt.as_mut() {
1428 prompt.insert_str(&text);
1429 }
1430 self.update_prompt_suggestions();
1431 }
1432 }
1433 _ => {
1434 self.apply_action_as_events(action)?;
1440 }
1441 }
1442
1443 Ok(())
1444 }
1445
1446 pub(super) fn handle_mouse_scroll(
1448 &mut self,
1449 col: u16,
1450 row: u16,
1451 delta: i32,
1452 ) -> AnyhowResult<()> {
1453 let buffer_id = self.active_buffer();
1455 self.plugin_manager.run_hook(
1456 "mouse_scroll",
1457 fresh_core::hooks::HookArgs::MouseScroll {
1458 buffer_id,
1459 delta,
1460 col,
1461 row,
1462 },
1463 );
1464
1465 if let Some(explorer_area) = self.cached_layout.file_explorer_area {
1467 if col >= explorer_area.x
1468 && col < explorer_area.x + explorer_area.width
1469 && row >= explorer_area.y
1470 && row < explorer_area.y + explorer_area.height
1471 {
1472 if let Some(explorer) = &mut self.file_explorer {
1474 let count = explorer.visible_count();
1475 if count == 0 {
1476 return Ok(());
1477 }
1478
1479 let current_index = explorer.get_selected_index().unwrap_or(0);
1481
1482 let new_index = if delta < 0 {
1484 current_index.saturating_sub(delta.unsigned_abs() as usize)
1486 } else {
1487 (current_index + delta as usize).min(count - 1)
1489 };
1490
1491 if let Some(node_id) = explorer.get_node_at_index(new_index) {
1493 explorer.set_selected(Some(node_id));
1494 explorer.update_scroll_for_selection();
1495 }
1496 }
1497 return Ok(());
1498 }
1499 }
1500
1501 let (target_split, buffer_id) = self
1504 .split_at_position(col, row)
1505 .unwrap_or_else(|| (self.split_manager.active_split(), self.active_buffer()));
1506
1507 if self.is_composite_buffer(buffer_id) {
1509 let max_row = self
1510 .composite_buffers
1511 .get(&buffer_id)
1512 .map(|c| c.row_count().saturating_sub(1))
1513 .unwrap_or(0);
1514 if let Some(view_state) = self
1515 .composite_view_states
1516 .get_mut(&(target_split, buffer_id))
1517 {
1518 view_state.scroll(delta as isize, max_row);
1519 tracing::trace!(
1520 "handle_mouse_scroll (composite): delta={}, scroll_row={}",
1521 delta,
1522 view_state.scroll_row
1523 );
1524 }
1525 return Ok(());
1526 }
1527
1528 let view_transform_tokens = self
1530 .split_view_states
1531 .get(&target_split)
1532 .and_then(|vs| vs.view_transform.as_ref())
1533 .map(|vt| vt.tokens.clone());
1534
1535 let state = self.buffers.get_mut(&buffer_id);
1537 let view_state = self.split_view_states.get_mut(&target_split);
1538
1539 if let (Some(state), Some(view_state)) = (state, view_state) {
1540 let buffer = &mut state.buffer;
1541 let top_byte_before = view_state.viewport.top_byte;
1542 if let Some(tokens) = view_transform_tokens {
1543 use crate::view::ui::view_pipeline::ViewLineIterator;
1545 let tab_size = self.config.editor.tab_size;
1546 let view_lines: Vec<_> =
1547 ViewLineIterator::new(&tokens, false, false, tab_size, false).collect();
1548 view_state
1549 .viewport
1550 .scroll_view_lines(&view_lines, delta as isize);
1551 } else {
1552 if delta < 0 {
1554 let lines_to_scroll = delta.unsigned_abs() as usize;
1556 view_state.viewport.scroll_up(buffer, lines_to_scroll);
1557 } else {
1558 let lines_to_scroll = delta as usize;
1560 view_state.viewport.scroll_down(buffer, lines_to_scroll);
1561 }
1562 }
1563 view_state.viewport.set_skip_ensure_visible();
1565
1566 if let Some(folds) = view_state.keyed_states.get(&buffer_id).map(|bs| &bs.folds) {
1567 if !folds.is_empty() {
1568 let top_line = buffer.get_line_number(view_state.viewport.top_byte);
1569 if let Some(range) = folds
1570 .resolved_ranges(buffer, &state.marker_list)
1571 .iter()
1572 .find(|r| top_line >= r.start_line && top_line <= r.end_line)
1573 {
1574 let target_line = if delta >= 0 {
1575 range.end_line.saturating_add(1)
1576 } else {
1577 range.header_line
1578 };
1579 let target_byte = buffer
1580 .line_start_offset(target_line)
1581 .unwrap_or_else(|| buffer.len());
1582 view_state.viewport.top_byte = target_byte;
1583 view_state.viewport.top_view_line_offset = 0;
1584 }
1585 }
1586 }
1587 tracing::trace!(
1588 "handle_mouse_scroll: delta={}, top_byte {} -> {}",
1589 delta,
1590 top_byte_before,
1591 view_state.viewport.top_byte
1592 );
1593 }
1594
1595 Ok(())
1596 }
1597
1598 pub(super) fn handle_horizontal_scroll(
1600 &mut self,
1601 col: u16,
1602 row: u16,
1603 delta: i32,
1604 ) -> AnyhowResult<()> {
1605 let target_split = self
1606 .split_at_position(col, row)
1607 .map(|(id, _)| id)
1608 .unwrap_or_else(|| self.split_manager.active_split());
1609
1610 if let Some(view_state) = self.split_view_states.get_mut(&target_split) {
1611 if view_state.viewport.line_wrap_enabled {
1613 return Ok(());
1614 }
1615
1616 let columns_to_scroll = delta.unsigned_abs() as usize;
1617 if delta < 0 {
1618 view_state.viewport.left_column = view_state
1620 .viewport
1621 .left_column
1622 .saturating_sub(columns_to_scroll);
1623 } else {
1624 let visible_width = view_state.viewport.width as usize;
1626 let max_scroll = view_state
1627 .viewport
1628 .max_line_length_seen
1629 .saturating_sub(visible_width);
1630 let new_left = view_state
1631 .viewport
1632 .left_column
1633 .saturating_add(columns_to_scroll);
1634 view_state.viewport.left_column = new_left.min(max_scroll);
1635 }
1636 view_state.viewport.set_skip_ensure_visible();
1638 }
1639
1640 Ok(())
1641 }
1642
1643 pub(super) fn handle_scrollbar_drag_relative(
1645 &mut self,
1646 row: u16,
1647 split_id: LeafId,
1648 buffer_id: BufferId,
1649 scrollbar_rect: ratatui::layout::Rect,
1650 ) -> AnyhowResult<()> {
1651 let drag_start_row = match self.mouse_state.drag_start_row {
1652 Some(r) => r,
1653 None => return Ok(()), };
1655
1656 if self.is_composite_buffer(buffer_id) {
1658 return self.handle_composite_scrollbar_drag_relative(
1659 row,
1660 drag_start_row,
1661 split_id,
1662 buffer_id,
1663 scrollbar_rect,
1664 );
1665 }
1666
1667 let drag_start_top_byte = match self.mouse_state.drag_start_top_byte {
1668 Some(b) => b,
1669 None => return Ok(()), };
1671
1672 let drag_start_view_line_offset = self.mouse_state.drag_start_view_line_offset.unwrap_or(0);
1673
1674 let row_offset = (row as i32) - (drag_start_row as i32);
1676
1677 let viewport_height = self
1679 .split_view_states
1680 .get(&split_id)
1681 .map(|vs| vs.viewport.height as usize)
1682 .unwrap_or(10);
1683
1684 let line_wrap_enabled = self
1686 .split_view_states
1687 .get(&split_id)
1688 .map(|vs| vs.viewport.line_wrap_enabled)
1689 .unwrap_or(false);
1690
1691 let viewport_width = self
1692 .split_view_states
1693 .get(&split_id)
1694 .map(|vs| vs.viewport.width as usize)
1695 .unwrap_or(80);
1696
1697 let scroll_position = if let Some(state) = self.buffers.get_mut(&buffer_id) {
1700 let scrollbar_height = scrollbar_rect.height as usize;
1701 if scrollbar_height == 0 {
1702 return Ok(());
1703 }
1704
1705 let buffer_len = state.buffer.len();
1706 let large_file_threshold = self.config.editor.large_file_threshold_bytes as usize;
1707
1708 if buffer_len <= large_file_threshold {
1710 if line_wrap_enabled {
1712 Self::calculate_scrollbar_drag_relative_visual(
1713 &mut state.buffer,
1714 row,
1715 scrollbar_rect.y,
1716 scrollbar_height,
1717 drag_start_row,
1718 drag_start_top_byte,
1719 drag_start_view_line_offset,
1720 viewport_height,
1721 viewport_width,
1722 )
1723 } else {
1724 let total_lines = if buffer_len > 0 {
1726 state.buffer.get_line_number(buffer_len.saturating_sub(1)) + 1
1727 } else {
1728 1
1729 };
1730
1731 let max_scroll_line = total_lines.saturating_sub(viewport_height);
1732
1733 if max_scroll_line == 0 || scrollbar_height <= 1 {
1734 (0, 0)
1736 } else {
1737 let start_line = state.buffer.get_line_number(drag_start_top_byte);
1739
1740 let thumb_size_raw = (viewport_height as f64 / total_lines as f64
1742 * scrollbar_height as f64)
1743 .ceil() as usize;
1744 let max_thumb_size = (scrollbar_height as f64 * 0.8).floor() as usize;
1745 let thumb_size = thumb_size_raw
1746 .max(1)
1747 .min(max_thumb_size)
1748 .min(scrollbar_height);
1749
1750 let max_thumb_start = scrollbar_height.saturating_sub(thumb_size);
1752
1753 if max_thumb_start == 0 {
1754 (0, 0)
1756 } else {
1757 let start_scroll_ratio =
1759 start_line.min(max_scroll_line) as f64 / max_scroll_line as f64;
1760 let thumb_row_at_start = scrollbar_rect.y as f64
1761 + start_scroll_ratio * max_thumb_start as f64;
1762
1763 let click_offset = drag_start_row as f64 - thumb_row_at_start;
1765
1766 let target_thumb_row = row as f64 - click_offset;
1768
1769 let target_scroll_ratio = ((target_thumb_row
1771 - scrollbar_rect.y as f64)
1772 / max_thumb_start as f64)
1773 .clamp(0.0, 1.0);
1774
1775 let target_line =
1777 (target_scroll_ratio * max_scroll_line as f64).round() as usize;
1778 let target_line = target_line.min(max_scroll_line);
1779
1780 let target_byte = state
1782 .buffer
1783 .line_start_offset(target_line)
1784 .unwrap_or(drag_start_top_byte);
1785
1786 (target_byte, 0)
1787 }
1788 }
1789 }
1790 } else {
1791 let bytes_per_pixel = buffer_len as f64 / scrollbar_height as f64;
1793 let byte_offset = (row_offset as f64 * bytes_per_pixel) as i64;
1794
1795 let new_top_byte = if byte_offset >= 0 {
1796 drag_start_top_byte.saturating_add(byte_offset as usize)
1797 } else {
1798 drag_start_top_byte.saturating_sub((-byte_offset) as usize)
1799 };
1800
1801 let new_top_byte = new_top_byte.min(buffer_len.saturating_sub(1));
1803
1804 let iter = state.buffer.line_iterator(new_top_byte, 80);
1806 (iter.current_position(), 0)
1807 }
1808 } else {
1809 return Ok(());
1810 };
1811
1812 if let Some(view_state) = self.split_view_states.get_mut(&split_id) {
1814 view_state.viewport.top_byte = scroll_position.0;
1815 view_state.viewport.top_view_line_offset = scroll_position.1;
1816 view_state.viewport.set_skip_ensure_visible();
1818 }
1819
1820 self.move_cursor_to_visible_area(split_id, buffer_id);
1822
1823 Ok(())
1824 }
1825
1826 pub(super) fn handle_scrollbar_jump(
1828 &mut self,
1829 _col: u16,
1830 row: u16,
1831 split_id: LeafId,
1832 buffer_id: BufferId,
1833 scrollbar_rect: ratatui::layout::Rect,
1834 ) -> AnyhowResult<()> {
1835 let scrollbar_height = scrollbar_rect.height as usize;
1837 if scrollbar_height == 0 {
1838 return Ok(());
1839 }
1840
1841 let relative_row = row.saturating_sub(scrollbar_rect.y);
1844 let ratio = if scrollbar_height > 1 {
1845 ((relative_row as f64) / ((scrollbar_height - 1) as f64)).clamp(0.0, 1.0)
1846 } else {
1847 0.0
1848 };
1849
1850 if self.is_composite_buffer(buffer_id) {
1852 return self.handle_composite_scrollbar_jump(
1853 ratio,
1854 split_id,
1855 buffer_id,
1856 scrollbar_rect,
1857 );
1858 }
1859
1860 let viewport_height = self
1862 .split_view_states
1863 .get(&split_id)
1864 .map(|vs| vs.viewport.height as usize)
1865 .unwrap_or(10);
1866
1867 let line_wrap_enabled = self
1869 .split_view_states
1870 .get(&split_id)
1871 .map(|vs| vs.viewport.line_wrap_enabled)
1872 .unwrap_or(false);
1873
1874 let viewport_width = self
1875 .split_view_states
1876 .get(&split_id)
1877 .map(|vs| vs.viewport.width as usize)
1878 .unwrap_or(80);
1879
1880 let scroll_position = if let Some(state) = self.buffers.get_mut(&buffer_id) {
1883 let buffer_len = state.buffer.len();
1884 let large_file_threshold = self.config.editor.large_file_threshold_bytes as usize;
1885
1886 if buffer_len <= large_file_threshold {
1889 if line_wrap_enabled {
1891 Self::calculate_scrollbar_jump_visual(
1894 &mut state.buffer,
1895 ratio,
1896 viewport_height,
1897 viewport_width,
1898 )
1899 } else {
1900 let total_lines = if buffer_len > 0 {
1902 state.buffer.get_line_number(buffer_len.saturating_sub(1)) + 1
1903 } else {
1904 1
1905 };
1906
1907 let max_scroll_line = total_lines.saturating_sub(viewport_height);
1908
1909 let target_byte = if max_scroll_line == 0 {
1910 0
1912 } else {
1913 let target_line = (ratio * max_scroll_line as f64).round() as usize;
1915 let target_line = target_line.min(max_scroll_line);
1916
1917 let mut iter = state.buffer.line_iterator(0, 80);
1921 let mut line_byte = 0;
1922
1923 for _ in 0..target_line {
1924 if let Some((pos, _content)) = iter.next_line() {
1925 line_byte = pos;
1926 } else {
1927 break;
1928 }
1929 }
1930
1931 if let Some((pos, _)) = iter.next_line() {
1933 pos
1934 } else {
1935 line_byte }
1937 };
1938
1939 let iter = state.buffer.line_iterator(target_byte, 80);
1941 let line_start = iter.current_position();
1942
1943 let max_top_byte =
1945 Self::calculate_max_scroll_position(&mut state.buffer, viewport_height);
1946 (line_start.min(max_top_byte), 0)
1947 }
1948 } else {
1949 let target_byte = (buffer_len as f64 * ratio) as usize;
1951 let target_byte = target_byte.min(buffer_len.saturating_sub(1));
1952
1953 let iter = state.buffer.line_iterator(target_byte, 80);
1955 let line_start = iter.current_position();
1956
1957 (line_start.min(buffer_len.saturating_sub(1)), 0)
1958 }
1959 } else {
1960 return Ok(());
1961 };
1962
1963 if let Some(view_state) = self.split_view_states.get_mut(&split_id) {
1965 view_state.viewport.top_byte = scroll_position.0;
1966 view_state.viewport.top_view_line_offset = scroll_position.1;
1967 view_state.viewport.set_skip_ensure_visible();
1969 }
1970
1971 self.move_cursor_to_visible_area(split_id, buffer_id);
1973
1974 Ok(())
1975 }
1976
1977 fn handle_composite_scrollbar_jump(
1980 &mut self,
1981 ratio: f64,
1982 split_id: LeafId,
1983 buffer_id: BufferId,
1984 scrollbar_rect: ratatui::layout::Rect,
1985 ) -> AnyhowResult<()> {
1986 let total_rows = self
1987 .composite_buffers
1988 .get(&buffer_id)
1989 .map(|c| c.row_count())
1990 .unwrap_or(0);
1991 let content_height = scrollbar_rect.height.saturating_sub(1) as usize;
1992 let max_scroll_row = total_rows.saturating_sub(content_height);
1993 let target_row = (ratio * max_scroll_row as f64).round() as usize;
1994 let target_row = target_row.min(max_scroll_row);
1995
1996 if let Some(view_state) = self.composite_view_states.get_mut(&(split_id, buffer_id)) {
1997 view_state.set_scroll_row(target_row, max_scroll_row);
1998 }
1999 Ok(())
2000 }
2001
2002 fn handle_composite_scrollbar_drag_relative(
2005 &mut self,
2006 row: u16,
2007 drag_start_row: u16,
2008 split_id: LeafId,
2009 buffer_id: BufferId,
2010 scrollbar_rect: ratatui::layout::Rect,
2011 ) -> AnyhowResult<()> {
2012 let drag_start_scroll_row = match self.mouse_state.drag_start_composite_scroll_row {
2013 Some(r) => r,
2014 None => return Ok(()),
2015 };
2016
2017 let total_rows = self
2018 .composite_buffers
2019 .get(&buffer_id)
2020 .map(|c| c.row_count())
2021 .unwrap_or(0);
2022 let content_height = scrollbar_rect.height.saturating_sub(1) as usize;
2023 let max_scroll_row = total_rows.saturating_sub(content_height);
2024
2025 if max_scroll_row == 0 {
2026 return Ok(());
2027 }
2028
2029 let scrollbar_height = scrollbar_rect.height as usize;
2030 if scrollbar_height <= 1 {
2031 return Ok(());
2032 }
2033
2034 let thumb_size_raw =
2036 (content_height as f64 / total_rows as f64 * scrollbar_height as f64).ceil() as usize;
2037 let max_thumb_size = (scrollbar_height as f64 * 0.8).floor() as usize;
2038 let thumb_size = thumb_size_raw
2039 .max(1)
2040 .min(max_thumb_size)
2041 .min(scrollbar_height);
2042 let max_thumb_start = scrollbar_height.saturating_sub(thumb_size);
2043
2044 if max_thumb_start == 0 {
2045 return Ok(());
2046 }
2047
2048 let start_scroll_ratio =
2050 drag_start_scroll_row.min(max_scroll_row) as f64 / max_scroll_row as f64;
2051 let thumb_row_at_start =
2052 scrollbar_rect.y as f64 + start_scroll_ratio * max_thumb_start as f64;
2053
2054 let click_offset = drag_start_row as f64 - thumb_row_at_start;
2056
2057 let target_thumb_row = row as f64 - click_offset;
2059
2060 let target_scroll_ratio =
2062 ((target_thumb_row - scrollbar_rect.y as f64) / max_thumb_start as f64).clamp(0.0, 1.0);
2063
2064 let target_row = (target_scroll_ratio * max_scroll_row as f64).round() as usize;
2066 let target_row = target_row.min(max_scroll_row);
2067
2068 if let Some(view_state) = self.composite_view_states.get_mut(&(split_id, buffer_id)) {
2069 view_state.set_scroll_row(target_row, max_scroll_row);
2070 }
2071 Ok(())
2072 }
2073
2074 pub(super) fn move_cursor_to_visible_area(&mut self, split_id: LeafId, buffer_id: BufferId) {
2077 let (top_byte, viewport_height) =
2079 if let Some(view_state) = self.split_view_states.get(&split_id) {
2080 (
2081 view_state.viewport.top_byte,
2082 view_state.viewport.height as usize,
2083 )
2084 } else {
2085 return;
2086 };
2087
2088 if let Some(state) = self.buffers.get_mut(&buffer_id) {
2089 let buffer_len = state.buffer.len();
2090
2091 let mut iter = state.buffer.line_iterator(top_byte, 80);
2094 let mut bottom_byte = buffer_len;
2095
2096 for _ in 0..viewport_height {
2098 if let Some((pos, line)) = iter.next_line() {
2099 bottom_byte = pos + line.len();
2101 } else {
2102 bottom_byte = buffer_len;
2104 break;
2105 }
2106 }
2107
2108 if let Some(view_state) = self.split_view_states.get_mut(&split_id) {
2110 let cursor_pos = view_state.cursors.primary().position;
2111 if cursor_pos < top_byte || cursor_pos > bottom_byte {
2112 let cursor = view_state.cursors.primary_mut();
2114 cursor.position = top_byte;
2115 }
2117 }
2118 }
2119 }
2120
2121 pub(super) fn calculate_max_scroll_position(
2124 buffer: &mut crate::model::buffer::Buffer,
2125 viewport_height: usize,
2126 ) -> usize {
2127 if viewport_height == 0 {
2128 return 0;
2129 }
2130
2131 let buffer_len = buffer.len();
2132 if buffer_len == 0 {
2133 return 0;
2134 }
2135
2136 let mut line_count = 0;
2138 let mut iter = buffer.line_iterator(0, 80);
2139 while iter.next_line().is_some() {
2140 line_count += 1;
2141 }
2142
2143 if line_count <= viewport_height {
2145 return 0;
2146 }
2147
2148 let scrollable_lines = line_count.saturating_sub(viewport_height);
2151
2152 let mut iter = buffer.line_iterator(0, 80);
2154 let mut current_line = 0;
2155 let mut max_byte_pos = 0;
2156
2157 while current_line < scrollable_lines {
2158 if let Some((pos, _content)) = iter.next_line() {
2159 max_byte_pos = pos;
2160 current_line += 1;
2161 } else {
2162 break;
2163 }
2164 }
2165
2166 max_byte_pos
2167 }
2168
2169 fn calculate_scrollbar_jump_visual(
2174 buffer: &mut crate::model::buffer::Buffer,
2175 ratio: f64,
2176 viewport_height: usize,
2177 viewport_width: usize,
2178 ) -> (usize, usize) {
2179 use crate::primitives::line_wrapping::{wrap_line, WrapConfig};
2180
2181 let buffer_len = buffer.len();
2182 if buffer_len == 0 || viewport_height == 0 {
2183 return (0, 0);
2184 }
2185
2186 let line_count = buffer.line_count().unwrap_or(1);
2188 let digits = (line_count as f64).log10().floor() as usize + 1;
2189 let gutter_width = 1 + digits.max(4) + 3; let wrap_config = WrapConfig::new(viewport_width, gutter_width, true, true);
2192
2193 let mut total_visual_rows = 0;
2195 let mut visual_row_positions: Vec<(usize, usize)> = Vec::new(); let mut iter = buffer.line_iterator(0, 80);
2198 while let Some((line_start, content)) = iter.next_line() {
2199 let line_content = content.trim_end_matches(['\n', '\r']).to_string();
2200 let segments = wrap_line(&line_content, &wrap_config);
2201 let visual_rows_in_line = segments.len().max(1);
2202
2203 for offset in 0..visual_rows_in_line {
2204 visual_row_positions.push((line_start, offset));
2205 }
2206 total_visual_rows += visual_rows_in_line;
2207 }
2208
2209 if total_visual_rows == 0 {
2210 return (0, 0);
2211 }
2212
2213 let max_scroll_row = total_visual_rows.saturating_sub(viewport_height);
2215
2216 if max_scroll_row == 0 {
2217 return (0, 0);
2219 }
2220
2221 let target_row = (ratio * max_scroll_row as f64).round() as usize;
2223 let target_row = target_row.min(max_scroll_row);
2224
2225 if target_row < visual_row_positions.len() {
2227 visual_row_positions[target_row]
2228 } else {
2229 visual_row_positions.last().copied().unwrap_or((0, 0))
2231 }
2232 }
2233
2234 #[allow(clippy::too_many_arguments)]
2238 fn calculate_scrollbar_drag_relative_visual(
2239 buffer: &mut crate::model::buffer::Buffer,
2240 current_row: u16,
2241 scrollbar_y: u16,
2242 scrollbar_height: usize,
2243 drag_start_row: u16,
2244 drag_start_top_byte: usize,
2245 drag_start_view_line_offset: usize,
2246 viewport_height: usize,
2247 viewport_width: usize,
2248 ) -> (usize, usize) {
2249 use crate::primitives::line_wrapping::{wrap_line, WrapConfig};
2250
2251 let buffer_len = buffer.len();
2252 if buffer_len == 0 || viewport_height == 0 || scrollbar_height <= 1 {
2253 return (0, 0);
2254 }
2255
2256 let line_count = buffer.line_count().unwrap_or(1);
2258 let digits = (line_count as f64).log10().floor() as usize + 1;
2259 let gutter_width = 1 + digits.max(4) + 3; let wrap_config = WrapConfig::new(viewport_width, gutter_width, true, true);
2262
2263 let mut total_visual_rows = 0;
2265 let mut visual_row_positions: Vec<(usize, usize)> = Vec::new();
2266
2267 let mut iter = buffer.line_iterator(0, 80);
2268 while let Some((line_start, content)) = iter.next_line() {
2269 let line_content = content.trim_end_matches(['\n', '\r']).to_string();
2270 let segments = wrap_line(&line_content, &wrap_config);
2271 let visual_rows_in_line = segments.len().max(1);
2272
2273 for offset in 0..visual_rows_in_line {
2274 visual_row_positions.push((line_start, offset));
2275 }
2276 total_visual_rows += visual_rows_in_line;
2277 }
2278
2279 if total_visual_rows == 0 {
2280 return (0, 0);
2281 }
2282
2283 let max_scroll_row = total_visual_rows.saturating_sub(viewport_height);
2284 if max_scroll_row == 0 {
2285 return (0, 0);
2286 }
2287
2288 let line_start_visual_row = visual_row_positions
2291 .iter()
2292 .position(|(byte, _)| *byte >= drag_start_top_byte)
2293 .unwrap_or(0);
2294 let start_visual_row =
2295 (line_start_visual_row + drag_start_view_line_offset).min(max_scroll_row);
2296
2297 let thumb_size_raw = (viewport_height as f64 / total_visual_rows as f64
2299 * scrollbar_height as f64)
2300 .ceil() as usize;
2301 let max_thumb_size = (scrollbar_height as f64 * 0.8).floor() as usize;
2302 let thumb_size = thumb_size_raw
2303 .max(1)
2304 .min(max_thumb_size)
2305 .min(scrollbar_height);
2306
2307 let max_thumb_start = scrollbar_height.saturating_sub(thumb_size);
2309
2310 let start_scroll_ratio = start_visual_row as f64 / max_scroll_row as f64;
2313 let thumb_row_at_start = scrollbar_y as f64 + start_scroll_ratio * max_thumb_start as f64;
2314
2315 let click_offset = drag_start_row as f64 - thumb_row_at_start;
2317
2318 let target_thumb_row = current_row as f64 - click_offset;
2320
2321 let target_scroll_ratio = if max_thumb_start > 0 {
2323 ((target_thumb_row - scrollbar_y as f64) / max_thumb_start as f64).clamp(0.0, 1.0)
2324 } else {
2325 0.0
2326 };
2327
2328 let target_row = (target_scroll_ratio * max_scroll_row as f64).round() as usize;
2330 let target_row = target_row.min(max_scroll_row);
2331
2332 if target_row < visual_row_positions.len() {
2334 visual_row_positions[target_row]
2335 } else {
2336 visual_row_positions.last().copied().unwrap_or((0, 0))
2337 }
2338 }
2339
2340 #[allow(clippy::too_many_arguments)]
2349 pub(crate) fn screen_to_buffer_position(
2350 col: u16,
2351 row: u16,
2352 content_rect: ratatui::layout::Rect,
2353 gutter_width: u16,
2354 cached_mappings: &Option<Vec<crate::app::types::ViewLineMapping>>,
2355 fallback_position: usize,
2356 allow_gutter_click: bool,
2357 compose_width: Option<u16>,
2358 ) -> Option<usize> {
2359 let content_rect = Self::adjust_content_rect_for_compose(content_rect, compose_width);
2361
2362 let content_col = col.saturating_sub(content_rect.x);
2364 let content_row = row.saturating_sub(content_rect.y);
2365
2366 tracing::trace!(
2367 col,
2368 row,
2369 ?content_rect,
2370 gutter_width,
2371 content_col,
2372 content_row,
2373 num_mappings = cached_mappings.as_ref().map(|m| m.len()),
2374 "screen_to_buffer_position"
2375 );
2376
2377 let text_col = if content_col < gutter_width {
2379 if !allow_gutter_click {
2380 return None; }
2382 0 } else {
2384 content_col.saturating_sub(gutter_width) as usize
2385 };
2386
2387 let visual_row = content_row as usize;
2389
2390 let position_from_mapping =
2392 |line_mapping: &crate::app::types::ViewLineMapping, col: usize| -> usize {
2393 if col < line_mapping.visual_to_char.len() {
2394 if let Some(byte_pos) = line_mapping.source_byte_at_visual_col(col) {
2396 return byte_pos;
2397 }
2398 for c in (0..col).rev() {
2400 if let Some(byte_pos) = line_mapping.source_byte_at_visual_col(c) {
2401 return byte_pos;
2402 }
2403 }
2404 line_mapping.line_end_byte
2405 } else {
2406 if line_mapping.visual_to_char.len() <= 1 {
2410 if let Some(Some(first_byte)) = line_mapping.char_source_bytes.first() {
2412 return *first_byte;
2413 }
2414 }
2415 line_mapping.line_end_byte
2416 }
2417 };
2418
2419 let position = cached_mappings
2420 .as_ref()
2421 .and_then(|mappings| {
2422 if let Some(line_mapping) = mappings.get(visual_row) {
2423 Some(position_from_mapping(line_mapping, text_col))
2425 } else if !mappings.is_empty() {
2426 let last_mapping = mappings.last().unwrap();
2428 Some(position_from_mapping(last_mapping, text_col))
2429 } else {
2430 None
2431 }
2432 })
2433 .unwrap_or(fallback_position);
2434
2435 Some(position)
2436 }
2437
2438 pub(super) fn adjust_content_rect_for_compose(
2439 content_rect: ratatui::layout::Rect,
2440 compose_width: Option<u16>,
2441 ) -> ratatui::layout::Rect {
2442 if let Some(cw) = compose_width {
2443 let clamped = cw.min(content_rect.width).max(1);
2444 if clamped < content_rect.width {
2445 let pad_total = content_rect.width - clamped;
2446 let left_pad = pad_total / 2;
2447 ratatui::layout::Rect::new(
2448 content_rect.x + left_pad,
2449 content_rect.y,
2450 clamped,
2451 content_rect.height,
2452 )
2453 } else {
2454 content_rect
2455 }
2456 } else {
2457 content_rect
2458 }
2459 }
2460
2461 fn fold_toggle_byte_from_position(
2464 state: &crate::state::EditorState,
2465 collapsed_header_bytes: &std::collections::BTreeMap<usize, Option<String>>,
2466 target_position: usize,
2467 content_col: u16,
2468 gutter_width: u16,
2469 ) -> Option<usize> {
2470 if content_col >= gutter_width {
2471 return None;
2472 }
2473
2474 use crate::view::folding::indent_folding;
2475 let line_start = indent_folding::find_line_start_byte(&state.buffer, target_position);
2476
2477 if collapsed_header_bytes.contains_key(&line_start) {
2479 return Some(target_position);
2480 }
2481
2482 if !state.folding_ranges.is_empty() {
2484 let line = state.buffer.get_line_number(target_position);
2485 let has_lsp_fold = state.folding_ranges.iter().any(|range| {
2486 let start_line = range.start_line as usize;
2487 let end_line = range.end_line as usize;
2488 start_line == line && end_line > start_line
2489 });
2490 if has_lsp_fold {
2491 return Some(target_position);
2492 }
2493 }
2494
2495 if state.folding_ranges.is_empty() {
2497 let tab_size = state.buffer_settings.tab_size;
2498 let max_scan = crate::config::INDENT_FOLD_INDICATOR_MAX_SCAN;
2499 let max_bytes = max_scan * state.buffer.estimated_line_length();
2500 if indent_folding::indent_fold_end_byte(&state.buffer, line_start, tab_size, max_bytes)
2501 .is_some()
2502 {
2503 return Some(target_position);
2504 }
2505 }
2506
2507 None
2508 }
2509
2510 pub(super) fn fold_toggle_line_at_screen_position(
2511 &self,
2512 col: u16,
2513 row: u16,
2514 ) -> Option<(BufferId, usize)> {
2515 for (split_id, buffer_id, content_rect, _scrollbar_rect, _thumb_start, _thumb_end) in
2516 &self.cached_layout.split_areas
2517 {
2518 if col < content_rect.x
2519 || col >= content_rect.x + content_rect.width
2520 || row < content_rect.y
2521 || row >= content_rect.y + content_rect.height
2522 {
2523 continue;
2524 }
2525
2526 if self.is_terminal_buffer(*buffer_id) || self.is_composite_buffer(*buffer_id) {
2527 continue;
2528 }
2529
2530 let (gutter_width, collapsed_header_bytes) = {
2531 let state = self.buffers.get(buffer_id)?;
2532 let headers = self
2533 .split_view_states
2534 .get(split_id)
2535 .map(|vs| {
2536 vs.folds
2537 .collapsed_header_bytes(&state.buffer, &state.marker_list)
2538 })
2539 .unwrap_or_default();
2540 (state.margins.left_total_width() as u16, headers)
2541 };
2542
2543 let cached_mappings = self.cached_layout.view_line_mappings.get(split_id).cloned();
2544 let fallback = self
2545 .split_view_states
2546 .get(split_id)
2547 .map(|vs| vs.viewport.top_byte)
2548 .unwrap_or(0);
2549 let compose_width = self
2550 .split_view_states
2551 .get(split_id)
2552 .and_then(|vs| vs.compose_width);
2553
2554 let target_position = Self::screen_to_buffer_position(
2555 col,
2556 row,
2557 *content_rect,
2558 gutter_width,
2559 &cached_mappings,
2560 fallback,
2561 true,
2562 compose_width,
2563 )?;
2564
2565 let adjusted_rect = Self::adjust_content_rect_for_compose(*content_rect, compose_width);
2566 let content_col = col.saturating_sub(adjusted_rect.x);
2567 let state = self.buffers.get(buffer_id)?;
2568 if let Some(byte_pos) = Self::fold_toggle_byte_from_position(
2569 state,
2570 &collapsed_header_bytes,
2571 target_position,
2572 content_col,
2573 gutter_width,
2574 ) {
2575 return Some((*buffer_id, byte_pos));
2576 }
2577 }
2578
2579 None
2580 }
2581
2582 pub(super) fn handle_editor_click(
2584 &mut self,
2585 col: u16,
2586 row: u16,
2587 split_id: crate::model::event::LeafId,
2588 buffer_id: BufferId,
2589 content_rect: ratatui::layout::Rect,
2590 modifiers: crossterm::event::KeyModifiers,
2591 ) -> AnyhowResult<()> {
2592 use crate::model::event::{CursorId, Event};
2593 use crossterm::event::KeyModifiers;
2594 let modifiers_str = if modifiers.contains(KeyModifiers::SHIFT) {
2596 "shift".to_string()
2597 } else {
2598 String::new()
2599 };
2600
2601 if self.plugin_manager.has_hook_handlers("mouse_click") {
2604 self.plugin_manager.run_hook(
2605 "mouse_click",
2606 HookArgs::MouseClick {
2607 column: col,
2608 row,
2609 button: "left".to_string(),
2610 modifiers: modifiers_str,
2611 content_x: content_rect.x,
2612 content_y: content_rect.y,
2613 },
2614 );
2615 }
2616
2617 self.focus_split(split_id, buffer_id);
2619
2620 if self.is_composite_buffer(buffer_id) {
2622 return self.handle_composite_click(col, row, split_id, buffer_id, content_rect);
2623 }
2624
2625 if !self.is_terminal_buffer(buffer_id) {
2628 self.key_context = crate::input::keybindings::KeyContext::Normal;
2629 }
2630
2631 let cached_mappings = self
2633 .cached_layout
2634 .view_line_mappings
2635 .get(&split_id)
2636 .cloned();
2637
2638 let fallback = self
2640 .split_view_states
2641 .get(&split_id)
2642 .map(|vs| vs.viewport.top_byte)
2643 .unwrap_or(0);
2644
2645 let compose_width = self
2647 .split_view_states
2648 .get(&split_id)
2649 .and_then(|vs| vs.compose_width);
2650
2651 let (toggle_fold_byte, onclick_action, target_position, cursor_snapshot) =
2653 if let Some(state) = self.buffers.get(&buffer_id) {
2654 let gutter_width = state.margins.left_total_width() as u16;
2655
2656 let Some(target_position) = Self::screen_to_buffer_position(
2657 col,
2658 row,
2659 content_rect,
2660 gutter_width,
2661 &cached_mappings,
2662 fallback,
2663 true, compose_width,
2665 ) else {
2666 return Ok(());
2667 };
2668
2669 let adjusted_rect =
2671 Self::adjust_content_rect_for_compose(content_rect, compose_width);
2672 let content_col = col.saturating_sub(adjusted_rect.x);
2673 let collapsed_header_bytes = self
2674 .split_view_states
2675 .get(&split_id)
2676 .map(|vs| {
2677 vs.folds
2678 .collapsed_header_bytes(&state.buffer, &state.marker_list)
2679 })
2680 .unwrap_or_default();
2681 let toggle_fold_byte = Self::fold_toggle_byte_from_position(
2682 state,
2683 &collapsed_header_bytes,
2684 target_position,
2685 content_col,
2686 gutter_width,
2687 );
2688
2689 let cursor_snapshot = self
2690 .split_view_states
2691 .get(&split_id)
2692 .map(|vs| {
2693 let cursor = vs.cursors.primary();
2694 (
2695 vs.cursors.primary_id(),
2696 cursor.position,
2697 cursor.anchor,
2698 cursor.sticky_column,
2699 cursor.deselect_on_move,
2700 )
2701 })
2702 .unwrap_or((CursorId(0), 0, None, 0, true));
2703
2704 let onclick_action = state
2707 .text_properties
2708 .get_at(target_position)
2709 .iter()
2710 .find_map(|prop| {
2711 prop.get("onClick")
2712 .and_then(|v| v.as_str())
2713 .map(|s| s.to_string())
2714 });
2715
2716 (
2717 toggle_fold_byte,
2718 onclick_action,
2719 target_position,
2720 cursor_snapshot,
2721 )
2722 } else {
2723 return Ok(());
2724 };
2725
2726 if toggle_fold_byte.is_some() {
2727 self.toggle_fold_at_byte(buffer_id, target_position);
2728 return Ok(());
2729 }
2730
2731 let (primary_cursor_id, old_position, old_anchor, old_sticky_column, deselect_on_move) =
2732 cursor_snapshot;
2733
2734 if let Some(action_name) = onclick_action {
2735 tracing::debug!(
2737 "onClick triggered at position {}: action={}",
2738 target_position,
2739 action_name
2740 );
2741 let empty_args = std::collections::HashMap::new();
2742 if let Some(action) = Action::from_str(&action_name, &empty_args) {
2743 return self.handle_action(action);
2744 }
2745 return Ok(());
2746 }
2747
2748 let extend_selection =
2751 modifiers.contains(KeyModifiers::SHIFT) || modifiers.contains(KeyModifiers::CONTROL);
2752 let new_anchor = if extend_selection {
2753 Some(old_anchor.unwrap_or(old_position))
2754 } else if deselect_on_move {
2755 None
2756 } else {
2757 old_anchor
2758 };
2759
2760 let new_sticky_column = self
2761 .buffers
2762 .get(&buffer_id)
2763 .and_then(|state| state.buffer.offset_to_position(target_position))
2764 .map(|pos| pos.column)
2765 .unwrap_or(0);
2766
2767 let event = Event::MoveCursor {
2768 cursor_id: primary_cursor_id,
2769 old_position,
2770 new_position: target_position,
2771 old_anchor,
2772 new_anchor,
2773 old_sticky_column,
2774 new_sticky_column,
2775 };
2776
2777 self.active_event_log_mut().append(event.clone());
2778 self.apply_event_to_active_buffer(&event);
2779 self.track_cursor_movement(&event);
2780
2781 self.mouse_state.dragging_text_selection = true;
2783 self.mouse_state.drag_selection_split = Some(split_id);
2784 self.mouse_state.drag_selection_anchor = Some(new_anchor.unwrap_or(target_position));
2785
2786 Ok(())
2787 }
2788
2789 pub(super) fn handle_file_explorer_click(
2791 &mut self,
2792 col: u16,
2793 row: u16,
2794 explorer_area: ratatui::layout::Rect,
2795 ) -> AnyhowResult<()> {
2796 if row == explorer_area.y {
2798 let close_button_x = explorer_area.x + explorer_area.width.saturating_sub(3);
2801 if col >= close_button_x && col < explorer_area.x + explorer_area.width {
2802 self.toggle_file_explorer();
2803 return Ok(());
2804 }
2805 }
2806
2807 self.key_context = crate::input::keybindings::KeyContext::FileExplorer;
2809
2810 let relative_row = row.saturating_sub(explorer_area.y + 1); if let Some(ref mut explorer) = self.file_explorer {
2815 let display_nodes = explorer.get_display_nodes();
2816 let scroll_offset = explorer.get_scroll_offset();
2817 let clicked_index = (relative_row as usize) + scroll_offset;
2818
2819 if clicked_index < display_nodes.len() {
2820 let (node_id, _indent) = display_nodes[clicked_index];
2821
2822 explorer.set_selected(Some(node_id));
2824
2825 let node = explorer.tree().get_node(node_id);
2827 if let Some(node) = node {
2828 if node.is_dir() {
2829 self.file_explorer_toggle_expand();
2831 } else if node.is_file() {
2832 let path = node.entry.path.clone();
2835 let name = node.entry.name.clone();
2836 match self.open_file(&path) {
2837 Ok(_) => {
2838 self.set_status_message(
2839 rust_i18n::t!("explorer.opened_file", name = &name).to_string(),
2840 );
2841 }
2842 Err(e) => {
2843 if let Some(confirmation) = e.downcast_ref::<
2845 crate::model::buffer::LargeFileEncodingConfirmation,
2846 >() {
2847 self.start_large_file_encoding_confirmation(confirmation);
2848 } else {
2849 self.set_status_message(
2850 rust_i18n::t!("file.error_opening", error = e.to_string())
2851 .to_string(),
2852 );
2853 }
2854 }
2855 }
2856 }
2857 }
2858 }
2859 }
2860
2861 Ok(())
2862 }
2863
2864 fn start_set_line_ending_prompt(&mut self) {
2866 use crate::model::buffer::LineEnding;
2867
2868 let current_line_ending = self.active_state().buffer.line_ending();
2869
2870 let options = [
2871 (LineEnding::LF, "LF", "Unix/Linux/Mac"),
2872 (LineEnding::CRLF, "CRLF", "Windows"),
2873 (LineEnding::CR, "CR", "Classic Mac"),
2874 ];
2875
2876 let current_index = options
2877 .iter()
2878 .position(|(le, _, _)| *le == current_line_ending)
2879 .unwrap_or(0);
2880
2881 let suggestions: Vec<crate::input::commands::Suggestion> = options
2882 .iter()
2883 .map(|(le, name, desc)| {
2884 let is_current = *le == current_line_ending;
2885 crate::input::commands::Suggestion {
2886 text: format!("{} ({})", name, desc),
2887 description: if is_current {
2888 Some("current".to_string())
2889 } else {
2890 None
2891 },
2892 value: Some(name.to_string()),
2893 disabled: false,
2894 keybinding: None,
2895 source: None,
2896 }
2897 })
2898 .collect();
2899
2900 self.prompt = Some(crate::view::prompt::Prompt::with_suggestions(
2901 "Line ending: ".to_string(),
2902 PromptType::SetLineEnding,
2903 suggestions,
2904 ));
2905
2906 if let Some(prompt) = self.prompt.as_mut() {
2907 if !prompt.suggestions.is_empty() {
2908 prompt.selected_suggestion = Some(current_index);
2909 let (_, name, desc) = options[current_index];
2910 prompt.input = format!("{} ({})", name, desc);
2911 prompt.cursor_pos = prompt.input.len();
2912 prompt.selection_anchor = Some(0);
2913 }
2914 }
2915 }
2916
2917 fn start_set_encoding_prompt(&mut self) {
2919 use crate::model::buffer::Encoding;
2920
2921 let current_encoding = self.active_state().buffer.encoding();
2922
2923 let suggestions: Vec<crate::input::commands::Suggestion> = Encoding::all()
2924 .iter()
2925 .map(|enc| {
2926 let is_current = *enc == current_encoding;
2927 crate::input::commands::Suggestion {
2928 text: format!("{} ({})", enc.display_name(), enc.description()),
2929 description: if is_current {
2930 Some("current".to_string())
2931 } else {
2932 None
2933 },
2934 value: Some(enc.display_name().to_string()),
2935 disabled: false,
2936 keybinding: None,
2937 source: None,
2938 }
2939 })
2940 .collect();
2941
2942 let current_index = Encoding::all()
2943 .iter()
2944 .position(|enc| *enc == current_encoding)
2945 .unwrap_or(0);
2946
2947 self.prompt = Some(crate::view::prompt::Prompt::with_suggestions(
2948 "Encoding: ".to_string(),
2949 PromptType::SetEncoding,
2950 suggestions,
2951 ));
2952
2953 if let Some(prompt) = self.prompt.as_mut() {
2954 if !prompt.suggestions.is_empty() {
2955 prompt.selected_suggestion = Some(current_index);
2956 let enc = Encoding::all()[current_index];
2957 prompt.input = format!("{} ({})", enc.display_name(), enc.description());
2958 prompt.cursor_pos = prompt.input.len();
2959 prompt.selection_anchor = Some(0);
2961 }
2962 }
2963 }
2964
2965 fn start_reload_with_encoding_prompt(&mut self) {
2970 use crate::model::buffer::Encoding;
2971
2972 let has_file = self
2974 .buffers
2975 .get(&self.active_buffer())
2976 .and_then(|s| s.buffer.file_path())
2977 .is_some();
2978
2979 if !has_file {
2980 self.set_status_message("Cannot reload: buffer has no file".to_string());
2981 return;
2982 }
2983
2984 let is_modified = self
2986 .buffers
2987 .get(&self.active_buffer())
2988 .map(|s| s.buffer.is_modified())
2989 .unwrap_or(false);
2990
2991 if is_modified {
2992 self.set_status_message(
2993 "Cannot reload: buffer has unsaved modifications (save first)".to_string(),
2994 );
2995 return;
2996 }
2997
2998 let current_encoding = self.active_state().buffer.encoding();
2999
3000 let suggestions: Vec<crate::input::commands::Suggestion> = Encoding::all()
3001 .iter()
3002 .map(|enc| {
3003 let is_current = *enc == current_encoding;
3004 crate::input::commands::Suggestion {
3005 text: format!("{} ({})", enc.display_name(), enc.description()),
3006 description: if is_current {
3007 Some("current".to_string())
3008 } else {
3009 None
3010 },
3011 value: Some(enc.display_name().to_string()),
3012 disabled: false,
3013 keybinding: None,
3014 source: None,
3015 }
3016 })
3017 .collect();
3018
3019 let current_index = Encoding::all()
3020 .iter()
3021 .position(|enc| *enc == current_encoding)
3022 .unwrap_or(0);
3023
3024 self.prompt = Some(crate::view::prompt::Prompt::with_suggestions(
3025 "Reload with encoding: ".to_string(),
3026 PromptType::ReloadWithEncoding,
3027 suggestions,
3028 ));
3029
3030 if let Some(prompt) = self.prompt.as_mut() {
3031 if !prompt.suggestions.is_empty() {
3032 prompt.selected_suggestion = Some(current_index);
3033 let enc = Encoding::all()[current_index];
3034 prompt.input = format!("{} ({})", enc.display_name(), enc.description());
3035 prompt.cursor_pos = prompt.input.len();
3036 prompt.selection_anchor = Some(0);
3037 }
3038 }
3039 }
3040
3041 fn start_set_language_prompt(&mut self) {
3043 use crate::input::commands::CommandSource;
3044
3045 let current_language = self.active_state().language.clone();
3046
3047 let mut syntax_to_config: std::collections::HashMap<String, (String, &str)> =
3050 std::collections::HashMap::new();
3051 for (lang_id, lang_config) in &self.config.languages {
3052 if let Some(syntax) = self
3053 .grammar_registry
3054 .find_syntax_for_lang_config(lang_config)
3055 {
3056 syntax_to_config
3057 .entry(syntax.name.clone())
3058 .or_insert((lang_id.clone(), "config"));
3059 }
3060 }
3061
3062 let mut suggestions: Vec<crate::input::commands::Suggestion> = vec![
3064 crate::input::commands::Suggestion {
3066 text: "Plain Text".to_string(),
3067 description: if current_language == "text" || current_language == "Plain Text" {
3068 Some("current".to_string())
3069 } else {
3070 None
3071 },
3072 value: Some("Plain Text".to_string()),
3073 disabled: false,
3074 keybinding: Some("text".to_string()),
3075 source: Some(CommandSource::Builtin),
3076 },
3077 ];
3078
3079 struct LangEntry {
3084 display_name: String,
3085 config_key: String,
3086 source: &'static str,
3087 }
3088
3089 let mut entries: Vec<LangEntry> = Vec::new();
3090
3091 for syntax_name in self.grammar_registry.available_syntaxes() {
3093 if syntax_name == "Plain Text" {
3094 continue;
3095 }
3096 let (config_key, source) = syntax_to_config
3097 .get(syntax_name)
3098 .map(|(k, s)| (k.clone(), *s))
3099 .unwrap_or_else(|| (syntax_name.to_lowercase(), "builtin"));
3100 entries.push(LangEntry {
3101 display_name: syntax_name.to_string(),
3102 config_key,
3103 source,
3104 });
3105 }
3106
3107 let entry_names_lower: std::collections::HashSet<String> = entries
3109 .iter()
3110 .map(|e| e.display_name.to_lowercase())
3111 .collect();
3112 for (lang_id, lang_config) in &self.config.languages {
3113 let has_grammar = !lang_config.grammar.is_empty()
3114 && self
3115 .grammar_registry
3116 .find_syntax_by_name(&lang_config.grammar)
3117 .is_some();
3118 if !has_grammar && !entry_names_lower.contains(&lang_id.to_lowercase()) {
3119 entries.push(LangEntry {
3120 display_name: lang_id.clone(),
3121 config_key: lang_id.clone(),
3122 source: "config",
3123 });
3124 }
3125 }
3126
3127 entries.sort_unstable_by(|a, b| {
3129 a.display_name
3130 .to_lowercase()
3131 .cmp(&b.display_name.to_lowercase())
3132 });
3133
3134 let mut current_index_found = None;
3135 for entry in &entries {
3136 let is_current =
3137 entry.config_key == current_language || entry.display_name == current_language;
3138 if is_current {
3139 current_index_found = Some(suggestions.len());
3140 }
3141
3142 let description = if is_current {
3143 format!("{} (current)", entry.config_key)
3144 } else {
3145 entry.config_key.clone()
3146 };
3147
3148 let source = if entry.source == "config" {
3149 Some(CommandSource::Plugin("config".to_string()))
3150 } else {
3151 Some(CommandSource::Builtin)
3152 };
3153
3154 suggestions.push(crate::input::commands::Suggestion {
3155 text: entry.display_name.clone(),
3156 description: Some(description),
3157 value: Some(entry.display_name.clone()),
3158 disabled: false,
3159 keybinding: None,
3160 source,
3161 });
3162 }
3163
3164 let current_index = current_index_found.unwrap_or(0);
3166
3167 self.prompt = Some(crate::view::prompt::Prompt::with_suggestions(
3168 "Language: ".to_string(),
3169 PromptType::SetLanguage,
3170 suggestions,
3171 ));
3172
3173 if let Some(prompt) = self.prompt.as_mut() {
3174 if !prompt.suggestions.is_empty() {
3175 prompt.selected_suggestion = Some(current_index);
3176 }
3179 }
3180 }
3181
3182 fn start_select_theme_prompt(&mut self) {
3184 let available_themes = self.theme_registry.list();
3185 let current_theme_key = &self.config.theme.0;
3186
3187 let current_index = available_themes
3189 .iter()
3190 .position(|info| info.key == *current_theme_key)
3191 .or_else(|| {
3192 let normalized = crate::view::theme::normalize_theme_name(current_theme_key);
3193 available_themes.iter().position(|info| {
3194 crate::view::theme::normalize_theme_name(&info.name) == normalized
3195 })
3196 })
3197 .unwrap_or(0);
3198
3199 let suggestions: Vec<crate::input::commands::Suggestion> = available_themes
3200 .iter()
3201 .map(|info| {
3202 let is_current = Some(info) == available_themes.get(current_index);
3203 let display_key: std::borrow::Cow<'_, str> =
3207 if let Some(path_str) = info.key.strip_prefix("file://") {
3208 let path = std::path::Path::new(path_str);
3209 let themes_dir = self.dir_context.themes_dir();
3210 path.strip_prefix(&themes_dir)
3211 .map(|rel| rel.to_string_lossy())
3212 .unwrap_or_else(|_| path.to_string_lossy())
3213 } else if let Some(rest) = info.key.strip_prefix("https://") {
3214 std::borrow::Cow::Borrowed(rest)
3215 } else if let Some(rest) = info.key.strip_prefix("http://") {
3216 std::borrow::Cow::Borrowed(rest)
3217 } else {
3218 std::borrow::Cow::Borrowed(info.key.as_str())
3219 };
3220 let description = if is_current {
3221 Some(format!("{} (current)", display_key))
3222 } else {
3223 Some(display_key.to_string())
3224 };
3225 crate::input::commands::Suggestion {
3226 text: info.name.clone(),
3227 description,
3228 value: Some(info.key.clone()),
3229 disabled: false,
3230 keybinding: None,
3231 source: None,
3232 }
3233 })
3234 .collect();
3235
3236 self.prompt = Some(crate::view::prompt::Prompt::with_suggestions(
3237 "Select theme: ".to_string(),
3238 PromptType::SelectTheme {
3239 original_theme: current_theme_key.clone(),
3240 },
3241 suggestions,
3242 ));
3243
3244 if let Some(prompt) = self.prompt.as_mut() {
3245 if !prompt.suggestions.is_empty() {
3246 prompt.selected_suggestion = Some(current_index);
3247 if let Some(suggestion) = prompt.suggestions.get(current_index) {
3249 prompt.input = suggestion.get_value().to_string();
3250 } else {
3251 prompt.input = current_theme_key.to_string();
3252 }
3253 prompt.cursor_pos = prompt.input.len();
3254 prompt.selection_anchor = Some(0);
3256 }
3257 }
3258 }
3259
3260 pub(super) fn apply_theme(&mut self, key_or_name: &str) {
3262 if !key_or_name.is_empty() {
3263 if let Some(theme) = self.theme_registry.get_cloned(key_or_name) {
3264 self.theme = theme;
3265
3266 self.theme.set_terminal_cursor_color();
3268
3269 self.reapply_all_overlays();
3272
3273 let resolved = self
3277 .theme_registry
3278 .resolve_key(key_or_name)
3279 .unwrap_or(key_or_name)
3280 .to_string();
3281 self.config.theme = resolved.into();
3282
3283 self.save_theme_to_config();
3285
3286 self.set_status_message(
3287 t!("view.theme_changed", theme = self.theme.name.clone()).to_string(),
3288 );
3289 } else {
3290 self.set_status_message(format!("Theme '{}' not found", key_or_name));
3291 }
3292 }
3293 }
3294
3295 fn reapply_all_overlays(&mut self) {
3299 crate::services::lsp::diagnostics::invalidate_cache_all();
3301 let entries: Vec<(String, Vec<lsp_types::Diagnostic>)> = self
3302 .stored_diagnostics
3303 .iter()
3304 .map(|(uri, diags)| (uri.clone(), diags.clone()))
3305 .collect();
3306 for (uri, diagnostics) in entries {
3307 if let Some(buffer_id) = self.find_buffer_by_uri(&uri) {
3308 if let Some(state) = self.buffers.get_mut(&buffer_id) {
3309 crate::services::lsp::diagnostics::apply_diagnostics_to_state_cached(
3310 state,
3311 &diagnostics,
3312 &self.theme,
3313 );
3314 }
3315 }
3316 }
3317
3318 let buffer_ids: Vec<_> = self.buffers.keys().cloned().collect();
3320 for buffer_id in buffer_ids {
3321 let tokens = self
3322 .buffers
3323 .get(&buffer_id)
3324 .and_then(|s| s.semantic_tokens.as_ref())
3325 .map(|store| store.tokens.clone());
3326 if let Some(tokens) = tokens {
3327 if let Some(state) = self.buffers.get_mut(&buffer_id) {
3328 crate::services::lsp::semantic_tokens::apply_semantic_tokens_to_state(
3329 state,
3330 &tokens,
3331 &self.theme,
3332 );
3333 }
3334 }
3335 }
3336 }
3337
3338 pub(super) fn preview_theme(&mut self, key_or_name: &str) {
3341 if !key_or_name.is_empty() {
3342 if let Some(theme) = self.theme_registry.get_cloned(key_or_name) {
3343 if theme.name != self.theme.name {
3344 self.theme = theme;
3345 self.theme.set_terminal_cursor_color();
3346 self.reapply_all_overlays();
3347 }
3348 }
3349 }
3350 }
3351
3352 fn save_theme_to_config(&mut self) {
3354 if let Err(e) = self.filesystem.create_dir_all(&self.dir_context.config_dir) {
3356 tracing::warn!("Failed to create config directory: {}", e);
3357 return;
3358 }
3359
3360 let resolver = ConfigResolver::new(self.dir_context.clone(), self.working_dir.clone());
3364 let config_path = resolver.user_config_path();
3365 tracing::info!(
3366 "Saving theme '{}' to user config at {}",
3367 self.config.theme.0,
3368 config_path.display()
3369 );
3370
3371 let mut changes = std::collections::HashMap::new();
3372 changes.insert(
3373 "/theme".to_string(),
3374 serde_json::Value::String(self.config.theme.0.clone()),
3375 );
3376
3377 match resolver.save_changes_to_layer(
3378 &changes,
3379 &std::collections::HashSet::new(),
3380 ConfigLayer::User,
3381 ) {
3382 Ok(()) => {
3383 tracing::info!("Theme saved successfully to {}", config_path.display());
3384 }
3385 Err(e) => {
3386 tracing::warn!("Failed to save theme to config: {}", e);
3387 }
3388 }
3389 }
3390
3391 fn start_select_keybinding_map_prompt(&mut self) {
3393 let builtin_maps = vec!["default", "emacs", "vscode", "macos"];
3395
3396 let user_maps: Vec<&str> = self
3398 .config
3399 .keybinding_maps
3400 .keys()
3401 .map(|s| s.as_str())
3402 .collect();
3403
3404 let mut all_maps: Vec<&str> = builtin_maps;
3406 for map in &user_maps {
3407 if !all_maps.contains(map) {
3408 all_maps.push(map);
3409 }
3410 }
3411
3412 let current_map = &self.config.active_keybinding_map;
3413
3414 let current_index = all_maps
3416 .iter()
3417 .position(|name| *name == current_map)
3418 .unwrap_or(0);
3419
3420 let suggestions: Vec<crate::input::commands::Suggestion> = all_maps
3421 .iter()
3422 .map(|map_name| {
3423 let is_current = *map_name == current_map;
3424 crate::input::commands::Suggestion {
3425 text: map_name.to_string(),
3426 description: if is_current {
3427 Some("(current)".to_string())
3428 } else {
3429 None
3430 },
3431 value: Some(map_name.to_string()),
3432 disabled: false,
3433 keybinding: None,
3434 source: None,
3435 }
3436 })
3437 .collect();
3438
3439 self.prompt = Some(crate::view::prompt::Prompt::with_suggestions(
3440 "Select keybinding map: ".to_string(),
3441 PromptType::SelectKeybindingMap,
3442 suggestions,
3443 ));
3444
3445 if let Some(prompt) = self.prompt.as_mut() {
3446 if !prompt.suggestions.is_empty() {
3447 prompt.selected_suggestion = Some(current_index);
3448 prompt.input = current_map.to_string();
3449 prompt.cursor_pos = prompt.input.len();
3450 prompt.selection_anchor = Some(0);
3451 }
3452 }
3453 }
3454
3455 pub(super) fn apply_keybinding_map(&mut self, map_name: &str) {
3457 if map_name.is_empty() {
3458 return;
3459 }
3460
3461 let is_builtin = matches!(map_name, "default" | "emacs" | "vscode" | "macos");
3463 let is_user_defined = self.config.keybinding_maps.contains_key(map_name);
3464
3465 if is_builtin || is_user_defined {
3466 self.config.active_keybinding_map = map_name.to_string().into();
3468
3469 *self.keybindings.write().unwrap() =
3471 crate::input::keybindings::KeybindingResolver::new(&self.config);
3472
3473 self.save_keybinding_map_to_config();
3475
3476 self.set_status_message(t!("view.keybindings_switched", map = map_name).to_string());
3477 } else {
3478 self.set_status_message(t!("view.keybindings_unknown", map = map_name).to_string());
3479 }
3480 }
3481
3482 fn save_keybinding_map_to_config(&mut self) {
3484 if let Err(e) = self.filesystem.create_dir_all(&self.dir_context.config_dir) {
3486 tracing::warn!("Failed to create config directory: {}", e);
3487 return;
3488 }
3489
3490 let resolver = ConfigResolver::new(self.dir_context.clone(), self.working_dir.clone());
3492 if let Err(e) = resolver.save_to_layer(&self.config, ConfigLayer::User) {
3493 tracing::warn!("Failed to save keybinding map to config: {}", e);
3494 }
3495 }
3496
3497 fn start_select_cursor_style_prompt(&mut self) {
3499 use crate::config::CursorStyle;
3500
3501 let current_style = self.config.editor.cursor_style;
3502
3503 let suggestions: Vec<crate::input::commands::Suggestion> = CursorStyle::OPTIONS
3505 .iter()
3506 .zip(CursorStyle::DESCRIPTIONS.iter())
3507 .map(|(style_name, description)| {
3508 let is_current = *style_name == current_style.as_str();
3509 crate::input::commands::Suggestion {
3510 text: description.to_string(),
3511 description: if is_current {
3512 Some("(current)".to_string())
3513 } else {
3514 None
3515 },
3516 value: Some(style_name.to_string()),
3517 disabled: false,
3518 keybinding: None,
3519 source: None,
3520 }
3521 })
3522 .collect();
3523
3524 let current_index = CursorStyle::OPTIONS
3526 .iter()
3527 .position(|s| *s == current_style.as_str())
3528 .unwrap_or(0);
3529
3530 self.prompt = Some(crate::view::prompt::Prompt::with_suggestions(
3531 "Select cursor style: ".to_string(),
3532 PromptType::SelectCursorStyle,
3533 suggestions,
3534 ));
3535
3536 if let Some(prompt) = self.prompt.as_mut() {
3537 if !prompt.suggestions.is_empty() {
3538 prompt.selected_suggestion = Some(current_index);
3539 prompt.input = CursorStyle::DESCRIPTIONS[current_index].to_string();
3540 prompt.cursor_pos = prompt.input.len();
3541 prompt.selection_anchor = Some(0);
3542 }
3543 }
3544 }
3545
3546 pub(super) fn apply_cursor_style(&mut self, style_name: &str) {
3548 use crate::config::CursorStyle;
3549
3550 if let Some(style) = CursorStyle::parse(style_name) {
3551 self.config.editor.cursor_style = style;
3553
3554 if self.session_mode {
3556 self.queue_escape_sequences(style.to_escape_sequence());
3558 } else {
3559 use std::io::stdout;
3561 #[allow(clippy::let_underscore_must_use)]
3563 let _ = crossterm::execute!(stdout(), style.to_crossterm_style());
3564 }
3565
3566 self.save_cursor_style_to_config();
3568
3569 let description = CursorStyle::OPTIONS
3571 .iter()
3572 .zip(CursorStyle::DESCRIPTIONS.iter())
3573 .find(|(name, _)| **name == style_name)
3574 .map(|(_, desc)| *desc)
3575 .unwrap_or(style_name);
3576
3577 self.set_status_message(
3578 t!("view.cursor_style_changed", style = description).to_string(),
3579 );
3580 }
3581 }
3582
3583 fn start_remove_ruler_prompt(&mut self) {
3585 let active_split = self.split_manager.active_split();
3586 let rulers = self
3587 .split_view_states
3588 .get(&active_split)
3589 .map(|vs| vs.rulers.clone())
3590 .unwrap_or_default();
3591
3592 if rulers.is_empty() {
3593 self.set_status_message(t!("rulers.none_configured").to_string());
3594 return;
3595 }
3596
3597 let suggestions: Vec<crate::input::commands::Suggestion> = rulers
3598 .iter()
3599 .map(|&col| crate::input::commands::Suggestion {
3600 text: format!("Column {}", col),
3601 description: None,
3602 value: Some(col.to_string()),
3603 disabled: false,
3604 keybinding: None,
3605 source: None,
3606 })
3607 .collect();
3608
3609 self.prompt = Some(crate::view::prompt::Prompt::with_suggestions(
3610 t!("rulers.remove_prompt").to_string(),
3611 PromptType::RemoveRuler,
3612 suggestions,
3613 ));
3614 }
3615
3616 fn save_cursor_style_to_config(&mut self) {
3618 if let Err(e) = self.filesystem.create_dir_all(&self.dir_context.config_dir) {
3620 tracing::warn!("Failed to create config directory: {}", e);
3621 return;
3622 }
3623
3624 let resolver = ConfigResolver::new(self.dir_context.clone(), self.working_dir.clone());
3626 if let Err(e) = resolver.save_to_layer(&self.config, ConfigLayer::User) {
3627 tracing::warn!("Failed to save cursor style to config: {}", e);
3628 }
3629 }
3630
3631 fn start_select_locale_prompt(&mut self) {
3633 let available_locales = crate::i18n::available_locales();
3634 let current_locale = crate::i18n::current_locale();
3635
3636 let current_index = available_locales
3638 .iter()
3639 .position(|name| *name == current_locale)
3640 .unwrap_or(0);
3641
3642 let suggestions: Vec<crate::input::commands::Suggestion> = available_locales
3643 .iter()
3644 .map(|locale_name| {
3645 let is_current = *locale_name == current_locale;
3646 let description = if let Some((english_name, native_name)) =
3647 crate::i18n::locale_display_name(locale_name)
3648 {
3649 if english_name == native_name {
3650 if is_current {
3652 format!("{} (current)", english_name)
3653 } else {
3654 english_name.to_string()
3655 }
3656 } else {
3657 if is_current {
3659 format!("{} / {} (current)", english_name, native_name)
3660 } else {
3661 format!("{} / {}", english_name, native_name)
3662 }
3663 }
3664 } else {
3665 if is_current {
3667 "(current)".to_string()
3668 } else {
3669 String::new()
3670 }
3671 };
3672 crate::input::commands::Suggestion {
3673 text: locale_name.to_string(),
3674 description: if description.is_empty() {
3675 None
3676 } else {
3677 Some(description)
3678 },
3679 value: Some(locale_name.to_string()),
3680 disabled: false,
3681 keybinding: None,
3682 source: None,
3683 }
3684 })
3685 .collect();
3686
3687 self.prompt = Some(crate::view::prompt::Prompt::with_suggestions(
3688 t!("locale.select_prompt").to_string(),
3689 PromptType::SelectLocale,
3690 suggestions,
3691 ));
3692
3693 if let Some(prompt) = self.prompt.as_mut() {
3694 if !prompt.suggestions.is_empty() {
3695 prompt.selected_suggestion = Some(current_index);
3696 prompt.input = String::new();
3698 prompt.cursor_pos = 0;
3699 }
3700 }
3701 }
3702
3703 pub(super) fn apply_locale(&mut self, locale_name: &str) {
3705 if !locale_name.is_empty() {
3706 crate::i18n::set_locale(locale_name);
3708
3709 self.config.locale = crate::config::LocaleName(Some(locale_name.to_string()));
3711
3712 self.menus = crate::config::MenuConfig::translated();
3714
3715 if let Ok(mut registry) = self.command_registry.write() {
3717 registry.refresh_builtin_commands();
3718 }
3719
3720 self.save_locale_to_config();
3722
3723 self.set_status_message(t!("locale.changed", locale_name = locale_name).to_string());
3724 }
3725 }
3726
3727 fn save_locale_to_config(&mut self) {
3729 if let Err(e) = self.filesystem.create_dir_all(&self.dir_context.config_dir) {
3731 tracing::warn!("Failed to create config directory: {}", e);
3732 return;
3733 }
3734
3735 let resolver = ConfigResolver::new(self.dir_context.clone(), self.working_dir.clone());
3737 if let Err(e) = resolver.save_to_layer(&self.config, ConfigLayer::User) {
3738 tracing::warn!("Failed to save locale to config: {}", e);
3739 }
3740 }
3741
3742 fn switch_to_previous_tab(&mut self) {
3745 use crate::view::split::TabTarget;
3746 let active_split = self.split_manager.active_split();
3747 let previous_tab = self
3748 .split_view_states
3749 .get(&active_split)
3750 .and_then(|vs| vs.previous_tab());
3751
3752 match previous_tab {
3753 Some(TabTarget::Buffer(prev_id)) => {
3754 let is_valid = self
3755 .split_view_states
3756 .get(&active_split)
3757 .is_some_and(|vs| vs.has_buffer(prev_id));
3758
3759 if is_valid && prev_id != self.active_buffer() {
3760 self.position_history.commit_pending_movement();
3761 let cursors = self.active_cursors();
3762 let position = cursors.primary().position;
3763 let anchor = cursors.primary().anchor;
3764 self.position_history
3765 .record_movement(self.active_buffer(), position, anchor);
3766 self.position_history.commit_pending_movement();
3767 self.set_active_buffer(prev_id);
3768 } else if !is_valid {
3769 self.set_status_message(t!("status.previous_tab_closed").to_string());
3770 }
3771 }
3772 Some(TabTarget::Group(leaf_id)) => {
3773 if self.grouped_subtrees.contains_key(&leaf_id) {
3774 self.activate_group_tab(leaf_id);
3775 } else {
3776 self.set_status_message(t!("status.previous_tab_closed").to_string());
3777 }
3778 }
3779 None => {
3780 self.set_status_message(t!("status.no_previous_tab").to_string());
3781 }
3782 }
3783 }
3784
3785 fn start_switch_to_tab_prompt(&mut self) {
3787 let active_split = self.split_manager.active_split();
3788 let open_buffers: Vec<BufferId> =
3789 if let Some(view_state) = self.split_view_states.get(&active_split) {
3790 view_state.buffer_tab_ids_vec()
3791 } else {
3792 return;
3793 };
3794
3795 if open_buffers.is_empty() {
3796 self.set_status_message(t!("status.no_tabs_in_split").to_string());
3797 return;
3798 }
3799
3800 let current_index = open_buffers
3802 .iter()
3803 .position(|&id| id == self.active_buffer())
3804 .unwrap_or(0);
3805
3806 let suggestions: Vec<crate::input::commands::Suggestion> = open_buffers
3807 .iter()
3808 .map(|&buffer_id| {
3809 let display_name = self
3810 .buffer_metadata
3811 .get(&buffer_id)
3812 .map(|m| m.display_name.clone())
3813 .unwrap_or_else(|| format!("Buffer {:?}", buffer_id));
3814
3815 let is_current = buffer_id == self.active_buffer();
3816 let is_modified = self
3817 .buffers
3818 .get(&buffer_id)
3819 .is_some_and(|b| b.buffer.is_modified());
3820
3821 let description = match (is_current, is_modified) {
3822 (true, true) => Some("(current, modified)".to_string()),
3823 (true, false) => Some("(current)".to_string()),
3824 (false, true) => Some("(modified)".to_string()),
3825 (false, false) => None,
3826 };
3827
3828 crate::input::commands::Suggestion {
3829 text: display_name,
3830 description,
3831 value: Some(buffer_id.0.to_string()),
3832 disabled: false,
3833 keybinding: None,
3834 source: None,
3835 }
3836 })
3837 .collect();
3838
3839 self.prompt = Some(crate::view::prompt::Prompt::with_suggestions(
3840 "Switch to tab: ".to_string(),
3841 PromptType::SwitchToTab,
3842 suggestions,
3843 ));
3844
3845 if let Some(prompt) = self.prompt.as_mut() {
3846 if !prompt.suggestions.is_empty() {
3847 prompt.selected_suggestion = Some(current_index);
3848 }
3849 }
3850 }
3851
3852 pub(crate) fn switch_to_tab(&mut self, buffer_id: BufferId) {
3854 let active_split = self.split_manager.active_split();
3856 let is_valid = self
3857 .split_view_states
3858 .get(&active_split)
3859 .is_some_and(|vs| vs.has_buffer(buffer_id));
3860
3861 if !is_valid {
3862 self.set_status_message(t!("status.tab_not_found").to_string());
3863 return;
3864 }
3865
3866 if buffer_id != self.active_buffer() {
3867 self.position_history.commit_pending_movement();
3869
3870 let cursors = self.active_cursors();
3871 let position = cursors.primary().position;
3872 let anchor = cursors.primary().anchor;
3873 self.position_history
3874 .record_movement(self.active_buffer(), position, anchor);
3875 self.position_history.commit_pending_movement();
3876
3877 self.set_active_buffer(buffer_id);
3878 }
3879 }
3880
3881 fn handle_insert_char_prompt(&mut self, c: char) -> AnyhowResult<()> {
3883 if let Some(ref prompt) = self.prompt {
3885 if prompt.prompt_type == PromptType::QueryReplaceConfirm {
3886 return self.handle_interactive_replace_key(c);
3887 }
3888 }
3889
3890 if let Some(ref prompt) = self.prompt {
3894 if let Some(key) = Self::prompt_type_to_history_key(&prompt.prompt_type) {
3895 if let Some(history) = self.prompt_histories.get_mut(&key) {
3896 history.reset_navigation();
3897 }
3898 }
3899 }
3900
3901 if let Some(prompt) = self.prompt_mut() {
3902 let s = c.to_string();
3904 prompt.insert_str(&s);
3905 }
3906 self.update_prompt_suggestions();
3907 Ok(())
3908 }
3909
3910 fn handle_insert_char_editor(&mut self, c: char) -> AnyhowResult<()> {
3912 if self.is_editing_disabled() {
3914 self.set_status_message(t!("buffer.editing_disabled").to_string());
3915 return Ok(());
3916 }
3917
3918 self.cancel_pending_lsp_requests();
3920
3921 if let Some(events) = self.action_to_events(Action::InsertChar(c)) {
3922 if events.len() > 1 {
3923 let description = format!("Insert '{}'", c);
3925 if let Some(bulk_edit) = self.apply_events_as_bulk_edit(events, description.clone())
3926 {
3927 self.active_event_log_mut().append(bulk_edit);
3928 }
3929 } else {
3930 for event in events {
3932 self.active_event_log_mut().append(event.clone());
3933 self.apply_event_to_active_buffer(&event);
3934 }
3935 }
3936 }
3937
3938 if c == '(' || c == ',' {
3940 self.request_signature_help();
3941 }
3942
3943 self.maybe_trigger_completion(c);
3945
3946 Ok(())
3947 }
3948
3949 fn apply_action_as_events(&mut self, action: Action) -> AnyhowResult<()> {
3955 let buffer_id = self.active_buffer();
3957 if self.is_composite_buffer(buffer_id) {
3958 if let Some(_handled) = self.handle_composite_action(buffer_id, &action) {
3959 return Ok(());
3960 }
3961 }
3962
3963 let action_description = format!("{:?}", action);
3965
3966 let is_editing_action = matches!(
3968 action,
3969 Action::InsertNewline
3970 | Action::InsertTab
3971 | Action::DeleteForward
3972 | Action::DeleteWordBackward
3973 | Action::DeleteWordForward
3974 | Action::DeleteLine
3975 | Action::DuplicateLine
3976 | Action::MoveLineUp
3977 | Action::MoveLineDown
3978 | Action::DedentSelection
3979 | Action::ToggleComment
3980 );
3981
3982 if is_editing_action && self.is_editing_disabled() {
3983 self.set_status_message(t!("buffer.editing_disabled").to_string());
3984 return Ok(());
3985 }
3986
3987 if let Some(events) = self.action_to_events(action) {
3988 if events.len() > 1 {
3989 let has_buffer_mods = events
3991 .iter()
3992 .any(|e| matches!(e, Event::Insert { .. } | Event::Delete { .. }));
3993
3994 if has_buffer_mods {
3995 if let Some(bulk_edit) =
3997 self.apply_events_as_bulk_edit(events.clone(), action_description)
3998 {
3999 self.active_event_log_mut().append(bulk_edit);
4000 }
4001 } else {
4002 let batch = Event::Batch {
4004 events: events.clone(),
4005 description: action_description,
4006 };
4007 self.active_event_log_mut().append(batch.clone());
4008 self.apply_event_to_active_buffer(&batch);
4009 }
4010
4011 for event in &events {
4013 self.track_cursor_movement(event);
4014 }
4015 } else {
4016 for event in events {
4018 self.log_and_apply_event(&event);
4019 self.track_cursor_movement(&event);
4020 }
4021 }
4022 }
4023
4024 Ok(())
4025 }
4026
4027 pub(super) fn track_cursor_movement(&mut self, event: &Event) {
4029 if self.in_navigation {
4030 return;
4031 }
4032
4033 if let Event::MoveCursor {
4034 new_position,
4035 new_anchor,
4036 ..
4037 } = event
4038 {
4039 self.position_history
4040 .record_movement(self.active_buffer(), *new_position, *new_anchor);
4041 }
4042 }
4043
4044 fn try_route_composite_key(
4048 &mut self,
4049 split_id: crate::model::event::LeafId,
4050 buffer_id: crate::model::event::BufferId,
4051 key_event: &crossterm::event::KeyEvent,
4052 ) -> Option<AnyhowResult<()>> {
4053 use crate::input::composite_router::{
4054 CompositeInputRouter, Direction, RoutedEvent, ScrollAction,
4055 };
4056
4057 let composite = self.composite_buffers.get(&buffer_id)?;
4058 let view_state = self.composite_view_states.get(&(split_id, buffer_id))?;
4059
4060 match CompositeInputRouter::route_key_event(composite, view_state, key_event) {
4061 RoutedEvent::Unhandled => None,
4062
4063 RoutedEvent::CompositeScroll(action) => {
4064 let delta = match action {
4065 ScrollAction::Up(n) => -(n as isize),
4066 ScrollAction::Down(n) => n as isize,
4067 _ => return Some(Ok(())),
4068 };
4069 self.composite_scroll(split_id, buffer_id, delta);
4070 Some(Ok(()))
4071 }
4072
4073 RoutedEvent::SwitchPane(dir) => {
4074 match dir {
4075 Direction::Next => self.composite_focus_next(split_id, buffer_id),
4076 Direction::Prev => self.composite_focus_prev(split_id, buffer_id),
4077 }
4078 Some(Ok(()))
4079 }
4080
4081 _ => None,
4083 }
4084 }
4085}