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