Skip to main content

fresh/view/settings/
input.rs

1//! Input handling for the Settings dialog.
2//!
3//! Implements the InputHandler trait for SettingsState, routing input
4//! through the focus hierarchy: Dialog -> Panel -> Control.
5
6use super::items::SettingControl;
7use super::state::{FocusPanel, SettingsState};
8use crate::input::handler::{DeferredAction, InputContext, InputHandler, InputResult};
9use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
10
11/// Button action in entry dialog
12enum ButtonAction {
13    Save,
14    Delete,
15    Cancel,
16}
17
18/// Control activation action in entry dialog
19enum ControlAction {
20    ToggleBool,
21    ToggleDropdown,
22    StartEditing,
23    OpenNestedDialog,
24}
25
26impl InputHandler for SettingsState {
27    fn handle_key_event(&mut self, event: &KeyEvent, ctx: &mut InputContext) -> InputResult {
28        // Entry-dialog "Delete X?" prompt takes priority over both
29        // the discard prompt and the entry dialog — it's the topmost
30        // overlay.
31        if self.showing_entry_delete_confirm {
32            return self.handle_entry_delete_confirm_input(event);
33        }
34
35        // Entry-dialog "Discard changes?" prompt takes priority over
36        // the entry dialog itself — it's stacked on top.
37        if self.showing_entry_discard_confirm {
38            return self.handle_entry_discard_confirm_input(event);
39        }
40
41        // Entry dialog takes priority when open
42        if self.has_entry_dialog() {
43            return self.handle_entry_dialog_input(event, ctx);
44        }
45
46        // Confirmation dialog takes priority
47        if self.showing_confirm_dialog {
48            return self.handle_confirm_dialog_input(event, ctx);
49        }
50
51        // Reset confirmation dialog takes priority
52        if self.showing_reset_dialog {
53            return self.handle_reset_dialog_input(event);
54        }
55
56        // Help overlay takes priority
57        if self.showing_help {
58            return self.handle_help_input(event, ctx);
59        }
60
61        // Search mode takes priority
62        if self.search_active {
63            return self.handle_search_input(event, ctx);
64        }
65
66        // Global shortcut: Ctrl+S to save
67        if event.modifiers.contains(KeyModifiers::CONTROL)
68            && matches!(event.code, KeyCode::Char('s') | KeyCode::Char('S'))
69        {
70            ctx.defer(DeferredAction::CloseSettings { save: true });
71            return InputResult::Consumed;
72        }
73
74        // Route to focused panel
75        match self.focus_panel() {
76            FocusPanel::Categories => self.handle_categories_input(event, ctx),
77            FocusPanel::Settings => self.handle_settings_input(event, ctx),
78            FocusPanel::Footer => self.handle_footer_input(event, ctx),
79        }
80    }
81
82    fn is_modal(&self) -> bool {
83        true // Settings dialog consumes all unhandled input
84    }
85}
86
87impl SettingsState {
88    /// Handle input when entry dialog is open
89    ///
90    /// Uses the same input flow as the main settings UI:
91    /// 1. If in text editing mode -> handle text input
92    /// 2. If dropdown is open -> handle dropdown navigation
93    /// 3. Otherwise -> handle navigation and control activation
94    fn handle_entry_dialog_input(
95        &mut self,
96        event: &KeyEvent,
97        ctx: &mut InputContext,
98    ) -> InputResult {
99        // Ctrl+S saves entry dialog from any mode
100        if event.modifiers.contains(KeyModifiers::CONTROL)
101            && matches!(event.code, KeyCode::Char('s') | KeyCode::Char('S'))
102        {
103            self.save_entry_dialog();
104            return InputResult::Consumed;
105        }
106
107        // Ctrl+R resets the focused field to its schema default. Only
108        // fires in navigation mode (not while typing into a control), so
109        // it doesn't conflict with editor inputs.
110        if event.modifiers.contains(KeyModifiers::CONTROL)
111            && matches!(event.code, KeyCode::Char('r') | KeyCode::Char('R'))
112        {
113            let editing = self.entry_dialog().map(|d| d.editing_text).unwrap_or(false);
114            if !editing {
115                self.reset_focused_entry_field();
116                return InputResult::Consumed;
117            }
118        }
119
120        // Check if we're in a special editing mode
121        let (editing_text, dropdown_open) = if let Some(dialog) = self.entry_dialog() {
122            let dropdown_open = dialog
123                .current_item()
124                .map(|item| matches!(&item.control, SettingControl::Dropdown(s) if s.open))
125                .unwrap_or(false);
126            (dialog.editing_text, dropdown_open)
127        } else {
128            return InputResult::Consumed;
129        };
130
131        // Route to appropriate handler based on mode
132        if editing_text {
133            self.handle_entry_dialog_text_editing(event, ctx)
134        } else if dropdown_open {
135            self.handle_entry_dialog_dropdown(event)
136        } else {
137            self.handle_entry_dialog_navigation(event, ctx)
138        }
139    }
140
141    /// Handle text editing input in entry dialog (same pattern as handle_text_editing_input)
142    fn handle_entry_dialog_text_editing(
143        &mut self,
144        event: &KeyEvent,
145        ctx: &mut InputContext,
146    ) -> InputResult {
147        // Check if we're editing JSON
148        let is_editing_json = self
149            .entry_dialog()
150            .map(|d| d.is_editing_json())
151            .unwrap_or(false);
152
153        // Check validation first before borrowing dialog mutably
154        let can_exit = self.entry_dialog_can_exit_text_editing();
155
156        let Some(dialog) = self.entry_dialog_mut() else {
157            return InputResult::Consumed;
158        };
159
160        match event.code {
161            KeyCode::Esc => {
162                // Escape accepts changes (same as Tab) - exit editing mode
163                if !can_exit {
164                    // If validation fails, just stop editing anyway (accept whatever is there)
165                }
166                dialog.stop_editing();
167            }
168            KeyCode::Enter => {
169                if is_editing_json {
170                    // Insert newline in JSON editor
171                    dialog.insert_newline();
172                } else {
173                    // Add item for TextList, or stop editing
174                    if let Some(item) = dialog.current_item_mut() {
175                        if let SettingControl::TextList(state) = &mut item.control {
176                            state.add_item();
177                        }
178                    }
179                }
180            }
181            KeyCode::Char(c) => {
182                if event.modifiers.contains(KeyModifiers::CONTROL) {
183                    match c {
184                        'a' | 'A' => {
185                            // Select all
186                            dialog.select_all();
187                        }
188                        'c' | 'C' => {
189                            // Copy selected text to clipboard
190                            if let Some(text) = dialog.selected_text() {
191                                ctx.defer(DeferredAction::CopyToClipboard(text));
192                            }
193                        }
194                        'v' | 'V' => {
195                            // Paste
196                            ctx.defer(DeferredAction::PasteToSettings);
197                        }
198                        _ => {}
199                    }
200                } else {
201                    dialog.insert_char(c);
202                }
203            }
204            KeyCode::Backspace => {
205                dialog.backspace();
206            }
207            KeyCode::Delete => {
208                if is_editing_json {
209                    // Delete character at cursor in JSON editor
210                    dialog.delete();
211                } else {
212                    // Delete item in TextList
213                    dialog.delete_list_item();
214                }
215            }
216            KeyCode::Home => {
217                dialog.cursor_home();
218            }
219            KeyCode::End => {
220                dialog.cursor_end();
221            }
222            KeyCode::Left => {
223                if is_editing_json && event.modifiers.contains(KeyModifiers::SHIFT) {
224                    dialog.cursor_left_selecting();
225                } else {
226                    dialog.cursor_left();
227                }
228            }
229            KeyCode::Right => {
230                if is_editing_json && event.modifiers.contains(KeyModifiers::SHIFT) {
231                    dialog.cursor_right_selecting();
232                } else {
233                    dialog.cursor_right();
234                }
235            }
236            KeyCode::Up => {
237                if is_editing_json {
238                    // Move cursor up in JSON editor
239                    if event.modifiers.contains(KeyModifiers::SHIFT) {
240                        dialog.cursor_up_selecting();
241                    } else {
242                        dialog.cursor_up();
243                    }
244                } else {
245                    // For a TextList: commit any pending new-item text,
246                    // then try to move focus within the list. If focus
247                    // was already on the trailing [+] Add new sentinel
248                    // (and no pending text), escape out of text-edit
249                    // mode and let the dialog navigate to the previous
250                    // field instead of trapping the user on the slot.
251                    let escape = if let Some(item) = dialog.current_item_mut() {
252                        if let SettingControl::TextList(state) = &mut item.control {
253                            let was_on_addnew = state.focused_item.is_none();
254                            let had_pending = !state.new_item_text.is_empty();
255                            state.add_item();
256                            if was_on_addnew && !had_pending {
257                                true
258                            } else {
259                                state.focus_prev();
260                                false
261                            }
262                        } else {
263                            false
264                        }
265                    } else {
266                        false
267                    };
268                    if escape {
269                        dialog.stop_editing();
270                        dialog.focus_prev();
271                    }
272                }
273            }
274            KeyCode::Down => {
275                if is_editing_json {
276                    // Move cursor down in JSON editor
277                    if event.modifiers.contains(KeyModifiers::SHIFT) {
278                        dialog.cursor_down_selecting();
279                    } else {
280                        dialog.cursor_down();
281                    }
282                } else {
283                    // See KeyCode::Up above for the escape semantics —
284                    // the trailing [+] Add new slot of a TextList must
285                    // not trap the user.
286                    let escape = if let Some(item) = dialog.current_item_mut() {
287                        if let SettingControl::TextList(state) = &mut item.control {
288                            let was_on_addnew = state.focused_item.is_none();
289                            let had_pending = !state.new_item_text.is_empty();
290                            state.add_item();
291                            if was_on_addnew && !had_pending {
292                                true
293                            } else {
294                                state.focus_next();
295                                false
296                            }
297                        } else {
298                            false
299                        }
300                    } else {
301                        false
302                    };
303                    if escape {
304                        dialog.stop_editing();
305                        dialog.focus_next();
306                    }
307                }
308            }
309            KeyCode::Tab => {
310                if is_editing_json {
311                    // Tab exits JSON editor if JSON is valid, otherwise ignored
312                    let is_valid = dialog
313                        .current_item()
314                        .map(|item| {
315                            if let SettingControl::Json(state) = &item.control {
316                                state.is_valid()
317                            } else {
318                                true
319                            }
320                        })
321                        .unwrap_or(true);
322
323                    if is_valid {
324                        // Commit changes and stop editing
325                        if let Some(item) = dialog.current_item_mut() {
326                            if let SettingControl::Json(state) = &mut item.control {
327                                state.commit();
328                            }
329                        }
330                        dialog.stop_editing();
331                    }
332                    // If not valid, Tab is ignored (user must fix or press Esc)
333                } else {
334                    // Tab on a TextList: commit any pending text, then
335                    // exit text-edit mode AND advance the dialog to the
336                    // next field so Tab doesn't strand the user on the
337                    // trailing add-new slot (UX review F19).
338                    let escape_forward = if let Some(item) = dialog.current_item_mut() {
339                        if let SettingControl::TextList(state) = &mut item.control {
340                            state.add_item();
341                            true
342                        } else {
343                            false
344                        }
345                    } else {
346                        false
347                    };
348                    dialog.stop_editing();
349                    if escape_forward {
350                        dialog.focus_next();
351                    }
352                }
353            }
354            _ => {}
355        }
356        InputResult::Consumed
357    }
358
359    /// Handle dropdown navigation in entry dialog (same pattern as handle_dropdown_input)
360    fn handle_entry_dialog_dropdown(&mut self, event: &KeyEvent) -> InputResult {
361        let Some(dialog) = self.entry_dialog_mut() else {
362            return InputResult::Consumed;
363        };
364
365        match event.code {
366            KeyCode::Up => {
367                dialog.dropdown_prev();
368            }
369            KeyCode::Down => {
370                dialog.dropdown_next();
371            }
372            KeyCode::Enter => {
373                dialog.dropdown_confirm();
374            }
375            KeyCode::Esc => {
376                dialog.dropdown_confirm(); // Close dropdown
377            }
378            _ => {}
379        }
380        InputResult::Consumed
381    }
382
383    /// Handle navigation and activation in entry dialog (same pattern as handle_settings_input)
384    fn handle_entry_dialog_navigation(
385        &mut self,
386        event: &KeyEvent,
387        ctx: &mut InputContext,
388    ) -> InputResult {
389        match event.code {
390            KeyCode::Esc => {
391                // Esc on a dialog with uncommitted edits prompts for
392                // confirmation; a clean dialog closes immediately.
393                // Without the dirty check, an accidental Esc silently
394                // destroys every field the user just typed in.
395                let dirty = self.entry_dialog().map(|d| d.is_dirty()).unwrap_or(false);
396                if dirty {
397                    self.showing_entry_discard_confirm = true;
398                    self.entry_discard_confirm_selection = 0;
399                } else {
400                    self.close_entry_dialog();
401                }
402            }
403            KeyCode::Up => {
404                if let Some(dialog) = self.entry_dialog_mut() {
405                    dialog.focus_prev();
406                }
407            }
408            KeyCode::Down => {
409                if let Some(dialog) = self.entry_dialog_mut() {
410                    dialog.focus_next();
411                }
412            }
413            KeyCode::Tab => {
414                // Tab cycles sequentially through all fields, sub-fields, and buttons
415                if let Some(dialog) = self.entry_dialog_mut() {
416                    dialog.focus_next();
417                }
418            }
419            KeyCode::BackTab => {
420                // Shift+Tab cycles in reverse
421                if let Some(dialog) = self.entry_dialog_mut() {
422                    dialog.focus_prev();
423                }
424            }
425            KeyCode::Delete => {
426                // Del on a focused TextList item removes the row.
427                // Without this, the only way to drop an entry was to
428                // mouse-click `[x]` — which (a) wasn't wired and (b)
429                // isn't obvious from the keyboard.
430                let removed = self
431                    .entry_dialog_mut()
432                    .map(|dialog| {
433                        if dialog.focus_on_buttons {
434                            return false;
435                        }
436                        if let Some(item) = dialog.current_item_mut() {
437                            if let SettingControl::TextList(state) = &mut item.control {
438                                if let Some(idx) = state.focused_item {
439                                    state.remove_item(idx);
440                                    dialog.user_edited = true;
441                                    return true;
442                                }
443                            }
444                        }
445                        false
446                    })
447                    .unwrap_or(false);
448                if !removed {
449                    // Fall through to nothing — no other Del semantic
450                    // in the dialog navigation mode for now.
451                }
452            }
453            KeyCode::Left => {
454                if let Some(dialog) = self.entry_dialog_mut() {
455                    if dialog.focus_on_buttons && dialog.focused_button > 0 {
456                        dialog.focused_button -= 1;
457                    }
458                }
459            }
460            KeyCode::Right => {
461                if let Some(dialog) = self.entry_dialog_mut() {
462                    if dialog.focus_on_buttons && dialog.focused_button + 1 < dialog.button_count()
463                    {
464                        dialog.focused_button += 1;
465                    }
466                }
467            }
468            KeyCode::Enter => {
469                // Check button state first with immutable borrow
470                // Button layout: [Save, Cancel] or [Save, Cancel, Delete].
471                // Save = 0, Cancel = 1, Delete = 2 (when present).
472                let button_action = self.entry_dialog().and_then(|dialog| {
473                    if dialog.focus_on_buttons {
474                        let has_delete = !dialog.is_new && !dialog.no_delete;
475                        match dialog.focused_button {
476                            0 => Some(ButtonAction::Save),
477                            1 => Some(ButtonAction::Cancel),
478                            2 if has_delete => Some(ButtonAction::Delete),
479                            _ => None,
480                        }
481                    } else {
482                        None
483                    }
484                });
485
486                if let Some(action) = button_action {
487                    match action {
488                        ButtonAction::Save => self.save_entry_dialog(),
489                        ButtonAction::Delete => self.request_entry_delete_confirm(),
490                        ButtonAction::Cancel => self.close_entry_dialog(),
491                    }
492                } else if event.modifiers.contains(KeyModifiers::CONTROL) {
493                    // Ctrl+Enter always saves
494                    self.save_entry_dialog();
495                } else {
496                    // Activate current control
497                    let control_action = self
498                        .entry_dialog()
499                        .and_then(|dialog| {
500                            dialog.current_item().map(|item| match &item.control {
501                                SettingControl::Toggle(_) => Some(ControlAction::ToggleBool),
502                                SettingControl::Dropdown(_) => Some(ControlAction::ToggleDropdown),
503                                SettingControl::Text(_)
504                                | SettingControl::TextList(_)
505                                | SettingControl::DualList(_)
506                                | SettingControl::Number(_)
507                                | SettingControl::Json(_) => Some(ControlAction::StartEditing),
508                                SettingControl::Map(_) | SettingControl::ObjectArray(_) => {
509                                    Some(ControlAction::OpenNestedDialog)
510                                }
511                                _ => None,
512                            })
513                        })
514                        .flatten();
515
516                    if let Some(action) = control_action {
517                        match action {
518                            ControlAction::ToggleBool => {
519                                if let Some(dialog) = self.entry_dialog_mut() {
520                                    dialog.toggle_bool();
521                                }
522                            }
523                            ControlAction::ToggleDropdown => {
524                                if let Some(dialog) = self.entry_dialog_mut() {
525                                    dialog.toggle_dropdown();
526                                }
527                            }
528                            ControlAction::StartEditing => {
529                                if let Some(dialog) = self.entry_dialog_mut() {
530                                    dialog.start_editing();
531                                }
532                            }
533                            ControlAction::OpenNestedDialog => {
534                                self.open_nested_entry_dialog();
535                            }
536                        }
537                    }
538                }
539            }
540            KeyCode::Char(' ') => {
541                // Space toggles booleans, activates dropdowns (but doesn't submit form)
542                let control_action = self.entry_dialog().and_then(|dialog| {
543                    if dialog.focus_on_buttons {
544                        return None; // Space on buttons does nothing (Enter activates)
545                    }
546                    dialog.current_item().and_then(|item| match &item.control {
547                        SettingControl::Toggle(_) => Some(ControlAction::ToggleBool),
548                        SettingControl::Dropdown(_) => Some(ControlAction::ToggleDropdown),
549                        _ => None,
550                    })
551                });
552
553                if let Some(action) = control_action {
554                    match action {
555                        ControlAction::ToggleBool => {
556                            if let Some(dialog) = self.entry_dialog_mut() {
557                                dialog.toggle_bool();
558                            }
559                        }
560                        ControlAction::ToggleDropdown => {
561                            if let Some(dialog) = self.entry_dialog_mut() {
562                                dialog.toggle_dropdown();
563                            }
564                        }
565                        _ => {}
566                    }
567                }
568            }
569            KeyCode::Char(c) => {
570                // Auto-enter edit mode when typing on a text or number field
571                let can_auto_edit = self
572                    .entry_dialog()
573                    .and_then(|dialog| {
574                        if dialog.focus_on_buttons {
575                            return None;
576                        }
577                        dialog.current_item().map(|item| match &item.control {
578                            SettingControl::Text(_) | SettingControl::TextList(_) => true,
579                            SettingControl::Number(_) => c.is_ascii_digit() || c == '-' || c == '.',
580                            _ => false,
581                        })
582                    })
583                    .unwrap_or(false);
584
585                if can_auto_edit {
586                    if let Some(dialog) = self.entry_dialog_mut() {
587                        dialog.start_editing();
588                    }
589                    // Now forward the character to the text editing handler
590                    return self.handle_entry_dialog_text_editing(
591                        &KeyEvent::new(KeyCode::Char(c), event.modifiers),
592                        ctx,
593                    );
594                }
595            }
596            _ => {}
597        }
598        InputResult::Consumed
599    }
600
601    /// Handle input when confirmation dialog is showing
602    fn handle_confirm_dialog_input(
603        &mut self,
604        event: &KeyEvent,
605        ctx: &mut InputContext,
606    ) -> InputResult {
607        match event.code {
608            KeyCode::Left | KeyCode::BackTab => {
609                if self.confirm_dialog_selection > 0 {
610                    self.confirm_dialog_selection -= 1;
611                }
612                InputResult::Consumed
613            }
614            KeyCode::Right | KeyCode::Tab => {
615                if self.confirm_dialog_selection < 2 {
616                    self.confirm_dialog_selection += 1;
617                }
618                InputResult::Consumed
619            }
620            KeyCode::Enter => {
621                match self.confirm_dialog_selection {
622                    0 => ctx.defer(DeferredAction::CloseSettings { save: true }), // Save
623                    1 => ctx.defer(DeferredAction::CloseSettings { save: false }), // Discard
624                    2 => self.showing_confirm_dialog = false, // Cancel - back to settings
625                    _ => {}
626                }
627                InputResult::Consumed
628            }
629            KeyCode::Esc => {
630                self.showing_confirm_dialog = false;
631                InputResult::Consumed
632            }
633            KeyCode::Char('s') | KeyCode::Char('S') => {
634                ctx.defer(DeferredAction::CloseSettings { save: true });
635                InputResult::Consumed
636            }
637            KeyCode::Char('d') | KeyCode::Char('D') => {
638                ctx.defer(DeferredAction::CloseSettings { save: false });
639                InputResult::Consumed
640            }
641            _ => InputResult::Consumed, // Modal: consume all
642        }
643    }
644
645    /// Handle input when reset confirmation dialog is showing
646    fn handle_reset_dialog_input(&mut self, event: &KeyEvent) -> InputResult {
647        match event.code {
648            KeyCode::Left | KeyCode::BackTab => {
649                if self.reset_dialog_selection > 0 {
650                    self.reset_dialog_selection -= 1;
651                }
652                InputResult::Consumed
653            }
654            KeyCode::Right | KeyCode::Tab => {
655                if self.reset_dialog_selection < 1 {
656                    self.reset_dialog_selection += 1;
657                }
658                InputResult::Consumed
659            }
660            KeyCode::Enter => {
661                match self.reset_dialog_selection {
662                    0 => {
663                        // Reset all changes
664                        self.discard_changes();
665                        self.showing_reset_dialog = false;
666                    }
667                    1 => {
668                        // Cancel - back to settings
669                        self.showing_reset_dialog = false;
670                    }
671                    _ => {}
672                }
673                InputResult::Consumed
674            }
675            KeyCode::Esc => {
676                self.showing_reset_dialog = false;
677                InputResult::Consumed
678            }
679            KeyCode::Char('r') | KeyCode::Char('R') => {
680                self.discard_changes();
681                self.showing_reset_dialog = false;
682                InputResult::Consumed
683            }
684            _ => InputResult::Consumed, // Modal: consume all
685        }
686    }
687
688    /// Handle input when the entry-dialog discard-confirm prompt is up.
689    /// Buttons: 0 = Keep editing (default), 1 = Discard.
690    fn handle_entry_discard_confirm_input(&mut self, event: &KeyEvent) -> InputResult {
691        match event.code {
692            KeyCode::Left | KeyCode::BackTab => {
693                if self.entry_discard_confirm_selection > 0 {
694                    self.entry_discard_confirm_selection -= 1;
695                }
696            }
697            KeyCode::Right | KeyCode::Tab => {
698                if self.entry_discard_confirm_selection < 1 {
699                    self.entry_discard_confirm_selection += 1;
700                }
701            }
702            KeyCode::Enter => {
703                match self.entry_discard_confirm_selection {
704                    0 => {
705                        // Keep editing — just dismiss the prompt.
706                        self.showing_entry_discard_confirm = false;
707                    }
708                    1 => {
709                        // Discard — close the entry dialog without saving.
710                        self.showing_entry_discard_confirm = false;
711                        self.close_entry_dialog();
712                    }
713                    _ => {}
714                }
715            }
716            KeyCode::Esc => {
717                // Esc on the prompt means "keep editing".
718                self.showing_entry_discard_confirm = false;
719            }
720            KeyCode::Char('d') | KeyCode::Char('D') => {
721                self.showing_entry_discard_confirm = false;
722                self.close_entry_dialog();
723            }
724            _ => {}
725        }
726        InputResult::Consumed
727    }
728
729    /// Handle input when the entry-dialog delete-confirm prompt is up.
730    /// Buttons: 0 = Cancel (default), 1 = Delete.
731    fn handle_entry_delete_confirm_input(&mut self, event: &KeyEvent) -> InputResult {
732        match event.code {
733            KeyCode::Left | KeyCode::BackTab => {
734                if self.entry_delete_confirm_selection > 0 {
735                    self.entry_delete_confirm_selection -= 1;
736                }
737            }
738            KeyCode::Right | KeyCode::Tab => {
739                if self.entry_delete_confirm_selection < 1 {
740                    self.entry_delete_confirm_selection += 1;
741                }
742            }
743            KeyCode::Enter => match self.entry_delete_confirm_selection {
744                0 => {
745                    self.showing_entry_delete_confirm = false;
746                }
747                1 => {
748                    self.showing_entry_delete_confirm = false;
749                    self.delete_entry_dialog();
750                }
751                _ => {}
752            },
753            KeyCode::Esc => {
754                self.showing_entry_delete_confirm = false;
755            }
756            _ => {}
757        }
758        InputResult::Consumed
759    }
760
761    /// Handle input when help overlay is showing
762    fn handle_help_input(&mut self, _event: &KeyEvent, _ctx: &mut InputContext) -> InputResult {
763        // Any key dismisses help
764        self.showing_help = false;
765        InputResult::Consumed
766    }
767
768    /// Handle input when search is active
769    fn handle_search_input(&mut self, event: &KeyEvent, _ctx: &mut InputContext) -> InputResult {
770        match event.code {
771            KeyCode::Esc => {
772                self.cancel_search();
773                InputResult::Consumed
774            }
775            KeyCode::Enter => {
776                self.jump_to_search_result();
777                InputResult::Consumed
778            }
779            KeyCode::Up => {
780                self.search_prev();
781                InputResult::Consumed
782            }
783            KeyCode::Down => {
784                self.search_next();
785                InputResult::Consumed
786            }
787            KeyCode::Char(c) => {
788                self.search_push_char(c);
789                InputResult::Consumed
790            }
791            KeyCode::Backspace => {
792                self.search_pop_char();
793                InputResult::Consumed
794            }
795            _ => InputResult::Consumed, // Modal: consume all
796        }
797    }
798
799    /// Handle input when Categories panel is focused
800    fn handle_categories_input(&mut self, event: &KeyEvent, ctx: &mut InputContext) -> InputResult {
801        match event.code {
802            KeyCode::Up => {
803                self.select_prev();
804                InputResult::Consumed
805            }
806            KeyCode::Down => {
807                self.select_next();
808                InputResult::Consumed
809            }
810            KeyCode::PageUp => {
811                // Page up in the tree view scrolls by viewport height.
812                let viewport = self.categories_scroll.scroll.viewport.max(1) as i32;
813                self.tree_step(-viewport);
814                InputResult::Consumed
815            }
816            KeyCode::PageDown => {
817                let viewport = self.categories_scroll.scroll.viewport.max(1) as i32;
818                self.tree_step(viewport);
819                InputResult::Consumed
820            }
821            KeyCode::Home => {
822                let rows = self.visible_tree();
823                let cur = self.tree_cursor_index(&rows) as i32;
824                if cur > 0 {
825                    self.tree_step(-cur);
826                }
827                InputResult::Consumed
828            }
829            KeyCode::End => {
830                let rows = self.visible_tree();
831                let cur = self.tree_cursor_index(&rows) as i32;
832                let last = rows.len() as i32 - 1;
833                if last > cur {
834                    self.tree_step(last - cur);
835                }
836                InputResult::Consumed
837            }
838            KeyCode::Tab => {
839                self.toggle_focus();
840                InputResult::Consumed
841            }
842            KeyCode::BackTab => {
843                self.toggle_focus_backward();
844                InputResult::Consumed
845            }
846            KeyCode::Char('/') => {
847                self.start_search();
848                InputResult::Consumed
849            }
850            KeyCode::Char('?') => {
851                self.toggle_help();
852                InputResult::Consumed
853            }
854            KeyCode::Esc => {
855                self.request_close(ctx);
856                InputResult::Consumed
857            }
858            KeyCode::Right => {
859                // Right ONLY expands an expandable category. Does not move
860                // focus into the body panel — that's Tab's job.
861                let cat_idx = self.selected_category;
862                if self.is_category_expandable(cat_idx)
863                    && !self.expanded_categories.contains(&cat_idx)
864                {
865                    self.expanded_categories.insert(cat_idx);
866                }
867                InputResult::Consumed
868            }
869            KeyCode::Left => {
870                // Left ONLY collapses an expanded category. No-op otherwise.
871                let cat_idx = self.selected_category;
872                if self.expanded_categories.contains(&cat_idx) {
873                    self.expanded_categories.remove(&cat_idx);
874                    // Sections aren't visible anymore — pull the cursor
875                    // back to the category row so the next Down step
876                    // walks to the *next* category, not into the
877                    // (now-hidden) sections.
878                    self.tree_cursor_section = None;
879                }
880                InputResult::Consumed
881            }
882            _ => InputResult::Ignored, // Let modal catch it
883        }
884    }
885
886    /// Handle input when Settings panel is focused
887    fn handle_settings_input(&mut self, event: &KeyEvent, ctx: &mut InputContext) -> InputResult {
888        // If editing text, handle text input
889        if self.editing_text {
890            return self.handle_text_editing_input(event, ctx);
891        }
892
893        // If editing number input, handle number input
894        if self.is_number_editing() {
895            return self.handle_number_editing_input(event, ctx);
896        }
897
898        // If dropdown is open, handle dropdown navigation
899        if self.is_dropdown_open() {
900            return self.handle_dropdown_input(event, ctx);
901        }
902
903        match event.code {
904            KeyCode::Up => {
905                self.select_prev();
906                InputResult::Consumed
907            }
908            KeyCode::Down => {
909                self.select_next();
910                InputResult::Consumed
911            }
912            KeyCode::Tab => {
913                self.toggle_focus();
914                InputResult::Consumed
915            }
916            KeyCode::BackTab => {
917                self.toggle_focus_backward();
918                InputResult::Consumed
919            }
920            KeyCode::Left => {
921                // Left always navigates back to categories — numbers no
922                // longer use Left/Right for inc/dec (direct typing only).
923                self.update_control_focus(false);
924                self.focus.set(FocusPanel::Categories);
925                InputResult::Consumed
926            }
927            KeyCode::Enter | KeyCode::Char(' ') => {
928                self.handle_control_activate(ctx);
929                InputResult::Consumed
930            }
931            // Type-to-edit: digit / '-' / '.' on a focused number control
932            // enters edit mode with the typed char replacing the value.
933            KeyCode::Char(c)
934                if self.is_number_control() && (c.is_ascii_digit() || c == '-' || c == '.') =>
935            {
936                self.start_number_editing();
937                self.number_insert(c);
938                self.on_value_changed();
939                InputResult::Consumed
940            }
941            KeyCode::PageDown => {
942                self.select_next_page();
943                InputResult::Consumed
944            }
945            KeyCode::PageUp => {
946                self.select_prev_page();
947                InputResult::Consumed
948            }
949            KeyCode::Char('/') => {
950                self.start_search();
951                InputResult::Consumed
952            }
953            KeyCode::Char('?') => {
954                self.toggle_help();
955                InputResult::Consumed
956            }
957            KeyCode::Delete => {
958                // Delete key: set nullable setting to null (inherit)
959                self.set_current_to_null();
960                InputResult::Consumed
961            }
962            KeyCode::Esc => {
963                self.request_close(ctx);
964                InputResult::Consumed
965            }
966            _ => InputResult::Ignored, // Let modal catch it
967        }
968    }
969
970    /// Handle input when Footer is focused
971    /// Footer buttons: [Layer] [Reset] [Save] [Cancel] + [Edit] on left for advanced users
972    /// Tab cycles between buttons; after last button, moves to Categories panel
973    fn handle_footer_input(&mut self, event: &KeyEvent, ctx: &mut InputContext) -> InputResult {
974        const FOOTER_BUTTON_COUNT: usize = 5;
975
976        match event.code {
977            KeyCode::Left | KeyCode::BackTab => {
978                // Move to previous button, or wrap to Categories panel
979                if self.footer_button_index > 0 {
980                    self.footer_button_index -= 1;
981                } else {
982                    self.focus.set(FocusPanel::Settings);
983                }
984                InputResult::Consumed
985            }
986            KeyCode::Right => {
987                // Move to next button
988                if self.footer_button_index < FOOTER_BUTTON_COUNT - 1 {
989                    self.footer_button_index += 1;
990                }
991                InputResult::Consumed
992            }
993            KeyCode::Tab => {
994                // Move to next button, or wrap to Categories panel
995                if self.footer_button_index < FOOTER_BUTTON_COUNT - 1 {
996                    self.footer_button_index += 1;
997                } else {
998                    self.focus.set(FocusPanel::Categories);
999                }
1000                InputResult::Consumed
1001            }
1002            KeyCode::Enter => {
1003                match self.footer_button_index {
1004                    0 => self.cycle_target_layer(), // Layer button
1005                    1 => {
1006                        // Reset/Inherit button — for nullable items, set to null (inherit);
1007                        // otherwise show reset-all dialog
1008                        let is_nullable_set = self
1009                            .current_item()
1010                            .map(|item| item.nullable && !item.is_null)
1011                            .unwrap_or(false);
1012                        if is_nullable_set {
1013                            self.set_current_to_null();
1014                        } else {
1015                            self.request_reset();
1016                        }
1017                    }
1018                    2 => ctx.defer(DeferredAction::CloseSettings { save: true }),
1019                    3 => self.request_close(ctx),
1020                    4 => ctx.defer(DeferredAction::OpenConfigFile {
1021                        layer: self.target_layer,
1022                    }), // Edit config file
1023                    _ => {}
1024                }
1025                InputResult::Consumed
1026            }
1027            KeyCode::Esc => {
1028                self.request_close(ctx);
1029                InputResult::Consumed
1030            }
1031            KeyCode::Char('/') => {
1032                self.start_search();
1033                InputResult::Consumed
1034            }
1035            KeyCode::Char('?') => {
1036                self.toggle_help();
1037                InputResult::Consumed
1038            }
1039            _ => InputResult::Ignored, // Let modal catch it
1040        }
1041    }
1042
1043    /// Handle input when editing text in a control
1044    fn handle_text_editing_input(
1045        &mut self,
1046        event: &KeyEvent,
1047        ctx: &mut InputContext,
1048    ) -> InputResult {
1049        let is_json = self.is_editing_json();
1050
1051        if is_json {
1052            return self.handle_json_editing_input(event, ctx);
1053        }
1054
1055        // DualList has its own keyboard handling (no text input)
1056        if self.is_editing_dual_list() {
1057            return self.handle_dual_list_editing_input(event);
1058        }
1059
1060        match event.code {
1061            KeyCode::Esc => {
1062                // Check if current text field requires JSON validation
1063                if !self.can_exit_text_editing() {
1064                    return InputResult::Consumed;
1065                }
1066                self.stop_editing();
1067                InputResult::Consumed
1068            }
1069            KeyCode::Enter => {
1070                self.text_add_item();
1071                InputResult::Consumed
1072            }
1073            KeyCode::Char(c) => {
1074                self.text_insert(c);
1075                InputResult::Consumed
1076            }
1077            KeyCode::Backspace => {
1078                self.text_backspace();
1079                InputResult::Consumed
1080            }
1081            KeyCode::Delete => {
1082                self.text_remove_focused();
1083                InputResult::Consumed
1084            }
1085            KeyCode::Left => {
1086                self.text_move_left();
1087                InputResult::Consumed
1088            }
1089            KeyCode::Right => {
1090                self.text_move_right();
1091                InputResult::Consumed
1092            }
1093            KeyCode::Up => {
1094                self.text_focus_prev();
1095                InputResult::Consumed
1096            }
1097            KeyCode::Down => {
1098                self.text_focus_next();
1099                InputResult::Consumed
1100            }
1101            KeyCode::Tab => {
1102                // Tab exits text editing mode and advances focus to the next panel
1103                self.stop_editing();
1104                self.toggle_focus();
1105                InputResult::Consumed
1106            }
1107            _ => InputResult::Consumed, // Consume all during text edit
1108        }
1109    }
1110
1111    /// Handle input when editing a DualList control
1112    fn handle_dual_list_editing_input(&mut self, event: &KeyEvent) -> InputResult {
1113        use crate::view::controls::DualListColumn;
1114        let shift = event.modifiers.contains(KeyModifiers::SHIFT);
1115        match event.code {
1116            KeyCode::Esc => {
1117                self.stop_editing();
1118            }
1119            // Tab/BackTab propagate to the settings panel (exit editing)
1120            KeyCode::Tab | KeyCode::BackTab => {
1121                self.stop_editing();
1122                // Return Ignored so the settings panel handles Tab/BackTab
1123                return InputResult::Ignored;
1124            }
1125            KeyCode::Up if shift => {
1126                self.with_current_dual_list_mut(|dl| dl.move_up());
1127                self.on_value_changed();
1128            }
1129            KeyCode::Down if shift => {
1130                self.with_current_dual_list_mut(|dl| dl.move_down());
1131                self.on_value_changed();
1132            }
1133            KeyCode::Up => {
1134                self.with_current_dual_list_mut(|dl| dl.cursor_up());
1135            }
1136            KeyCode::Down => {
1137                self.with_current_dual_list_mut(|dl| dl.cursor_down());
1138            }
1139            KeyCode::Right if shift => {
1140                // Shift+Right: add selected available item to included, follow it
1141                let changed = self
1142                    .with_current_dual_list_mut(|dl| {
1143                        if dl.active_column == DualListColumn::Available {
1144                            dl.add_selected();
1145                            // Move focus to the Included column, cursor on the newly added item (last)
1146                            dl.active_column = DualListColumn::Included;
1147                            dl.included_cursor = dl.included.len().saturating_sub(1);
1148                            true
1149                        } else {
1150                            false
1151                        }
1152                    })
1153                    .unwrap_or(false);
1154                if changed {
1155                    self.on_value_changed();
1156                    self.refresh_dual_list_sibling();
1157                }
1158            }
1159            KeyCode::Left if shift => {
1160                // Shift+Left: remove selected included item back to available, follow it
1161                let changed = self
1162                    .with_current_dual_list_mut(|dl| {
1163                        if dl.active_column == DualListColumn::Included {
1164                            let value = dl.included.get(dl.included_cursor).cloned();
1165                            dl.remove_selected();
1166                            // Move focus to Available column, find the removed item
1167                            dl.active_column = DualListColumn::Available;
1168                            if let Some(val) = value {
1169                                let avail = dl.available_items();
1170                                if let Some(pos) = avail.iter().position(|(v, _)| *v == val) {
1171                                    dl.available_cursor = pos;
1172                                }
1173                            }
1174                            true
1175                        } else {
1176                            false
1177                        }
1178                    })
1179                    .unwrap_or(false);
1180                if changed {
1181                    self.on_value_changed();
1182                    self.refresh_dual_list_sibling();
1183                }
1184            }
1185            KeyCode::Right => {
1186                // Plain Right: switch to Included column
1187                self.with_current_dual_list_mut(|dl| {
1188                    dl.active_column = DualListColumn::Included;
1189                });
1190            }
1191            KeyCode::Left => {
1192                // Plain Left: switch to Available column
1193                self.with_current_dual_list_mut(|dl| {
1194                    dl.active_column = DualListColumn::Available;
1195                });
1196            }
1197            KeyCode::Enter => {
1198                // Enter adds/removes based on active column
1199                let changed = self
1200                    .with_current_dual_list_mut(|dl| match dl.active_column {
1201                        DualListColumn::Available => dl.add_selected(),
1202                        DualListColumn::Included => dl.remove_selected(),
1203                    })
1204                    .is_some();
1205                if changed {
1206                    self.on_value_changed();
1207                    self.refresh_dual_list_sibling();
1208                }
1209            }
1210            _ => {}
1211        }
1212        InputResult::Consumed
1213    }
1214
1215    /// Handle input when editing a JSON control (multiline editor)
1216    fn handle_json_editing_input(
1217        &mut self,
1218        event: &KeyEvent,
1219        ctx: &mut InputContext,
1220    ) -> InputResult {
1221        match event.code {
1222            KeyCode::Esc | KeyCode::Tab => {
1223                // Accept if valid JSON, revert if invalid, then stop editing
1224                self.json_exit_editing();
1225            }
1226            KeyCode::Enter => {
1227                self.json_insert_newline();
1228            }
1229            KeyCode::Char(c) => {
1230                if event.modifiers.contains(KeyModifiers::CONTROL) {
1231                    match c {
1232                        'a' | 'A' => self.json_select_all(),
1233                        'c' | 'C' => {
1234                            if let Some(text) = self.json_selected_text() {
1235                                ctx.defer(DeferredAction::CopyToClipboard(text));
1236                            }
1237                        }
1238                        'v' | 'V' => {
1239                            ctx.defer(DeferredAction::PasteToSettings);
1240                        }
1241                        _ => {}
1242                    }
1243                } else {
1244                    self.text_insert(c);
1245                }
1246            }
1247            KeyCode::Backspace => {
1248                self.text_backspace();
1249            }
1250            KeyCode::Delete => {
1251                self.json_delete();
1252            }
1253            KeyCode::Left => {
1254                if event.modifiers.contains(KeyModifiers::SHIFT) {
1255                    self.json_cursor_left_selecting();
1256                } else {
1257                    self.text_move_left();
1258                }
1259            }
1260            KeyCode::Right => {
1261                if event.modifiers.contains(KeyModifiers::SHIFT) {
1262                    self.json_cursor_right_selecting();
1263                } else {
1264                    self.text_move_right();
1265                }
1266            }
1267            KeyCode::Up => {
1268                if event.modifiers.contains(KeyModifiers::SHIFT) {
1269                    self.json_cursor_up_selecting();
1270                } else {
1271                    self.json_cursor_up();
1272                }
1273            }
1274            KeyCode::Down => {
1275                if event.modifiers.contains(KeyModifiers::SHIFT) {
1276                    self.json_cursor_down_selecting();
1277                } else {
1278                    self.json_cursor_down();
1279                }
1280            }
1281            _ => {}
1282        }
1283        InputResult::Consumed
1284    }
1285
1286    /// Handle input when editing a number input control
1287    fn handle_number_editing_input(
1288        &mut self,
1289        event: &KeyEvent,
1290        _ctx: &mut InputContext,
1291    ) -> InputResult {
1292        let ctrl = event.modifiers.contains(KeyModifiers::CONTROL);
1293        let shift = event.modifiers.contains(KeyModifiers::SHIFT);
1294
1295        match event.code {
1296            KeyCode::Esc => {
1297                self.number_cancel();
1298            }
1299            KeyCode::Enter => {
1300                self.number_confirm();
1301            }
1302            KeyCode::Tab | KeyCode::BackTab => {
1303                // Tab commits the pending edit and exits editing mode. We
1304                // don't move panel focus here so the user can continue to
1305                // tweak the same setting with Left/Right after Tab — matching
1306                // the muscle memory of "Tab to leave the input box."
1307                self.number_confirm();
1308            }
1309            KeyCode::Char('a') if ctrl => {
1310                self.number_select_all();
1311            }
1312            KeyCode::Char(c) => {
1313                self.number_insert(c);
1314            }
1315            KeyCode::Backspace if ctrl => {
1316                self.number_delete_word_backward();
1317            }
1318            KeyCode::Backspace => {
1319                self.number_backspace();
1320            }
1321            KeyCode::Delete if ctrl => {
1322                self.number_delete_word_forward();
1323            }
1324            KeyCode::Delete => {
1325                self.number_delete();
1326            }
1327            KeyCode::Left if ctrl && shift => {
1328                self.number_move_word_left_selecting();
1329            }
1330            KeyCode::Left if ctrl => {
1331                self.number_move_word_left();
1332            }
1333            KeyCode::Left if shift => {
1334                self.number_move_left_selecting();
1335            }
1336            KeyCode::Left => {
1337                self.number_move_left();
1338            }
1339            KeyCode::Right if ctrl && shift => {
1340                self.number_move_word_right_selecting();
1341            }
1342            KeyCode::Right if ctrl => {
1343                self.number_move_word_right();
1344            }
1345            KeyCode::Right if shift => {
1346                self.number_move_right_selecting();
1347            }
1348            KeyCode::Right => {
1349                self.number_move_right();
1350            }
1351            KeyCode::Home if shift => {
1352                self.number_move_home_selecting();
1353            }
1354            KeyCode::Home => {
1355                self.number_move_home();
1356            }
1357            KeyCode::End if shift => {
1358                self.number_move_end_selecting();
1359            }
1360            KeyCode::End => {
1361                self.number_move_end();
1362            }
1363            _ => {}
1364        }
1365        InputResult::Consumed // Consume all during number edit
1366    }
1367
1368    /// Handle input when dropdown is open
1369    fn handle_dropdown_input(&mut self, event: &KeyEvent, _ctx: &mut InputContext) -> InputResult {
1370        match event.code {
1371            KeyCode::Up => {
1372                self.dropdown_prev();
1373                InputResult::Consumed
1374            }
1375            KeyCode::Down => {
1376                self.dropdown_next();
1377                InputResult::Consumed
1378            }
1379            KeyCode::Home => {
1380                self.dropdown_home();
1381                InputResult::Consumed
1382            }
1383            KeyCode::End => {
1384                self.dropdown_end();
1385                InputResult::Consumed
1386            }
1387            KeyCode::Enter => {
1388                self.dropdown_confirm();
1389                InputResult::Consumed
1390            }
1391            KeyCode::Esc => {
1392                self.dropdown_cancel();
1393                InputResult::Consumed
1394            }
1395            _ => InputResult::Consumed, // Consume all while dropdown is open
1396        }
1397    }
1398
1399    /// Request to reset all changes (shows confirm dialog if there are changes)
1400    fn request_reset(&mut self) {
1401        if self.has_changes() {
1402            self.showing_reset_dialog = true;
1403            self.reset_dialog_selection = 0;
1404        }
1405    }
1406
1407    /// Request to close settings (shows confirm dialog if there are changes)
1408    fn request_close(&mut self, ctx: &mut InputContext) {
1409        if self.has_changes() {
1410            self.showing_confirm_dialog = true;
1411            self.confirm_dialog_selection = 0;
1412        } else {
1413            ctx.defer(DeferredAction::CloseSettings { save: false });
1414        }
1415    }
1416
1417    /// Handle control activation (Enter/Space on a setting)
1418    fn handle_control_activate(&mut self, _ctx: &mut InputContext) {
1419        if let Some(item) = self.current_item_mut() {
1420            match &mut item.control {
1421                SettingControl::Toggle(ref mut state) => {
1422                    state.checked = !state.checked;
1423                    self.on_value_changed();
1424                }
1425                SettingControl::Dropdown(_) => {
1426                    self.dropdown_toggle();
1427                }
1428                SettingControl::Number(_) => {
1429                    self.start_number_editing();
1430                }
1431                SettingControl::Text(_) => {
1432                    self.start_editing();
1433                }
1434                SettingControl::TextList(_) | SettingControl::DualList(_) => {
1435                    self.start_editing();
1436                }
1437                SettingControl::Map(ref mut state) => {
1438                    if state.focused_entry.is_none() {
1439                        // On add-new row: open dialog with empty key
1440                        if state.value_schema.is_some() {
1441                            self.open_add_entry_dialog();
1442                        }
1443                    } else if state.value_schema.is_some() {
1444                        // Has schema: open entry dialog
1445                        self.open_entry_dialog();
1446                    } else {
1447                        // Toggle expanded
1448                        if let Some(idx) = state.focused_entry {
1449                            if state.expanded.contains(&idx) {
1450                                state.expanded.retain(|&i| i != idx);
1451                            } else {
1452                                state.expanded.push(idx);
1453                            }
1454                        }
1455                    }
1456                    self.on_value_changed();
1457                }
1458                SettingControl::Json(_) => {
1459                    self.start_editing();
1460                }
1461                SettingControl::ObjectArray(ref state) => {
1462                    if state.focused_index.is_none() {
1463                        // On add-new row: open dialog with empty item
1464                        if state.item_schema.is_some() {
1465                            self.open_add_array_item_dialog();
1466                        }
1467                    } else if state.item_schema.is_some() {
1468                        // Has schema: open edit dialog
1469                        self.open_edit_array_item_dialog();
1470                    }
1471                }
1472                SettingControl::Complex { .. } => {
1473                    // Not editable via simple controls
1474                }
1475            }
1476        }
1477    }
1478}
1479
1480#[cfg(test)]
1481mod tests {
1482    use super::*;
1483    use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
1484
1485    fn key(code: KeyCode) -> KeyEvent {
1486        KeyEvent::new(code, KeyModifiers::NONE)
1487    }
1488
1489    #[test]
1490    fn test_settings_is_modal() {
1491        // SettingsState should be modal - consume all unhandled input
1492        let schema = include_str!("../../../plugins/config-schema.json");
1493        let config = crate::config::Config::default();
1494        let state = SettingsState::new(schema, &config).unwrap();
1495        assert!(state.is_modal());
1496    }
1497
1498    #[test]
1499    fn test_categories_panel_does_not_leak_to_settings() {
1500        let schema = include_str!("../../../plugins/config-schema.json");
1501        let config = crate::config::Config::default();
1502        let mut state = SettingsState::new(schema, &config).unwrap();
1503        state.visible = true;
1504        state.focus.set(FocusPanel::Categories);
1505
1506        let mut ctx = InputContext::new();
1507
1508        // Per the tree-view spec: only Tab switches panels. Enter,
1509        // Left, and Right are *no longer* shortcuts to move focus
1510        // out of the categories panel.
1511        // * Enter falls through (Ignored) — let the modal handle it.
1512        // * Right expands the focused category (no-op for non-
1513        //   expandable ones); does NOT move focus to Settings.
1514        // * Left collapses; same — does not switch panels.
1515        // * Tab is the only key that switches panels.
1516        let result = state.handle_key_event(&key(KeyCode::Enter), &mut ctx);
1517        assert_eq!(result, InputResult::Ignored);
1518        assert_eq!(state.focus_panel(), FocusPanel::Categories);
1519
1520        let result = state.handle_key_event(&key(KeyCode::Right), &mut ctx);
1521        assert_eq!(result, InputResult::Consumed);
1522        assert_eq!(state.focus_panel(), FocusPanel::Categories);
1523
1524        let result = state.handle_key_event(&key(KeyCode::Left), &mut ctx);
1525        assert_eq!(result, InputResult::Consumed);
1526        assert_eq!(state.focus_panel(), FocusPanel::Categories);
1527
1528        // Tab is the panel switcher.
1529        let result = state.handle_key_event(&key(KeyCode::Tab), &mut ctx);
1530        assert_eq!(result, InputResult::Consumed);
1531        assert_eq!(state.focus_panel(), FocusPanel::Settings);
1532    }
1533
1534    #[test]
1535    fn test_tab_cycles_focus_panels() {
1536        let schema = include_str!("../../../plugins/config-schema.json");
1537        let config = crate::config::Config::default();
1538        let mut state = SettingsState::new(schema, &config).unwrap();
1539        state.visible = true;
1540
1541        let mut ctx = InputContext::new();
1542
1543        // Start at Categories
1544        assert_eq!(state.focus_panel(), FocusPanel::Categories);
1545
1546        // Tab -> Settings
1547        state.handle_key_event(&key(KeyCode::Tab), &mut ctx);
1548        assert_eq!(state.focus_panel(), FocusPanel::Settings);
1549
1550        // Tab -> Footer (defaults to Layer button, index 0)
1551        state.handle_key_event(&key(KeyCode::Tab), &mut ctx);
1552        assert_eq!(state.focus_panel(), FocusPanel::Footer);
1553        assert_eq!(state.footer_button_index, 0);
1554
1555        // Tab through footer buttons: 0 -> 1 -> 2 -> 3 -> 4 -> wrap to Categories
1556        state.handle_key_event(&key(KeyCode::Tab), &mut ctx);
1557        assert_eq!(state.footer_button_index, 1);
1558        state.handle_key_event(&key(KeyCode::Tab), &mut ctx);
1559        assert_eq!(state.footer_button_index, 2);
1560        state.handle_key_event(&key(KeyCode::Tab), &mut ctx);
1561        assert_eq!(state.footer_button_index, 3);
1562        state.handle_key_event(&key(KeyCode::Tab), &mut ctx);
1563        assert_eq!(state.footer_button_index, 4); // Edit button
1564        state.handle_key_event(&key(KeyCode::Tab), &mut ctx);
1565        assert_eq!(state.focus_panel(), FocusPanel::Categories);
1566
1567        // SECOND LOOP: Tab again should still land on Layer button when entering Footer
1568        // Tab -> Settings
1569        state.handle_key_event(&key(KeyCode::Tab), &mut ctx);
1570        assert_eq!(state.focus_panel(), FocusPanel::Settings);
1571
1572        // Tab -> Footer (should reset to Layer button, not stay on Edit)
1573        state.handle_key_event(&key(KeyCode::Tab), &mut ctx);
1574        assert_eq!(state.focus_panel(), FocusPanel::Footer);
1575        assert_eq!(
1576            state.footer_button_index, 0,
1577            "Footer should reset to Layer button (index 0) on second loop"
1578        );
1579    }
1580
1581    #[test]
1582    fn test_escape_shows_confirm_dialog_with_changes() {
1583        let schema = include_str!("../../../plugins/config-schema.json");
1584        let config = crate::config::Config::default();
1585        let mut state = SettingsState::new(schema, &config).unwrap();
1586        state.visible = true;
1587
1588        // Simulate a change
1589        state
1590            .pending_changes
1591            .insert("/test".to_string(), serde_json::json!(true));
1592
1593        let mut ctx = InputContext::new();
1594
1595        // Escape should show confirm dialog, not close directly
1596        state.handle_key_event(&key(KeyCode::Esc), &mut ctx);
1597        assert!(state.showing_confirm_dialog);
1598        assert!(ctx.deferred_actions.is_empty()); // No close action yet
1599    }
1600
1601    #[test]
1602    fn test_escape_closes_directly_without_changes() {
1603        let schema = include_str!("../../../plugins/config-schema.json");
1604        let config = crate::config::Config::default();
1605        let mut state = SettingsState::new(schema, &config).unwrap();
1606        state.visible = true;
1607
1608        let mut ctx = InputContext::new();
1609
1610        // Escape without changes should defer close action
1611        state.handle_key_event(&key(KeyCode::Esc), &mut ctx);
1612        assert!(!state.showing_confirm_dialog);
1613        assert_eq!(ctx.deferred_actions.len(), 1);
1614        assert!(matches!(
1615            ctx.deferred_actions[0],
1616            DeferredAction::CloseSettings { save: false }
1617        ));
1618    }
1619
1620    #[test]
1621    fn test_confirm_dialog_navigation() {
1622        let schema = include_str!("../../../plugins/config-schema.json");
1623        let config = crate::config::Config::default();
1624        let mut state = SettingsState::new(schema, &config).unwrap();
1625        state.visible = true;
1626        state.showing_confirm_dialog = true;
1627        state.confirm_dialog_selection = 0; // Save
1628
1629        let mut ctx = InputContext::new();
1630
1631        // Right -> Discard
1632        state.handle_key_event(&key(KeyCode::Right), &mut ctx);
1633        assert_eq!(state.confirm_dialog_selection, 1);
1634
1635        // Right -> Cancel
1636        state.handle_key_event(&key(KeyCode::Right), &mut ctx);
1637        assert_eq!(state.confirm_dialog_selection, 2);
1638
1639        // Right again -> stays at Cancel (no wrap)
1640        state.handle_key_event(&key(KeyCode::Right), &mut ctx);
1641        assert_eq!(state.confirm_dialog_selection, 2);
1642
1643        // Left -> Discard
1644        state.handle_key_event(&key(KeyCode::Left), &mut ctx);
1645        assert_eq!(state.confirm_dialog_selection, 1);
1646    }
1647
1648    #[test]
1649    fn test_search_mode_captures_typing() {
1650        let schema = include_str!("../../../plugins/config-schema.json");
1651        let config = crate::config::Config::default();
1652        let mut state = SettingsState::new(schema, &config).unwrap();
1653        state.visible = true;
1654
1655        let mut ctx = InputContext::new();
1656
1657        // Start search
1658        state.handle_key_event(&key(KeyCode::Char('/')), &mut ctx);
1659        assert!(state.search_active);
1660
1661        // Type search query
1662        state.handle_key_event(&key(KeyCode::Char('t')), &mut ctx);
1663        state.handle_key_event(&key(KeyCode::Char('a')), &mut ctx);
1664        state.handle_key_event(&key(KeyCode::Char('b')), &mut ctx);
1665        assert_eq!(state.search_query, "tab");
1666
1667        // Escape cancels search
1668        state.handle_key_event(&key(KeyCode::Esc), &mut ctx);
1669        assert!(!state.search_active);
1670        assert!(state.search_query.is_empty());
1671    }
1672
1673    #[test]
1674    fn test_footer_button_activation() {
1675        let schema = include_str!("../../../plugins/config-schema.json");
1676        let config = crate::config::Config::default();
1677        let mut state = SettingsState::new(schema, &config).unwrap();
1678        state.visible = true;
1679        state.focus.set(FocusPanel::Footer);
1680        state.footer_button_index = 2; // Save button (0=Layer, 1=Reset, 2=Save, 3=Cancel)
1681
1682        let mut ctx = InputContext::new();
1683
1684        // Enter on Save button should defer save action
1685        state.handle_key_event(&key(KeyCode::Enter), &mut ctx);
1686        assert_eq!(ctx.deferred_actions.len(), 1);
1687        assert!(matches!(
1688            ctx.deferred_actions[0],
1689            DeferredAction::CloseSettings { save: true }
1690        ));
1691    }
1692
1693    /// Reproducer for issue #1825: Tab while editing a Number control was a
1694    /// no-op, leaving the user "stuck" in the input. Tab should commit the
1695    /// pending edit and exit number-editing mode (matching the Text-control
1696    /// behavior).
1697    #[test]
1698    fn test_tab_exits_number_editing() {
1699        use crate::view::settings::items::SettingControl;
1700
1701        let schema = include_str!("../../../plugins/config-schema.json");
1702        let config = crate::config::Config::default();
1703        let mut state = SettingsState::new(schema, &config).unwrap();
1704        state.visible = true;
1705        state.focus.set(FocusPanel::Settings);
1706
1707        // Find a number setting (any will do)
1708        let number_idx = state
1709            .pages
1710            .get(state.selected_category)
1711            .and_then(|page| {
1712                page.items
1713                    .iter()
1714                    .position(|item| matches!(item.control, SettingControl::Number(_)))
1715            })
1716            .expect("expected at least one Number control on the default page");
1717        state.selected_item = number_idx;
1718
1719        // Enter number editing mode and type a digit so we have a pending edit
1720        state.start_number_editing();
1721        assert!(
1722            state.is_number_editing(),
1723            "precondition: should be in number-editing mode"
1724        );
1725        state.number_insert('7');
1726
1727        let mut ctx = InputContext::new();
1728
1729        // Tab should exit editing mode (currently fails: Tab is unhandled)
1730        state.handle_key_event(&key(KeyCode::Tab), &mut ctx);
1731        assert!(
1732            !state.is_number_editing(),
1733            "Tab while editing a Number control must exit editing mode"
1734        );
1735    }
1736}