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