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