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