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                    state.cursor = state.value.len();
831                    state.editing = true;
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            match &mut item.control {
856                SettingControl::Number(state) => state.cancel_editing(),
857                SettingControl::Text(state) => state.editing = false,
858                _ => {}
859            }
860        }
861        self.editing_text = false;
862    }
863
864    /// Handle character input
865    pub fn insert_char(&mut self, c: char) {
866        if !self.editing_text {
867            return;
868        }
869        if let Some(item) = self.current_item_mut() {
870            match &mut item.control {
871                SettingControl::Text(state) => {
872                    state.insert(c);
873                }
874                SettingControl::TextList(state) => {
875                    state.insert(c);
876                }
877                SettingControl::Number(state) => {
878                    state.insert_char(c);
879                }
880                SettingControl::Json(state) => {
881                    state.insert(c);
882                }
883                _ => {}
884            }
885        }
886    }
887
888    pub fn insert_str(&mut self, s: &str) {
889        if !self.editing_text {
890            return;
891        }
892        if let Some(item) = self.current_item_mut() {
893            match &mut item.control {
894                SettingControl::Text(state) => {
895                    state.insert_str(s);
896                }
897                SettingControl::TextList(state) => {
898                    state.insert_str(s);
899                }
900                SettingControl::Number(state) => {
901                    for c in s.chars() {
902                        state.insert_char(c);
903                    }
904                }
905                SettingControl::Json(state) => {
906                    state.insert_str(s);
907                }
908                _ => {}
909            }
910        }
911    }
912
913    /// Handle backspace
914    pub fn backspace(&mut self) {
915        if !self.editing_text {
916            return;
917        }
918        if let Some(item) = self.current_item_mut() {
919            match &mut item.control {
920                SettingControl::Text(state) => {
921                    state.backspace();
922                }
923                SettingControl::TextList(state) => {
924                    state.backspace();
925                }
926                SettingControl::Number(state) => {
927                    state.backspace();
928                }
929                SettingControl::Json(state) => {
930                    state.backspace();
931                }
932                _ => {}
933            }
934        }
935    }
936
937    /// Handle cursor left
938    pub fn cursor_left(&mut self) {
939        if !self.editing_text {
940            return;
941        }
942        if let Some(item) = self.current_item_mut() {
943            match &mut item.control {
944                SettingControl::Text(state) => {
945                    state.move_left();
946                }
947                SettingControl::TextList(state) => {
948                    state.move_left();
949                }
950                SettingControl::Json(state) => {
951                    state.move_left();
952                }
953                _ => {}
954            }
955        }
956    }
957
958    /// Handle cursor left with selection (Shift+Left)
959    pub fn cursor_left_selecting(&mut self) {
960        if !self.editing_text {
961            return;
962        }
963        if let Some(item) = self.current_item_mut() {
964            if let SettingControl::Json(state) = &mut item.control {
965                state.editor.move_left_selecting();
966            }
967        }
968    }
969
970    /// Handle cursor right
971    pub fn cursor_right(&mut self) {
972        if !self.editing_text {
973            return;
974        }
975        if let Some(item) = self.current_item_mut() {
976            match &mut item.control {
977                SettingControl::Text(state) => {
978                    state.move_right();
979                }
980                SettingControl::TextList(state) => {
981                    state.move_right();
982                }
983                SettingControl::Json(state) => {
984                    state.move_right();
985                }
986                _ => {}
987            }
988        }
989    }
990
991    /// Handle cursor right with selection (Shift+Right)
992    pub fn cursor_right_selecting(&mut self) {
993        if !self.editing_text {
994            return;
995        }
996        if let Some(item) = self.current_item_mut() {
997            if let SettingControl::Json(state) = &mut item.control {
998                state.editor.move_right_selecting();
999            }
1000        }
1001    }
1002
1003    /// Handle cursor up (for multiline controls)
1004    pub fn cursor_up(&mut self) {
1005        if !self.editing_text {
1006            return;
1007        }
1008        if let Some(item) = self.current_item_mut() {
1009            if let SettingControl::Json(state) = &mut item.control {
1010                state.move_up();
1011            }
1012        }
1013        self.ensure_cursor_visible();
1014    }
1015
1016    /// Handle cursor up with selection (Shift+Up)
1017    pub fn cursor_up_selecting(&mut self) {
1018        if !self.editing_text {
1019            return;
1020        }
1021        if let Some(item) = self.current_item_mut() {
1022            if let SettingControl::Json(state) = &mut item.control {
1023                state.editor.move_up_selecting();
1024            }
1025        }
1026        self.ensure_cursor_visible();
1027    }
1028
1029    /// Handle cursor down (for multiline controls)
1030    pub fn cursor_down(&mut self) {
1031        if !self.editing_text {
1032            return;
1033        }
1034        if let Some(item) = self.current_item_mut() {
1035            if let SettingControl::Json(state) = &mut item.control {
1036                state.move_down();
1037            }
1038        }
1039        self.ensure_cursor_visible();
1040    }
1041
1042    /// Handle cursor down with selection (Shift+Down)
1043    pub fn cursor_down_selecting(&mut self) {
1044        if !self.editing_text {
1045            return;
1046        }
1047        if let Some(item) = self.current_item_mut() {
1048            if let SettingControl::Json(state) = &mut item.control {
1049                state.editor.move_down_selecting();
1050            }
1051        }
1052        self.ensure_cursor_visible();
1053    }
1054
1055    /// Insert newline in JSON editor
1056    pub fn insert_newline(&mut self) {
1057        if !self.editing_text {
1058            return;
1059        }
1060        if let Some(item) = self.current_item_mut() {
1061            if let SettingControl::Json(state) = &mut item.control {
1062                state.insert('\n');
1063            }
1064        }
1065    }
1066
1067    /// Revert JSON changes to original and stop editing
1068    pub fn revert_json_and_stop(&mut self) {
1069        if let Some(item) = self.current_item_mut() {
1070            if let SettingControl::Json(state) = &mut item.control {
1071                state.revert();
1072            }
1073        }
1074        self.editing_text = false;
1075    }
1076
1077    /// Check if current control is a JSON editor
1078    pub fn is_editing_json(&self) -> bool {
1079        if !self.editing_text {
1080            return false;
1081        }
1082        self.current_item()
1083            .map(|item| matches!(&item.control, SettingControl::Json(_)))
1084            .unwrap_or(false)
1085    }
1086
1087    /// Toggle boolean value
1088    pub fn toggle_bool(&mut self) {
1089        if let Some(item) = self.current_item_mut() {
1090            // Don't allow toggling read-only fields
1091            if item.read_only {
1092                return;
1093            }
1094            if let SettingControl::Toggle(state) = &mut item.control {
1095                state.checked = !state.checked;
1096            }
1097        }
1098    }
1099
1100    /// Toggle dropdown open state
1101    pub fn toggle_dropdown(&mut self) {
1102        if let Some(item) = self.current_item_mut() {
1103            // Don't allow editing read-only fields
1104            if item.read_only {
1105                return;
1106            }
1107            if let SettingControl::Dropdown(state) = &mut item.control {
1108                state.open = !state.open;
1109            }
1110        }
1111    }
1112
1113    /// Move dropdown selection up
1114    pub fn dropdown_prev(&mut self) {
1115        if let Some(item) = self.current_item_mut() {
1116            if let SettingControl::Dropdown(state) = &mut item.control {
1117                if state.open {
1118                    state.select_prev();
1119                }
1120            }
1121        }
1122    }
1123
1124    /// Move dropdown selection down
1125    pub fn dropdown_next(&mut self) {
1126        if let Some(item) = self.current_item_mut() {
1127            if let SettingControl::Dropdown(state) = &mut item.control {
1128                if state.open {
1129                    state.select_next();
1130                }
1131            }
1132        }
1133    }
1134
1135    /// Confirm dropdown selection
1136    pub fn dropdown_confirm(&mut self) {
1137        if let Some(item) = self.current_item_mut() {
1138            if let SettingControl::Dropdown(state) = &mut item.control {
1139                state.open = false;
1140            }
1141        }
1142    }
1143
1144    /// Increment number value
1145    pub fn increment_number(&mut self) {
1146        if let Some(item) = self.current_item_mut() {
1147            // Don't allow editing read-only fields
1148            if item.read_only {
1149                return;
1150            }
1151            if let SettingControl::Number(state) = &mut item.control {
1152                state.increment();
1153            }
1154        }
1155    }
1156
1157    /// Decrement number value
1158    pub fn decrement_number(&mut self) {
1159        if let Some(item) = self.current_item_mut() {
1160            // Don't allow editing read-only fields
1161            if item.read_only {
1162                return;
1163            }
1164            if let SettingControl::Number(state) = &mut item.control {
1165                state.decrement();
1166            }
1167        }
1168    }
1169
1170    /// Delete the currently focused item from a TextList control
1171    pub fn delete_list_item(&mut self) {
1172        if let Some(item) = self.current_item_mut() {
1173            if let SettingControl::TextList(state) = &mut item.control {
1174                // Remove the currently focused item if any
1175                if let Some(idx) = state.focused_item {
1176                    state.remove_item(idx);
1177                }
1178            }
1179        }
1180    }
1181
1182    /// Delete character at cursor (forward delete)
1183    pub fn delete(&mut self) {
1184        if !self.editing_text {
1185            return;
1186        }
1187        if let Some(item) = self.current_item_mut() {
1188            match &mut item.control {
1189                SettingControl::Text(state) => {
1190                    state.delete();
1191                }
1192                SettingControl::TextList(state) => {
1193                    state.delete();
1194                }
1195                SettingControl::Json(state) => {
1196                    state.delete();
1197                }
1198                _ => {}
1199            }
1200        }
1201    }
1202
1203    /// Move cursor to beginning of line
1204    pub fn cursor_home(&mut self) {
1205        if !self.editing_text {
1206            return;
1207        }
1208        if let Some(item) = self.current_item_mut() {
1209            match &mut item.control {
1210                SettingControl::Text(state) => {
1211                    state.move_home();
1212                }
1213                SettingControl::TextList(state) => {
1214                    state.move_home();
1215                }
1216                SettingControl::Json(state) => {
1217                    state.move_home();
1218                }
1219                _ => {}
1220            }
1221        }
1222    }
1223
1224    /// Move cursor to end of line
1225    pub fn cursor_end(&mut self) {
1226        if !self.editing_text {
1227            return;
1228        }
1229        if let Some(item) = self.current_item_mut() {
1230            match &mut item.control {
1231                SettingControl::Text(state) => {
1232                    state.move_end();
1233                }
1234                SettingControl::TextList(state) => {
1235                    state.move_end();
1236                }
1237                SettingControl::Json(state) => {
1238                    state.move_end();
1239                }
1240                _ => {}
1241            }
1242        }
1243    }
1244
1245    /// Select all text in current control
1246    pub fn select_all(&mut self) {
1247        if !self.editing_text {
1248            return;
1249        }
1250        if let Some(item) = self.current_item_mut() {
1251            if let SettingControl::Json(state) = &mut item.control {
1252                state.select_all();
1253            }
1254            // Note: Text and TextList don't have select_all implemented
1255        }
1256    }
1257
1258    /// Get selected text from current JSON control
1259    pub fn selected_text(&self) -> Option<String> {
1260        if !self.editing_text {
1261            return None;
1262        }
1263        if let Some(item) = self.current_item() {
1264            if let SettingControl::Json(state) = &item.control {
1265                return state.selected_text();
1266            }
1267        }
1268        None
1269    }
1270
1271    /// Check if any field is currently in edit mode
1272    pub fn is_editing(&self) -> bool {
1273        self.editing_text
1274            || self
1275                .current_item()
1276                .map(|item| {
1277                    matches!(
1278                        &item.control,
1279                        SettingControl::Dropdown(s) if s.open
1280                    )
1281                })
1282                .unwrap_or(false)
1283    }
1284}
1285
1286#[cfg(test)]
1287mod tests {
1288    use super::*;
1289
1290    fn create_test_schema() -> SettingSchema {
1291        SettingSchema {
1292            path: "/test".to_string(),
1293            name: "Test".to_string(),
1294            description: Some("Test schema".to_string()),
1295            setting_type: SettingType::Object {
1296                properties: vec![
1297                    SettingSchema {
1298                        path: "/enabled".to_string(),
1299                        name: "Enabled".to_string(),
1300                        description: Some("Enable this".to_string()),
1301                        setting_type: SettingType::Boolean,
1302                        default: Some(serde_json::json!(true)),
1303                        read_only: false,
1304                        section: None,
1305                        order: None,
1306                        nullable: false,
1307                        enum_from: None,
1308                        dual_list_sibling: None,
1309                    },
1310                    SettingSchema {
1311                        path: "/command".to_string(),
1312                        name: "Command".to_string(),
1313                        description: Some("Command to run".to_string()),
1314                        setting_type: SettingType::String,
1315                        default: Some(serde_json::json!("")),
1316                        read_only: false,
1317                        section: None,
1318                        order: None,
1319                        nullable: false,
1320                        enum_from: None,
1321                        dual_list_sibling: None,
1322                    },
1323                ],
1324            },
1325            default: None,
1326            read_only: false,
1327            section: None,
1328            order: None,
1329            nullable: false,
1330            enum_from: None,
1331            dual_list_sibling: None,
1332        }
1333    }
1334
1335    #[test]
1336    fn from_schema_creates_key_item_first() {
1337        let schema = create_test_schema();
1338        let dialog = EntryDialogState::from_schema(
1339            "test".to_string(),
1340            &serde_json::json!({}),
1341            &schema,
1342            "/test",
1343            false,
1344            false,
1345        );
1346
1347        assert!(!dialog.items.is_empty());
1348        assert_eq!(dialog.items[0].path, "__key__");
1349        assert_eq!(dialog.items[0].name, "Key");
1350    }
1351
1352    #[test]
1353    fn from_schema_creates_items_from_properties() {
1354        let schema = create_test_schema();
1355        let dialog = EntryDialogState::from_schema(
1356            "test".to_string(),
1357            &serde_json::json!({"enabled": true, "command": "test-cmd"}),
1358            &schema,
1359            "/test",
1360            false,
1361            false,
1362        );
1363
1364        // Key + 2 properties = 3 items
1365        assert_eq!(dialog.items.len(), 3);
1366        assert_eq!(dialog.items[1].name, "Enabled");
1367        assert_eq!(dialog.items[2].name, "Command");
1368    }
1369
1370    #[test]
1371    fn get_key_returns_key_value() {
1372        let schema = create_test_schema();
1373        let dialog = EntryDialogState::from_schema(
1374            "mykey".to_string(),
1375            &serde_json::json!({}),
1376            &schema,
1377            "/test",
1378            false,
1379            false,
1380        );
1381
1382        assert_eq!(dialog.get_key(), "mykey");
1383    }
1384
1385    #[test]
1386    fn to_value_excludes_key() {
1387        let schema = create_test_schema();
1388        let dialog = EntryDialogState::from_schema(
1389            "test".to_string(),
1390            &serde_json::json!({"enabled": true, "command": "cmd"}),
1391            &schema,
1392            "/test",
1393            false,
1394            false,
1395        );
1396
1397        let value = dialog.to_value();
1398        assert!(value.get("__key__").is_none());
1399        assert!(value.get("enabled").is_some());
1400    }
1401
1402    #[test]
1403    fn focus_navigation_works() {
1404        let schema = create_test_schema();
1405        let mut dialog = EntryDialogState::from_schema(
1406            "test".to_string(),
1407            &serde_json::json!({}),
1408            &schema,
1409            "/test",
1410            false, // existing entry - Key is read-only
1411            false, // allow delete
1412        );
1413
1414        // With is_new=false, Key is read-only and sorted first
1415        // Items: [Key (read-only), Enabled, Command]
1416        // Focus starts at first editable item (index 1)
1417        assert_eq!(dialog.first_editable_index, 1);
1418        assert_eq!(dialog.selected_item, 1); // First editable (Enabled)
1419        assert!(!dialog.focus_on_buttons);
1420
1421        dialog.focus_next();
1422        assert_eq!(dialog.selected_item, 2); // Command
1423
1424        dialog.focus_next();
1425        assert!(dialog.focus_on_buttons); // No more editable items
1426        assert_eq!(dialog.focused_button, 0);
1427
1428        // Going back should skip read-only Key
1429        dialog.focus_prev();
1430        assert!(!dialog.focus_on_buttons);
1431        assert_eq!(dialog.selected_item, 2); // Last editable (Command)
1432
1433        dialog.focus_prev();
1434        assert_eq!(dialog.selected_item, 1); // First editable (Enabled)
1435
1436        dialog.focus_prev();
1437        assert!(dialog.focus_on_buttons); // Wraps to buttons, not to read-only Key
1438    }
1439
1440    #[test]
1441    fn entry_path_joins_map_path_and_entry_key() {
1442        let schema = create_test_schema();
1443
1444        // Existing entry: full path is map_path + "/" + entry_key
1445        let existing = EntryDialogState::from_schema(
1446            "rust".to_string(),
1447            &serde_json::json!({}),
1448            &schema,
1449            "/lsp",
1450            false,
1451            false,
1452        );
1453        assert_eq!(existing.entry_path(), "/lsp/rust");
1454
1455        // New entry with no key typed yet falls back to the parent map path.
1456        // Nested dialogs keyed off this are outside the scope of this test.
1457        let new_entry = EntryDialogState::from_schema(
1458            String::new(),
1459            &serde_json::json!({}),
1460            &schema,
1461            "/lsp",
1462            true,
1463            false,
1464        );
1465        assert_eq!(new_entry.entry_path(), "/lsp");
1466    }
1467
1468    #[test]
1469    fn entry_path_tracks_live_key_edits_for_new_entries() {
1470        let schema = create_test_schema();
1471        let mut dialog = EntryDialogState::from_schema(
1472            String::new(),
1473            &serde_json::json!({}),
1474            &schema,
1475            "/universal_lsp",
1476            true,
1477            false,
1478        );
1479
1480        // User types a key into the editable key field.
1481        for item in dialog.items.iter_mut() {
1482            if item.path == "__key__" {
1483                if let SettingControl::Text(state) = &mut item.control {
1484                    state.value = "myserver".to_string();
1485                }
1486            }
1487        }
1488
1489        assert_eq!(dialog.entry_path(), "/universal_lsp/myserver");
1490    }
1491
1492    #[test]
1493    fn button_count_differs_for_new_vs_existing() {
1494        let schema = create_test_schema();
1495
1496        let new_dialog = EntryDialogState::from_schema(
1497            "test".to_string(),
1498            &serde_json::json!({}),
1499            &schema,
1500            "/test",
1501            true,
1502            false,
1503        );
1504        assert_eq!(new_dialog.button_count(), 2); // Save, Cancel
1505
1506        let existing_dialog = EntryDialogState::from_schema(
1507            "test".to_string(),
1508            &serde_json::json!({}),
1509            &schema,
1510            "/test",
1511            false,
1512            false, // allow delete
1513        );
1514        assert_eq!(existing_dialog.button_count(), 3); // Save, Delete, Cancel
1515
1516        // no_delete hides the Delete button even for existing entries
1517        let no_delete_dialog = EntryDialogState::from_schema(
1518            "test".to_string(),
1519            &serde_json::json!({}),
1520            &schema,
1521            "/test",
1522            false,
1523            true, // no delete (auto-managed entries like plugins)
1524        );
1525        assert_eq!(no_delete_dialog.button_count(), 2); // Save, Cancel (no Delete)
1526    }
1527}