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