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