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