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