Skip to main content

fresh/view/settings/
state.rs

1//! Settings state management
2//!
3//! Tracks the current state of the settings UI, pending changes,
4//! and provides methods for reading/writing config values.
5
6use super::entry_dialog::EntryDialogState;
7use super::items::{control_to_value, SettingControl, SettingItem, SettingsPage};
8use super::layout::SettingsHit;
9use super::schema::{parse_schema, SettingCategory, SettingSchema};
10use super::search::{search_settings, DeepMatch, SearchResult};
11use crate::config::Config;
12use crate::config_io::ConfigLayer;
13use crate::view::controls::FocusState;
14use crate::view::ui::{FocusManager, ScrollItem, ScrollablePanel};
15use std::collections::HashMap;
16
17/// Info needed to open a nested dialog (extracted before mutable borrow)
18enum NestedDialogInfo {
19    MapEntry {
20        key: String,
21        value: serde_json::Value,
22        schema: SettingSchema,
23        path: String,
24        is_new: bool,
25        no_delete: bool,
26    },
27    ArrayItem {
28        index: Option<usize>,
29        value: serde_json::Value,
30        schema: SettingSchema,
31        path: String,
32        is_new: bool,
33    },
34}
35
36/// Which panel currently has keyboard focus
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
38pub enum FocusPanel {
39    /// Category list (left panel)
40    #[default]
41    Categories,
42    /// Settings items (right panel)
43    Settings,
44    /// Footer buttons (Reset/Save/Cancel)
45    Footer,
46}
47
48/// The state of the settings UI
49#[derive(Debug)]
50pub struct SettingsState {
51    /// Parsed schema categories
52    categories: Vec<SettingCategory>,
53    /// Pages built from categories
54    pub pages: Vec<SettingsPage>,
55    /// Currently selected category index
56    pub selected_category: usize,
57    /// Currently selected item index within the category
58    pub selected_item: usize,
59    /// Which panel currently has keyboard focus
60    pub focus: FocusManager<FocusPanel>,
61    /// Selected footer button index (0=Reset, 1=Save, 2=Cancel)
62    pub footer_button_index: usize,
63    /// Pending changes (path -> new value)
64    pub pending_changes: HashMap<String, serde_json::Value>,
65    /// The original config value (for detecting changes)
66    original_config: serde_json::Value,
67    /// Whether the settings panel is visible
68    pub visible: bool,
69    /// Current search query
70    pub search_query: String,
71    /// Whether search is active
72    pub search_active: bool,
73    /// Current search results
74    pub search_results: Vec<SearchResult>,
75    /// Selected search result index
76    pub selected_search_result: usize,
77    /// Scroll offset for search results (first visible result index)
78    pub search_scroll_offset: usize,
79    /// Maximum number of visible search results (set during render)
80    pub search_max_visible: usize,
81    /// Whether the unsaved changes confirmation dialog is showing
82    pub showing_confirm_dialog: bool,
83    /// Selected option in confirmation dialog (0=Save, 1=Discard, 2=Cancel)
84    pub confirm_dialog_selection: usize,
85    /// Hovered option in confirmation dialog (for mouse hover feedback)
86    pub confirm_dialog_hover: Option<usize>,
87    /// Whether the reset confirmation dialog is showing
88    pub showing_reset_dialog: bool,
89    /// Selected option in reset dialog (0=Reset, 1=Cancel)
90    pub reset_dialog_selection: usize,
91    /// Hovered option in reset dialog (for mouse hover feedback)
92    pub reset_dialog_hover: Option<usize>,
93    /// Whether the help overlay is showing
94    pub showing_help: bool,
95    /// Scrollable panel for settings items
96    pub scroll_panel: ScrollablePanel,
97    /// Sub-focus index within the selected item (for TextList/Map navigation)
98    pub sub_focus: Option<usize>,
99    /// Whether we're in text editing mode (for TextList controls)
100    pub editing_text: bool,
101    /// Current mouse hover position (for hover feedback)
102    pub hover_position: Option<(u16, u16)>,
103    /// Current hover hit result (computed from hover_position and cached layout)
104    pub hover_hit: Option<SettingsHit>,
105    /// Stack of entry dialogs (for nested editing of Maps/ObjectArrays)
106    /// The top of the stack (last element) is the currently active dialog.
107    pub entry_dialog_stack: Vec<EntryDialogState>,
108    /// Which configuration layer to save changes to.
109    /// User layer is the default (global settings).
110    /// Project layer saves to the current project's .fresh/config.json.
111    pub target_layer: ConfigLayer,
112    /// Source layer for each setting path (where the value came from).
113    /// Maps JSON pointer paths (e.g., "/editor/tab_size") to their source layer.
114    /// Values not in this map come from system defaults.
115    pub layer_sources: HashMap<String, ConfigLayer>,
116    /// Paths to be removed from the current layer on save.
117    /// When a user "resets" a setting, we remove it from the delta rather than
118    /// setting it to the schema default.
119    pub pending_deletions: std::collections::HashSet<String>,
120    /// Last known layout width for the body panel. Set during render so input
121    /// handlers (which run between renders) can recompute scroll math without
122    /// access to the frame area.
123    pub layout_width: u16,
124    /// Visual style applied to every item in this state. Toggle with
125    /// [`Self::set_item_style`] to swap between card / flat presentation.
126    pub item_style: super::items::ItemBoxStyle,
127    /// Categories whose sections are currently expanded in the left-panel
128    /// tree view. Only categories with `sections.len() > 1` are eligible —
129    /// a category with zero or one section stays flat.
130    pub expanded_categories: std::collections::HashSet<usize>,
131    /// Scroll state for the categories panel itself, separate from the body
132    /// panel's `scroll_panel`. Drives mouse-wheel + page-up/down on the left.
133    pub categories_scroll: ScrollablePanel,
134    /// Cursor position inside the currently-selected category's tree row.
135    /// `None` = cursor is on the category row itself (the category row
136    /// shows the `>` indicator).
137    /// `Some(s)` = cursor is on the s-th section row of the current
138    /// category (that section row shows the `>` indicator).
139    ///
140    /// Tracked explicitly so it is independent of the body's scroll
141    /// position — pressing Right to expand a category keeps the cursor
142    /// on the category, pressing Down then walks into the sections,
143    /// and scrolling the body updates this so Up/Down resumes from the
144    /// section the user is actually looking at.
145    pub tree_cursor_section: Option<usize>,
146}
147
148/// One row of the left-panel tree. Either a top-level category, or a section
149/// row that appears under an expanded category.
150///
151/// Sections only appear when their owning category is in
152/// `expanded_categories` AND has more than one section — single-section
153/// categories show their items flat without a tree node.
154#[derive(Debug, Clone, Copy)]
155pub enum TreeRow {
156    Category {
157        idx: usize,
158        expandable: bool,
159        expanded: bool,
160    },
161    Section {
162        cat_idx: usize,
163        section_idx: usize,
164    },
165}
166
167impl crate::view::ui::ScrollItem for TreeRow {
168    fn height(&self, _width: u16) -> u16 {
169        1
170    }
171}
172
173impl SettingsState {
174    /// Create a new settings state from schema and current config
175    pub fn new(schema_json: &str, config: &Config) -> Result<Self, serde_json::Error> {
176        let categories = parse_schema(schema_json)?;
177        let config_value = serde_json::to_value(config)?;
178        let layer_sources = HashMap::new(); // Populated via set_layer_sources()
179        let target_layer = ConfigLayer::User; // Default to user-global settings
180        let pages =
181            super::items::build_pages(&categories, &config_value, &layer_sources, target_layer);
182
183        Ok(Self {
184            categories,
185            pages,
186            selected_category: 0,
187            selected_item: 0,
188            focus: FocusManager::new(vec![
189                FocusPanel::Categories,
190                FocusPanel::Settings,
191                FocusPanel::Footer,
192            ]),
193            footer_button_index: 2, // Default to Save button (0=Layer, 1=Reset, 2=Save, 3=Cancel)
194            pending_changes: HashMap::new(),
195            original_config: config_value,
196            visible: false,
197            search_query: String::new(),
198            search_active: false,
199            search_results: Vec::new(),
200            selected_search_result: 0,
201            search_scroll_offset: 0,
202            search_max_visible: 5, // Default, updated during render
203            showing_confirm_dialog: false,
204            confirm_dialog_selection: 0,
205            confirm_dialog_hover: None,
206            showing_reset_dialog: false,
207            reset_dialog_selection: 0,
208            reset_dialog_hover: None,
209            showing_help: false,
210            scroll_panel: ScrollablePanel::new(),
211            sub_focus: None,
212            editing_text: false,
213            hover_position: None,
214            hover_hit: None,
215            entry_dialog_stack: Vec::new(),
216            target_layer,
217            layer_sources,
218            pending_deletions: std::collections::HashSet::new(),
219            layout_width: 0,
220            item_style: super::items::ItemBoxStyle::default(),
221            expanded_categories: std::collections::HashSet::new(),
222            categories_scroll: ScrollablePanel::new(),
223            tree_cursor_section: None,
224        })
225    }
226
227    /// Get the currently focused panel
228    #[inline]
229    pub fn focus_panel(&self) -> FocusPanel {
230        self.focus.current().unwrap_or_default()
231    }
232
233    /// Show the settings panel
234    pub fn show(&mut self) {
235        self.visible = true;
236        self.focus.set(FocusPanel::Categories);
237        self.footer_button_index = 2; // Default to Save button (0=Layer, 1=Reset, 2=Save, 3=Cancel)
238        self.selected_category = 0;
239        self.selected_item = 0;
240        self.scroll_panel = ScrollablePanel::new();
241        self.sub_focus = None;
242        // Reset all dialog states so re-opening settings starts clean
243        self.showing_confirm_dialog = false;
244        self.confirm_dialog_selection = 0;
245        self.confirm_dialog_hover = None;
246        self.showing_reset_dialog = false;
247        self.reset_dialog_selection = 0;
248        self.reset_dialog_hover = None;
249        self.showing_help = false;
250    }
251
252    /// Hide the settings panel
253    pub fn hide(&mut self) {
254        self.visible = false;
255        self.search_active = false;
256        self.search_query.clear();
257    }
258
259    /// Get the current entry dialog (top of stack), if any
260    pub fn entry_dialog(&self) -> Option<&EntryDialogState> {
261        self.entry_dialog_stack.last()
262    }
263
264    /// Get the current entry dialog mutably (top of stack), if any
265    pub fn entry_dialog_mut(&mut self) -> Option<&mut EntryDialogState> {
266        self.entry_dialog_stack.last_mut()
267    }
268
269    /// Check if any entry dialog is open
270    pub fn has_entry_dialog(&self) -> bool {
271        !self.entry_dialog_stack.is_empty()
272    }
273
274    /// Get the currently selected page
275    pub fn current_page(&self) -> Option<&SettingsPage> {
276        self.pages.get(self.selected_category)
277    }
278
279    /// Get the currently selected page mutably
280    pub fn current_page_mut(&mut self) -> Option<&mut SettingsPage> {
281        self.pages.get_mut(self.selected_category)
282    }
283
284    /// Index of the item currently sitting at the top of the body
285    /// viewport, computed from the scroll offset and per-item heights. The
286    /// left-panel section indicator follows this so scrolling visibly moves
287    /// the highlight in the tree, not just keyboard navigation.
288    pub fn topmost_visible_item_index(&self) -> Option<usize> {
289        let page = self.pages.get(self.selected_category)?;
290        if page.items.is_empty() {
291            return None;
292        }
293        let target = self.scroll_panel.scroll.offset;
294        let width = self.layout_width;
295        let mut y: u16 = 0;
296        for (idx, item) in page.items.iter().enumerate() {
297            let h = <SettingItem as ScrollItem>::height(item, width);
298            if y + h > target {
299                return Some(idx);
300            }
301            y += h;
302        }
303        Some(page.items.len() - 1)
304    }
305
306    /// Section currently displayed in the body — the section whose item
307    /// range contains either the focused item or the topmost visible item
308    /// (whichever is later). Returns `None` when the page has no sections
309    /// or when the cursor is above the first section.
310    pub fn current_section_index(&self) -> Option<usize> {
311        let page = self.pages.get(self.selected_category)?;
312        if page.sections.is_empty() {
313            return None;
314        }
315        // Drive the section indicator off the topmost visible item — i.e.
316        // strictly what the user is looking at right now, regardless of
317        // where their last click landed. Earlier code did
318        // `topmost.max(selected_item)`, which clamped the indicator so
319        // wheel-UP after a click couldn't move the highlight back to an
320        // earlier section. Falling back to selected_item only when the
321        // body is genuinely empty (no items, no viewport) gives the
322        // expected "tree follows scroll, both directions" behavior.
323        let item_idx = self
324            .topmost_visible_item_index()
325            .unwrap_or(self.selected_item);
326        // Walk sections in order and pick the last one whose first_item_index <= item_idx.
327        let mut current: Option<usize> = None;
328        for (s_idx, section) in page.sections.iter().enumerate() {
329            if section.first_item_index <= item_idx {
330                current = Some(s_idx);
331            } else {
332                break;
333            }
334        }
335        current
336    }
337
338    /// Whether a category should render with a chevron + be expandable in
339    /// the tree view. We require strictly more than one section, since one
340    /// section adds no information beyond the category itself.
341    pub fn is_category_expandable(&self, cat_idx: usize) -> bool {
342        self.pages
343            .get(cat_idx)
344            .is_some_and(|p| p.sections.len() > 1)
345    }
346
347    /// Move the cursor in the categories tree by `delta` rows (positive =
348    /// down, negative = up). The cursor walks every visible row — both
349    /// category rows and the section rows under any expanded category — so
350    /// users can step into discovered sections without leaving the keyboard.
351    ///
352    /// Maps the new row to state:
353    /// * Category row → `selected_category = idx`, `selected_item = 0`.
354    /// * Section row → category + first item of that section (same effect
355    ///   as clicking the section).
356    pub fn tree_step(&mut self, delta: i32) {
357        let rows = self.visible_tree();
358        if rows.is_empty() {
359            return;
360        }
361        let cur = self.tree_cursor_index(&rows);
362        let len = rows.len() as i32;
363        let target = (cur as i32 + delta).clamp(0, len - 1) as usize;
364        if target == cur {
365            return;
366        }
367        let prev_category = self.selected_category;
368        self.update_control_focus(false);
369        match rows[target] {
370            TreeRow::Category { idx, .. } => {
371                // Cursor on a category row: body shows the page's first
372                // item but the tree highlight stays on the category
373                // header (no section is "current" yet).
374                self.selected_category = idx;
375                self.selected_item = 0;
376                self.tree_cursor_section = None;
377                if idx != prev_category {
378                    self.scroll_panel = ScrollablePanel::new();
379                }
380                self.sub_focus = None;
381                self.update_control_focus(true);
382            }
383            TreeRow::Section {
384                cat_idx,
385                section_idx,
386            } => {
387                let first = self.pages[cat_idx].sections[section_idx].first_item_index;
388                self.selected_category = cat_idx;
389                self.selected_item = first;
390                self.tree_cursor_section = Some(section_idx);
391                if cat_idx != prev_category {
392                    self.scroll_panel = ScrollablePanel::new();
393                }
394                self.sub_focus = None;
395                self.init_map_focus(true);
396                self.update_control_focus(true);
397            }
398        }
399        // We deliberately do NOT auto-expand here: Up/Down sequential
400        // navigation should walk through categories without unfolding
401        // each one as you pass over it — that would balloon the tree
402        // every time the user holds Down. Auto-expand fires on
403        // deliberate visits (click, search-jump, Enter on a section).
404        let width = self.layout_width;
405        if let Some(page) = self.pages.get(self.selected_category) {
406            self.scroll_panel.update_content_height(&page.items, width);
407            // When the cursor lands on a section, snap the body's scroll
408            // to that section's first item — same UX as clicking a
409            // section in the tree. Without this, `ensure_focused_visible`
410            // would only scroll *just enough* for the item to be in
411            // view, leaving `topmost_visible_item_index` pointing at an
412            // earlier section and making the cursor visually "stick" on
413            // the previous section row.
414            if matches!(rows[target], TreeRow::Section { .. }) {
415                let item_y =
416                    self.scroll_panel
417                        .item_y_offset(&page.items, self.selected_item, width);
418                self.scroll_panel.scroll.offset = item_y;
419            } else {
420                let selected_item = self.selected_item;
421                let sub_focus = self.sub_focus;
422                self.scroll_panel.ensure_focused_visible(
423                    &page.items,
424                    selected_item,
425                    sub_focus,
426                    width,
427                );
428            }
429        }
430        let new_rows = self.visible_tree();
431        let new_cur = self.tree_cursor_index(&new_rows);
432        self.categories_scroll
433            .ensure_focused_visible(&new_rows, new_cur, None, width);
434    }
435
436    /// Find the visible-tree index for the current selection. Prefers the
437    /// section row matching the explicit `tree_cursor_section` cursor
438    /// (when set), or the category row otherwise. The cursor is
439    /// independent of body scroll position, so pressing Right to expand
440    /// a category does NOT make the cursor jump to the first section.
441    /// `Up`/`Down` walks the visible rows linearly; clicks and section
442    /// jumps update `tree_cursor_section` directly.
443    pub(super) fn tree_cursor_index(&self, rows: &[TreeRow]) -> usize {
444        let cat = self.selected_category;
445        if let Some(s_idx) = self.tree_cursor_section {
446            for (i, row) in rows.iter().enumerate() {
447                if let TreeRow::Section {
448                    cat_idx,
449                    section_idx,
450                } = *row
451                {
452                    if cat_idx == cat && section_idx == s_idx {
453                        return i;
454                    }
455                }
456            }
457        }
458        for (i, row) in rows.iter().enumerate() {
459            if let TreeRow::Category { idx, .. } = *row {
460                if idx == cat {
461                    return i;
462                }
463            }
464        }
465        0
466    }
467
468    /// Toggle whether a category is expanded in the tree view. No-op for
469    /// categories that aren't expandable (zero or one section).
470    /// Ensure the currently selected category is expanded in the tree view
471    /// when it has more than one section. Called on any path that "visits"
472    /// a category (Up/Down to it, click, search-jump) so the user
473    /// immediately sees that the category contains sections — they don't
474    /// have to remember to press Right.
475    ///
476    /// No-op for non-expandable categories (≤ 1 section). Idempotent.
477    pub fn auto_expand_current_category(&mut self) {
478        let idx = self.selected_category;
479        if self.is_category_expandable(idx) {
480            self.expanded_categories.insert(idx);
481        }
482    }
483
484    pub fn toggle_category_expanded(&mut self, cat_idx: usize) {
485        if !self.is_category_expandable(cat_idx) {
486            return;
487        }
488        if !self.expanded_categories.insert(cat_idx) {
489            self.expanded_categories.remove(&cat_idx);
490        }
491    }
492
493    /// Jump the body panel to a specific section within a category. The
494    /// category becomes the selected category, and the body's selected_item
495    /// jumps to the section's first item.
496    pub fn jump_to_section(&mut self, cat_idx: usize, section_idx: usize) {
497        let Some(page) = self.pages.get(cat_idx) else {
498            return;
499        };
500        let Some(section) = page.sections.get(section_idx) else {
501            return;
502        };
503        let target_item = section.first_item_index;
504        self.update_control_focus(false);
505        self.selected_category = cat_idx;
506        self.selected_item = target_item;
507        self.tree_cursor_section = Some(section_idx);
508        self.focus.set(FocusPanel::Settings);
509        let width = self.layout_width;
510        if let Some(page) = self.pages.get(self.selected_category) {
511            self.scroll_panel.update_content_height(&page.items, width);
512            // Snap the body to the top of the section. `ensure_visible`
513            // would only scroll *just enough* to bring the target item
514            // into view, which puts it at the bottom of the viewport
515            // (and on tight viewports clips its body below the footer);
516            // jumping to a section instead means "show this section at
517            // the top".
518            let item_y = self
519                .scroll_panel
520                .item_y_offset(&page.items, target_item, width);
521            self.scroll_panel.scroll.offset = item_y;
522        }
523        self.sub_focus = None;
524        self.init_map_focus(true);
525        self.update_control_focus(true);
526        self.auto_expand_current_category();
527    }
528
529    /// Flatten the categories list + currently expanded sections into the
530    /// row order rendered in the left panel. Single source of truth for
531    /// rendering, hit-testing, and Up/Down navigation in the tree.
532    pub fn visible_tree(&self) -> Vec<TreeRow> {
533        let mut rows = Vec::with_capacity(self.pages.len());
534        for (idx, page) in self.pages.iter().enumerate() {
535            let expandable = page.sections.len() > 1;
536            let expanded = expandable && self.expanded_categories.contains(&idx);
537            rows.push(TreeRow::Category {
538                idx,
539                expandable,
540                expanded,
541            });
542            if expanded {
543                for section_idx in 0..page.sections.len() {
544                    rows.push(TreeRow::Section {
545                        cat_idx: idx,
546                        section_idx,
547                    });
548                }
549            }
550        }
551        rows
552    }
553
554    /// Get the currently selected item
555    pub fn current_item(&self) -> Option<&SettingItem> {
556        self.current_page()
557            .and_then(|page| page.items.get(self.selected_item))
558    }
559
560    /// Get the currently selected item mutably
561    pub fn current_item_mut(&mut self) -> Option<&mut SettingItem> {
562        self.pages
563            .get_mut(self.selected_category)
564            .and_then(|page| page.items.get_mut(self.selected_item))
565    }
566
567    /// Check if the current text field can be exited (valid JSON if required)
568    pub fn can_exit_text_editing(&self) -> bool {
569        self.current_item()
570            .map(|item| {
571                if let SettingControl::Text(state) = &item.control {
572                    state.is_valid()
573                } else {
574                    true
575                }
576            })
577            .unwrap_or(true)
578    }
579
580    /// Check if entry dialog's current text field can be exited (valid JSON if required)
581    pub fn entry_dialog_can_exit_text_editing(&self) -> bool {
582        self.entry_dialog()
583            .and_then(|dialog| dialog.current_item())
584            .map(|item| {
585                if let SettingControl::Text(state) = &item.control {
586                    state.is_valid()
587                } else {
588                    true
589                }
590            })
591            .unwrap_or(true)
592    }
593
594    /// Initialize map focus when entering a Map control.
595    /// `from_above`: true = start at first entry, false = start at add-new field
596    fn init_map_focus(&mut self, from_above: bool) {
597        if let Some(item) = self.current_item_mut() {
598            if let SettingControl::Map(ref mut map_state) = item.control {
599                map_state.init_focus(from_above);
600            }
601        }
602        // Update sub_focus to match the map's focus position
603        self.update_map_sub_focus();
604    }
605
606    /// Update the focus state of the current item's control.
607    /// This should be called when selection changes to ensure the control
608    /// knows whether it's focused (for proper "[Enter to edit]" hints, etc.)
609    pub(super) fn update_control_focus(&mut self, focused: bool) {
610        let focus_state = if focused {
611            FocusState::Focused
612        } else {
613            FocusState::Normal
614        };
615        if let Some(item) = self.current_item_mut() {
616            match &mut item.control {
617                SettingControl::Map(ref mut state) => state.focus = focus_state,
618                SettingControl::TextList(ref mut state) => state.focus = focus_state,
619                SettingControl::DualList(ref mut state) => state.focus = focus_state,
620                SettingControl::ObjectArray(ref mut state) => state.focus = focus_state,
621                SettingControl::Toggle(ref mut state) => state.focus = focus_state,
622                SettingControl::Number(ref mut state) => state.focus = focus_state,
623                SettingControl::Dropdown(ref mut state) => state.focus = focus_state,
624                SettingControl::Text(ref mut state) => {
625                    state.focus = focus_state;
626                    // Leaving a text input via navigation also exits
627                    // edit mode, so the cursor never lingers on a row
628                    // the user is no longer looking at.
629                    if !focused {
630                        state.editing = false;
631                    }
632                }
633                SettingControl::Json(_) | SettingControl::Complex { .. } => {} // These don't have focus state
634            }
635        }
636    }
637
638    /// Update sub_focus based on the current Map control's focus position.
639    /// Maps focus_regions use: id=0 for label, id=1+i for entry i, id=1+len for add-new
640    fn update_map_sub_focus(&mut self) {
641        self.sub_focus = self.current_item().and_then(|item| {
642            if let SettingControl::Map(ref map_state) = item.control {
643                // Map focus_regions: id=0 (label), id=1+i (entry), id=1+len (add-new)
644                Some(match map_state.focused_entry {
645                    Some(i) => 1 + i,
646                    None => 1 + map_state.entries.len(), // add-new field
647                })
648            } else {
649                None
650            }
651        });
652    }
653
654    /// Move selection up
655    pub fn select_prev(&mut self) {
656        match self.focus_panel() {
657            FocusPanel::Categories => {
658                self.tree_step(-1);
659            }
660            FocusPanel::Settings => {
661                // Try to navigate within current Map control first
662                let handled = self
663                    .current_item_mut()
664                    .and_then(|item| match &mut item.control {
665                        SettingControl::Map(map_state) => Some(map_state.focus_prev()),
666                        _ => None,
667                    })
668                    .unwrap_or(false);
669
670                if handled {
671                    // Update sub_focus for Map navigation
672                    self.update_map_sub_focus();
673                } else if self.selected_item > 0 {
674                    self.update_control_focus(false); // Unfocus old item
675                    self.selected_item -= 1;
676                    self.sub_focus = None;
677                    self.init_map_focus(false); // entering from below
678                    self.update_control_focus(true); // Focus new item
679                }
680                self.ensure_visible();
681            }
682            FocusPanel::Footer => {
683                // Navigate between footer buttons (left)
684                if self.footer_button_index > 0 {
685                    self.footer_button_index -= 1;
686                }
687            }
688        }
689    }
690
691    /// Move selection down
692    pub fn select_next(&mut self) {
693        match self.focus_panel() {
694            FocusPanel::Categories => {
695                self.tree_step(1);
696            }
697            FocusPanel::Settings => {
698                // Try to navigate within current Map control first
699                let handled = self
700                    .current_item_mut()
701                    .and_then(|item| match &mut item.control {
702                        SettingControl::Map(map_state) => Some(map_state.focus_next()),
703                        _ => None,
704                    })
705                    .unwrap_or(false);
706
707                if handled {
708                    // Update sub_focus for Map navigation
709                    self.update_map_sub_focus();
710                } else {
711                    let can_move = self
712                        .current_page()
713                        .is_some_and(|page| self.selected_item + 1 < page.items.len());
714                    if can_move {
715                        self.update_control_focus(false); // Unfocus old item
716                        self.selected_item += 1;
717                        self.sub_focus = None;
718                        self.init_map_focus(true); // entering from above
719                        self.update_control_focus(true); // Focus new item
720                    }
721                }
722                self.ensure_visible();
723            }
724            FocusPanel::Footer => {
725                // Navigate between footer buttons (right)
726                if self.footer_button_index < 2 {
727                    self.footer_button_index += 1;
728                }
729            }
730        }
731    }
732
733    /// Move selection down by a page (viewport height worth of items)
734    pub fn select_next_page(&mut self) {
735        let page_size = self.scroll_panel.viewport_height().max(1);
736        for _ in 0..page_size {
737            self.select_next();
738        }
739    }
740
741    /// Move selection up by a page (viewport height worth of items)
742    pub fn select_prev_page(&mut self) {
743        let page_size = self.scroll_panel.viewport_height().max(1);
744        for _ in 0..page_size {
745            self.select_prev();
746        }
747    }
748
749    /// Switch focus between panels: Categories -> Settings -> Footer -> Categories
750    pub fn toggle_focus(&mut self) {
751        let old_panel = self.focus_panel();
752        self.focus.focus_next();
753        self.on_panel_changed(old_panel, true);
754    }
755
756    /// Switch focus to the previous panel: Categories <- Settings <- Footer <- Categories
757    pub fn toggle_focus_backward(&mut self) {
758        let old_panel = self.focus_panel();
759        self.focus.focus_prev();
760        self.on_panel_changed(old_panel, false);
761    }
762
763    /// Common logic after panel focus changes
764    fn on_panel_changed(&mut self, old_panel: FocusPanel, forward: bool) {
765        // Unfocus control when leaving Settings panel
766        if old_panel == FocusPanel::Settings {
767            self.update_control_focus(false);
768        }
769
770        // Reset item selection when switching to settings
771        if self.focus_panel() == FocusPanel::Settings
772            && self.selected_item >= self.current_page().map_or(0, |p| p.items.len())
773        {
774            self.selected_item = 0;
775        }
776        self.sub_focus = None;
777
778        if self.focus_panel() == FocusPanel::Settings {
779            self.init_map_focus(forward); // entering from above if forward
780            self.update_control_focus(true); // Focus the control
781        }
782
783        // Reset footer button when entering Footer panel
784        if self.focus_panel() == FocusPanel::Footer {
785            self.footer_button_index = if forward {
786                0 // Start at first button (Layer) when tabbing forward
787            } else {
788                4 // Start at last button (Edit) when tabbing backward
789            };
790        }
791
792        self.ensure_visible();
793    }
794
795    /// Toggle the visual style applied to every item.
796    ///
797    /// Style is cached per-item so the `ScrollItem::height(width)` trait impl
798    /// can compute the correct height without taking a style parameter; this
799    /// method propagates the change to every item across every page in one
800    /// pass. Recomputes the scroll panel content height too, since heights
801    /// just changed.
802    pub fn set_item_style(&mut self, style: super::items::ItemBoxStyle) {
803        if self.item_style == style {
804            return;
805        }
806        self.item_style = style;
807        for page in &mut self.pages {
808            for item in &mut page.items {
809                item.style = style;
810            }
811        }
812        let width = self.layout_width;
813        if let Some(page) = self.pages.get(self.selected_category) {
814            self.scroll_panel.update_content_height(&page.items, width);
815        }
816    }
817
818    /// Ensure the selected item is visible in the viewport.
819    pub fn ensure_visible(&mut self) {
820        if self.focus_panel() != FocusPanel::Settings {
821            return;
822        }
823
824        // Need to avoid borrowing self for both page and scroll_panel
825        let selected_item = self.selected_item;
826        let sub_focus = self.sub_focus;
827        let width = self.layout_width;
828        let prev_offset = self.scroll_panel.scroll.offset;
829        if let Some(page) = self.pages.get(self.selected_category) {
830            self.scroll_panel
831                .ensure_focused_visible(&page.items, selected_item, sub_focus, width);
832        }
833        // If body Up/Down moved the scroll, the tree cursor must follow
834        // so the left-panel highlight tracks the section the user is
835        // looking at — same contract as wheel/scrollbar scroll.
836        if self.scroll_panel.scroll.offset != prev_offset {
837            self.sync_tree_cursor_to_body_scroll();
838        }
839    }
840
841    /// Record a pending change for a setting
842    pub fn set_pending_change(&mut self, path: &str, value: serde_json::Value) {
843        // Check if this is the same as the original value
844        let original = self.original_config.pointer(path);
845        if original == Some(&value) {
846            self.pending_changes.remove(path);
847        } else {
848            self.pending_changes.insert(path.to_string(), value);
849        }
850    }
851
852    /// Check if there are unsaved changes
853    pub fn has_changes(&self) -> bool {
854        !self.pending_changes.is_empty() || !self.pending_deletions.is_empty()
855    }
856
857    /// Apply pending changes to a config
858    pub fn apply_changes(&self, config: &Config) -> Result<Config, serde_json::Error> {
859        let mut config_value = serde_json::to_value(config)?;
860
861        for (path, value) in &self.pending_changes {
862            if let Some(target) = config_value.pointer_mut(path) {
863                *target = value.clone();
864            }
865        }
866
867        serde_json::from_value(config_value)
868    }
869
870    /// Discard all pending changes
871    pub fn discard_changes(&mut self) {
872        self.pending_changes.clear();
873        self.pending_deletions.clear();
874        // Rebuild pages from original config with layer info
875        self.pages = super::items::build_pages(
876            &self.categories,
877            &self.original_config,
878            &self.layer_sources,
879            self.target_layer,
880        );
881    }
882
883    /// Set the target layer for saving changes.
884    pub fn set_target_layer(&mut self, layer: ConfigLayer) {
885        if layer != ConfigLayer::System {
886            // Cannot target System layer (read-only)
887            self.target_layer = layer;
888            // Clear pending changes when switching layers
889            self.pending_changes.clear();
890            self.pending_deletions.clear();
891            // Rebuild pages with new target layer (affects "modified" indicators)
892            self.pages = super::items::build_pages(
893                &self.categories,
894                &self.original_config,
895                &self.layer_sources,
896                self.target_layer,
897            );
898        }
899    }
900
901    /// Cycle through writable layers: User -> Project -> Session -> User
902    pub fn cycle_target_layer(&mut self) {
903        self.target_layer = match self.target_layer {
904            ConfigLayer::System => ConfigLayer::User, // Should never be System, but handle it
905            ConfigLayer::User => ConfigLayer::Project,
906            ConfigLayer::Project => ConfigLayer::Session,
907            ConfigLayer::Session => ConfigLayer::User,
908        };
909        // Clear pending changes when switching layers
910        self.pending_changes.clear();
911        self.pending_deletions.clear();
912        // Rebuild pages with new target layer (affects "modified" indicators)
913        self.pages = super::items::build_pages(
914            &self.categories,
915            &self.original_config,
916            &self.layer_sources,
917            self.target_layer,
918        );
919    }
920
921    /// Get a display name for the current target layer.
922    pub fn target_layer_name(&self) -> &'static str {
923        match self.target_layer {
924            ConfigLayer::System => "System (read-only)",
925            ConfigLayer::User => "User",
926            ConfigLayer::Project => "Project",
927            ConfigLayer::Session => "Session",
928        }
929    }
930
931    /// Set the layer sources map (called by Editor when opening settings).
932    /// This also rebuilds pages to update modified indicators.
933    pub fn set_layer_sources(&mut self, sources: HashMap<String, ConfigLayer>) {
934        self.layer_sources = sources;
935        // Rebuild pages with new layer sources (affects "modified" indicators)
936        self.pages = super::items::build_pages(
937            &self.categories,
938            &self.original_config,
939            &self.layer_sources,
940            self.target_layer,
941        );
942    }
943
944    /// Get the source layer for a setting path.
945    /// Returns the layer where this value was defined, or System if it's the default.
946    pub fn get_layer_source(&self, path: &str) -> ConfigLayer {
947        self.layer_sources
948            .get(path)
949            .copied()
950            .unwrap_or(ConfigLayer::System)
951    }
952
953    /// Get a short label for a layer source (for UI display).
954    pub fn layer_source_label(layer: ConfigLayer) -> &'static str {
955        match layer {
956            ConfigLayer::System => "default",
957            ConfigLayer::User => "user",
958            ConfigLayer::Project => "project",
959            ConfigLayer::Session => "session",
960        }
961    }
962
963    /// Reset the current item by removing it from the target layer.
964    ///
965    /// NEW SEMANTICS: Instead of setting to schema default, we remove the value
966    /// from the current layer's delta. The value then falls back to inherited
967    /// (from lower-precedence layers) or to the schema default.
968    ///
969    /// Only items defined in the target layer can be reset.
970    pub fn reset_current_to_default(&mut self) {
971        // Get the info we need first, then release the borrow
972        let reset_info = self.current_item().and_then(|item| {
973            // Only allow reset if the item is defined in the target layer
974            // (i.e., if it's "modified" in the new semantics)
975            if !item.modified || item.is_auto_managed {
976                return None;
977            }
978            item.default
979                .as_ref()
980                .map(|default| (item.path.clone(), default.clone()))
981        });
982
983        if let Some((path, default)) = reset_info {
984            // Mark this path for deletion from the target layer
985            self.pending_deletions.insert(path.clone());
986            // Remove any pending change for this path
987            self.pending_changes.remove(&path);
988
989            // Update the control state to show the inherited value.
990            // Since we don't have access to other layers' values here,
991            // we use the schema default as the fallback display value.
992            if let Some(item) = self.current_item_mut() {
993                update_control_from_value(&mut item.control, &default);
994                item.modified = false;
995                // Update layer source to show where value now comes from
996                item.layer_source = ConfigLayer::System; // Falls back to default
997            }
998        }
999    }
1000
1001    /// Set the current nullable setting to null (inherit value).
1002    ///
1003    /// This explicitly sets the value to null in the current layer,
1004    /// indicating that the setting should be inherited rather than overridden.
1005    /// Only applies to nullable settings that are not currently null.
1006    pub fn set_current_to_null(&mut self) {
1007        let target_layer = self.target_layer;
1008        let change_info = self.current_item().and_then(|item| {
1009            if !item.nullable || item.is_null || item.read_only {
1010                return None;
1011            }
1012            Some(item.path.clone())
1013        });
1014
1015        if let Some(path) = change_info {
1016            // Set value to null (not a deletion — this is an explicit null value)
1017            self.pending_changes
1018                .insert(path.clone(), serde_json::Value::Null);
1019            self.pending_deletions.remove(&path);
1020
1021            // Update the item's visual state
1022            if let Some(item) = self.current_item_mut() {
1023                item.is_null = true;
1024                item.modified = true;
1025                item.layer_source = target_layer;
1026            }
1027        }
1028    }
1029
1030    /// Clear a nullable category by setting its path to null and updating all items.
1031    ///
1032    /// This sets the category's root path (e.g., `/fallback`) to null in the target layer,
1033    /// effectively removing the entire section. All items within the category are marked
1034    /// as null/inherited.
1035    pub fn clear_current_category(&mut self) {
1036        let target_layer = self.target_layer;
1037        let page = match self.current_page() {
1038            Some(p) if p.nullable => p,
1039            _ => return,
1040        };
1041        let page_path = page.path.clone();
1042
1043        // Set the category root to null
1044        self.pending_changes
1045            .insert(page_path.clone(), serde_json::Value::Null);
1046
1047        // Also remove any pending changes/deletions for child paths
1048        let prefix = format!("{}/", page_path);
1049        self.pending_changes
1050            .retain(|path, _| !path.starts_with(&prefix));
1051        self.pending_deletions
1052            .retain(|path| !path.starts_with(&prefix));
1053
1054        // Update all items on the current page to reflect null/inherited state
1055        if let Some(page) = self.current_page_mut() {
1056            for item in &mut page.items {
1057                if item.nullable {
1058                    item.is_null = true;
1059                    item.modified = false;
1060                    item.layer_source = target_layer;
1061                }
1062            }
1063        }
1064    }
1065
1066    /// Check if any items in the current nullable category have non-null values.
1067    pub fn current_category_has_values(&self) -> bool {
1068        match self.current_page() {
1069            Some(page) if page.nullable => {
1070                page.items.iter().any(|item| !item.is_null && item.nullable)
1071                    || page.items.iter().any(|item| item.modified)
1072            }
1073            _ => false,
1074        }
1075    }
1076
1077    /// Handle a value change from user interaction
1078    pub fn on_value_changed(&mut self) {
1079        // Capture target_layer before any borrows
1080        let target_layer = self.target_layer;
1081
1082        // Get value and path first, then release borrow
1083        let change_info = self.current_item().map(|item| {
1084            let value = control_to_value(&item.control);
1085            (item.path.clone(), value)
1086        });
1087
1088        if let Some((path, value)) = change_info {
1089            // When user changes a value, it becomes "modified" (defined in target layer)
1090            // Remove from pending deletions if it was scheduled for removal
1091            self.pending_deletions.remove(&path);
1092
1093            // Update the item's state
1094            if let Some(item) = self.current_item_mut() {
1095                item.modified = true; // New semantic: value is now defined in target layer
1096                item.layer_source = target_layer; // Value now comes from target layer
1097                item.is_null = false; // Explicit value clears the inherited state
1098            }
1099            self.set_pending_change(&path, value);
1100        }
1101    }
1102
1103    /// Update focus states for rendering
1104    pub fn update_focus_states(&mut self) {
1105        let current_focus = self.focus_panel();
1106        for (page_idx, page) in self.pages.iter_mut().enumerate() {
1107            for (item_idx, item) in page.items.iter_mut().enumerate() {
1108                let is_focused = current_focus == FocusPanel::Settings
1109                    && page_idx == self.selected_category
1110                    && item_idx == self.selected_item;
1111
1112                let focus = if is_focused {
1113                    FocusState::Focused
1114                } else {
1115                    FocusState::Normal
1116                };
1117
1118                match &mut item.control {
1119                    SettingControl::Toggle(state) => state.focus = focus,
1120                    SettingControl::Number(state) => state.focus = focus,
1121                    SettingControl::Dropdown(state) => state.focus = focus,
1122                    SettingControl::Text(state) => state.focus = focus,
1123                    SettingControl::TextList(state) => state.focus = focus,
1124                    SettingControl::DualList(state) => state.focus = focus,
1125                    SettingControl::Map(state) => state.focus = focus,
1126                    SettingControl::ObjectArray(state) => state.focus = focus,
1127                    SettingControl::Json(state) => state.focus = focus,
1128                    SettingControl::Complex { .. } => {}
1129                }
1130            }
1131        }
1132    }
1133
1134    /// Start search mode
1135    pub fn start_search(&mut self) {
1136        self.search_active = true;
1137        self.search_query.clear();
1138        self.search_results.clear();
1139        self.selected_search_result = 0;
1140        self.search_scroll_offset = 0;
1141    }
1142
1143    /// Cancel search mode
1144    pub fn cancel_search(&mut self) {
1145        self.search_active = false;
1146        self.search_query.clear();
1147        self.search_results.clear();
1148        self.selected_search_result = 0;
1149        self.search_scroll_offset = 0;
1150    }
1151
1152    /// Update search query and refresh results
1153    pub fn set_search_query(&mut self, query: String) {
1154        self.search_query = query;
1155        self.search_results = search_settings(&self.pages, &self.search_query);
1156        self.selected_search_result = 0;
1157        self.search_scroll_offset = 0;
1158    }
1159
1160    /// Add a character to the search query
1161    pub fn search_push_char(&mut self, c: char) {
1162        self.search_query.push(c);
1163        self.search_results = search_settings(&self.pages, &self.search_query);
1164        self.selected_search_result = 0;
1165        self.search_scroll_offset = 0;
1166    }
1167
1168    /// Remove the last character from the search query
1169    pub fn search_pop_char(&mut self) {
1170        self.search_query.pop();
1171        self.search_results = search_settings(&self.pages, &self.search_query);
1172        self.selected_search_result = 0;
1173        self.search_scroll_offset = 0;
1174    }
1175
1176    /// Navigate to previous search result
1177    pub fn search_prev(&mut self) {
1178        if !self.search_results.is_empty() && self.selected_search_result > 0 {
1179            self.selected_search_result -= 1;
1180            // Scroll up if selection moved above visible area
1181            if self.selected_search_result < self.search_scroll_offset {
1182                self.search_scroll_offset = self.selected_search_result;
1183            }
1184        }
1185    }
1186
1187    /// Navigate to next search result
1188    pub fn search_next(&mut self) {
1189        if !self.search_results.is_empty()
1190            && self.selected_search_result + 1 < self.search_results.len()
1191        {
1192            self.selected_search_result += 1;
1193            // Scroll down if selection moved below visible area
1194            if self.selected_search_result >= self.search_scroll_offset + self.search_max_visible {
1195                self.search_scroll_offset =
1196                    self.selected_search_result - self.search_max_visible + 1;
1197            }
1198        }
1199    }
1200
1201    /// Scroll search results up by delta items
1202    pub fn search_scroll_up(&mut self, delta: usize) -> bool {
1203        if self.search_results.is_empty() || self.search_scroll_offset == 0 {
1204            return false;
1205        }
1206        self.search_scroll_offset = self.search_scroll_offset.saturating_sub(delta);
1207        // Keep selection visible
1208        if self.selected_search_result >= self.search_scroll_offset + self.search_max_visible {
1209            self.selected_search_result = self.search_scroll_offset + self.search_max_visible - 1;
1210        }
1211        true
1212    }
1213
1214    /// Scroll search results down by delta items
1215    pub fn search_scroll_down(&mut self, delta: usize) -> bool {
1216        if self.search_results.is_empty() {
1217            return false;
1218        }
1219        let max_offset = self
1220            .search_results
1221            .len()
1222            .saturating_sub(self.search_max_visible);
1223        if self.search_scroll_offset >= max_offset {
1224            return false;
1225        }
1226        self.search_scroll_offset = (self.search_scroll_offset + delta).min(max_offset);
1227        // Keep selection visible
1228        if self.selected_search_result < self.search_scroll_offset {
1229            self.selected_search_result = self.search_scroll_offset;
1230        }
1231        true
1232    }
1233
1234    /// Scroll search results to a ratio (0.0 = top, 1.0 = bottom)
1235    pub fn search_scroll_to_ratio(&mut self, ratio: f32) -> bool {
1236        if self.search_results.is_empty() {
1237            return false;
1238        }
1239        let max_offset = self
1240            .search_results
1241            .len()
1242            .saturating_sub(self.search_max_visible);
1243        let new_offset = (ratio * max_offset as f32) as usize;
1244        if new_offset != self.search_scroll_offset {
1245            self.search_scroll_offset = new_offset.min(max_offset);
1246            // Keep selection visible
1247            if self.selected_search_result < self.search_scroll_offset {
1248                self.selected_search_result = self.search_scroll_offset;
1249            } else if self.selected_search_result
1250                >= self.search_scroll_offset + self.search_max_visible
1251            {
1252                self.selected_search_result =
1253                    self.search_scroll_offset + self.search_max_visible - 1;
1254            }
1255            return true;
1256        }
1257        false
1258    }
1259
1260    /// Jump to the currently selected search result
1261    pub fn jump_to_search_result(&mut self) {
1262        // Extract values first to avoid borrow issues
1263        let Some(result) = self
1264            .search_results
1265            .get(self.selected_search_result)
1266            .cloned()
1267        else {
1268            return;
1269        };
1270        let page_index = result.page_index;
1271        let item_index = result.item_index;
1272
1273        // Unfocus old item first
1274        self.update_control_focus(false);
1275        self.selected_category = page_index;
1276        self.selected_item = item_index;
1277        self.focus.set(FocusPanel::Settings);
1278        // Reset scroll offset but preserve viewport for ensure_visible
1279        self.scroll_panel.scroll.offset = 0;
1280        // Update content height for the new category's items
1281        let width = self.layout_width;
1282        if let Some(page) = self.pages.get(self.selected_category) {
1283            self.scroll_panel.update_content_height(&page.items, width);
1284        }
1285        self.sub_focus = None;
1286        self.init_map_focus(true);
1287
1288        // Navigate into the deep match target if present
1289        if let Some(ref deep_match) = result.deep_match {
1290            self.jump_to_deep_match(deep_match);
1291        }
1292
1293        self.update_control_focus(true); // Focus the new item
1294        self.auto_expand_current_category();
1295        // Whichever section the matched item lives in becomes the tree
1296        // cursor — so when the user closes search and Tabs to the
1297        // categories panel, Up/Down resumes from the right place.
1298        self.tree_cursor_section = self.current_section_index();
1299        self.ensure_visible();
1300        self.cancel_search();
1301    }
1302
1303    /// Navigate into a composite control to focus a specific deep match
1304    fn jump_to_deep_match(&mut self, deep_match: &DeepMatch) {
1305        match deep_match {
1306            DeepMatch::MapKey { entry_index, .. } | DeepMatch::MapValue { entry_index, .. } => {
1307                if let Some(item) = self.current_item_mut() {
1308                    if let SettingControl::Map(ref mut map_state) = item.control {
1309                        map_state.focused_entry = Some(*entry_index);
1310                    }
1311                }
1312                self.update_map_sub_focus();
1313            }
1314            DeepMatch::TextListItem { item_index, .. } => {
1315                if let Some(item) = self.current_item_mut() {
1316                    if let SettingControl::TextList(ref mut list_state) = item.control {
1317                        list_state.focused_item = Some(*item_index);
1318                    }
1319                }
1320                // Update sub_focus for TextList
1321                self.sub_focus = Some(1 + *item_index);
1322            }
1323        }
1324    }
1325
1326    /// Get the currently selected search result
1327    pub fn current_search_result(&self) -> Option<&SearchResult> {
1328        self.search_results.get(self.selected_search_result)
1329    }
1330
1331    /// Show the unsaved changes confirmation dialog
1332    pub fn show_confirm_dialog(&mut self) {
1333        self.showing_confirm_dialog = true;
1334        self.confirm_dialog_selection = 0; // Default to "Save and Exit"
1335    }
1336
1337    /// Hide the confirmation dialog
1338    pub fn hide_confirm_dialog(&mut self) {
1339        self.showing_confirm_dialog = false;
1340        self.confirm_dialog_selection = 0;
1341    }
1342
1343    /// Move to next option in confirmation dialog
1344    pub fn confirm_dialog_next(&mut self) {
1345        self.confirm_dialog_selection = (self.confirm_dialog_selection + 1) % 3;
1346    }
1347
1348    /// Move to previous option in confirmation dialog
1349    pub fn confirm_dialog_prev(&mut self) {
1350        self.confirm_dialog_selection = if self.confirm_dialog_selection == 0 {
1351            2
1352        } else {
1353            self.confirm_dialog_selection - 1
1354        };
1355    }
1356
1357    /// Toggle the help overlay
1358    pub fn toggle_help(&mut self) {
1359        self.showing_help = !self.showing_help;
1360    }
1361
1362    /// Hide the help overlay
1363    pub fn hide_help(&mut self) {
1364        self.showing_help = false;
1365    }
1366
1367    /// Check if the entry dialog is showing
1368    pub fn showing_entry_dialog(&self) -> bool {
1369        self.has_entry_dialog()
1370    }
1371
1372    /// Open the entry dialog for the currently focused map entry
1373    pub fn open_entry_dialog(&mut self) {
1374        let Some(item) = self.current_item() else {
1375            return;
1376        };
1377
1378        // Determine what type of entry we're editing based on the path
1379        let path = item.path.as_str();
1380        let SettingControl::Map(map_state) = &item.control else {
1381            return;
1382        };
1383
1384        // Get the focused entry
1385        let Some(entry_idx) = map_state.focused_entry else {
1386            return;
1387        };
1388        let Some((key, value)) = map_state.entries.get(entry_idx) else {
1389            return;
1390        };
1391
1392        // Get the value schema for this map
1393        let Some(schema) = map_state.value_schema.as_ref() else {
1394            return; // No schema available, can't create dialog
1395        };
1396
1397        // If the map doesn't allow adding, it also doesn't allow deleting (auto-managed entries)
1398        let no_delete = map_state.no_add;
1399
1400        // Create dialog from schema
1401        let dialog =
1402            EntryDialogState::from_schema(key.clone(), value, schema, path, false, no_delete);
1403        self.entry_dialog_stack.push(dialog);
1404    }
1405
1406    /// Open entry dialog for adding a new entry (with empty key)
1407    pub fn open_add_entry_dialog(&mut self) {
1408        let Some(item) = self.current_item() else {
1409            return;
1410        };
1411        let SettingControl::Map(map_state) = &item.control else {
1412            return;
1413        };
1414        let Some(schema) = map_state.value_schema.as_ref() else {
1415            return;
1416        };
1417        let path = item.path.clone();
1418
1419        // Create dialog with empty key - user will fill it in
1420        // no_delete is false for new entries (Delete button is not shown anyway for new entries)
1421        let dialog = EntryDialogState::from_schema(
1422            String::new(),
1423            &serde_json::json!({}),
1424            schema,
1425            &path,
1426            true,
1427            false,
1428        );
1429        self.entry_dialog_stack.push(dialog);
1430    }
1431
1432    /// Open dialog for adding a new array item
1433    pub fn open_add_array_item_dialog(&mut self) {
1434        let Some(item) = self.current_item() else {
1435            return;
1436        };
1437        let SettingControl::ObjectArray(array_state) = &item.control else {
1438            return;
1439        };
1440        let Some(schema) = array_state.item_schema.as_ref() else {
1441            return;
1442        };
1443        let path = item.path.clone();
1444
1445        // Create dialog with empty value - user will fill it in
1446        let dialog =
1447            EntryDialogState::for_array_item(None, &serde_json::json!({}), schema, &path, true);
1448        self.entry_dialog_stack.push(dialog);
1449    }
1450
1451    /// Open dialog for editing an existing array item
1452    pub fn open_edit_array_item_dialog(&mut self) {
1453        let Some(item) = self.current_item() else {
1454            return;
1455        };
1456        let SettingControl::ObjectArray(array_state) = &item.control else {
1457            return;
1458        };
1459        let Some(schema) = array_state.item_schema.as_ref() else {
1460            return;
1461        };
1462        let Some(index) = array_state.focused_index else {
1463            return;
1464        };
1465        let Some(value) = array_state.bindings.get(index) else {
1466            return;
1467        };
1468        let path = item.path.clone();
1469
1470        let dialog = EntryDialogState::for_array_item(Some(index), value, schema, &path, false);
1471        self.entry_dialog_stack.push(dialog);
1472    }
1473
1474    /// Close the entry dialog without saving (pops from stack)
1475    pub fn close_entry_dialog(&mut self) {
1476        self.entry_dialog_stack.pop();
1477    }
1478
1479    /// Open a nested entry dialog for a Map or ObjectArray field within the current dialog
1480    ///
1481    /// This enables recursive editing: if a dialog field is itself a Map or ObjectArray,
1482    /// pressing Enter will open a new dialog on top of the stack for that nested structure.
1483    pub fn open_nested_entry_dialog(&mut self) {
1484        // Get info from the current dialog's focused field
1485        let nested_info = self.entry_dialog().and_then(|dialog| {
1486            let item = dialog.current_item()?;
1487            // The nested dialog path must root at the current entry's full
1488            // path, not just at `map_path`. Otherwise the entry key segment
1489            // (e.g. `quicklsp` under `/universal_lsp`) is dropped and the
1490            // nested save records a pending change at `/universal_lsp/`,
1491            // which eventually writes an empty-string key into the config.
1492            let base = dialog.entry_path();
1493            let relative = item.path.trim_start_matches('/');
1494            let path = if relative.is_empty() {
1495                // `is_single_value` dialogs use an empty item path because
1496                // the single non-key item IS the entry's value. In that
1497                // case the nested dialog lives at the entry path itself.
1498                base
1499            } else {
1500                format!("{}/{}", base, relative)
1501            };
1502
1503            match &item.control {
1504                SettingControl::Map(map_state) => {
1505                    let schema = map_state.value_schema.as_ref()?;
1506                    let no_delete = map_state.no_add; // If can't add, can't delete either
1507                    if let Some(entry_idx) = map_state.focused_entry {
1508                        // Edit existing entry
1509                        let (key, value) = map_state.entries.get(entry_idx)?;
1510                        Some(NestedDialogInfo::MapEntry {
1511                            key: key.clone(),
1512                            value: value.clone(),
1513                            schema: schema.as_ref().clone(),
1514                            path,
1515                            is_new: false,
1516                            no_delete,
1517                        })
1518                    } else {
1519                        // Add new entry
1520                        Some(NestedDialogInfo::MapEntry {
1521                            key: String::new(),
1522                            value: serde_json::json!({}),
1523                            schema: schema.as_ref().clone(),
1524                            path,
1525                            is_new: true,
1526                            no_delete: false, // New entries don't show Delete anyway
1527                        })
1528                    }
1529                }
1530                SettingControl::ObjectArray(array_state) => {
1531                    let schema = array_state.item_schema.as_ref()?;
1532                    if let Some(index) = array_state.focused_index {
1533                        // Edit existing item
1534                        let value = array_state.bindings.get(index)?;
1535                        Some(NestedDialogInfo::ArrayItem {
1536                            index: Some(index),
1537                            value: value.clone(),
1538                            schema: schema.as_ref().clone(),
1539                            path,
1540                            is_new: false,
1541                        })
1542                    } else {
1543                        // Add new item
1544                        Some(NestedDialogInfo::ArrayItem {
1545                            index: None,
1546                            value: serde_json::json!({}),
1547                            schema: schema.as_ref().clone(),
1548                            path,
1549                            is_new: true,
1550                        })
1551                    }
1552                }
1553                _ => None,
1554            }
1555        });
1556
1557        // Now create and push the dialog (outside the borrow)
1558        if let Some(info) = nested_info {
1559            let dialog = match info {
1560                NestedDialogInfo::MapEntry {
1561                    key,
1562                    value,
1563                    schema,
1564                    path,
1565                    is_new,
1566                    no_delete,
1567                } => EntryDialogState::from_schema(key, &value, &schema, &path, is_new, no_delete),
1568                NestedDialogInfo::ArrayItem {
1569                    index,
1570                    value,
1571                    schema,
1572                    path,
1573                    is_new,
1574                } => EntryDialogState::for_array_item(index, &value, &schema, &path, is_new),
1575            };
1576            self.entry_dialog_stack.push(dialog);
1577        }
1578    }
1579
1580    /// Save the entry dialog and apply changes
1581    ///
1582    /// Automatically detects whether this is a Map or ObjectArray dialog
1583    /// and handles saving appropriately.
1584    pub fn save_entry_dialog(&mut self) {
1585        // Determine if this is an array dialog by checking where we need to save
1586        // For nested dialogs (stack len > 1), check the parent dialog's item type
1587        // For top-level dialogs (stack len == 1), check current_item()
1588        let is_array = if self.entry_dialog_stack.len() > 1 {
1589            // Nested dialog - check parent dialog's focused item
1590            self.entry_dialog_stack
1591                .get(self.entry_dialog_stack.len() - 2)
1592                .and_then(|parent| parent.current_item())
1593                .map(|item| matches!(item.control, SettingControl::ObjectArray(_)))
1594                .unwrap_or(false)
1595        } else {
1596            // Top-level dialog - check main settings page item
1597            self.current_item()
1598                .map(|item| matches!(item.control, SettingControl::ObjectArray(_)))
1599                .unwrap_or(false)
1600        };
1601
1602        if is_array {
1603            self.save_array_item_dialog_inner();
1604        } else {
1605            self.save_map_entry_dialog_inner();
1606        }
1607    }
1608
1609    /// Save a Map entry dialog
1610    fn save_map_entry_dialog_inner(&mut self) {
1611        let Some(dialog) = self.entry_dialog_stack.pop() else {
1612            return;
1613        };
1614
1615        // Get key from the dialog's key field (may have been edited)
1616        let key = dialog.get_key();
1617        if key.is_empty() {
1618            return; // Can't save with empty key
1619        }
1620
1621        let value = dialog.to_value();
1622        let map_path = dialog.map_path.clone();
1623        let original_key = dialog.entry_key.clone();
1624        let is_new = dialog.is_new;
1625        let key_changed = !is_new && key != original_key;
1626
1627        // Update the map control with the new value
1628        if let Some(item) = self.current_item_mut() {
1629            if let SettingControl::Map(map_state) = &mut item.control {
1630                // If key was changed, remove old entry first
1631                if key_changed {
1632                    if let Some(idx) = map_state
1633                        .entries
1634                        .iter()
1635                        .position(|(k, _)| k == &original_key)
1636                    {
1637                        map_state.entries.remove(idx);
1638                    }
1639                }
1640
1641                // Find or add the entry with the (possibly new) key
1642                if let Some(entry) = map_state.entries.iter_mut().find(|(k, _)| k == &key) {
1643                    entry.1 = value.clone();
1644                } else {
1645                    map_state.entries.push((key.clone(), value.clone()));
1646                    map_state.entries.sort_by(|a, b| a.0.cmp(&b.0));
1647                }
1648            }
1649        }
1650
1651        // Record deletion of old key if key was changed
1652        if key_changed {
1653            let old_path = format!("{}/{}", map_path, original_key);
1654            self.pending_changes
1655                .insert(old_path, serde_json::Value::Null);
1656        }
1657
1658        // Record the pending change
1659        let path = format!("{}/{}", map_path, key);
1660        self.set_pending_change(&path, value);
1661    }
1662
1663    /// Save an ObjectArray item dialog
1664    fn save_array_item_dialog_inner(&mut self) {
1665        let Some(dialog) = self.entry_dialog_stack.pop() else {
1666            return;
1667        };
1668
1669        let value = dialog.to_value();
1670        let array_path = dialog.map_path.clone();
1671        let is_new = dialog.is_new;
1672        let entry_key = dialog.entry_key.clone();
1673
1674        // Determine if this is a nested dialog (parent still in stack)
1675        let is_nested = !self.entry_dialog_stack.is_empty();
1676
1677        if is_nested {
1678            // Nested dialog - update the parent dialog's ObjectArray item.
1679            // Extract the item path within the parent dialog by stripping the
1680            // parent's full entry path (map_path + "/" + entry_key) from the
1681            // nested dialog's array path. For an is_single_value parent (e.g.
1682            // a quicklsp entry whose value schema is an array), the inner
1683            // ObjectArray item has path "" and the nested dialog lives exactly
1684            // at the entry path, so the stripped item path is "".
1685            let parent_entry_path = self
1686                .entry_dialog_stack
1687                .last()
1688                .map(|p| p.entry_path())
1689                .unwrap_or_default();
1690            let item_path = array_path
1691                .strip_prefix(parent_entry_path.as_str())
1692                .unwrap_or(&array_path)
1693                .trim_end_matches('/')
1694                .to_string();
1695
1696            // Find and update the ObjectArray in the parent dialog
1697            if let Some(parent) = self.entry_dialog_stack.last_mut() {
1698                if let Some(item) = parent.items.iter_mut().find(|i| i.path == item_path) {
1699                    if let SettingControl::ObjectArray(array_state) = &mut item.control {
1700                        if is_new {
1701                            array_state.bindings.push(value.clone());
1702                        } else if let Ok(index) = entry_key.parse::<usize>() {
1703                            if index < array_state.bindings.len() {
1704                                array_state.bindings[index] = value.clone();
1705                            }
1706                        }
1707                    }
1708                }
1709            }
1710
1711            // For nested arrays, the pending change will be recorded when parent dialog saves
1712            // We still record a pending change so the value persists
1713            if let Some(parent) = self.entry_dialog_stack.last() {
1714                if let Some(item) = parent.items.iter().find(|i| i.path == item_path) {
1715                    if let SettingControl::ObjectArray(array_state) = &item.control {
1716                        let array_value = serde_json::Value::Array(array_state.bindings.clone());
1717                        self.set_pending_change(&array_path, array_value);
1718                    }
1719                }
1720            }
1721        } else {
1722            // Top-level dialog - update the main settings page item
1723            if let Some(item) = self.current_item_mut() {
1724                if let SettingControl::ObjectArray(array_state) = &mut item.control {
1725                    if is_new {
1726                        array_state.bindings.push(value.clone());
1727                    } else if let Ok(index) = entry_key.parse::<usize>() {
1728                        if index < array_state.bindings.len() {
1729                            array_state.bindings[index] = value.clone();
1730                        }
1731                    }
1732                }
1733            }
1734
1735            // Record the pending change for the entire array
1736            if let Some(item) = self.current_item() {
1737                if let SettingControl::ObjectArray(array_state) = &item.control {
1738                    let array_value = serde_json::Value::Array(array_state.bindings.clone());
1739                    self.set_pending_change(&array_path, array_value);
1740                }
1741            }
1742        }
1743    }
1744
1745    /// Delete the entry from the map and close the dialog
1746    pub fn delete_entry_dialog(&mut self) {
1747        // Check if this is a nested dialog BEFORE popping
1748        let is_nested = self.entry_dialog_stack.len() > 1;
1749
1750        let Some(dialog) = self.entry_dialog_stack.pop() else {
1751            return;
1752        };
1753
1754        let path = format!("{}/{}", dialog.map_path, dialog.entry_key);
1755
1756        // Remove from the map control
1757        if is_nested {
1758            // Nested dialog - update the parent dialog's Map item
1759            // Extract the map field name from the path (last segment of map_path)
1760            let map_field = dialog.map_path.rsplit('/').next().unwrap_or("").to_string();
1761            let item_path = format!("/{}", map_field);
1762
1763            // Find and update the Map in the parent dialog
1764            if let Some(parent) = self.entry_dialog_stack.last_mut() {
1765                if let Some(item) = parent.items.iter_mut().find(|i| i.path == item_path) {
1766                    if let SettingControl::Map(map_state) = &mut item.control {
1767                        if let Some(idx) = map_state
1768                            .entries
1769                            .iter()
1770                            .position(|(k, _)| k == &dialog.entry_key)
1771                        {
1772                            map_state.remove_entry(idx);
1773                        }
1774                    }
1775                }
1776            }
1777        } else {
1778            // Top-level dialog - remove from the main settings page item
1779            if let Some(item) = self.current_item_mut() {
1780                if let SettingControl::Map(map_state) = &mut item.control {
1781                    if let Some(idx) = map_state
1782                        .entries
1783                        .iter()
1784                        .position(|(k, _)| k == &dialog.entry_key)
1785                    {
1786                        map_state.remove_entry(idx);
1787                    }
1788                }
1789            }
1790        }
1791
1792        // Record the pending change (null value signals deletion)
1793        self.set_pending_change(&path, serde_json::Value::Null);
1794    }
1795
1796    /// Get the maximum scroll offset for the current page (in rows)
1797    pub fn max_scroll(&self) -> u16 {
1798        self.scroll_panel.scroll.max_offset()
1799    }
1800
1801    /// Scroll up by a given number of rows
1802    /// Returns true if the scroll offset changed
1803    pub fn scroll_up(&mut self, delta: usize) -> bool {
1804        let old = self.scroll_panel.scroll.offset;
1805        self.scroll_panel.scroll_up(delta as u16);
1806        let changed = old != self.scroll_panel.scroll.offset;
1807        if changed {
1808            self.sync_tree_cursor_to_body_scroll();
1809        }
1810        changed
1811    }
1812
1813    /// Scroll down by a given number of rows
1814    /// Returns true if the scroll offset changed
1815    pub fn scroll_down(&mut self, delta: usize) -> bool {
1816        let old = self.scroll_panel.scroll.offset;
1817        self.scroll_panel.scroll_down(delta as u16);
1818        let changed = old != self.scroll_panel.scroll.offset;
1819        if changed {
1820            self.sync_tree_cursor_to_body_scroll();
1821        }
1822        changed
1823    }
1824
1825    /// Scroll to a position based on a ratio (0.0 to 1.0)
1826    /// Returns true if the scroll offset changed
1827    pub fn scroll_to_ratio(&mut self, ratio: f32) -> bool {
1828        let old = self.scroll_panel.scroll.offset;
1829        self.scroll_panel.scroll_to_ratio(ratio);
1830        let changed = old != self.scroll_panel.scroll.offset;
1831        if changed {
1832            self.sync_tree_cursor_to_body_scroll();
1833        }
1834        changed
1835    }
1836
1837    /// After the body scroll position changes, snap the tree cursor to
1838    /// the section that now contains the topmost visible item — so the
1839    /// left-panel highlight follows wheel/scrollbar interaction in both
1840    /// directions, and a subsequent Up/Down on the tree resumes from
1841    /// the section the user is actually looking at.
1842    pub(super) fn sync_tree_cursor_to_body_scroll(&mut self) {
1843        if let Some(section_idx) = self.current_section_index() {
1844            self.tree_cursor_section = Some(section_idx);
1845        }
1846        // No section under the topmost visible item (e.g. above the
1847        // first section) → leave the cursor where it is. Forcing it to
1848        // None would be a worse UX: the user typically wants the
1849        // highlight to *track* something, not blink off entirely.
1850    }
1851
1852    /// Start text editing mode for TextList, Text, or Map controls
1853    /// Check if the current control is a number input
1854    pub fn is_number_control(&self) -> bool {
1855        self.current_item()
1856            .is_some_and(|item| matches!(item.control, SettingControl::Number(_)))
1857    }
1858
1859    pub fn start_editing(&mut self) {
1860        if let Some(item) = self.current_item() {
1861            if matches!(
1862                item.control,
1863                SettingControl::TextList(_)
1864                    | SettingControl::DualList(_)
1865                    | SettingControl::Text(_)
1866                    | SettingControl::Map(_)
1867                    | SettingControl::Json(_)
1868            ) {
1869                self.editing_text = true;
1870            }
1871        }
1872        if let Some(item) = self.current_item_mut() {
1873            match item.control {
1874                SettingControl::DualList(ref mut dl) => {
1875                    dl.editing = true;
1876                }
1877                SettingControl::Text(ref mut state) => {
1878                    state.editing = true;
1879                    // Mirror the spinner's "select-all on enter edit"
1880                    // UX: the first printable keystroke replaces the
1881                    // current value. Arrow keys or deletion cancel it
1882                    // and the input behaves normally from then on.
1883                    state.arm_replace_on_type();
1884                }
1885                _ => {}
1886            }
1887        }
1888    }
1889
1890    /// Stop text editing mode
1891    pub fn stop_editing(&mut self) {
1892        self.editing_text = false;
1893        if let Some(item) = self.current_item_mut() {
1894            match item.control {
1895                SettingControl::DualList(ref mut dl) => {
1896                    dl.editing = false;
1897                }
1898                SettingControl::Text(ref mut state) => {
1899                    state.editing = false;
1900                }
1901                _ => {}
1902            }
1903        }
1904    }
1905
1906    /// Check if the current item is editable (TextList, DualList, Text, Map, or Json)
1907    pub fn is_editable_control(&self) -> bool {
1908        self.current_item().is_some_and(|item| {
1909            matches!(
1910                item.control,
1911                SettingControl::TextList(_)
1912                    | SettingControl::DualList(_)
1913                    | SettingControl::Text(_)
1914                    | SettingControl::Map(_)
1915                    | SettingControl::Json(_)
1916            )
1917        })
1918    }
1919
1920    /// Check if currently editing a JSON control
1921    pub fn is_editing_json(&self) -> bool {
1922        if !self.editing_text {
1923            return false;
1924        }
1925        self.current_item()
1926            .map(|item| matches!(&item.control, SettingControl::Json(_)))
1927            .unwrap_or(false)
1928    }
1929
1930    /// Insert a character into the current editable control
1931    pub fn text_insert(&mut self, c: char) {
1932        if let Some(item) = self.current_item_mut() {
1933            match &mut item.control {
1934                SettingControl::TextList(state) => state.insert(c),
1935                SettingControl::Text(state) => state.insert(c),
1936                SettingControl::Map(state) => {
1937                    state.new_key_text.insert(state.cursor, c);
1938                    state.cursor += c.len_utf8();
1939                }
1940                SettingControl::Json(state) => state.insert(c),
1941                _ => {}
1942            }
1943        }
1944    }
1945
1946    /// Backspace in the current editable control
1947    pub fn text_backspace(&mut self) {
1948        if let Some(item) = self.current_item_mut() {
1949            match &mut item.control {
1950                SettingControl::TextList(state) => state.backspace(),
1951                SettingControl::Text(state) => state.backspace(),
1952                SettingControl::Map(state) => {
1953                    if state.cursor > 0 {
1954                        let mut char_start = state.cursor - 1;
1955                        while char_start > 0 && !state.new_key_text.is_char_boundary(char_start) {
1956                            char_start -= 1;
1957                        }
1958                        state.new_key_text.remove(char_start);
1959                        state.cursor = char_start;
1960                    }
1961                }
1962                SettingControl::Json(state) => state.backspace(),
1963                _ => {}
1964            }
1965        }
1966    }
1967
1968    /// Move cursor left in the current editable control
1969    pub fn text_move_left(&mut self) {
1970        if let Some(item) = self.current_item_mut() {
1971            match &mut item.control {
1972                SettingControl::TextList(state) => state.move_left(),
1973                SettingControl::Text(state) => state.move_left(),
1974                SettingControl::Map(state) => {
1975                    if state.cursor > 0 {
1976                        let mut new_pos = state.cursor - 1;
1977                        while new_pos > 0 && !state.new_key_text.is_char_boundary(new_pos) {
1978                            new_pos -= 1;
1979                        }
1980                        state.cursor = new_pos;
1981                    }
1982                }
1983                SettingControl::Json(state) => state.move_left(),
1984                _ => {}
1985            }
1986        }
1987    }
1988
1989    /// Move cursor right in the current editable control
1990    pub fn text_move_right(&mut self) {
1991        if let Some(item) = self.current_item_mut() {
1992            match &mut item.control {
1993                SettingControl::TextList(state) => state.move_right(),
1994                SettingControl::Text(state) => state.move_right(),
1995                SettingControl::Map(state) => {
1996                    if state.cursor < state.new_key_text.len() {
1997                        let mut new_pos = state.cursor + 1;
1998                        while new_pos < state.new_key_text.len()
1999                            && !state.new_key_text.is_char_boundary(new_pos)
2000                        {
2001                            new_pos += 1;
2002                        }
2003                        state.cursor = new_pos;
2004                    }
2005                }
2006                SettingControl::Json(state) => state.move_right(),
2007                _ => {}
2008            }
2009        }
2010    }
2011
2012    /// Move focus to previous item in TextList/Map (wraps within control)
2013    pub fn text_focus_prev(&mut self) {
2014        if let Some(item) = self.current_item_mut() {
2015            match &mut item.control {
2016                SettingControl::TextList(state) => state.focus_prev(),
2017                SettingControl::Map(state) => {
2018                    state.focus_prev();
2019                }
2020                _ => {}
2021            }
2022        }
2023    }
2024
2025    /// Move focus to next item in TextList/Map (wraps within control)
2026    pub fn text_focus_next(&mut self) {
2027        if let Some(item) = self.current_item_mut() {
2028            match &mut item.control {
2029                SettingControl::TextList(state) => state.focus_next(),
2030                SettingControl::Map(state) => {
2031                    state.focus_next();
2032                }
2033                _ => {}
2034            }
2035        }
2036    }
2037
2038    /// Add new item in TextList/Map (from the new item field)
2039    pub fn text_add_item(&mut self) {
2040        if let Some(item) = self.current_item_mut() {
2041            match &mut item.control {
2042                SettingControl::TextList(state) => state.add_item(),
2043                SettingControl::Map(state) => state.add_entry_from_input(),
2044                _ => {}
2045            }
2046        }
2047        // Record the change
2048        self.on_value_changed();
2049    }
2050
2051    /// Remove the currently focused item in TextList/Map
2052    pub fn text_remove_focused(&mut self) {
2053        if let Some(item) = self.current_item_mut() {
2054            match &mut item.control {
2055                SettingControl::TextList(state) => {
2056                    if let Some(idx) = state.focused_item {
2057                        state.remove_item(idx);
2058                    }
2059                }
2060                SettingControl::Map(state) => {
2061                    if let Some(idx) = state.focused_entry {
2062                        state.remove_entry(idx);
2063                    }
2064                }
2065                _ => {}
2066            }
2067        }
2068        // Record the change
2069        self.on_value_changed();
2070    }
2071
2072    /// Check if currently editing a DualList control
2073    pub fn is_editing_dual_list(&self) -> bool {
2074        if !self.editing_text {
2075            return false;
2076        }
2077        self.current_item()
2078            .map(|item| matches!(&item.control, SettingControl::DualList(_)))
2079            .unwrap_or(false)
2080    }
2081
2082    // =========== DualList methods ===========
2083
2084    /// Access the DualList at `item_idx` in the current page and run `f` on it.
2085    /// Returns `None` if the item isn't a DualList or the index is out of bounds.
2086    pub fn with_dual_list_mut<R>(
2087        &mut self,
2088        item_idx: usize,
2089        f: impl FnOnce(&mut crate::view::controls::DualListState) -> R,
2090    ) -> Option<R> {
2091        let page = self.pages.get_mut(self.selected_category)?;
2092        let item = page.items.get_mut(item_idx)?;
2093        if let SettingControl::DualList(ref mut state) = item.control {
2094            Some(f(state))
2095        } else {
2096            None
2097        }
2098    }
2099
2100    /// Access the currently selected DualList and run `f` on it.
2101    /// Returns `None` if the current item isn't a DualList.
2102    pub fn with_current_dual_list_mut<R>(
2103        &mut self,
2104        f: impl FnOnce(&mut crate::view::controls::DualListState) -> R,
2105    ) -> Option<R> {
2106        if let Some(item) = self.current_item_mut() {
2107            if let SettingControl::DualList(ref mut state) = item.control {
2108                return Some(f(state));
2109            }
2110        }
2111        None
2112    }
2113
2114    /// After changing a DualList, refresh the sibling's excluded set.
2115    ///
2116    /// Assumes the sibling setting lives on the same page as the current item.
2117    /// This holds for the current use case (`status_bar.left` and `.right` are both
2118    /// flattened into the Editor page under the "Status Bar" section). Cross-category
2119    /// siblings would silently no-op until the next `build_pages()`.
2120    pub fn refresh_dual_list_sibling(&mut self) {
2121        let (new_included, sibling_path) = {
2122            let Some(item) = self.current_item() else {
2123                return;
2124            };
2125            let SettingControl::DualList(state) = &item.control else {
2126                return;
2127            };
2128            let Some(ref sib_path) = item.dual_list_sibling else {
2129                return;
2130            };
2131            (state.included.clone(), sib_path.clone())
2132        };
2133
2134        // Find sibling item in same page and update its excluded
2135        if let Some(page) = self.pages.get_mut(self.selected_category) {
2136            for other in page.items.iter_mut() {
2137                if other.path == sibling_path {
2138                    if let SettingControl::DualList(ref mut sib_state) = other.control {
2139                        sib_state.excluded = new_included;
2140                    }
2141                    break;
2142                }
2143            }
2144        }
2145    }
2146
2147    // =========== JSON editing methods ===========
2148
2149    /// Move cursor up in JSON editor
2150    pub fn json_cursor_up(&mut self) {
2151        if let Some(item) = self.current_item_mut() {
2152            if let SettingControl::Json(state) = &mut item.control {
2153                state.move_up();
2154            }
2155        }
2156    }
2157
2158    /// Move cursor down in JSON editor
2159    pub fn json_cursor_down(&mut self) {
2160        if let Some(item) = self.current_item_mut() {
2161            if let SettingControl::Json(state) = &mut item.control {
2162                state.move_down();
2163            }
2164        }
2165    }
2166
2167    /// Insert newline in JSON editor
2168    pub fn json_insert_newline(&mut self) {
2169        if let Some(item) = self.current_item_mut() {
2170            if let SettingControl::Json(state) = &mut item.control {
2171                state.insert('\n');
2172            }
2173        }
2174    }
2175
2176    /// Delete character at cursor in JSON editor
2177    pub fn json_delete(&mut self) {
2178        if let Some(item) = self.current_item_mut() {
2179            if let SettingControl::Json(state) = &mut item.control {
2180                state.delete();
2181            }
2182        }
2183    }
2184
2185    /// Stop JSON editing: commit if valid, revert if invalid
2186    pub fn json_exit_editing(&mut self) {
2187        let is_valid = self
2188            .current_item()
2189            .map(|item| {
2190                if let SettingControl::Json(state) = &item.control {
2191                    state.is_valid()
2192                } else {
2193                    true
2194                }
2195            })
2196            .unwrap_or(true);
2197
2198        if is_valid {
2199            if let Some(item) = self.current_item_mut() {
2200                if let SettingControl::Json(state) = &mut item.control {
2201                    state.commit();
2202                }
2203            }
2204            self.on_value_changed();
2205        } else if let Some(item) = self.current_item_mut() {
2206            if let SettingControl::Json(state) = &mut item.control {
2207                state.revert();
2208            }
2209        }
2210        self.editing_text = false;
2211    }
2212
2213    /// Select all text in JSON editor
2214    pub fn json_select_all(&mut self) {
2215        if let Some(item) = self.current_item_mut() {
2216            if let SettingControl::Json(state) = &mut item.control {
2217                state.select_all();
2218            }
2219        }
2220    }
2221
2222    /// Get selected text from JSON editor
2223    pub fn json_selected_text(&self) -> Option<String> {
2224        if let Some(item) = self.current_item() {
2225            if let SettingControl::Json(state) = &item.control {
2226                return state.selected_text();
2227            }
2228        }
2229        None
2230    }
2231
2232    /// Move cursor up with selection in JSON editor
2233    pub fn json_cursor_up_selecting(&mut self) {
2234        if let Some(item) = self.current_item_mut() {
2235            if let SettingControl::Json(state) = &mut item.control {
2236                state.editor.move_up_selecting();
2237            }
2238        }
2239    }
2240
2241    /// Move cursor down with selection in JSON editor
2242    pub fn json_cursor_down_selecting(&mut self) {
2243        if let Some(item) = self.current_item_mut() {
2244            if let SettingControl::Json(state) = &mut item.control {
2245                state.editor.move_down_selecting();
2246            }
2247        }
2248    }
2249
2250    /// Move cursor left with selection in JSON editor
2251    pub fn json_cursor_left_selecting(&mut self) {
2252        if let Some(item) = self.current_item_mut() {
2253            if let SettingControl::Json(state) = &mut item.control {
2254                state.editor.move_left_selecting();
2255            }
2256        }
2257    }
2258
2259    /// Move cursor right with selection in JSON editor
2260    pub fn json_cursor_right_selecting(&mut self) {
2261        if let Some(item) = self.current_item_mut() {
2262            if let SettingControl::Json(state) = &mut item.control {
2263                state.editor.move_right_selecting();
2264            }
2265        }
2266    }
2267
2268    // =========== Dropdown methods ===========
2269
2270    /// Check if current item is a dropdown with menu open
2271    pub fn is_dropdown_open(&self) -> bool {
2272        self.current_item().is_some_and(|item| {
2273            if let SettingControl::Dropdown(ref d) = item.control {
2274                d.open
2275            } else {
2276                false
2277            }
2278        })
2279    }
2280
2281    /// Toggle dropdown open/closed
2282    pub fn dropdown_toggle(&mut self) {
2283        let mut opened = false;
2284        if let Some(item) = self.current_item_mut() {
2285            if let SettingControl::Dropdown(ref mut d) = item.control {
2286                d.toggle_open();
2287                opened = d.open;
2288            }
2289        }
2290
2291        // When dropdown opens, update content height and ensure it's visible
2292        if opened {
2293            // Update content height since item is now taller
2294            let selected_item = self.selected_item;
2295            let width = self.layout_width;
2296            if let Some(page) = self.pages.get(self.selected_category) {
2297                self.scroll_panel.update_content_height(&page.items, width);
2298                // Ensure the dropdown item is visible with its new expanded height
2299                self.scroll_panel
2300                    .ensure_focused_visible(&page.items, selected_item, None, width);
2301            }
2302        }
2303    }
2304
2305    /// Select previous option in dropdown
2306    pub fn dropdown_prev(&mut self) {
2307        if let Some(item) = self.current_item_mut() {
2308            if let SettingControl::Dropdown(ref mut d) = item.control {
2309                d.select_prev();
2310            }
2311        }
2312    }
2313
2314    /// Select next option in dropdown
2315    pub fn dropdown_next(&mut self) {
2316        if let Some(item) = self.current_item_mut() {
2317            if let SettingControl::Dropdown(ref mut d) = item.control {
2318                d.select_next();
2319            }
2320        }
2321    }
2322
2323    /// Jump to first option in dropdown
2324    pub fn dropdown_home(&mut self) {
2325        if let Some(item) = self.current_item_mut() {
2326            if let SettingControl::Dropdown(ref mut d) = item.control {
2327                if !d.options.is_empty() {
2328                    d.selected = 0;
2329                    d.ensure_visible();
2330                }
2331            }
2332        }
2333    }
2334
2335    /// Jump to last option in dropdown
2336    pub fn dropdown_end(&mut self) {
2337        if let Some(item) = self.current_item_mut() {
2338            if let SettingControl::Dropdown(ref mut d) = item.control {
2339                if !d.options.is_empty() {
2340                    d.selected = d.options.len() - 1;
2341                    d.ensure_visible();
2342                }
2343            }
2344        }
2345    }
2346
2347    /// Confirm dropdown selection (close and record change)
2348    pub fn dropdown_confirm(&mut self) {
2349        if let Some(item) = self.current_item_mut() {
2350            if let SettingControl::Dropdown(ref mut d) = item.control {
2351                d.confirm();
2352            }
2353        }
2354        self.on_value_changed();
2355    }
2356
2357    /// Cancel dropdown (restore original value and close)
2358    pub fn dropdown_cancel(&mut self) {
2359        if let Some(item) = self.current_item_mut() {
2360            if let SettingControl::Dropdown(ref mut d) = item.control {
2361                d.cancel();
2362            }
2363        }
2364    }
2365
2366    /// Select a specific dropdown option by index and confirm
2367    pub fn dropdown_select(&mut self, option_idx: usize) {
2368        if let Some(item) = self.current_item_mut() {
2369            if let SettingControl::Dropdown(ref mut d) = item.control {
2370                if option_idx < d.options.len() {
2371                    d.selected = option_idx;
2372                    d.confirm();
2373                }
2374            }
2375        }
2376        self.on_value_changed();
2377    }
2378
2379    /// Set dropdown hover index (for mouse hover indication)
2380    /// Returns true if the hover index changed
2381    pub fn set_dropdown_hover(&mut self, hover_idx: Option<usize>) -> bool {
2382        if let Some(item) = self.current_item_mut() {
2383            if let SettingControl::Dropdown(ref mut d) = item.control {
2384                if d.open && d.hover_index != hover_idx {
2385                    d.hover_index = hover_idx;
2386                    return true;
2387                }
2388            }
2389        }
2390        false
2391    }
2392
2393    /// Scroll open dropdown by delta (positive = down, negative = up)
2394    pub fn dropdown_scroll(&mut self, delta: i32) {
2395        if let Some(item) = self.current_item_mut() {
2396            if let SettingControl::Dropdown(ref mut d) = item.control {
2397                if d.open {
2398                    d.scroll_by(delta);
2399                }
2400            }
2401        }
2402    }
2403
2404    // =========== Number editing methods ===========
2405
2406    /// Check if current item is a number input being edited
2407    pub fn is_number_editing(&self) -> bool {
2408        self.current_item().is_some_and(|item| {
2409            if let SettingControl::Number(ref n) = item.control {
2410                n.editing()
2411            } else {
2412                false
2413            }
2414        })
2415    }
2416
2417    /// Start number editing mode
2418    pub fn start_number_editing(&mut self) {
2419        if let Some(item) = self.current_item_mut() {
2420            if let SettingControl::Number(ref mut n) = item.control {
2421                n.start_editing();
2422            }
2423        }
2424    }
2425
2426    /// Insert a character into number input
2427    pub fn number_insert(&mut self, c: char) {
2428        if let Some(item) = self.current_item_mut() {
2429            if let SettingControl::Number(ref mut n) = item.control {
2430                n.insert_char(c);
2431            }
2432        }
2433    }
2434
2435    /// Backspace in number input
2436    pub fn number_backspace(&mut self) {
2437        if let Some(item) = self.current_item_mut() {
2438            if let SettingControl::Number(ref mut n) = item.control {
2439                n.backspace();
2440            }
2441        }
2442    }
2443
2444    /// Confirm number editing
2445    pub fn number_confirm(&mut self) {
2446        if let Some(item) = self.current_item_mut() {
2447            if let SettingControl::Number(ref mut n) = item.control {
2448                n.confirm_editing();
2449            }
2450        }
2451        self.on_value_changed();
2452    }
2453
2454    /// Cancel number editing
2455    pub fn number_cancel(&mut self) {
2456        if let Some(item) = self.current_item_mut() {
2457            if let SettingControl::Number(ref mut n) = item.control {
2458                n.cancel_editing();
2459            }
2460        }
2461    }
2462
2463    /// Delete character forward in number input
2464    pub fn number_delete(&mut self) {
2465        if let Some(item) = self.current_item_mut() {
2466            if let SettingControl::Number(ref mut n) = item.control {
2467                n.delete();
2468            }
2469        }
2470    }
2471
2472    /// Move cursor left in number input
2473    pub fn number_move_left(&mut self) {
2474        if let Some(item) = self.current_item_mut() {
2475            if let SettingControl::Number(ref mut n) = item.control {
2476                n.move_left();
2477            }
2478        }
2479    }
2480
2481    /// Move cursor right in number input
2482    pub fn number_move_right(&mut self) {
2483        if let Some(item) = self.current_item_mut() {
2484            if let SettingControl::Number(ref mut n) = item.control {
2485                n.move_right();
2486            }
2487        }
2488    }
2489
2490    /// Move cursor to start of number input
2491    pub fn number_move_home(&mut self) {
2492        if let Some(item) = self.current_item_mut() {
2493            if let SettingControl::Number(ref mut n) = item.control {
2494                n.move_home();
2495            }
2496        }
2497    }
2498
2499    /// Move cursor to end of number input
2500    pub fn number_move_end(&mut self) {
2501        if let Some(item) = self.current_item_mut() {
2502            if let SettingControl::Number(ref mut n) = item.control {
2503                n.move_end();
2504            }
2505        }
2506    }
2507
2508    /// Move cursor left selecting in number input
2509    pub fn number_move_left_selecting(&mut self) {
2510        if let Some(item) = self.current_item_mut() {
2511            if let SettingControl::Number(ref mut n) = item.control {
2512                n.move_left_selecting();
2513            }
2514        }
2515    }
2516
2517    /// Move cursor right selecting in number input
2518    pub fn number_move_right_selecting(&mut self) {
2519        if let Some(item) = self.current_item_mut() {
2520            if let SettingControl::Number(ref mut n) = item.control {
2521                n.move_right_selecting();
2522            }
2523        }
2524    }
2525
2526    /// Move cursor to start selecting in number input
2527    pub fn number_move_home_selecting(&mut self) {
2528        if let Some(item) = self.current_item_mut() {
2529            if let SettingControl::Number(ref mut n) = item.control {
2530                n.move_home_selecting();
2531            }
2532        }
2533    }
2534
2535    /// Move cursor to end selecting in number input
2536    pub fn number_move_end_selecting(&mut self) {
2537        if let Some(item) = self.current_item_mut() {
2538            if let SettingControl::Number(ref mut n) = item.control {
2539                n.move_end_selecting();
2540            }
2541        }
2542    }
2543
2544    /// Move word left in number input
2545    pub fn number_move_word_left(&mut self) {
2546        if let Some(item) = self.current_item_mut() {
2547            if let SettingControl::Number(ref mut n) = item.control {
2548                n.move_word_left();
2549            }
2550        }
2551    }
2552
2553    /// Move word right in number input
2554    pub fn number_move_word_right(&mut self) {
2555        if let Some(item) = self.current_item_mut() {
2556            if let SettingControl::Number(ref mut n) = item.control {
2557                n.move_word_right();
2558            }
2559        }
2560    }
2561
2562    /// Move word left selecting in number input
2563    pub fn number_move_word_left_selecting(&mut self) {
2564        if let Some(item) = self.current_item_mut() {
2565            if let SettingControl::Number(ref mut n) = item.control {
2566                n.move_word_left_selecting();
2567            }
2568        }
2569    }
2570
2571    /// Move word right selecting in number input
2572    pub fn number_move_word_right_selecting(&mut self) {
2573        if let Some(item) = self.current_item_mut() {
2574            if let SettingControl::Number(ref mut n) = item.control {
2575                n.move_word_right_selecting();
2576            }
2577        }
2578    }
2579
2580    /// Select all text in number input
2581    pub fn number_select_all(&mut self) {
2582        if let Some(item) = self.current_item_mut() {
2583            if let SettingControl::Number(ref mut n) = item.control {
2584                n.select_all();
2585            }
2586        }
2587    }
2588
2589    /// Delete word backward in number input
2590    pub fn number_delete_word_backward(&mut self) {
2591        if let Some(item) = self.current_item_mut() {
2592            if let SettingControl::Number(ref mut n) = item.control {
2593                n.delete_word_backward();
2594            }
2595        }
2596    }
2597
2598    /// Delete word forward in number input
2599    pub fn number_delete_word_forward(&mut self) {
2600        if let Some(item) = self.current_item_mut() {
2601            if let SettingControl::Number(ref mut n) = item.control {
2602                n.delete_word_forward();
2603            }
2604        }
2605    }
2606
2607    /// Get list of pending changes for display
2608    pub fn get_change_descriptions(&self) -> Vec<String> {
2609        let mut descriptions: Vec<String> = self
2610            .pending_changes
2611            .iter()
2612            .map(|(path, value)| {
2613                let value_str = match value {
2614                    serde_json::Value::Bool(b) => b.to_string(),
2615                    serde_json::Value::Number(n) => n.to_string(),
2616                    serde_json::Value::String(s) => format!("\"{}\"", s),
2617                    _ => value.to_string(),
2618                };
2619                format!("{}: {}", path, value_str)
2620            })
2621            .collect();
2622        // Also include pending deletions (resets)
2623        for path in &self.pending_deletions {
2624            descriptions.push(format!("{}: (reset to default)", path));
2625        }
2626        descriptions.sort();
2627        descriptions
2628    }
2629}
2630
2631/// Update a control's state from a JSON value
2632fn update_control_from_value(control: &mut SettingControl, value: &serde_json::Value) {
2633    match control {
2634        SettingControl::Toggle(state) => {
2635            if let Some(b) = value.as_bool() {
2636                state.checked = b;
2637            }
2638        }
2639        SettingControl::Number(state) => {
2640            if let Some(n) = value.as_i64() {
2641                state.value = n;
2642            }
2643        }
2644        SettingControl::Dropdown(state) => {
2645            if let Some(s) = value.as_str() {
2646                if let Some(idx) = state.options.iter().position(|o| o == s) {
2647                    state.selected = idx;
2648                }
2649            }
2650        }
2651        SettingControl::Text(state) => {
2652            if let Some(s) = value.as_str() {
2653                state.value = s.to_string();
2654                state.cursor = state.value.len();
2655            }
2656        }
2657        SettingControl::TextList(state) => {
2658            if let Some(arr) = value.as_array() {
2659                state.items = arr
2660                    .iter()
2661                    .filter_map(|v| {
2662                        if state.is_integer {
2663                            v.as_i64()
2664                                .map(|n| n.to_string())
2665                                .or_else(|| v.as_u64().map(|n| n.to_string()))
2666                                .or_else(|| v.as_f64().map(|n| n.to_string()))
2667                        } else {
2668                            v.as_str().map(String::from)
2669                        }
2670                    })
2671                    .collect();
2672            }
2673        }
2674        SettingControl::DualList(state) => {
2675            if let Some(arr) = value.as_array() {
2676                state.included = arr
2677                    .iter()
2678                    .filter_map(|v| v.as_str().map(String::from))
2679                    .collect();
2680            }
2681        }
2682        SettingControl::Map(state) => {
2683            if let Some(obj) = value.as_object() {
2684                state.entries = obj.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
2685                state.entries.sort_by(|a, b| a.0.cmp(&b.0));
2686            }
2687        }
2688        SettingControl::ObjectArray(state) => {
2689            if let Some(arr) = value.as_array() {
2690                state.bindings = arr.clone();
2691            }
2692        }
2693        SettingControl::Json(state) => {
2694            // Re-create from value with pretty printing
2695            let json_str =
2696                serde_json::to_string_pretty(value).unwrap_or_else(|_| "null".to_string());
2697            let json_str = if json_str.is_empty() {
2698                "null".to_string()
2699            } else {
2700                json_str
2701            };
2702            state.original_text = json_str.clone();
2703            state.editor.set_value(&json_str);
2704            state.scroll_offset = 0;
2705        }
2706        SettingControl::Complex { .. } => {}
2707    }
2708}
2709
2710#[cfg(test)]
2711mod tests {
2712    use super::*;
2713
2714    const TEST_SCHEMA: &str = r#"
2715{
2716  "type": "object",
2717  "properties": {
2718    "theme": {
2719      "type": "string",
2720      "default": "dark"
2721    },
2722    "line_numbers": {
2723      "type": "boolean",
2724      "default": true
2725    }
2726  },
2727  "$defs": {}
2728}
2729"#;
2730
2731    fn test_config() -> Config {
2732        Config::default()
2733    }
2734
2735    #[test]
2736    fn test_settings_state_creation() {
2737        let config = test_config();
2738        let state = SettingsState::new(TEST_SCHEMA, &config).unwrap();
2739
2740        assert!(!state.visible);
2741        assert_eq!(state.selected_category, 0);
2742        assert!(!state.has_changes());
2743    }
2744
2745    #[test]
2746    fn test_navigation() {
2747        let config = test_config();
2748        let mut state = SettingsState::new(TEST_SCHEMA, &config).unwrap();
2749
2750        // Start in category focus
2751        assert_eq!(state.focus_panel(), FocusPanel::Categories);
2752
2753        // Toggle to settings
2754        state.toggle_focus();
2755        assert_eq!(state.focus_panel(), FocusPanel::Settings);
2756
2757        // Navigate items
2758        state.select_next();
2759        assert_eq!(state.selected_item, 1);
2760
2761        state.select_prev();
2762        assert_eq!(state.selected_item, 0);
2763    }
2764
2765    #[test]
2766    fn test_pending_changes() {
2767        let config = test_config();
2768        let mut state = SettingsState::new(TEST_SCHEMA, &config).unwrap();
2769
2770        assert!(!state.has_changes());
2771
2772        state.set_pending_change("/theme", serde_json::Value::String("light".to_string()));
2773        assert!(state.has_changes());
2774
2775        state.discard_changes();
2776        assert!(!state.has_changes());
2777    }
2778
2779    #[test]
2780    fn test_show_hide() {
2781        let config = test_config();
2782        let mut state = SettingsState::new(TEST_SCHEMA, &config).unwrap();
2783
2784        assert!(!state.visible);
2785
2786        state.show();
2787        assert!(state.visible);
2788        assert_eq!(state.focus_panel(), FocusPanel::Categories);
2789
2790        state.hide();
2791        assert!(!state.visible);
2792    }
2793
2794    // Schema with dropdown (enum) and number controls for testing
2795    const TEST_SCHEMA_CONTROLS: &str = r#"
2796{
2797  "type": "object",
2798  "properties": {
2799    "theme": {
2800      "type": "string",
2801      "enum": ["dark", "light", "high-contrast"],
2802      "default": "dark"
2803    },
2804    "tab_size": {
2805      "type": "integer",
2806      "minimum": 1,
2807      "maximum": 8,
2808      "default": 4
2809    },
2810    "line_numbers": {
2811      "type": "boolean",
2812      "default": true
2813    }
2814  },
2815  "$defs": {}
2816}
2817"#;
2818
2819    #[test]
2820    fn test_dropdown_toggle() {
2821        let config = test_config();
2822        let mut state = SettingsState::new(TEST_SCHEMA_CONTROLS, &config).unwrap();
2823        state.show();
2824        state.toggle_focus(); // Move to settings
2825
2826        // Items are sorted alphabetically: line_numbers, tab_size, theme
2827        // Navigate to theme (dropdown) at index 2
2828        state.select_next();
2829        state.select_next();
2830        assert!(!state.is_dropdown_open());
2831
2832        state.dropdown_toggle();
2833        assert!(state.is_dropdown_open());
2834
2835        state.dropdown_toggle();
2836        assert!(!state.is_dropdown_open());
2837    }
2838
2839    #[test]
2840    fn test_dropdown_cancel_restores() {
2841        let config = test_config();
2842        let mut state = SettingsState::new(TEST_SCHEMA_CONTROLS, &config).unwrap();
2843        state.show();
2844        state.toggle_focus();
2845
2846        // Items are sorted alphabetically: line_numbers, tab_size, theme
2847        // Navigate to theme (dropdown) at index 2
2848        state.select_next();
2849        state.select_next();
2850
2851        // Open dropdown
2852        state.dropdown_toggle();
2853        assert!(state.is_dropdown_open());
2854
2855        // Get initial selection
2856        let initial = state.current_item().and_then(|item| {
2857            if let SettingControl::Dropdown(ref d) = item.control {
2858                Some(d.selected)
2859            } else {
2860                None
2861            }
2862        });
2863
2864        // Change selection
2865        state.dropdown_next();
2866        let after_change = state.current_item().and_then(|item| {
2867            if let SettingControl::Dropdown(ref d) = item.control {
2868                Some(d.selected)
2869            } else {
2870                None
2871            }
2872        });
2873        assert_ne!(initial, after_change);
2874
2875        // Cancel - should restore
2876        state.dropdown_cancel();
2877        assert!(!state.is_dropdown_open());
2878
2879        let after_cancel = state.current_item().and_then(|item| {
2880            if let SettingControl::Dropdown(ref d) = item.control {
2881                Some(d.selected)
2882            } else {
2883                None
2884            }
2885        });
2886        assert_eq!(initial, after_cancel);
2887    }
2888
2889    #[test]
2890    fn test_dropdown_confirm_keeps_selection() {
2891        let config = test_config();
2892        let mut state = SettingsState::new(TEST_SCHEMA_CONTROLS, &config).unwrap();
2893        state.show();
2894        state.toggle_focus();
2895
2896        // Open dropdown
2897        state.dropdown_toggle();
2898
2899        // Change selection
2900        state.dropdown_next();
2901        let after_change = state.current_item().and_then(|item| {
2902            if let SettingControl::Dropdown(ref d) = item.control {
2903                Some(d.selected)
2904            } else {
2905                None
2906            }
2907        });
2908
2909        // Confirm - should keep new selection
2910        state.dropdown_confirm();
2911        assert!(!state.is_dropdown_open());
2912
2913        let after_confirm = state.current_item().and_then(|item| {
2914            if let SettingControl::Dropdown(ref d) = item.control {
2915                Some(d.selected)
2916            } else {
2917                None
2918            }
2919        });
2920        assert_eq!(after_change, after_confirm);
2921    }
2922
2923    #[test]
2924    fn test_number_editing() {
2925        let config = test_config();
2926        let mut state = SettingsState::new(TEST_SCHEMA_CONTROLS, &config).unwrap();
2927        state.show();
2928        state.toggle_focus();
2929
2930        // Navigate to tab_size (second item)
2931        state.select_next();
2932
2933        // Should not be editing yet
2934        assert!(!state.is_number_editing());
2935
2936        // Start editing
2937        state.start_number_editing();
2938        assert!(state.is_number_editing());
2939
2940        // Insert characters
2941        state.number_insert('8');
2942
2943        // Confirm
2944        state.number_confirm();
2945        assert!(!state.is_number_editing());
2946    }
2947
2948    #[test]
2949    fn test_number_cancel_editing() {
2950        let config = test_config();
2951        let mut state = SettingsState::new(TEST_SCHEMA_CONTROLS, &config).unwrap();
2952        state.show();
2953        state.toggle_focus();
2954
2955        // Navigate to tab_size
2956        state.select_next();
2957
2958        // Get initial value
2959        let initial_value = state.current_item().and_then(|item| {
2960            if let SettingControl::Number(ref n) = item.control {
2961                Some(n.value)
2962            } else {
2963                None
2964            }
2965        });
2966
2967        // Start editing and make changes
2968        state.start_number_editing();
2969        state.number_backspace();
2970        state.number_insert('9');
2971        state.number_insert('9');
2972
2973        // Cancel
2974        state.number_cancel();
2975        assert!(!state.is_number_editing());
2976
2977        // Value should be unchanged (edit text was just cleared)
2978        let after_cancel = state.current_item().and_then(|item| {
2979            if let SettingControl::Number(ref n) = item.control {
2980                Some(n.value)
2981            } else {
2982                None
2983            }
2984        });
2985        assert_eq!(initial_value, after_cancel);
2986    }
2987
2988    #[test]
2989    fn test_number_backspace() {
2990        let config = test_config();
2991        let mut state = SettingsState::new(TEST_SCHEMA_CONTROLS, &config).unwrap();
2992        state.show();
2993        state.toggle_focus();
2994        state.select_next();
2995
2996        state.start_number_editing();
2997        state.number_backspace();
2998
2999        // Check edit text was modified
3000        let display_text = state.current_item().and_then(|item| {
3001            if let SettingControl::Number(ref n) = item.control {
3002                Some(n.display_text())
3003            } else {
3004                None
3005            }
3006        });
3007        // Original "4" should have last char removed, leaving ""
3008        assert_eq!(display_text, Some(String::new()));
3009
3010        state.number_cancel();
3011    }
3012
3013    #[test]
3014    fn test_layer_selection() {
3015        let config = test_config();
3016        let mut state = SettingsState::new(TEST_SCHEMA, &config).unwrap();
3017
3018        // Default is User layer
3019        assert_eq!(state.target_layer, ConfigLayer::User);
3020        assert_eq!(state.target_layer_name(), "User");
3021
3022        // Cycle through layers
3023        state.cycle_target_layer();
3024        assert_eq!(state.target_layer, ConfigLayer::Project);
3025        assert_eq!(state.target_layer_name(), "Project");
3026
3027        state.cycle_target_layer();
3028        assert_eq!(state.target_layer, ConfigLayer::Session);
3029        assert_eq!(state.target_layer_name(), "Session");
3030
3031        state.cycle_target_layer();
3032        assert_eq!(state.target_layer, ConfigLayer::User);
3033
3034        // Set directly
3035        state.set_target_layer(ConfigLayer::Project);
3036        assert_eq!(state.target_layer, ConfigLayer::Project);
3037
3038        // Setting to System should be ignored (read-only)
3039        state.set_target_layer(ConfigLayer::System);
3040        assert_eq!(state.target_layer, ConfigLayer::Project);
3041    }
3042
3043    #[test]
3044    fn test_layer_switch_clears_pending_changes() {
3045        let config = test_config();
3046        let mut state = SettingsState::new(TEST_SCHEMA, &config).unwrap();
3047
3048        // Add a pending change
3049        state.set_pending_change("/theme", serde_json::Value::String("light".to_string()));
3050        assert!(state.has_changes());
3051
3052        // Switching layers clears pending changes
3053        state.cycle_target_layer();
3054        assert!(!state.has_changes());
3055    }
3056
3057    /// Regression test for the quicklsp settings-save bug.
3058    ///
3059    /// When editing an existing map entry whose value schema is itself an
3060    /// array (the `is_single_value` case — e.g. `universal_lsp.quicklsp`
3061    /// where the value schema is `LspLanguageConfig` = array of
3062    /// `LspServerConfig`), opening a nested ArrayItem dialog used to
3063    /// compute its `map_path` from `parent.map_path + item.path` only —
3064    /// dropping the entry key segment whenever `item.path` was `""`.
3065    /// The nested dialog's save would then record a pending change at
3066    /// `/universal_lsp/`, which downstream wrote an empty-string key
3067    /// under `universal_lsp` in the saved config file.
3068    ///
3069    /// This test exercises the real `open_nested_entry_dialog` + save
3070    /// path using a schema shaped like `LspLanguageConfig` and asserts:
3071    /// 1. The nested dialog's `map_path` is the full entry path.
3072    /// 2. The recorded pending-change path is the full entry path, not
3073    ///    `/universal_lsp/` and not any `/universal_lsp/*` path with a
3074    ///    trailing slash.
3075    #[test]
3076    fn nested_array_save_records_full_entry_path() {
3077        // EntryDialogState is already re-exported via `use super::*;`.
3078        // Pull in SettingType from the sibling schema module explicitly.
3079        use crate::view::settings::schema::SettingType;
3080
3081        let config = test_config();
3082        let mut state = SettingsState::new(TEST_SCHEMA, &config).unwrap();
3083
3084        // LspServerConfig-ish: a single "enabled" boolean field.
3085        let item_schema = SettingSchema {
3086            path: "/item".to_string(),
3087            name: "Server".to_string(),
3088            description: None,
3089            setting_type: SettingType::Object {
3090                properties: vec![SettingSchema {
3091                    path: "/enabled".to_string(),
3092                    name: "Enabled".to_string(),
3093                    description: None,
3094                    setting_type: SettingType::Boolean,
3095                    default: Some(serde_json::json!(false)),
3096                    read_only: false,
3097                    section: None,
3098                    order: None,
3099                    nullable: false,
3100                    enum_from: None,
3101                    dual_list_sibling: None,
3102                }],
3103            },
3104            default: None,
3105            read_only: false,
3106            section: None,
3107            order: None,
3108            nullable: false,
3109            enum_from: None,
3110            dual_list_sibling: None,
3111        };
3112
3113        // universal_lsp's value schema: ObjectArray of the item schema above.
3114        // Note: path is "" just like the real schema parser produces for
3115        // `parse_setting("value", "", ...)` — this is what drives the
3116        // `is_single_value` code path in EntryDialogState::from_schema.
3117        let value_schema = SettingSchema {
3118            path: String::new(),
3119            name: "value".to_string(),
3120            description: None,
3121            setting_type: SettingType::ObjectArray {
3122                item_schema: Box::new(item_schema.clone()),
3123                display_field: None,
3124            },
3125            default: None,
3126            read_only: false,
3127            section: None,
3128            order: None,
3129            nullable: false,
3130            enum_from: None,
3131            dual_list_sibling: None,
3132        };
3133
3134        // Parent dialog: user is editing the existing "quicklsp" entry
3135        // under /universal_lsp. This is the MapEntry dialog the real UI
3136        // opens via `open_entry_dialog`.
3137        let parent = EntryDialogState::from_schema(
3138            "quicklsp".to_string(),
3139            &serde_json::json!([{ "enabled": true }]),
3140            &value_schema,
3141            "/universal_lsp",
3142            false, // existing entry
3143            false,
3144        );
3145
3146        // Precondition: is_single_value triggers and entry_path is correct.
3147        assert!(
3148            parent.is_single_value,
3149            "array value_schema should trigger is_single_value path"
3150        );
3151        assert_eq!(parent.entry_path(), "/universal_lsp/quicklsp");
3152
3153        state.entry_dialog_stack.push(parent);
3154
3155        // Exercise the REAL open_nested_entry_dialog — this is the code
3156        // path that used to produce the wrong path. The outer dialog's
3157        // ObjectArray item is already focused with its first entry
3158        // selected (init_object_array_focus in from_schema).
3159        state.open_nested_entry_dialog();
3160
3161        // A nested dialog should have been pushed.
3162        assert_eq!(
3163            state.entry_dialog_stack.len(),
3164            2,
3165            "open_nested_entry_dialog should have pushed a nested dialog"
3166        );
3167
3168        // CRITICAL (part 1): the nested dialog must root at the full
3169        // entry path, not at the parent's map_path alone.
3170        let nested_map_path = state
3171            .entry_dialog_stack
3172            .last()
3173            .map(|d| d.map_path.clone())
3174            .unwrap();
3175        assert_eq!(
3176            nested_map_path, "/universal_lsp/quicklsp",
3177            "BUG: nested dialog's map_path dropped the 'quicklsp' key segment"
3178        );
3179
3180        // Save the nested dialog via the normal dispatch.
3181        state.save_entry_dialog();
3182
3183        // Nested dialog should be popped, parent still on the stack.
3184        assert_eq!(state.entry_dialog_stack.len(), 1);
3185
3186        // CRITICAL (part 2): the pending change must be rooted at the
3187        // full entry path, not at `/universal_lsp/` with a trailing slash.
3188        assert!(
3189            !state.pending_changes.contains_key("/universal_lsp/"),
3190            "regression: pending change recorded under empty-key path /universal_lsp/. \
3191             All keys: {:?}",
3192            state.pending_changes.keys().collect::<Vec<_>>()
3193        );
3194        assert!(
3195            !state
3196                .pending_changes
3197                .keys()
3198                .any(|k| k.starts_with("/universal_lsp") && k.ends_with('/')),
3199            "no /universal_lsp/* path should end in a trailing slash; got {:?}",
3200            state.pending_changes.keys().collect::<Vec<_>>()
3201        );
3202        assert!(
3203            state
3204                .pending_changes
3205                .contains_key("/universal_lsp/quicklsp"),
3206            "expected pending change at /universal_lsp/quicklsp, got {:?}",
3207            state.pending_changes.keys().collect::<Vec<_>>()
3208        );
3209    }
3210
3211    #[test]
3212    fn test_refresh_dual_list_sibling_updates_excluded() {
3213        use crate::view::controls::DualListState;
3214
3215        // Uses the real config schema (which has /editor/status_bar/left and /right
3216        // as DualList siblings).
3217        let schema = include_str!("../../../plugins/config-schema.json");
3218        let config = test_config();
3219        let mut state = SettingsState::new(schema, &config).unwrap();
3220
3221        // Find the Editor page and the status bar left/right items
3222        let editor_page_idx = state
3223            .pages
3224            .iter()
3225            .position(|p| p.path == "/editor")
3226            .expect("editor page");
3227        state.selected_category = editor_page_idx;
3228
3229        let (left_idx, right_idx) = {
3230            let page = &state.pages[editor_page_idx];
3231            let l = page
3232                .items
3233                .iter()
3234                .position(|i| i.path == "/editor/status_bar/left")
3235                .expect("left item");
3236            let r = page
3237                .items
3238                .iter()
3239                .position(|i| i.path == "/editor/status_bar/right")
3240                .expect("right item");
3241            (l, r)
3242        };
3243
3244        // Sanity: both should be DualList controls
3245        assert!(matches!(
3246            &state.pages[editor_page_idx].items[left_idx].control,
3247            SettingControl::DualList(_)
3248        ));
3249
3250        // Capture the initial left.excluded — should match right's default values.
3251        let default_right_items: Vec<String> =
3252            match &state.pages[editor_page_idx].items[right_idx].control {
3253                SettingControl::DualList(dl) => dl.included.clone(),
3254                _ => panic!("right should be DualList"),
3255            };
3256        let initial_left_excluded: Vec<String> =
3257            match &state.pages[editor_page_idx].items[left_idx].control {
3258                SettingControl::DualList(dl) => dl.excluded.clone(),
3259                _ => panic!("left should be DualList"),
3260            };
3261        assert_eq!(
3262            initial_left_excluded, default_right_items,
3263            "left.excluded should mirror right's included on initial build"
3264        );
3265
3266        // Mutate left: add a new element that's not in right
3267        let new_element = "{chord}".to_string();
3268        state.selected_item = left_idx;
3269        state
3270            .with_current_dual_list_mut(|dl: &mut DualListState| {
3271                if !dl.included.contains(&new_element) {
3272                    dl.included.push(new_element.clone());
3273                }
3274            })
3275            .expect("current item is a DualList");
3276
3277        // Refresh the sibling: right.excluded should now contain the new element
3278        state.refresh_dual_list_sibling();
3279
3280        match &state.pages[editor_page_idx].items[right_idx].control {
3281            SettingControl::DualList(dl) => {
3282                assert!(
3283                    dl.excluded.contains(&new_element),
3284                    "right.excluded should be updated to reflect left's new inclusion"
3285                );
3286            }
3287            _ => panic!("right should be DualList"),
3288        }
3289    }
3290
3291    #[test]
3292    fn test_with_dual_list_mut_returns_none_for_non_dual_list() {
3293        let config = test_config();
3294        let mut state = SettingsState::new(TEST_SCHEMA, &config).unwrap();
3295
3296        // TEST_SCHEMA has no DualList items, so all calls should return None
3297        let result = state.with_dual_list_mut(0, |_| ());
3298        assert!(result.is_none());
3299    }
3300}