Skip to main content

fresh/view/settings/
entry_dialog.rs

1//! Entry detail dialog for editing complex map entries
2//!
3//! Provides a modal dialog for editing complex map entries using the same
4//! SettingItem/SettingControl infrastructure as the main settings UI.
5
6use super::items::{
7    build_item_from_value, control_to_value, ItemBoxStyle, SettingControl, SettingItem,
8};
9use super::schema::{SettingSchema, SettingType};
10use crate::view::controls::{FocusState, TextInputState};
11use serde_json::Value;
12use std::collections::HashMap;
13
14/// State for the entry detail dialog
15#[derive(Debug, Clone)]
16pub struct EntryDialogState {
17    /// The entry key (e.g., "rust" for language)
18    pub entry_key: String,
19    /// The map path this entry belongs to (e.g., "/languages", "/lsp")
20    pub map_path: String,
21    /// Human-readable title for the dialog
22    pub title: String,
23    /// Whether this is a new entry (vs editing existing)
24    pub is_new: bool,
25    /// Items in the dialog (using same SettingItem structure as main settings)
26    pub items: Vec<SettingItem>,
27    /// Currently selected item index
28    pub selected_item: usize,
29    /// Sub-focus index within the selected item (for TextList/Map navigation)
30    pub sub_focus: Option<usize>,
31    /// Whether we're in text editing mode
32    pub editing_text: bool,
33    /// Currently focused button (0=Save, 1=Delete, 2=Cancel for existing; 0=Save, 1=Cancel for new)
34    pub focused_button: usize,
35    /// Whether focus is on buttons (true) or items (false)
36    pub focus_on_buttons: bool,
37    /// Whether deletion was requested
38    pub delete_requested: bool,
39    /// Scroll offset for the items area
40    pub scroll_offset: usize,
41    /// Last known viewport height (updated during render)
42    pub viewport_height: usize,
43    /// Hovered item index (for mouse hover feedback)
44    pub hover_item: Option<usize>,
45    /// Hovered button index (for mouse hover feedback)
46    pub hover_button: Option<usize>,
47    /// Original value when dialog was opened (for Cancel to restore)
48    pub original_value: Value,
49    /// Index of first editable item (items before this are read-only)
50    /// Used for rendering separator and focus navigation
51    pub first_editable_index: usize,
52    /// Whether deletion is disabled (for auto-managed entries like plugins)
53    pub no_delete: bool,
54    /// When true, the dialog wraps a single non-Object value (e.g., an ObjectArray).
55    /// `to_value()` returns the raw control value instead of wrapping in an Object.
56    pub is_single_value: bool,
57    /// True when the dialog edits an item in an array (constructed via
58    /// `for_array_item`); false for map entries (`from_schema`). Drives
59    /// the Delete button's label/confirmation copy so the prompt doesn't
60    /// show a numeric index as if it were a meaningful name.
61    pub is_array_item: bool,
62    /// Set to true on the first user-driven mutation (typed char,
63    /// toggled bool, list add/remove, etc.). Drives the dirty
64    /// indicator + the Esc discard prompt without relying on a
65    /// JSON-equality check that's too noisy at the schema layer.
66    pub user_edited: bool,
67}
68
69impl EntryDialogState {
70    /// Create a dialog from a schema definition
71    ///
72    /// This is the primary, schema-driven constructor. It builds items
73    /// dynamically from the SettingSchema's properties using the same
74    /// build logic as the main settings UI.
75    pub fn from_schema(
76        key: String,
77        value: &Value,
78        schema: &SettingSchema,
79        map_path: &str,
80        is_new: bool,
81        no_delete: bool,
82        available_status_bar_tokens: &HashMap<String, String>,
83    ) -> Self {
84        let mut items = Vec::new();
85
86        // Add key field as first item (read-only for existing entries)
87        let key_item = SettingItem {
88            path: "__key__".to_string(),
89            name: "Key".to_string(),
90            description: Some("unique identifier for this entry".to_string()),
91            control: SettingControl::Text(TextInputState::new("Key").with_value(&key)),
92            default: None,
93            modified: false,
94            layer_source: crate::config_io::ConfigLayer::System,
95            read_only: !is_new, // Key is editable only for new entries
96            is_auto_managed: false,
97            nullable: false,
98            is_null: false,
99            section: None,
100            is_section_start: false,
101            style: ItemBoxStyle::default(),
102            dual_list_sibling: None,
103        };
104        items.push(key_item);
105
106        // Add schema-driven items from object properties
107        let is_single_value = !matches!(&schema.setting_type, SettingType::Object { .. });
108        if let SettingType::Object { properties } = &schema.setting_type {
109            for prop in properties {
110                let field_name = prop.path.trim_start_matches('/');
111                let field_value = value.get(field_name);
112                let item = build_item_from_value(prop, field_value, available_status_bar_tokens);
113                items.push(item);
114            }
115        } else {
116            // For non-object types (e.g., ObjectArray, Map), build a single item
117            // from the entire value so the dialog can render it
118            let item = build_item_from_value(schema, Some(value), available_status_bar_tokens);
119            items.push(item);
120        }
121
122        // Sort items: read-only first, then editable (stable sort preserves x-order)
123        items.sort_by_key(|item| !item.read_only);
124
125        // Compute is_section_start for section headers in entry dialogs
126        Self::compute_section_starts(&mut items);
127
128        // Find the first editable item index
129        let first_editable_index = items
130            .iter()
131            .position(|item| !item.read_only)
132            .unwrap_or(items.len());
133
134        // If all items are read-only, start with focus on buttons
135        let focus_on_buttons = first_editable_index >= items.len();
136        let selected_item = if focus_on_buttons {
137            0
138        } else {
139            first_editable_index
140        };
141
142        let title = if is_new {
143            format!("Add {}", schema.name)
144        } else {
145            format!("Edit {}", schema.name)
146        };
147
148        let mut result = Self {
149            entry_key: key,
150            map_path: map_path.to_string(),
151            title,
152            is_new,
153            items,
154            selected_item,
155            sub_focus: None,
156            editing_text: false,
157            focused_button: 0,
158            focus_on_buttons,
159            delete_requested: false,
160            scroll_offset: 0,
161            viewport_height: 20, // Default, updated during render
162            hover_item: None,
163            hover_button: None,
164            original_value: value.clone(),
165            first_editable_index,
166            no_delete,
167            is_single_value,
168            is_array_item: false,
169            user_edited: false,
170        };
171        // Pre-focus the first item in any ObjectArray controls so pressing
172        // Enter opens the item editor instead of "Add new".
173        result.init_object_array_focus();
174        result
175    }
176
177    /// Create a dialog for an array item (no key field)
178    ///
179    /// Used for ObjectArray controls where items are identified by index, not key.
180    pub fn for_array_item(
181        index: Option<usize>,
182        value: &Value,
183        schema: &SettingSchema,
184        array_path: &str,
185        is_new: bool,
186        available_status_bar_tokens: &HashMap<String, String>,
187    ) -> Self {
188        let mut items = Vec::new();
189
190        // Add schema-driven items from object properties (no key field for arrays)
191        if let SettingType::Object { properties } = &schema.setting_type {
192            for prop in properties {
193                let field_name = prop.path.trim_start_matches('/');
194                let field_value = value.get(field_name);
195                let item = build_item_from_value(prop, field_value, available_status_bar_tokens);
196                items.push(item);
197            }
198        }
199
200        // Sort items: read-only first, then editable
201        items.sort_by_key(|item| !item.read_only);
202
203        // Compute is_section_start for section headers
204        Self::compute_section_starts(&mut items);
205
206        // Find the first editable item index
207        let first_editable_index = items
208            .iter()
209            .position(|item| !item.read_only)
210            .unwrap_or(items.len());
211
212        // If all items are read-only, start with focus on buttons
213        let focus_on_buttons = first_editable_index >= items.len();
214        let selected_item = if focus_on_buttons {
215            0
216        } else {
217            first_editable_index
218        };
219
220        let title = if is_new {
221            format!("Add {}", schema.name)
222        } else {
223            format!("Edit {}", schema.name)
224        };
225
226        Self {
227            entry_key: index.map_or(String::new(), |i| i.to_string()),
228            map_path: array_path.to_string(),
229            title,
230            is_new,
231            items,
232            selected_item,
233            sub_focus: None,
234            editing_text: false,
235            focused_button: 0,
236            focus_on_buttons,
237            delete_requested: false,
238            scroll_offset: 0,
239            viewport_height: 20,
240            hover_item: None,
241            hover_button: None,
242            original_value: value.clone(),
243            first_editable_index,
244            no_delete: false, // Arrays typically allow deletion
245            is_single_value: false,
246            is_array_item: true,
247            user_edited: false,
248        }
249    }
250
251    /// Compute is_section_start flags for section headers.
252    /// Marks the first item in each new section so the renderer can draw headers.
253    fn compute_section_starts(items: &mut [SettingItem]) {
254        let mut last_section: Option<&str> = None;
255        for item in items.iter_mut() {
256            let current = item.section.as_deref();
257            if current.is_some() && current != last_section {
258                item.is_section_start = true;
259            }
260            if current.is_some() {
261                last_section = current;
262            }
263        }
264    }
265
266    /// Get the current key value from the key item
267    pub fn get_key(&self) -> String {
268        // Find the key item by path (may not be first after sorting)
269        for item in &self.items {
270            if item.path == "__key__" {
271                if let SettingControl::Text(state) = &item.control {
272                    return state.value.clone();
273                }
274            }
275        }
276        self.entry_key.clone()
277    }
278
279    /// Full JSON pointer path to the entry this dialog edits.
280    ///
281    /// For an existing map entry under `/universal_lsp` with key `quicklsp`,
282    /// this returns `/universal_lsp/quicklsp`. For array items, `entry_key`
283    /// is the stringified index. For brand-new map entries whose key has
284    /// not been chosen yet, this falls back to `map_path` (the parent
285    /// container) — callers are expected to avoid writing at that path.
286    ///
287    /// Nested dialogs and any pending-change paths derived from this dialog
288    /// must be rooted here — not at `map_path` — otherwise the entry key
289    /// segment is dropped and changes land under `""` in the saved config.
290    pub fn entry_path(&self) -> String {
291        // Use the live key field so new entries pick up whatever the user
292        // has typed before opening a nested dialog. For existing entries
293        // the key field is read-only and equals `entry_key`, so this is
294        // consistent with the on-disk path.
295        let key = self.get_key();
296        if key.is_empty() {
297            self.map_path.clone()
298        } else {
299            format!("{}/{}", self.map_path, key)
300        }
301    }
302
303    /// Get button count (3 for existing entries with Delete, 2 for new/no_delete entries)
304    pub fn button_count(&self) -> usize {
305        if self.is_new || self.no_delete {
306            2 // Save, Cancel (no Delete for new entries or when no_delete is set)
307        } else {
308            3
309        }
310    }
311
312    /// True when the user has made *any* change to the dialog since
313    /// it was opened. Tracked as an explicit flag (`user_edited`)
314    /// rather than comparing `to_value() != original_value`, because
315    /// the rebuilt JSON shape can differ from the input shape by
316    /// schema-default normalization (e.g. an absent optional field
317    /// rebuilds as an explicit empty string) — which would make the
318    /// dialog read as dirty at open, with no user input.
319    ///
320    /// Used to gate the Esc 'Discard changes?' prompt and to drive
321    /// the title-bar modified indicator.
322    pub fn is_dirty(&self) -> bool {
323        self.user_edited
324    }
325
326    /// Mark the dialog as edited. Called from every mutator path
327    /// (insert_char, toggle_bool, list add/remove, etc.) — anywhere
328    /// the user can produce a change the dialog should remember.
329    pub fn mark_edited(&mut self) {
330        self.user_edited = true;
331    }
332
333    /// Convert dialog state back to JSON value (excludes the __key__ item)
334    /// Auto-commit any draft text sitting in a TextList's trailing
335    /// `[+] Add new` slot. Without this, saving a dialog while the user
336    /// has typed (but not pressed Enter or ↓) into the new-item row
337    /// silently drops that text — the diverging commit semantics
338    /// between text fields ("typed value is just there") and list rows
339    /// ("typing isn't enough — you must commit") was the F21 surprise.
340    /// Run this from every save path so the saved value matches what
341    /// the user sees on screen.
342    pub fn commit_pending_list_drafts(&mut self) {
343        for item in &mut self.items {
344            if let SettingControl::TextList(state) = &mut item.control {
345                if !state.new_item_text.is_empty() {
346                    state.add_item();
347                }
348            }
349        }
350    }
351
352    pub fn to_value(&self) -> Value {
353        // For single-value dialogs (non-Object schemas like ObjectArray),
354        // return the control's value directly instead of wrapping in an Object.
355        if self.is_single_value {
356            for item in &self.items {
357                if item.path != "__key__" {
358                    return control_to_value(&item.control);
359                }
360            }
361        }
362
363        let mut obj = serde_json::Map::new();
364
365        for item in &self.items {
366            // Skip the special key item - it's stored separately
367            if item.path == "__key__" {
368                continue;
369            }
370
371            let field_name = item.path.trim_start_matches('/');
372            let value = control_to_value(&item.control);
373            obj.insert(field_name.to_string(), value);
374        }
375
376        Value::Object(obj)
377    }
378
379    /// Get currently selected item
380    pub fn current_item(&self) -> Option<&SettingItem> {
381        if self.focus_on_buttons {
382            None
383        } else {
384            self.items.get(self.selected_item)
385        }
386    }
387
388    /// Get currently selected item mutably
389    pub fn current_item_mut(&mut self) -> Option<&mut SettingItem> {
390        if self.focus_on_buttons {
391            None
392        } else {
393            self.items.get_mut(self.selected_item)
394        }
395    }
396
397    /// Move focus to next editable item, navigating within composite controls first.
398    ///
399    /// For composite controls (Map, ObjectArray, TextList), Down first navigates
400    /// through their internal entries and [+] Add new row before moving to the
401    /// next dialog item. When at the last editable item, wraps to buttons.
402    /// When on the last button, wraps back to the first editable item.
403    pub fn focus_next(&mut self) {
404        if self.editing_text {
405            return;
406        }
407
408        if self.focus_on_buttons {
409            if self.focused_button + 1 < self.button_count() {
410                self.focused_button += 1;
411            } else {
412                // Wrap to first editable item
413                if self.first_editable_index < self.items.len() {
414                    self.focus_on_buttons = false;
415                    self.selected_item = self.first_editable_index;
416                    self.sub_focus = None;
417                    self.init_composite_focus(true);
418                }
419            }
420        } else {
421            // Try navigating within a composite control first
422            let handled = self.try_composite_focus_next();
423            if !handled {
424                // Composite is at its exit boundary (or not a composite) — advance to next item
425                if self.selected_item + 1 < self.items.len() {
426                    self.selected_item += 1;
427                    self.sub_focus = None;
428                    self.init_composite_focus(true);
429                } else {
430                    // Past last item, go to buttons
431                    self.focus_on_buttons = true;
432                    self.focused_button = 0;
433                }
434            }
435        }
436
437        self.update_focus_states();
438        self.ensure_selected_visible(self.viewport_height);
439    }
440
441    /// Move focus to previous editable item, navigating within composite controls first.
442    ///
443    /// For composite controls, Up first navigates backwards through their internal
444    /// entries before moving to the previous dialog item. When at the first editable
445    /// item, wraps to buttons. When on the first button, wraps back to the last item.
446    pub fn focus_prev(&mut self) {
447        if self.editing_text {
448            return;
449        }
450
451        if self.focus_on_buttons {
452            if self.focused_button > 0 {
453                self.focused_button -= 1;
454            } else {
455                // Wrap to last editable item
456                if self.first_editable_index < self.items.len() {
457                    self.focus_on_buttons = false;
458                    self.selected_item = self.items.len().saturating_sub(1);
459                    self.sub_focus = None;
460                    self.init_composite_focus(false);
461                }
462            }
463        } else {
464            // Try navigating within a composite control first
465            let handled = self.try_composite_focus_prev();
466            if !handled {
467                // Composite is at its entry boundary (or not a composite) — go to previous item
468                if self.selected_item > self.first_editable_index {
469                    self.selected_item -= 1;
470                    self.sub_focus = None;
471                    self.init_composite_focus(false);
472                } else {
473                    // Before first editable item, go to buttons
474                    self.focus_on_buttons = true;
475                    self.focused_button = self.button_count().saturating_sub(1);
476                }
477            }
478        }
479
480        self.update_focus_states();
481        self.ensure_selected_visible(self.viewport_height);
482    }
483
484    /// Try to navigate forward within the current composite control.
485    /// Returns true if the navigation was handled internally, false if at the exit boundary.
486    fn try_composite_focus_next(&mut self) -> bool {
487        let item = match self.items.get(self.selected_item) {
488            Some(item) => item,
489            None => return false,
490        };
491        match &item.control {
492            SettingControl::Map(state) => {
493                // Map returns bool: true = handled internally, false = at boundary
494                let at_boundary = state.focused_entry.is_none(); // On add-new → exit
495                if at_boundary {
496                    return false;
497                }
498                if let Some(item) = self.items.get_mut(self.selected_item) {
499                    if let SettingControl::Map(state) = &mut item.control {
500                        return state.focus_next();
501                    }
502                }
503                false
504            }
505            SettingControl::ObjectArray(state) => {
506                // ObjectArray: None = on add-new → exit
507                if state.focused_index.is_none() {
508                    return false;
509                }
510                if let Some(item) = self.items.get_mut(self.selected_item) {
511                    if let SettingControl::ObjectArray(state) = &mut item.control {
512                        state.focus_next();
513                        return true;
514                    }
515                }
516                false
517            }
518            SettingControl::TextList(state) => {
519                // TextList: None = on add-new → exit
520                if state.focused_item.is_none() {
521                    return false;
522                }
523                if let Some(item) = self.items.get_mut(self.selected_item) {
524                    if let SettingControl::TextList(state) = &mut item.control {
525                        state.focus_next();
526                        return true;
527                    }
528                }
529                false
530            }
531            _ => false,
532        }
533    }
534
535    /// Try to navigate backward within the current composite control.
536    /// Returns true if the navigation was handled internally, false if at the entry boundary.
537    fn try_composite_focus_prev(&mut self) -> bool {
538        let item = match self.items.get(self.selected_item) {
539            Some(item) => item,
540            None => return false,
541        };
542        match &item.control {
543            SettingControl::Map(state) => {
544                // Map: Some(0) = at first entry → exit
545                let at_boundary = matches!(state.focused_entry, Some(0))
546                    || (state.focused_entry.is_none() && state.entries.is_empty());
547                if at_boundary {
548                    return false;
549                }
550                if let Some(item) = self.items.get_mut(self.selected_item) {
551                    if let SettingControl::Map(state) = &mut item.control {
552                        return state.focus_prev();
553                    }
554                }
555                false
556            }
557            SettingControl::ObjectArray(state) => {
558                // ObjectArray: Some(0) = at first entry → exit
559                if matches!(state.focused_index, Some(0))
560                    || (state.focused_index.is_none() && state.bindings.is_empty())
561                {
562                    return false;
563                }
564                if let Some(item) = self.items.get_mut(self.selected_item) {
565                    if let SettingControl::ObjectArray(state) = &mut item.control {
566                        state.focus_prev();
567                        return true;
568                    }
569                }
570                false
571            }
572            SettingControl::TextList(state) => {
573                // TextList: Some(0) = at first item → exit
574                if matches!(state.focused_item, Some(0))
575                    || (state.focused_item.is_none() && state.items.is_empty())
576                {
577                    return false;
578                }
579                if let Some(item) = self.items.get_mut(self.selected_item) {
580                    if let SettingControl::TextList(state) = &mut item.control {
581                        state.focus_prev();
582                        return true;
583                    }
584                }
585                false
586            }
587            _ => false,
588        }
589    }
590
591    /// Initialize a composite control's focus when entering it.
592    /// `from_above`: true = entering from the item above (start at first entry),
593    ///               false = entering from below (start at add-new / last entry).
594    fn init_composite_focus(&mut self, from_above: bool) {
595        if let Some(item) = self.items.get_mut(self.selected_item) {
596            match &mut item.control {
597                SettingControl::Map(state) => {
598                    state.init_focus(from_above);
599                }
600                SettingControl::ObjectArray(state) => {
601                    if from_above {
602                        state.focused_index = if state.bindings.is_empty() {
603                            None
604                        } else {
605                            Some(0)
606                        };
607                    } else {
608                        // Coming from below: start at add-new
609                        state.focused_index = None;
610                    }
611                }
612                SettingControl::TextList(state) => {
613                    if from_above {
614                        state.focused_item = if state.items.is_empty() {
615                            None
616                        } else {
617                            Some(0)
618                        };
619                    } else {
620                        // Coming from below: start at add-new
621                        state.focused_item = None;
622                    }
623                }
624                _ => {}
625            }
626        }
627    }
628
629    /// Toggle focus between items region and buttons region.
630    /// Used by Tab key to provide region-level navigation.
631    pub fn toggle_focus_region(&mut self) {
632        self.toggle_focus_region_direction(true);
633    }
634
635    /// Toggle between items and buttons regions.
636    /// When in buttons region, Tab cycles through buttons before returning to items.
637    /// `forward` controls direction: true = Tab, false = Shift+Tab.
638    pub fn toggle_focus_region_direction(&mut self, forward: bool) {
639        if self.editing_text {
640            return;
641        }
642
643        if self.focus_on_buttons {
644            if forward {
645                // Tab forward through buttons, then back to items
646                if self.focused_button + 1 < self.button_count() {
647                    self.focused_button += 1;
648                } else {
649                    // Past last button — return to items
650                    if self.first_editable_index < self.items.len() {
651                        self.focus_on_buttons = false;
652                        if self.selected_item < self.first_editable_index {
653                            self.selected_item = self.first_editable_index;
654                        }
655                    } else {
656                        // All items read-only, wrap to first button
657                        self.focused_button = 0;
658                    }
659                }
660            } else {
661                // Shift+Tab backward through buttons, then back to items
662                if self.focused_button > 0 {
663                    self.focused_button -= 1;
664                } else {
665                    // Before first button — return to items
666                    if self.first_editable_index < self.items.len() {
667                        self.focus_on_buttons = false;
668                        if self.selected_item < self.first_editable_index {
669                            self.selected_item = self.first_editable_index;
670                        }
671                    } else {
672                        // All items read-only, wrap to last button
673                        self.focused_button = self.button_count().saturating_sub(1);
674                    }
675                }
676            }
677        } else {
678            // Move to buttons
679            self.focus_on_buttons = true;
680            self.focused_button = if forward {
681                0
682            } else {
683                self.button_count().saturating_sub(1)
684            };
685        }
686
687        self.update_focus_states();
688        self.ensure_selected_visible(self.viewport_height);
689    }
690
691    /// Initialize composite control focus for the selected item (when dialog opens)
692    fn init_object_array_focus(&mut self) {
693        self.init_composite_focus(true);
694    }
695
696    /// Update focus states for all items
697    pub fn update_focus_states(&mut self) {
698        for (idx, item) in self.items.iter_mut().enumerate() {
699            let state = if !self.focus_on_buttons && idx == self.selected_item {
700                FocusState::Focused
701            } else {
702                FocusState::Normal
703            };
704
705            match &mut item.control {
706                SettingControl::Toggle(s) => s.focus = state,
707                SettingControl::Number(s) => s.focus = state,
708                SettingControl::Dropdown(s) => s.focus = state,
709                SettingControl::Text(s) => s.focus = state,
710                SettingControl::TextList(s) => s.focus = state,
711                SettingControl::DualList(s) => s.focus = state,
712                SettingControl::Map(s) => s.focus = state,
713                SettingControl::ObjectArray(s) => s.focus = state,
714                SettingControl::Json(s) => s.focus = state,
715                SettingControl::Complex { .. } => {}
716            }
717        }
718    }
719
720    /// Height of a section header (label + blank line)
721    const SECTION_HEADER_HEIGHT: usize = 2;
722
723    /// Calculate total content height for all items (including separator and section headers)
724    pub fn total_content_height(&self) -> usize {
725        let items_height: usize = self
726            .items
727            .iter()
728            .map(|item| {
729                let section_h = if item.is_section_start {
730                    Self::SECTION_HEADER_HEIGHT
731                } else {
732                    0
733                };
734                item.control.control_height() as usize + section_h
735            })
736            .sum();
737        // Add 1 for separator if we have both read-only and editable items
738        let separator_height =
739            if self.first_editable_index > 0 && self.first_editable_index < self.items.len() {
740                1
741            } else {
742                0
743            };
744        items_height + separator_height
745    }
746
747    /// Calculate the Y offset of the selected item (including separator and section headers)
748    pub fn selected_item_offset(&self) -> usize {
749        let items_offset: usize = self
750            .items
751            .iter()
752            .take(self.selected_item)
753            .map(|item| {
754                let section_h = if item.is_section_start {
755                    Self::SECTION_HEADER_HEIGHT
756                } else {
757                    0
758                };
759                item.control.control_height() as usize + section_h
760            })
761            .sum();
762        // Add 1 for separator if selected item is after it
763        let separator_offset = if self.first_editable_index > 0
764            && self.first_editable_index < self.items.len()
765            && self.selected_item >= self.first_editable_index
766        {
767            1
768        } else {
769            0
770        };
771        // Add section header height if the selected item itself starts a section
772        let own_section_h = self
773            .items
774            .get(self.selected_item)
775            .map(|item| {
776                if item.is_section_start {
777                    Self::SECTION_HEADER_HEIGHT
778                } else {
779                    0
780                }
781            })
782            .unwrap_or(0);
783        items_offset + separator_offset + own_section_h
784    }
785
786    /// Calculate the height of the selected item
787    pub fn selected_item_height(&self) -> usize {
788        self.items
789            .get(self.selected_item)
790            .map(|item| item.control.control_height() as usize)
791            .unwrap_or(1)
792    }
793
794    /// Ensure the selected item is visible within the viewport
795    pub fn ensure_selected_visible(&mut self, viewport_height: usize) {
796        if self.focus_on_buttons {
797            // Scroll to bottom when buttons are focused
798            let total = self.total_content_height();
799            if total > viewport_height {
800                self.scroll_offset = total.saturating_sub(viewport_height);
801            }
802            return;
803        }
804
805        let item_start = self.selected_item_offset();
806        let item_end = item_start + self.selected_item_height();
807
808        // If item starts before viewport, scroll up
809        if item_start < self.scroll_offset {
810            self.scroll_offset = item_start;
811        }
812        // If item ends after viewport, scroll down
813        else if item_end > self.scroll_offset + viewport_height {
814            self.scroll_offset = item_end.saturating_sub(viewport_height);
815        }
816    }
817
818    /// Ensure the cursor within a JSON editor is visible
819    ///
820    /// When editing a multiline JSON control, this adjusts scroll_offset
821    /// to keep the cursor row visible within the viewport.
822    pub fn ensure_cursor_visible(&mut self) {
823        if !self.editing_text || self.focus_on_buttons {
824            return;
825        }
826
827        // Get cursor row from current item (if it's a JSON editor)
828        let cursor_row = if let Some(item) = self.items.get(self.selected_item) {
829            if let SettingControl::Json(state) = &item.control {
830                state.cursor_pos().0
831            } else {
832                return; // Not a JSON editor
833            }
834        } else {
835            return;
836        };
837
838        // Calculate absolute position of cursor row in content:
839        // item_offset + 1 (for label row) + cursor_row
840        let item_offset = self.selected_item_offset();
841        let cursor_content_row = item_offset + 1 + cursor_row;
842
843        let viewport_height = self.viewport_height;
844
845        // If cursor is above viewport, scroll up
846        if cursor_content_row < self.scroll_offset {
847            self.scroll_offset = cursor_content_row;
848        }
849        // If cursor is below viewport, scroll down
850        else if cursor_content_row >= self.scroll_offset + viewport_height {
851            self.scroll_offset = cursor_content_row.saturating_sub(viewport_height) + 1;
852        }
853    }
854
855    /// Scroll up by one line
856    pub fn scroll_up(&mut self) {
857        self.scroll_offset = self.scroll_offset.saturating_sub(1);
858    }
859
860    /// Scroll down by one line
861    pub fn scroll_down(&mut self, viewport_height: usize) {
862        let max_scroll = self.total_content_height().saturating_sub(viewport_height);
863        if self.scroll_offset < max_scroll {
864            self.scroll_offset += 1;
865        }
866    }
867
868    /// Scroll to a position based on ratio (0.0 = top, 1.0 = bottom)
869    ///
870    /// Used for scrollbar drag operations.
871    pub fn scroll_to_ratio(&mut self, ratio: f32) {
872        let max_scroll = self
873            .total_content_height()
874            .saturating_sub(self.viewport_height);
875        let new_offset = (ratio * max_scroll as f32).round() as usize;
876        self.scroll_offset = new_offset.min(max_scroll);
877    }
878
879    /// Start text editing mode for the current control
880    pub fn start_editing(&mut self) {
881        if let Some(item) = self.current_item_mut() {
882            // Don't allow editing read-only fields
883            if item.read_only {
884                return;
885            }
886            match &mut item.control {
887                SettingControl::Text(state) => {
888                    state.cursor = state.value.len();
889                    state.editing = true;
890                    self.editing_text = true;
891                }
892                SettingControl::TextList(state) => {
893                    // If focused on a committed item, leave focus there
894                    // and just flip into edit mode. Otherwise (focus on
895                    // the trailing `[+] Add new` slot), explicitly
896                    // activate input mode so the row morphs from
897                    // `[+] Add new` into the bracketed input box.
898                    if state.focused_item.is_none() {
899                        state.activate_pending();
900                    }
901                    self.editing_text = true;
902                }
903                SettingControl::Number(state) => {
904                    state.start_editing();
905                    self.editing_text = true;
906                }
907                SettingControl::Json(state) => {
908                    // Wipe the `null` placeholder so typing replaces it
909                    // instead of concatenating onto the literal text.
910                    state.clear_placeholder_for_edit();
911                    self.editing_text = true;
912                }
913                _ => {}
914            }
915        }
916    }
917
918    /// Stop text editing mode
919    pub fn stop_editing(&mut self) {
920        if let Some(item) = self.current_item_mut() {
921            match &mut item.control {
922                SettingControl::Number(state) => state.cancel_editing(),
923                SettingControl::Text(state) => state.editing = false,
924                // Cancelling on a pending list row (the trailing
925                // [+] add-new slot) discards whatever the user typed
926                // and collapses the row back to `[+] Add new`. Without
927                // this, Esc was a silent no-op that left the draft
928                // text dangling until the user committed or cleared it
929                // manually.
930                SettingControl::TextList(state) if state.focused_item.is_none() => {
931                    state.cancel_pending();
932                }
933                // If the user opened a JSON field but didn't type
934                // anything (or deleted everything), put the `null`
935                // sentinel back so the value still round-trips as JSON.
936                SettingControl::Json(state) => state.restore_unset_if_empty(),
937                _ => {}
938            }
939        }
940        self.editing_text = false;
941    }
942
943    /// Handle character input
944    pub fn insert_char(&mut self, c: char) {
945        if !self.editing_text {
946            return;
947        }
948        self.user_edited = true;
949        if let Some(item) = self.current_item_mut() {
950            match &mut item.control {
951                SettingControl::Text(state) => {
952                    state.insert(c);
953                }
954                SettingControl::TextList(state) => {
955                    state.insert(c);
956                }
957                SettingControl::Number(state) => {
958                    state.insert_char(c);
959                }
960                SettingControl::Json(state) => {
961                    state.insert(c);
962                }
963                _ => {}
964            }
965        }
966    }
967
968    pub fn insert_str(&mut self, s: &str) {
969        if !self.editing_text {
970            return;
971        }
972        self.user_edited = true;
973        if let Some(item) = self.current_item_mut() {
974            match &mut item.control {
975                SettingControl::Text(state) => {
976                    state.insert_str(s);
977                }
978                SettingControl::TextList(state) => {
979                    state.insert_str(s);
980                }
981                SettingControl::Number(state) => {
982                    for c in s.chars() {
983                        state.insert_char(c);
984                    }
985                }
986                SettingControl::Json(state) => {
987                    state.insert_str(s);
988                }
989                _ => {}
990            }
991        }
992    }
993
994    /// Handle backspace
995    pub fn backspace(&mut self) {
996        if !self.editing_text {
997            return;
998        }
999        self.user_edited = true;
1000        if let Some(item) = self.current_item_mut() {
1001            match &mut item.control {
1002                SettingControl::Text(state) => {
1003                    state.backspace();
1004                }
1005                SettingControl::TextList(state) => {
1006                    state.backspace();
1007                }
1008                SettingControl::Number(state) => {
1009                    state.backspace();
1010                }
1011                SettingControl::Json(state) => {
1012                    state.backspace();
1013                }
1014                _ => {}
1015            }
1016        }
1017    }
1018
1019    /// Handle cursor left
1020    pub fn cursor_left(&mut self) {
1021        if !self.editing_text {
1022            return;
1023        }
1024        if let Some(item) = self.current_item_mut() {
1025            match &mut item.control {
1026                SettingControl::Text(state) => {
1027                    state.move_left();
1028                }
1029                SettingControl::TextList(state) => {
1030                    state.move_left();
1031                }
1032                SettingControl::Json(state) => {
1033                    state.move_left();
1034                }
1035                _ => {}
1036            }
1037        }
1038    }
1039
1040    /// Handle cursor left with selection (Shift+Left)
1041    pub fn cursor_left_selecting(&mut self) {
1042        if !self.editing_text {
1043            return;
1044        }
1045        if let Some(item) = self.current_item_mut() {
1046            if let SettingControl::Json(state) = &mut item.control {
1047                state.editor.move_left_selecting();
1048            }
1049        }
1050    }
1051
1052    /// Handle cursor right
1053    pub fn cursor_right(&mut self) {
1054        if !self.editing_text {
1055            return;
1056        }
1057        if let Some(item) = self.current_item_mut() {
1058            match &mut item.control {
1059                SettingControl::Text(state) => {
1060                    state.move_right();
1061                }
1062                SettingControl::TextList(state) => {
1063                    state.move_right();
1064                }
1065                SettingControl::Json(state) => {
1066                    state.move_right();
1067                }
1068                _ => {}
1069            }
1070        }
1071    }
1072
1073    /// Handle cursor right with selection (Shift+Right)
1074    pub fn cursor_right_selecting(&mut self) {
1075        if !self.editing_text {
1076            return;
1077        }
1078        if let Some(item) = self.current_item_mut() {
1079            if let SettingControl::Json(state) = &mut item.control {
1080                state.editor.move_right_selecting();
1081            }
1082        }
1083    }
1084
1085    /// Handle cursor up (for multiline controls)
1086    pub fn cursor_up(&mut self) {
1087        if !self.editing_text {
1088            return;
1089        }
1090        if let Some(item) = self.current_item_mut() {
1091            if let SettingControl::Json(state) = &mut item.control {
1092                state.move_up();
1093            }
1094        }
1095        self.ensure_cursor_visible();
1096    }
1097
1098    /// Handle cursor up with selection (Shift+Up)
1099    pub fn cursor_up_selecting(&mut self) {
1100        if !self.editing_text {
1101            return;
1102        }
1103        if let Some(item) = self.current_item_mut() {
1104            if let SettingControl::Json(state) = &mut item.control {
1105                state.editor.move_up_selecting();
1106            }
1107        }
1108        self.ensure_cursor_visible();
1109    }
1110
1111    /// Handle cursor down (for multiline controls)
1112    pub fn cursor_down(&mut self) {
1113        if !self.editing_text {
1114            return;
1115        }
1116        if let Some(item) = self.current_item_mut() {
1117            if let SettingControl::Json(state) = &mut item.control {
1118                state.move_down();
1119            }
1120        }
1121        self.ensure_cursor_visible();
1122    }
1123
1124    /// Handle cursor down with selection (Shift+Down)
1125    pub fn cursor_down_selecting(&mut self) {
1126        if !self.editing_text {
1127            return;
1128        }
1129        if let Some(item) = self.current_item_mut() {
1130            if let SettingControl::Json(state) = &mut item.control {
1131                state.editor.move_down_selecting();
1132            }
1133        }
1134        self.ensure_cursor_visible();
1135    }
1136
1137    /// Insert newline in JSON editor
1138    pub fn insert_newline(&mut self) {
1139        self.user_edited = true;
1140        if !self.editing_text {
1141            return;
1142        }
1143        if let Some(item) = self.current_item_mut() {
1144            if let SettingControl::Json(state) = &mut item.control {
1145                state.insert('\n');
1146            }
1147        }
1148    }
1149
1150    /// Revert JSON changes to original and stop editing
1151    pub fn revert_json_and_stop(&mut self) {
1152        if let Some(item) = self.current_item_mut() {
1153            if let SettingControl::Json(state) = &mut item.control {
1154                state.revert();
1155            }
1156        }
1157        self.editing_text = false;
1158    }
1159
1160    /// Check if current control is a JSON editor
1161    pub fn is_editing_json(&self) -> bool {
1162        if !self.editing_text {
1163            return false;
1164        }
1165        self.current_item()
1166            .map(|item| matches!(&item.control, SettingControl::Json(_)))
1167            .unwrap_or(false)
1168    }
1169
1170    /// Toggle boolean value
1171    pub fn toggle_bool(&mut self) {
1172        self.user_edited = true;
1173        if let Some(item) = self.current_item_mut() {
1174            // Don't allow toggling read-only fields
1175            if item.read_only {
1176                return;
1177            }
1178            if let SettingControl::Toggle(state) = &mut item.control {
1179                state.checked = !state.checked;
1180            }
1181        }
1182    }
1183
1184    /// Toggle dropdown open state
1185    pub fn toggle_dropdown(&mut self) {
1186        if let Some(item) = self.current_item_mut() {
1187            // Don't allow editing read-only fields
1188            if item.read_only {
1189                return;
1190            }
1191            if let SettingControl::Dropdown(state) = &mut item.control {
1192                state.open = !state.open;
1193            }
1194        }
1195    }
1196
1197    /// Move dropdown selection up
1198    pub fn dropdown_prev(&mut self) {
1199        self.user_edited = true;
1200        if let Some(item) = self.current_item_mut() {
1201            if let SettingControl::Dropdown(state) = &mut item.control {
1202                if state.open {
1203                    state.select_prev();
1204                }
1205            }
1206        }
1207    }
1208
1209    /// Move dropdown selection down
1210    pub fn dropdown_next(&mut self) {
1211        self.user_edited = true;
1212        if let Some(item) = self.current_item_mut() {
1213            if let SettingControl::Dropdown(state) = &mut item.control {
1214                if state.open {
1215                    state.select_next();
1216                }
1217            }
1218        }
1219    }
1220
1221    /// Confirm dropdown selection
1222    pub fn dropdown_confirm(&mut self) {
1223        if let Some(item) = self.current_item_mut() {
1224            if let SettingControl::Dropdown(state) = &mut item.control {
1225                state.open = false;
1226            }
1227        }
1228    }
1229
1230    /// Delete the currently focused item from a TextList control
1231    pub fn delete_list_item(&mut self) {
1232        self.user_edited = true;
1233        if let Some(item) = self.current_item_mut() {
1234            if let SettingControl::TextList(state) = &mut item.control {
1235                // Remove the currently focused item if any
1236                if let Some(idx) = state.focused_item {
1237                    state.remove_item(idx);
1238                }
1239            }
1240        }
1241    }
1242
1243    /// Delete character at cursor (forward delete)
1244    pub fn delete(&mut self) {
1245        if !self.editing_text {
1246            return;
1247        }
1248        self.user_edited = true;
1249        if let Some(item) = self.current_item_mut() {
1250            match &mut item.control {
1251                SettingControl::Text(state) => {
1252                    state.delete();
1253                }
1254                SettingControl::TextList(state) => {
1255                    state.delete();
1256                }
1257                SettingControl::Json(state) => {
1258                    state.delete();
1259                }
1260                _ => {}
1261            }
1262        }
1263    }
1264
1265    /// Move cursor to beginning of line
1266    pub fn cursor_home(&mut self) {
1267        if !self.editing_text {
1268            return;
1269        }
1270        if let Some(item) = self.current_item_mut() {
1271            match &mut item.control {
1272                SettingControl::Text(state) => {
1273                    state.move_home();
1274                }
1275                SettingControl::TextList(state) => {
1276                    state.move_home();
1277                }
1278                SettingControl::Json(state) => {
1279                    state.move_home();
1280                }
1281                _ => {}
1282            }
1283        }
1284    }
1285
1286    /// Move cursor to end of line
1287    pub fn cursor_end(&mut self) {
1288        if !self.editing_text {
1289            return;
1290        }
1291        if let Some(item) = self.current_item_mut() {
1292            match &mut item.control {
1293                SettingControl::Text(state) => {
1294                    state.move_end();
1295                }
1296                SettingControl::TextList(state) => {
1297                    state.move_end();
1298                }
1299                SettingControl::Json(state) => {
1300                    state.move_end();
1301                }
1302                _ => {}
1303            }
1304        }
1305    }
1306
1307    /// Select all text in current control
1308    pub fn select_all(&mut self) {
1309        if !self.editing_text {
1310            return;
1311        }
1312        if let Some(item) = self.current_item_mut() {
1313            if let SettingControl::Json(state) = &mut item.control {
1314                state.select_all();
1315            }
1316            // Note: Text and TextList don't have select_all implemented
1317        }
1318    }
1319
1320    /// Get selected text from current JSON control
1321    pub fn selected_text(&self) -> Option<String> {
1322        if !self.editing_text {
1323            return None;
1324        }
1325        if let Some(item) = self.current_item() {
1326            if let SettingControl::Json(state) = &item.control {
1327                return state.selected_text();
1328            }
1329        }
1330        None
1331    }
1332
1333    /// Check if any field is currently in edit mode
1334    pub fn is_editing(&self) -> bool {
1335        self.editing_text
1336            || self
1337                .current_item()
1338                .map(|item| {
1339                    matches!(
1340                        &item.control,
1341                        SettingControl::Dropdown(s) if s.open
1342                    )
1343                })
1344                .unwrap_or(false)
1345    }
1346}
1347
1348#[cfg(test)]
1349mod tests {
1350    use super::*;
1351
1352    fn create_test_schema() -> SettingSchema {
1353        SettingSchema {
1354            path: "/test".to_string(),
1355            name: "Test".to_string(),
1356            description: Some("Test schema".to_string()),
1357            setting_type: SettingType::Object {
1358                properties: vec![
1359                    SettingSchema {
1360                        path: "/enabled".to_string(),
1361                        name: "Enabled".to_string(),
1362                        description: Some("Enable this".to_string()),
1363                        setting_type: SettingType::Boolean,
1364                        default: Some(serde_json::json!(true)),
1365                        read_only: false,
1366                        section: None,
1367                        order: None,
1368                        nullable: false,
1369                        enum_from: None,
1370                        dual_list_sibling: None,
1371                        dynamically_extendable_status_bar_elements: false,
1372                    },
1373                    SettingSchema {
1374                        path: "/command".to_string(),
1375                        name: "Command".to_string(),
1376                        description: Some("Command to run".to_string()),
1377                        setting_type: SettingType::String,
1378                        default: Some(serde_json::json!("")),
1379                        read_only: false,
1380                        section: None,
1381                        order: None,
1382                        nullable: false,
1383                        enum_from: None,
1384                        dual_list_sibling: None,
1385                        dynamically_extendable_status_bar_elements: false,
1386                    },
1387                ],
1388            },
1389            default: None,
1390            read_only: false,
1391            section: None,
1392            order: None,
1393            nullable: false,
1394            enum_from: None,
1395            dual_list_sibling: None,
1396            dynamically_extendable_status_bar_elements: false,
1397        }
1398    }
1399
1400    #[test]
1401    fn from_schema_creates_key_item_first() {
1402        let schema = create_test_schema();
1403        let dialog = EntryDialogState::from_schema(
1404            "test".to_string(),
1405            &serde_json::json!({}),
1406            &schema,
1407            "/test",
1408            false,
1409            false,
1410            &HashMap::new(),
1411        );
1412
1413        assert!(!dialog.items.is_empty());
1414        assert_eq!(dialog.items[0].path, "__key__");
1415        assert_eq!(dialog.items[0].name, "Key");
1416    }
1417
1418    #[test]
1419    fn from_schema_creates_items_from_properties() {
1420        let schema = create_test_schema();
1421        let dialog = EntryDialogState::from_schema(
1422            "test".to_string(),
1423            &serde_json::json!({"enabled": true, "command": "test-cmd"}),
1424            &schema,
1425            "/test",
1426            false,
1427            false,
1428            &HashMap::new(),
1429        );
1430
1431        // Key + 2 properties = 3 items
1432        assert_eq!(dialog.items.len(), 3);
1433        assert_eq!(dialog.items[1].name, "Enabled");
1434        assert_eq!(dialog.items[2].name, "Command");
1435    }
1436
1437    #[test]
1438    fn get_key_returns_key_value() {
1439        let schema = create_test_schema();
1440        let dialog = EntryDialogState::from_schema(
1441            "mykey".to_string(),
1442            &serde_json::json!({}),
1443            &schema,
1444            "/test",
1445            false,
1446            false,
1447            &HashMap::new(),
1448        );
1449
1450        assert_eq!(dialog.get_key(), "mykey");
1451    }
1452
1453    #[test]
1454    fn to_value_excludes_key() {
1455        let schema = create_test_schema();
1456        let dialog = EntryDialogState::from_schema(
1457            "test".to_string(),
1458            &serde_json::json!({"enabled": true, "command": "cmd"}),
1459            &schema,
1460            "/test",
1461            false,
1462            false,
1463            &HashMap::new(),
1464        );
1465
1466        let value = dialog.to_value();
1467        assert!(value.get("__key__").is_none());
1468        assert!(value.get("enabled").is_some());
1469    }
1470
1471    #[test]
1472    fn focus_navigation_works() {
1473        let schema = create_test_schema();
1474        let mut dialog = EntryDialogState::from_schema(
1475            "test".to_string(),
1476            &serde_json::json!({}),
1477            &schema,
1478            "/test",
1479            false, // existing entry - Key is read-only
1480            false, // allow delete
1481            &HashMap::new(),
1482        );
1483
1484        // With is_new=false, Key is read-only and sorted first
1485        // Items: [Key (read-only), Enabled, Command]
1486        // Focus starts at first editable item (index 1)
1487        assert_eq!(dialog.first_editable_index, 1);
1488        assert_eq!(dialog.selected_item, 1); // First editable (Enabled)
1489        assert!(!dialog.focus_on_buttons);
1490
1491        dialog.focus_next();
1492        assert_eq!(dialog.selected_item, 2); // Command
1493
1494        dialog.focus_next();
1495        assert!(dialog.focus_on_buttons); // No more editable items
1496        assert_eq!(dialog.focused_button, 0);
1497
1498        // Going back should skip read-only Key
1499        dialog.focus_prev();
1500        assert!(!dialog.focus_on_buttons);
1501        assert_eq!(dialog.selected_item, 2); // Last editable (Command)
1502
1503        dialog.focus_prev();
1504        assert_eq!(dialog.selected_item, 1); // First editable (Enabled)
1505
1506        dialog.focus_prev();
1507        assert!(dialog.focus_on_buttons); // Wraps to buttons, not to read-only Key
1508    }
1509
1510    #[test]
1511    fn entry_path_joins_map_path_and_entry_key() {
1512        let schema = create_test_schema();
1513
1514        // Existing entry: full path is map_path + "/" + entry_key
1515        let existing = EntryDialogState::from_schema(
1516            "rust".to_string(),
1517            &serde_json::json!({}),
1518            &schema,
1519            "/lsp",
1520            false,
1521            false,
1522            &HashMap::new(),
1523        );
1524        assert_eq!(existing.entry_path(), "/lsp/rust");
1525
1526        // New entry with no key typed yet falls back to the parent map path.
1527        // Nested dialogs keyed off this are outside the scope of this test.
1528        let new_entry = EntryDialogState::from_schema(
1529            String::new(),
1530            &serde_json::json!({}),
1531            &schema,
1532            "/lsp",
1533            true,
1534            false,
1535            &HashMap::new(),
1536        );
1537        assert_eq!(new_entry.entry_path(), "/lsp");
1538    }
1539
1540    #[test]
1541    fn entry_path_tracks_live_key_edits_for_new_entries() {
1542        let schema = create_test_schema();
1543        let mut dialog = EntryDialogState::from_schema(
1544            String::new(),
1545            &serde_json::json!({}),
1546            &schema,
1547            "/universal_lsp",
1548            true,
1549            false,
1550            &HashMap::new(),
1551        );
1552
1553        // User types a key into the editable key field.
1554        for item in dialog.items.iter_mut() {
1555            if item.path == "__key__" {
1556                if let SettingControl::Text(state) = &mut item.control {
1557                    state.value = "myserver".to_string();
1558                }
1559            }
1560        }
1561
1562        assert_eq!(dialog.entry_path(), "/universal_lsp/myserver");
1563    }
1564
1565    #[test]
1566    fn button_count_differs_for_new_vs_existing() {
1567        let schema = create_test_schema();
1568
1569        let new_dialog = EntryDialogState::from_schema(
1570            "test".to_string(),
1571            &serde_json::json!({}),
1572            &schema,
1573            "/test",
1574            true,
1575            false,
1576            &HashMap::new(),
1577        );
1578        assert_eq!(new_dialog.button_count(), 2); // Save, Cancel
1579
1580        let existing_dialog = EntryDialogState::from_schema(
1581            "test".to_string(),
1582            &serde_json::json!({}),
1583            &schema,
1584            "/test",
1585            false,
1586            false, // allow delete
1587            &HashMap::new(),
1588        );
1589        assert_eq!(existing_dialog.button_count(), 3); // Save, Delete, Cancel
1590
1591        // no_delete hides the Delete button even for existing entries
1592        let no_delete_dialog = EntryDialogState::from_schema(
1593            "test".to_string(),
1594            &serde_json::json!({}),
1595            &schema,
1596            "/test",
1597            false,
1598            true, // no delete (auto-managed entries like plugins)
1599            &HashMap::new(),
1600        );
1601        assert_eq!(no_delete_dialog.button_count(), 2); // Save, Cancel (no Delete)
1602    }
1603}