Skip to main content

fresh/view/settings/
state.rs

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