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