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