Skip to main content

fresh/app/
keybinding_editor_actions.rs

1//! Keybinding editor action handling
2//!
3//! This module provides the action handlers for the keybinding editor modal.
4
5use super::keybinding_editor::KeybindingEditor;
6use super::Editor;
7use crate::input::handler::InputResult;
8use crate::view::keybinding_editor::{handle_keybinding_editor_input, KeybindingEditorAction};
9use crate::view::ui::point_in_rect;
10use crossterm::event::{KeyEvent, MouseButton, MouseEvent, MouseEventKind};
11
12impl Editor {
13    /// Open the keybinding editor modal
14    pub fn open_keybinding_editor(&mut self) {
15        use crate::config::MenuExt;
16        let config_path = self.dir_context.config_path().display().to_string();
17        let cmd_registry = self.command_registry.read().unwrap();
18        let keybindings = self.keybindings.read().unwrap();
19        // Enumerate top-level menu ids (File, Edit, …, plus plugin menus) so
20        // the action dropdown can offer `menu_open:<name>` variants instead of
21        // one un-parseable bare `menu_open` row.
22        let menu_names: Vec<String> = self
23            .menus
24            .menus
25            .iter()
26            .chain(self.menu_state.plugin_menus.iter())
27            .map(|m| m.match_id().to_string())
28            .collect();
29        self.keybinding_editor = Some(KeybindingEditor::new(
30            &self.config,
31            &keybindings,
32            &self.mode_registry,
33            &cmd_registry,
34            config_path,
35            &menu_names,
36        ));
37    }
38
39    /// Handle input when keybinding editor is active
40    pub fn handle_keybinding_editor_input(&mut self, event: &KeyEvent) -> InputResult {
41        let mut editor = match self.keybinding_editor.take() {
42            Some(e) => e,
43            None => return InputResult::Ignored,
44        };
45
46        let action = handle_keybinding_editor_input(&mut editor, event);
47
48        match action {
49            KeybindingEditorAction::Consumed => {
50                self.keybinding_editor = Some(editor);
51                InputResult::Consumed
52            }
53            KeybindingEditorAction::Close => {
54                // Close without saving
55                self.set_status_message("Keybinding editor closed".to_string());
56                InputResult::Consumed
57            }
58            KeybindingEditorAction::SaveAndClose => {
59                // Save custom bindings to config
60                self.save_keybinding_editor_changes(&editor);
61                InputResult::Consumed
62            }
63            KeybindingEditorAction::StatusMessage(msg) => {
64                self.set_status_message(msg);
65                self.keybinding_editor = Some(editor);
66                InputResult::Consumed
67            }
68        }
69    }
70
71    /// Save keybinding editor changes to config
72    fn save_keybinding_editor_changes(&mut self, editor: &KeybindingEditor) {
73        if !editor.has_changes {
74            return;
75        }
76
77        // Remove deleted custom bindings from config
78        for remove in editor.get_pending_removes() {
79            self.config_mut().keybindings.retain(|kb| {
80                !(kb.action == remove.action
81                    && kb.key == remove.key
82                    && kb.modifiers == remove.modifiers
83                    && kb.when == remove.when)
84            });
85        }
86
87        // Add new custom bindings
88        let new_bindings = editor.get_custom_bindings();
89        for binding in new_bindings {
90            self.config_mut().keybindings.push(binding);
91        }
92
93        // Rebuild the keybinding resolver, keeping plugin-contributed
94        // bindings alive across the rebuild (#2307).
95        self.keybindings
96            .write()
97            .unwrap()
98            .reload_from_config(&self.config);
99
100        // Save to config file via the pending changes mechanism
101        let config_value = match serde_json::to_value(&self.config.keybindings) {
102            Ok(v) => v,
103            Err(e) => {
104                self.set_status_message(format!("Failed to serialize keybindings: {}", e));
105                return;
106            }
107        };
108
109        let mut changes = std::collections::HashMap::new();
110        changes.insert("/keybindings".to_string(), config_value);
111
112        let resolver = crate::config_io::ConfigResolver::new(
113            self.dir_context.clone(),
114            self.working_dir().to_path_buf(),
115        );
116
117        match resolver.save_changes_to_layer(
118            &changes,
119            &std::collections::HashSet::new(),
120            crate::config_io::ConfigLayer::User,
121        ) {
122            Ok(()) => {
123                self.set_status_message("Keybinding changes saved".to_string());
124            }
125            Err(e) => {
126                self.set_status_message(format!("Failed to save keybindings: {}", e));
127            }
128        }
129    }
130
131    /// Check if keybinding editor is active
132    pub fn is_keybinding_editor_active(&self) -> bool {
133        self.keybinding_editor.is_some()
134    }
135
136    /// Handle mouse events when keybinding editor is active
137    /// Returns Ok(true) if a re-render is needed
138    pub fn handle_keybinding_editor_mouse(
139        &mut self,
140        mouse_event: MouseEvent,
141    ) -> anyhow::Result<bool> {
142        let mut editor = match self.keybinding_editor.take() {
143            Some(e) => e,
144            None => return Ok(false),
145        };
146
147        let col = mouse_event.column;
148        let row = mouse_event.row;
149        let layout = &editor.layout;
150
151        // All mouse events inside modal are consumed (masked from reaching underlying editor)
152        // Events outside the modal are ignored (but still consumed to prevent leaking)
153        if !point_in_rect(layout.modal_area, col, row) {
154            self.keybinding_editor = Some(editor);
155            return Ok(false);
156        }
157
158        match mouse_event.kind {
159            MouseEventKind::ScrollUp => {
160                // Scroll the viewport without touching selection. Coupling
161                // wheel to selection meant any prior scrollbar drag snapped
162                // back via `ensure_visible` on the next wheel tick. Three
163                // rows per tick matches the settings modal.
164                if editor.edit_dialog.is_none() && !editor.showing_confirm_dialog {
165                    editor.scroll.scroll_by(-3);
166                }
167            }
168            MouseEventKind::ScrollDown => {
169                if editor.edit_dialog.is_none() && !editor.showing_confirm_dialog {
170                    editor.scroll.scroll_by(3);
171                }
172            }
173            MouseEventKind::Drag(MouseButton::Left) => {
174                // Continue dragging the scrollbar thumb (no selection or
175                // dialog disambiguation needed: the press that started the
176                // drag already gated those).
177                if let Some(sb) = editor.layout.table_scrollbar {
178                    let sb_state = scrollbar_state_for(&editor);
179                    if let Some(new_offset) = editor.scrollbar_mouse.drag(sb_state, sb, row) {
180                        editor.scroll.offset = new_offset as u16;
181                    }
182                }
183            }
184            MouseEventKind::Up(MouseButton::Left) => {
185                editor.scrollbar_mouse.release();
186            }
187            MouseEventKind::Down(MouseButton::Left) => {
188                // Handle confirm dialog clicks first
189                if editor.showing_confirm_dialog {
190                    if let Some((save_r, discard_r, cancel_r)) = layout.confirm_buttons {
191                        if point_in_rect(save_r, col, row) {
192                            self.save_keybinding_editor_changes(&editor);
193                            return Ok(true);
194                        } else if point_in_rect(discard_r, col, row) {
195                            self.set_status_message("Keybinding editor closed".to_string());
196                            return Ok(true);
197                        } else if point_in_rect(cancel_r, col, row) {
198                            editor.showing_confirm_dialog = false;
199                        }
200                    }
201                    self.keybinding_editor = Some(editor);
202                    return Ok(true);
203                }
204
205                // Handle edit dialog clicks
206                if editor.edit_dialog.is_some() {
207                    // Button clicks
208                    if let Some((save_r, cancel_r)) = layout.dialog_buttons {
209                        if point_in_rect(save_r, col, row) {
210                            // Save button
211                            if let Some(err) = editor.apply_edit_dialog() {
212                                self.set_status_message(err);
213                            }
214                            self.keybinding_editor = Some(editor);
215                            return Ok(true);
216                        } else if point_in_rect(cancel_r, col, row) {
217                            // Cancel button - close dialog
218                            editor.edit_dialog = None;
219                            self.keybinding_editor = Some(editor);
220                            return Ok(true);
221                        }
222                    }
223                    // Field clicks
224                    if let Some(r) = layout.dialog_key_field {
225                        if point_in_rect(r, col, row) {
226                            if let Some(ref mut dialog) = editor.edit_dialog {
227                                dialog.focus_area = 0;
228                                dialog.mode = crate::app::keybinding_editor::EditMode::RecordingKey;
229                            }
230                        }
231                    }
232                    if let Some(r) = layout.dialog_action_field {
233                        if point_in_rect(r, col, row) {
234                            if let Some(ref mut dialog) = editor.edit_dialog {
235                                dialog.focus_area = 1;
236                                dialog.mode =
237                                    crate::app::keybinding_editor::EditMode::EditingAction;
238                            }
239                        }
240                    }
241                    if let Some(r) = layout.dialog_context_field {
242                        if point_in_rect(r, col, row) {
243                            if let Some(ref mut dialog) = editor.edit_dialog {
244                                dialog.focus_area = 2;
245                                dialog.mode =
246                                    crate::app::keybinding_editor::EditMode::EditingContext;
247                            }
248                        }
249                    }
250                    self.keybinding_editor = Some(editor);
251                    return Ok(true);
252                }
253
254                // Click on search bar to focus it
255                if let Some(search_r) = layout.search_bar {
256                    if point_in_rect(search_r, col, row) {
257                        editor.start_search();
258                        self.keybinding_editor = Some(editor);
259                        return Ok(true);
260                    }
261                }
262
263                // Press on the scrollbar — delegate to the shared widget
264                // so press-on-thumb (no jump), press-on-track (recentre),
265                // and the follow-up drag all run through the same well-
266                // tested math. Checked before the row-click branch because
267                // the scrollbar overlaps the rightmost column of `table_area`.
268                if let Some(sb) = layout.table_scrollbar {
269                    let sb_state = scrollbar_state_for(&editor);
270                    if let Some(new_offset) = editor.scrollbar_mouse.press(sb_state, sb, col, row) {
271                        editor.scroll.offset = new_offset as u16;
272                        self.keybinding_editor = Some(editor);
273                        return Ok(true);
274                    }
275                }
276
277                // Click on table row to select (or toggle section header)
278                let table_area = layout.table_area;
279                let first_row_y = layout.table_first_row_y;
280                if point_in_rect(table_area, col, row) && row >= first_row_y {
281                    let clicked_row = (row - first_row_y) as usize;
282                    let new_selected = editor.scroll.offset as usize + clicked_row;
283                    if new_selected < editor.display_rows.len() {
284                        editor.selected = new_selected;
285                        if editor.selected_is_section_header() {
286                            editor.toggle_section_at_selected();
287                        }
288                    }
289                }
290            }
291            _ => {}
292        }
293
294        self.keybinding_editor = Some(editor);
295        Ok(true)
296    }
297
298    /// Select a display row by index (and toggle it if it's a section header) —
299    /// the same effect as a TUI click on that table row. Used by the web
300    /// `/kbedit` route so a native row click selects through the real editor.
301    pub(crate) fn kbedit_select_display_row(&mut self, idx: usize) {
302        if let Some(ed) = self.keybinding_editor.as_mut() {
303            if idx < ed.display_rows.len() {
304                ed.selected = idx;
305                if ed.selected_is_section_header() {
306                    ed.toggle_section_at_selected();
307                }
308            }
309        }
310    }
311}
312
313/// Snapshot the keybinding editor's scroll state as a `ScrollbarState`,
314/// so we can call into the shared scrollbar widget for click/drag math.
315fn scrollbar_state_for(editor: &KeybindingEditor) -> crate::view::ui::scrollbar::ScrollbarState {
316    crate::view::ui::scrollbar::ScrollbarState::new(
317        editor.scroll.content_height as usize,
318        editor.scroll.viewport as usize,
319        editor.scroll.offset as usize,
320    )
321}