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