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