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