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