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