Skip to main content

fresh/view/settings/
items.rs

1//! Setting items for the UI
2//!
3//! Converts schema information into renderable setting items.
4
5use super::schema::{SettingCategory, SettingSchema, SettingType};
6use crate::config_io::ConfigLayer;
7use crate::view::controls::{
8    DropdownState, DualListState, FocusState, KeybindingListState, MapState, NumberInputState,
9    TextInputState, TextListState, ToggleState,
10};
11use crate::view::ui::{FocusRegion, ScrollItem, TextEdit};
12use std::collections::{HashMap, HashSet};
13
14/// State for multiline JSON editing
15#[derive(Debug, Clone)]
16pub struct JsonEditState {
17    /// The text editor state
18    pub editor: TextEdit,
19    /// Original text (for revert on Escape)
20    pub original_text: String,
21    /// Label for the control
22    pub label: String,
23    /// Focus state
24    pub focus: FocusState,
25    /// Scroll offset for viewing (used by entry dialog)
26    pub scroll_offset: usize,
27    /// Maximum visible lines (for main settings panel)
28    pub max_visible_lines: usize,
29}
30
31impl JsonEditState {
32    /// Create a new JSON edit state with pretty-printed JSON
33    pub fn new(label: impl Into<String>, value: Option<&serde_json::Value>) -> Self {
34        let json_str = value
35            .map(|v| serde_json::to_string_pretty(v).unwrap_or_else(|_| "null".to_string()))
36            .unwrap_or_else(|| "null".to_string());
37
38        Self {
39            original_text: json_str.clone(),
40            editor: TextEdit::with_text(&json_str),
41            label: label.into(),
42            focus: FocusState::Normal,
43            scroll_offset: 0,
44            max_visible_lines: 6,
45        }
46    }
47
48    /// Revert to original value (for Escape key)
49    pub fn revert(&mut self) {
50        self.editor.set_value(&self.original_text);
51        self.scroll_offset = 0;
52    }
53
54    /// Commit current value as the new original (after saving)
55    pub fn commit(&mut self) {
56        self.original_text = self.editor.value();
57    }
58
59    /// Get the full text value
60    pub fn value(&self) -> String {
61        self.editor.value()
62    }
63
64    /// Check if the JSON is valid
65    pub fn is_valid(&self) -> bool {
66        serde_json::from_str::<serde_json::Value>(&self.value()).is_ok()
67    }
68
69    /// Get number of lines to display (all lines)
70    pub fn display_height(&self) -> usize {
71        self.editor.line_count()
72    }
73
74    /// Get number of lines for constrained view (e.g., main settings panel)
75    pub fn display_height_capped(&self) -> usize {
76        self.editor.line_count().min(self.max_visible_lines)
77    }
78
79    /// Get lines for rendering
80    pub fn lines(&self) -> &[String] {
81        &self.editor.lines
82    }
83
84    /// Get cursor position (row, col)
85    pub fn cursor_pos(&self) -> (usize, usize) {
86        (self.editor.cursor_row, self.editor.cursor_col)
87    }
88
89    // Delegate editing methods to TextEdit
90    pub fn insert(&mut self, c: char) {
91        self.editor.insert_char(c);
92    }
93
94    pub fn insert_str(&mut self, s: &str) {
95        self.editor.insert_str(s);
96    }
97
98    pub fn backspace(&mut self) {
99        self.editor.backspace();
100    }
101
102    pub fn delete(&mut self) {
103        self.editor.delete();
104    }
105
106    pub fn move_left(&mut self) {
107        self.editor.move_left();
108    }
109
110    pub fn move_right(&mut self) {
111        self.editor.move_right();
112    }
113
114    pub fn move_up(&mut self) {
115        self.editor.move_up();
116    }
117
118    pub fn move_down(&mut self) {
119        self.editor.move_down();
120    }
121
122    pub fn move_home(&mut self) {
123        self.editor.move_home();
124    }
125
126    pub fn move_end(&mut self) {
127        self.editor.move_end();
128    }
129
130    pub fn move_word_left(&mut self) {
131        self.editor.move_word_left();
132    }
133
134    pub fn move_word_right(&mut self) {
135        self.editor.move_word_right();
136    }
137
138    // Selection methods
139    pub fn has_selection(&self) -> bool {
140        self.editor.has_selection()
141    }
142
143    pub fn selection_range(&self) -> Option<((usize, usize), (usize, usize))> {
144        self.editor.selection_range()
145    }
146
147    pub fn selected_text(&self) -> Option<String> {
148        self.editor.selected_text()
149    }
150
151    pub fn delete_selection(&mut self) -> Option<String> {
152        self.editor.delete_selection()
153    }
154
155    pub fn clear_selection(&mut self) {
156        self.editor.clear_selection();
157    }
158
159    pub fn move_left_selecting(&mut self) {
160        self.editor.move_left_selecting();
161    }
162
163    pub fn move_right_selecting(&mut self) {
164        self.editor.move_right_selecting();
165    }
166
167    pub fn move_up_selecting(&mut self) {
168        self.editor.move_up_selecting();
169    }
170
171    pub fn move_down_selecting(&mut self) {
172        self.editor.move_down_selecting();
173    }
174
175    pub fn move_home_selecting(&mut self) {
176        self.editor.move_home_selecting();
177    }
178
179    pub fn move_end_selecting(&mut self) {
180        self.editor.move_end_selecting();
181    }
182
183    pub fn move_word_left_selecting(&mut self) {
184        self.editor.move_word_left_selecting();
185    }
186
187    pub fn move_word_right_selecting(&mut self) {
188        self.editor.move_word_right_selecting();
189    }
190
191    pub fn select_all(&mut self) {
192        self.editor.select_all();
193    }
194
195    pub fn delete_word_forward(&mut self) {
196        self.editor.delete_word_forward();
197    }
198
199    pub fn delete_word_backward(&mut self) {
200        self.editor.delete_word_backward();
201    }
202
203    pub fn delete_to_end(&mut self) {
204        self.editor.delete_to_end();
205    }
206}
207
208/// Create a JSON control for editing arbitrary JSON values (multiline)
209fn json_control(
210    name: &str,
211    current_value: Option<&serde_json::Value>,
212    default: Option<&serde_json::Value>,
213) -> SettingControl {
214    let value = current_value.or(default);
215    SettingControl::Json(JsonEditState::new(name, value))
216}
217
218/// Extract a JSON array of strings from a value (or fall back to a default).
219fn value_as_string_array(
220    current: Option<&serde_json::Value>,
221    default: Option<&serde_json::Value>,
222) -> Vec<String> {
223    let from = |v: &serde_json::Value| -> Option<Vec<String>> {
224        v.as_array().map(|arr| {
225            arr.iter()
226                .filter_map(|v| v.as_str().map(String::from))
227                .collect()
228        })
229    };
230    current
231        .and_then(from)
232        .or_else(|| default.and_then(from))
233        .unwrap_or_default()
234}
235
236/// Build a DualListState from schema options, current value, and optional sibling excluded set.
237fn build_dual_list_state(
238    schema: &SettingSchema,
239    options: &[crate::view::settings::schema::EnumOption],
240    current_value: Option<&serde_json::Value>,
241    excluded: Vec<String>,
242) -> DualListState {
243    let all_options: Vec<(String, String)> = options
244        .iter()
245        .map(|o| (o.value.clone(), o.name.clone()))
246        .collect();
247    let included = value_as_string_array(current_value, schema.default.as_ref());
248    DualListState::new(&schema.name, all_options)
249        .with_included(included)
250        .with_excluded(excluded)
251}
252
253/// A renderable setting item
254#[derive(Debug, Clone)]
255pub struct SettingItem {
256    /// JSON pointer path
257    pub path: String,
258    /// Display name
259    pub name: String,
260    /// Description
261    pub description: Option<String>,
262    /// The control for this setting
263    pub control: SettingControl,
264    /// Default value (for reset)
265    pub default: Option<serde_json::Value>,
266    /// Whether this setting is defined in the current target layer.
267    /// This is the new semantic: modified means "explicitly set in target layer",
268    /// not "differs from schema default".
269    pub modified: bool,
270    /// Which layer this setting's current value comes from.
271    /// System means it's using the schema default.
272    pub layer_source: ConfigLayer,
273    /// Whether this field is read-only (cannot be edited)
274    pub read_only: bool,
275    /// Whether this is an auto-managed map (no_add) that should never show as modified
276    pub is_auto_managed: bool,
277    /// Whether this setting accepts null (can be "unset" to inherit)
278    pub nullable: bool,
279    /// Whether this setting's current value is null (inherited/unset)
280    pub is_null: bool,
281    /// Section/group within the category (from x-section)
282    pub section: Option<String>,
283    /// Whether this item is the first in its section (for rendering section headers)
284    pub is_section_start: bool,
285    /// Visual style (card border thickness, padding, etc.) for this item.
286    /// Cached on the item so the `ScrollItem::height(width)` trait impl can
287    /// compute the correct height without taking a style parameter; flipped
288    /// in bulk by `SettingsState::set_item_style` when the user toggles UI mode.
289    pub style: ItemBoxStyle,
290    /// Path to sibling dual-list setting (for cross-exclusion refresh)
291    pub dual_list_sibling: Option<String>,
292}
293
294/// The type of control to render for a setting
295#[derive(Debug, Clone)]
296pub enum SettingControl {
297    Toggle(ToggleState),
298    Number(NumberInputState),
299    Dropdown(DropdownState),
300    Text(TextInputState),
301    TextList(TextListState),
302    /// Dual-list picker for ordered subset selection (e.g., status bar elements)
303    DualList(DualListState),
304    /// Map/dictionary control for key-value pairs
305    Map(MapState),
306    /// Array of objects control (for keybindings, etc.)
307    ObjectArray(KeybindingListState),
308    /// Multiline JSON editor
309    Json(JsonEditState),
310    /// Complex settings that can't be edited inline
311    Complex {
312        type_name: String,
313    },
314}
315
316impl SettingControl {
317    /// Calculate the height needed for this control (in lines)
318    pub fn control_height(&self) -> u16 {
319        match self {
320            // TextList needs: 1 label line + items + 1 "add new" row
321            Self::TextList(state) => {
322                // 1 for label + items count + 1 for add-new row
323                (state.items.len() + 2) as u16
324            }
325            // DualList needs: 1 label + 1 header + body rows
326            Self::DualList(state) => 2 + state.body_rows() as u16,
327            // Map needs: 1 label + 1 header (if display_field) + entries + expanded content + 1 add-new row (if allowed)
328            Self::Map(state) => {
329                let header_row = if state.display_field.is_some() { 1 } else { 0 };
330                let add_new_row = if state.no_add { 0 } else { 1 };
331                let base = 1 + header_row + state.entries.len() + add_new_row; // label + header? + entries + add-new?
332                                                                               // Add extra height for expanded entries (up to 6 lines each)
333                let expanded_height: usize = state
334                    .expanded
335                    .iter()
336                    .filter_map(|&idx| state.entries.get(idx))
337                    .map(|(_, v)| {
338                        if let Some(obj) = v.as_object() {
339                            obj.len().min(5) + if obj.len() > 5 { 1 } else { 0 }
340                        } else {
341                            0
342                        }
343                    })
344                    .sum();
345                (base + expanded_height) as u16
346            }
347            // Dropdown needs extra height when open to show options
348            Self::Dropdown(state) => {
349                if state.open {
350                    // 1 for label/button + number of options (max 8 visible)
351                    1 + state.options.len().min(8) as u16
352                } else {
353                    1
354                }
355            }
356            // KeybindingList needs: 1 label + bindings + 1 add-new row
357            Self::ObjectArray(state) => {
358                // 1 for label + bindings count + 1 for add-new row
359                (state.bindings.len() + 2) as u16
360            }
361            // Json needs: 1 label + visible lines
362            Self::Json(state) => {
363                // 1 for label + displayed lines
364                1 + state.display_height() as u16
365            }
366            // All other controls fit in 1 line
367            _ => 1,
368        }
369    }
370
371    /// Whether this is a composite control (TextList, Map, ObjectArray) that has
372    /// internal sub-items. For composite controls, highlighting should be per-row,
373    /// not across the entire control area.
374    pub fn is_composite(&self) -> bool {
375        matches!(
376            self,
377            Self::TextList(_) | Self::DualList(_) | Self::Map(_) | Self::ObjectArray(_)
378        )
379    }
380
381    /// Get the row offset of the focused sub-item within a composite control.
382    /// Returns 0 for non-composite controls or if no sub-item is focused.
383    /// The offset is relative to the start of the control's render area.
384    pub fn focused_sub_row(&self) -> u16 {
385        match self {
386            Self::TextList(state) => {
387                // Row 0 = label, rows 1..N = items, row N+1 = add-new
388                match state.focused_item {
389                    Some(idx) => 1 + idx as u16,          // item rows start at offset 1
390                    None => 1 + state.items.len() as u16, // add-new row
391                }
392            }
393            Self::DualList(state) => {
394                // Row 0 = label, Row 1 = headers, Rows 2+ = body
395                use crate::view::controls::DualListColumn;
396                let row = match state.active_column {
397                    DualListColumn::Available => state.available_cursor,
398                    DualListColumn::Included => state.included_cursor,
399                };
400                2 + row as u16
401            }
402            Self::ObjectArray(state) => {
403                // Row 0 = label, rows 1..N = bindings, row N+1 = add-new
404                match state.focused_index {
405                    Some(idx) => 1 + idx as u16,
406                    None => 1 + state.bindings.len() as u16,
407                }
408            }
409            Self::Map(state) => {
410                // Row 0 = label, row 1 = header (if display_field), then entries, then add-new
411                let header_offset = if state.display_field.is_some() { 1 } else { 0 };
412                match state.focused_entry {
413                    Some(idx) => 1 + header_offset + idx as u16,
414                    None => 1 + header_offset + state.entries.len() as u16,
415                }
416            }
417            _ => 0,
418        }
419    }
420}
421
422// === Layout primitives ===
423//
424// Every magic number that used to be sprinkled through the render path lives
425// inside `ItemBoxStyle`. The struct is `Copy`, has a `Default` impl, and is
426// stored on each `SettingItem` — so toggling cards on/off, removing the
427// indicator gutter, or tightening the padding is a single state mutation
428// rather than a code change.
429
430/// Visual style for a setting item: tunes every dimension of the layout so
431/// chrome (card border, padding, section header, indicator gutter) can be
432/// toggled or tweaked from one place.
433///
434/// All values are in terminal cells (rows or columns). Setting a row/col count
435/// to `0` disables that piece of chrome; the rest of the layout still works.
436#[derive(Debug, Clone, Copy, PartialEq, Eq)]
437pub struct ItemBoxStyle {
438    /// Rows occupied by a section header: title row + blank gap below it.
439    /// Set to `0` to suppress section headings entirely.
440    pub section_header_rows: u16,
441    /// Top/bottom border thickness of the per-item card (rows).
442    pub card_border_rows: u16,
443    /// Left/right border thickness of the per-item card (columns).
444    pub card_border_cols: u16,
445    /// Columns reserved on the left of the card's interior for the focus
446    /// indicator (`>`), the modified marker (`●`), and a single-space gutter.
447    pub focus_indicator_cols: u16,
448    /// Right-side padding inside the card so wrapped description text doesn't
449    /// butt up against the right border.
450    pub description_right_padding_cols: u16,
451}
452
453impl ItemBoxStyle {
454    /// The default look used by the settings panel: 1-row top/bottom card
455    /// borders, 1-col side borders, 2-row section headers.
456    pub const fn cards() -> Self {
457        Self {
458            section_header_rows: 2,
459            card_border_rows: 1,
460            card_border_cols: 1,
461            focus_indicator_cols: 3,
462            description_right_padding_cols: 2,
463        }
464    }
465
466    /// A flat look with no card border. Items still get 1-row gap chrome
467    /// (carried by the section header) and the indicator gutter.
468    pub const fn flat() -> Self {
469        Self {
470            section_header_rows: 2,
471            card_border_rows: 0,
472            card_border_cols: 0,
473            focus_indicator_cols: 3,
474            description_right_padding_cols: 2,
475        }
476    }
477
478    /// Width available for wrapped description text inside a card of the
479    /// given outer width (subtracting both borders, the focus gutter, and
480    /// the right padding).
481    pub fn inner_text_width(&self, card_outer_width: u16) -> u16 {
482        card_outer_width
483            .saturating_sub(2 * self.card_border_cols)
484            .saturating_sub(self.focus_indicator_cols)
485            .saturating_sub(self.description_right_padding_cols)
486    }
487}
488
489impl Default for ItemBoxStyle {
490    fn default() -> Self {
491        Self::cards()
492    }
493}
494
495/// Vertical layout descriptor for a single setting item.
496///
497/// Fields are named bands of rows; together they describe both the total
498/// height of the item and where each band lives along the y-axis. The render
499/// path uses these offsets directly instead of recomputing them inline.
500///
501/// All offsets are relative to the top of the area allocated to the item.
502#[derive(Debug, Clone, Copy, Default)]
503pub struct ItemBox {
504    /// Section header band above the card (0 if not a section start).
505    pub section_header_rows: u16,
506    /// Top edge of the card.
507    pub top_border_rows: u16,
508    /// The control widget (toggle, dropdown, multi-row list, …).
509    pub control_rows: u16,
510    /// The wrapped description text below the control.
511    pub description_rows: u16,
512    /// Bottom edge of the card.
513    pub bottom_border_rows: u16,
514}
515
516impl ItemBox {
517    pub fn total_rows(&self) -> u16 {
518        self.section_header_rows
519            + self.top_border_rows
520            + self.control_rows
521            + self.description_rows
522            + self.bottom_border_rows
523    }
524
525    /// Y of the card's top border.
526    pub fn card_top_y(&self) -> u16 {
527        self.section_header_rows
528    }
529
530    /// Y of the first content row (the control).
531    pub fn control_y(&self) -> u16 {
532        self.card_top_y() + self.top_border_rows
533    }
534
535    /// Y of the first description row.
536    pub fn description_y(&self) -> u16 {
537        self.control_y() + self.control_rows
538    }
539
540    /// Y of the bottom border.
541    pub fn bottom_border_y(&self) -> u16 {
542        self.description_y() + self.description_rows
543    }
544
545    /// Total card height (top border + content + bottom border).
546    pub fn card_height(&self) -> u16 {
547        self.top_border_rows + self.control_rows + self.description_rows + self.bottom_border_rows
548    }
549
550    /// Card content rows (control + description, no borders).
551    pub fn content_rows(&self) -> u16 {
552        self.control_rows + self.description_rows
553    }
554}
555
556impl SettingItem {
557    /// Compute the visual layout of this item for a given outer width and
558    /// style. `width` is the full width allocated to the item (including the
559    /// card borders and the focus-indicator columns).
560    pub fn layout_box(&self, width: u16, style: &ItemBoxStyle) -> ItemBox {
561        ItemBox {
562            section_header_rows: if self.is_section_start {
563                style.section_header_rows
564            } else {
565                0
566            },
567            top_border_rows: style.card_border_rows,
568            control_rows: self.control.control_height(),
569            description_rows: self.description_rows_for(style.inner_text_width(width)),
570            bottom_border_rows: style.card_border_rows,
571        }
572    }
573
574    /// Rows needed for the description when wrapped to `inner_width` columns.
575    ///
576    /// The wrapping here is a byte-based approximation that overestimates
577    /// slightly compared to the word-wrap used at render time; that's fine —
578    /// the renderer clips to the available rows, never to fewer than the
579    /// number of wrapped lines it produces.
580    pub fn description_rows_for(&self, inner_width: u16) -> u16 {
581        let Some(desc) = self.description.as_deref() else {
582            return 0;
583        };
584        if desc.is_empty() {
585            return 0;
586        }
587        if inner_width == 0 {
588            return 1;
589        }
590        desc.len().div_ceil(inner_width as usize) as u16
591    }
592}
593
594/// Clean a description to remove redundancy with the name.
595/// Returns None if the description is empty or essentially just repeats the name.
596pub fn clean_description(name: &str, description: Option<&str>) -> Option<String> {
597    let desc = description?;
598    if desc.is_empty() {
599        return None;
600    }
601
602    // Build a set of significant words from the name (lowercase for comparison)
603    let name_words: HashSet<String> = name
604        .to_lowercase()
605        .split(|c: char| !c.is_alphanumeric())
606        .filter(|w| !w.is_empty() && w.len() > 2)
607        .map(String::from)
608        .collect();
609
610    // Common filler words to ignore when checking for new info
611    let filler_words: HashSet<&str> = [
612        "the", "a", "an", "to", "for", "of", "in", "on", "is", "are", "be", "and", "or", "when",
613        "whether", "if", "this", "that", "with", "from", "by", "as", "at", "show", "enable",
614        "disable", "set", "use", "allow", "default", "true", "false",
615    ]
616    .into_iter()
617    .collect();
618
619    // Split description into words
620    let desc_words: Vec<&str> = desc
621        .split(|c: char| !c.is_alphanumeric())
622        .filter(|w| !w.is_empty())
623        .collect();
624
625    // Check if description has any meaningful new information
626    let has_new_info = desc_words.iter().any(|word| {
627        let lower = word.to_lowercase();
628        lower.len() > 2 && !name_words.contains(&lower) && !filler_words.contains(lower.as_str())
629    });
630
631    if !has_new_info {
632        return None;
633    }
634
635    Some(desc.to_string())
636}
637
638impl ScrollItem for SettingItem {
639    fn height(&self, width: u16) -> u16 {
640        self.layout_box(width, &self.style).total_rows()
641    }
642
643    fn focus_regions(&self, width: u16) -> Vec<FocusRegion> {
644        // y_offset is ABSOLUTE within the item — `ScrollablePanel` adds it
645        // to the cumulative item-y to compute a screen y for
646        // `ensure_visible`. Since the item now starts with a section header
647        // and/or a card top border above the control row, y=0 of the
648        // control is `plan.control_y()` rows down from the item top. Using
649        // 0 here scrolls the viewport to the chrome, not to the actual
650        // entry, which is exactly the bug that hid the focused map entry
651        // off-screen on search-jump.
652        let plan = self.layout_box(width, &self.style);
653        let label_y = plan.control_y();
654
655        match &self.control {
656            // TextList: each row is a focus region
657            SettingControl::TextList(state) => {
658                let mut regions = Vec::new();
659                // Label row
660                regions.push(FocusRegion {
661                    id: 0,
662                    y_offset: label_y,
663                    height: 1,
664                });
665                // Each item row (id = 1 + row_index)
666                for i in 0..state.items.len() {
667                    regions.push(FocusRegion {
668                        id: 1 + i,
669                        y_offset: label_y + 1 + i as u16,
670                        height: 1,
671                    });
672                }
673                // Add-new row
674                regions.push(FocusRegion {
675                    id: 1 + state.items.len(),
676                    y_offset: label_y + 1 + state.items.len() as u16,
677                    height: 1,
678                });
679                regions
680            }
681            // DualList: label + header + body rows
682            SettingControl::DualList(state) => {
683                let mut regions = Vec::new();
684                // Label row
685                regions.push(FocusRegion {
686                    id: 0,
687                    y_offset: label_y,
688                    height: 1,
689                });
690                // Header row (not selectable, but takes space)
691                // Body rows (id = 1 + row_index)
692                let body = state.body_rows();
693                for i in 0..body {
694                    regions.push(FocusRegion {
695                        id: 1 + i,
696                        y_offset: label_y + 2 + i as u16, // after label + header
697                        height: 1,
698                    });
699                }
700                regions
701            }
702            // Map: each entry row is a focus region
703            SettingControl::Map(state) => {
704                let mut regions = Vec::new();
705                let mut y = label_y;
706
707                // Label row
708                regions.push(FocusRegion {
709                    id: 0,
710                    y_offset: y,
711                    height: 1,
712                });
713                y += 1;
714
715                // Column header row (if display_field is set)
716                if state.display_field.is_some() {
717                    y += 1;
718                }
719
720                // Each entry (id = 1 + entry_index)
721                for (i, (_, v)) in state.entries.iter().enumerate() {
722                    let mut entry_height = 1u16;
723                    // Add expanded content height if expanded
724                    if state.expanded.contains(&i) {
725                        if let Some(obj) = v.as_object() {
726                            entry_height += obj.len().min(5) as u16;
727                            if obj.len() > 5 {
728                                entry_height += 1;
729                            }
730                        }
731                    }
732                    regions.push(FocusRegion {
733                        id: 1 + i,
734                        y_offset: y,
735                        height: entry_height,
736                    });
737                    y += entry_height;
738                }
739
740                // Add-new row
741                regions.push(FocusRegion {
742                    id: 1 + state.entries.len(),
743                    y_offset: y,
744                    height: 1,
745                });
746                regions
747            }
748            // KeybindingList: each entry row is a focus region
749            SettingControl::ObjectArray(state) => {
750                let mut regions = Vec::new();
751                // Label row
752                regions.push(FocusRegion {
753                    id: 0,
754                    y_offset: label_y,
755                    height: 1,
756                });
757                // Each binding (id = 1 + index)
758                for i in 0..state.bindings.len() {
759                    regions.push(FocusRegion {
760                        id: 1 + i,
761                        y_offset: label_y + 1 + i as u16,
762                        height: 1,
763                    });
764                }
765                // Add-new row
766                regions.push(FocusRegion {
767                    id: 1 + state.bindings.len(),
768                    y_offset: label_y + 1 + state.bindings.len() as u16,
769                    height: 1,
770                });
771                regions
772            }
773            // Other controls: single region covering the card content.
774            _ => {
775                vec![FocusRegion {
776                    id: 0,
777                    y_offset: label_y,
778                    height: plan.content_rows(),
779                }]
780            }
781        }
782    }
783}
784
785/// A page of settings (corresponds to a category)
786#[derive(Debug, Clone)]
787pub struct SettingsPage {
788    /// Page name
789    pub name: String,
790    /// JSON path prefix
791    pub path: String,
792    /// Description
793    pub description: Option<String>,
794    /// Whether this page represents a nullable category that can be cleared as a whole
795    pub nullable: bool,
796    /// Settings on this page
797    pub items: Vec<SettingItem>,
798    /// Subpages
799    pub subpages: Vec<SettingsPage>,
800    /// Cached section list for the tree view in the left panel.
801    /// Computed once after sorting items in `build_page`.
802    pub sections: Vec<SectionInfo>,
803}
804
805/// One section within a page — name plus the index of its first item, used by
806/// the left-panel tree view to jump straight to that section when clicked.
807#[derive(Debug, Clone)]
808pub struct SectionInfo {
809    pub name: String,
810    pub first_item_index: usize,
811}
812
813/// Context for building setting items with layer awareness
814pub struct BuildContext<'a> {
815    /// The merged config value (effective values)
816    pub config_value: &'a serde_json::Value,
817    /// Map of paths to their source layer
818    pub layer_sources: &'a HashMap<String, ConfigLayer>,
819    /// The layer currently being edited
820    pub target_layer: ConfigLayer,
821}
822
823/// Convert a category tree into pages with control states
824pub fn build_pages(
825    categories: &[SettingCategory],
826    config_value: &serde_json::Value,
827    layer_sources: &HashMap<String, ConfigLayer>,
828    target_layer: ConfigLayer,
829) -> Vec<SettingsPage> {
830    let ctx = BuildContext {
831        config_value,
832        layer_sources,
833        target_layer,
834    };
835    categories.iter().map(|cat| build_page(cat, &ctx)).collect()
836}
837
838/// Build a single page from a category
839fn build_page(category: &SettingCategory, ctx: &BuildContext) -> SettingsPage {
840    let mut items: Vec<SettingItem> = category
841        .settings
842        .iter()
843        .flat_map(|s| expand_or_build(s, ctx))
844        .collect();
845
846    // Sort items: by section first (None comes last), then alphabetically by name
847    items.sort_by(|a, b| match (&a.section, &b.section) {
848        (Some(sec_a), Some(sec_b)) => sec_a.cmp(sec_b).then_with(|| a.name.cmp(&b.name)),
849        (Some(_), None) => std::cmp::Ordering::Less,
850        (None, Some(_)) => std::cmp::Ordering::Greater,
851        (None, None) => a.name.cmp(&b.name),
852    });
853
854    // Mark items that start a new section, and capture the section list
855    // for the left-panel tree view in one pass.
856    let mut sections: Vec<SectionInfo> = Vec::new();
857    let mut prev_section: Option<&String> = None;
858    for (idx, item) in items.iter_mut().enumerate() {
859        let is_new_section = match (&item.section, prev_section) {
860            (Some(sec), Some(prev)) => sec != prev,
861            (Some(_), None) => true,
862            (None, Some(_)) => false, // Unsectioned items after sectioned ones don't start a section
863            (None, None) => false,
864        };
865        item.is_section_start = is_new_section;
866        if is_new_section {
867            if let Some(name) = item.section.clone() {
868                sections.push(SectionInfo {
869                    name,
870                    first_item_index: idx,
871                });
872            }
873        }
874        prev_section = item.section.as_ref();
875    }
876
877    let subpages = category
878        .subcategories
879        .iter()
880        .map(|sub| build_page(sub, ctx))
881        .collect();
882
883    SettingsPage {
884        name: category.name.clone(),
885        path: category.path.clone(),
886        description: category.description.clone(),
887        nullable: category.nullable,
888        items,
889        subpages,
890        sections,
891    }
892}
893
894/// Expand an Object schema into its children when every child has a native
895/// (non-JSON) control, otherwise build it as a single item. This lets compound
896/// config structs like `StatusBarConfig` surface their children as individual
897/// settings with proper DualList / toggle / etc. controls, while objects whose
898/// children would all fall through to JSON editors stay collapsed.
899fn expand_or_build(schema: &SettingSchema, ctx: &BuildContext) -> Vec<SettingItem> {
900    if let SettingType::Object { properties } = &schema.setting_type {
901        let all_native = !properties.is_empty()
902            && properties.iter().all(|child| {
903                !matches!(
904                    child.setting_type,
905                    SettingType::Object { .. } | SettingType::Complex
906                )
907            });
908        if all_native {
909            // Children parsed inside determine_type have paths relative to ""
910            // (e.g. "/left"); prefix with the parent's path to get absolute
911            // paths (e.g. "/editor/status_bar/left").
912            return properties
913                .iter()
914                .map(|child| {
915                    let mut child = child.clone();
916                    if !child.path.starts_with(&schema.path) {
917                        child.path = format!("{}{}", schema.path, child.path);
918                    }
919                    if let Some(ref mut sib) = child.dual_list_sibling {
920                        if !sib.starts_with(&schema.path) {
921                            *sib = format!("{}{}", schema.path, sib);
922                        }
923                    }
924                    build_item(&child, ctx)
925                })
926                .collect();
927        }
928    }
929    vec![build_item(schema, ctx)]
930}
931
932/// Build a setting item with its control state initialized from current config
933pub fn build_item(schema: &SettingSchema, ctx: &BuildContext) -> SettingItem {
934    // Get current value from config
935    let current_value = ctx.config_value.pointer(&schema.path);
936
937    // Detect if the current value is null (inherited/unset) for nullable fields
938    let is_null = schema.nullable
939        && current_value
940            .map(|v| v.is_null())
941            .unwrap_or(schema.default.as_ref().map(|d| d.is_null()).unwrap_or(true));
942
943    // Check if this is an auto-managed map (no_add)
944    let is_auto_managed = matches!(&schema.setting_type, SettingType::Map { no_add: true, .. });
945
946    // Create control based on type
947    let control = match &schema.setting_type {
948        SettingType::Boolean => {
949            let checked = current_value
950                .and_then(|v| v.as_bool())
951                .or_else(|| schema.default.as_ref().and_then(|d| d.as_bool()))
952                .unwrap_or(false);
953            SettingControl::Toggle(ToggleState::new(checked, &schema.name))
954        }
955
956        SettingType::Integer { minimum, maximum } => {
957            let value = current_value
958                .and_then(|v| v.as_i64())
959                .or_else(|| schema.default.as_ref().and_then(|d| d.as_i64()))
960                .unwrap_or(0);
961
962            let mut state = NumberInputState::new(value, &schema.name);
963            if let Some(min) = minimum {
964                state = state.with_min(*min);
965            }
966            if let Some(max) = maximum {
967                state = state.with_max(*max);
968            }
969            SettingControl::Number(state)
970        }
971
972        SettingType::Number { minimum, maximum } => {
973            // For floats, we display as integers (multiply by 100 for percentages)
974            let value = current_value
975                .and_then(|v| v.as_f64())
976                .or_else(|| schema.default.as_ref().and_then(|d| d.as_f64()))
977                .unwrap_or(0.0);
978
979            // Convert to integer representation
980            let int_value = (value * 100.0).round() as i64;
981            let mut state = NumberInputState::new(int_value, &schema.name).with_percentage();
982            if let Some(min) = minimum {
983                state = state.with_min((*min * 100.0) as i64);
984            }
985            if let Some(max) = maximum {
986                state = state.with_max((*max * 100.0) as i64);
987            }
988            SettingControl::Number(state)
989        }
990
991        SettingType::String => {
992            let value = current_value
993                .and_then(|v| v.as_str())
994                .or_else(|| schema.default.as_ref().and_then(|d| d.as_str()))
995                .unwrap_or("");
996
997            // Check for dynamic enum: derive dropdown options from another config field's keys
998            if let Some(ref source_path) = schema.enum_from {
999                let mut options: Vec<String> = ctx
1000                    .config_value
1001                    .pointer(source_path)
1002                    .and_then(|v| v.as_object())
1003                    .map(|obj| obj.keys().cloned().collect())
1004                    .unwrap_or_default();
1005                options.sort();
1006
1007                // Add empty option for nullable fields (unset/inherit)
1008                let mut display_names = Vec::new();
1009                let mut values = Vec::new();
1010                if schema.nullable {
1011                    display_names.push("(none)".to_string());
1012                    values.push(String::new());
1013                }
1014                for key in &options {
1015                    display_names.push(key.clone());
1016                    values.push(key.clone());
1017                }
1018
1019                let current = if is_null { "" } else { value };
1020                let selected = values.iter().position(|v| v == current).unwrap_or(0);
1021                let state = DropdownState::with_values(display_names, values, &schema.name)
1022                    .with_selected(selected);
1023                SettingControl::Dropdown(state)
1024            } else {
1025                let state = TextInputState::new(&schema.name).with_value(value);
1026                SettingControl::Text(state)
1027            }
1028        }
1029
1030        SettingType::Enum { options } => {
1031            // Handle null values in enums (represented as empty string in dropdown values)
1032            let current = if current_value.map(|v| v.is_null()).unwrap_or(false) {
1033                "" // null maps to empty string (Auto-detect option)
1034            } else {
1035                current_value
1036                    .and_then(|v| v.as_str())
1037                    .or_else(|| {
1038                        let default = schema.default.as_ref()?;
1039                        if default.is_null() {
1040                            Some("")
1041                        } else {
1042                            default.as_str()
1043                        }
1044                    })
1045                    .unwrap_or("")
1046            };
1047
1048            let display_names: Vec<String> = options.iter().map(|o| o.name.clone()).collect();
1049            let values: Vec<String> = options.iter().map(|o| o.value.clone()).collect();
1050            let selected = values.iter().position(|v| v == current).unwrap_or(0);
1051            let state = DropdownState::with_values(display_names, values, &schema.name)
1052                .with_selected(selected);
1053            SettingControl::Dropdown(state)
1054        }
1055
1056        SettingType::DualList {
1057            options,
1058            sibling_path,
1059        } => {
1060            let excluded = sibling_path
1061                .as_ref()
1062                .and_then(|path| ctx.config_value.pointer(path))
1063                .map(|v| value_as_string_array(Some(v), None))
1064                .unwrap_or_default();
1065            SettingControl::DualList(build_dual_list_state(
1066                schema,
1067                options,
1068                current_value,
1069                excluded,
1070            ))
1071        }
1072
1073        SettingType::StringArray => {
1074            let items = value_as_string_array(current_value, schema.default.as_ref());
1075            let state = TextListState::new(&schema.name).with_items(items);
1076            SettingControl::TextList(state)
1077        }
1078
1079        SettingType::IntegerArray => {
1080            let items: Vec<String> = current_value
1081                .and_then(|v| v.as_array())
1082                .map(|arr| {
1083                    arr.iter()
1084                        .filter_map(|v| {
1085                            v.as_i64()
1086                                .map(|n| n.to_string())
1087                                .or_else(|| v.as_u64().map(|n| n.to_string()))
1088                                .or_else(|| v.as_f64().map(|n| n.to_string()))
1089                        })
1090                        .collect()
1091                })
1092                .or_else(|| {
1093                    schema.default.as_ref().and_then(|d| {
1094                        d.as_array().map(|arr| {
1095                            arr.iter()
1096                                .filter_map(|v| {
1097                                    v.as_i64()
1098                                        .map(|n| n.to_string())
1099                                        .or_else(|| v.as_u64().map(|n| n.to_string()))
1100                                        .or_else(|| v.as_f64().map(|n| n.to_string()))
1101                                })
1102                                .collect()
1103                        })
1104                    })
1105                })
1106                .unwrap_or_default();
1107
1108            let state = TextListState::new(&schema.name)
1109                .with_items(items)
1110                .with_integer_mode();
1111            SettingControl::TextList(state)
1112        }
1113
1114        SettingType::Object { .. } => {
1115            json_control(&schema.name, current_value, schema.default.as_ref())
1116        }
1117
1118        SettingType::Map {
1119            value_schema,
1120            display_field,
1121            no_add,
1122        } => {
1123            // Get current map value or default
1124            let map_value = current_value
1125                .cloned()
1126                .or_else(|| schema.default.clone())
1127                .unwrap_or_else(|| serde_json::json!({}));
1128
1129            let mut state = MapState::new(&schema.name).with_entries(&map_value);
1130            state = state.with_value_schema((**value_schema).clone());
1131            if let Some(field) = display_field {
1132                state = state.with_display_field(field.clone());
1133            }
1134            if *no_add {
1135                state = state.with_no_add(true);
1136            }
1137            SettingControl::Map(state)
1138        }
1139
1140        SettingType::ObjectArray {
1141            item_schema,
1142            display_field,
1143        } => {
1144            // Get current array or default
1145            let array_value = current_value
1146                .cloned()
1147                .or_else(|| schema.default.clone())
1148                .unwrap_or_else(|| serde_json::json!([]));
1149
1150            let mut state = KeybindingListState::new(&schema.name).with_bindings(&array_value);
1151            state = state.with_item_schema((**item_schema).clone());
1152            if let Some(field) = display_field {
1153                state = state.with_display_field(field.clone());
1154            }
1155            SettingControl::ObjectArray(state)
1156        }
1157
1158        SettingType::Complex => json_control(&schema.name, current_value, schema.default.as_ref()),
1159    };
1160
1161    // Determine layer source for this setting
1162    let layer_source = ctx
1163        .layer_sources
1164        .get(&schema.path)
1165        .copied()
1166        .unwrap_or(ConfigLayer::System);
1167
1168    // NEW SEMANTICS: "modified" means the value is defined in the target layer being edited.
1169    // Auto-managed maps (no_add like plugins/languages) are never "modified" at the container level.
1170    let modified = if is_auto_managed {
1171        false // Auto-managed content never shows as modified
1172    } else {
1173        layer_source == ctx.target_layer
1174    };
1175
1176    // Clean description to remove redundancy with name
1177    let cleaned_description = clean_description(&schema.name, schema.description.as_deref());
1178
1179    SettingItem {
1180        path: schema.path.clone(),
1181        name: schema.name.clone(),
1182        description: cleaned_description,
1183        control,
1184        default: schema.default.clone(),
1185        modified,
1186        layer_source,
1187        read_only: schema.read_only,
1188        is_auto_managed,
1189        nullable: schema.nullable,
1190        is_null,
1191        section: schema.section.clone(),
1192        is_section_start: false, // Set later in build_page after sorting
1193        style: ItemBoxStyle::default(),
1194        dual_list_sibling: schema.dual_list_sibling.clone(),
1195    }
1196}
1197
1198/// Build a setting item with a value provided directly (for dialogs)
1199pub fn build_item_from_value(
1200    schema: &SettingSchema,
1201    current_value: Option<&serde_json::Value>,
1202) -> SettingItem {
1203    // Create control based on type
1204    let control = match &schema.setting_type {
1205        SettingType::Boolean => {
1206            let checked = current_value
1207                .and_then(|v| v.as_bool())
1208                .or_else(|| schema.default.as_ref().and_then(|d| d.as_bool()))
1209                .unwrap_or(false);
1210            SettingControl::Toggle(ToggleState::new(checked, &schema.name))
1211        }
1212
1213        SettingType::Integer { minimum, maximum } => {
1214            let value = current_value
1215                .and_then(|v| v.as_i64())
1216                .or_else(|| schema.default.as_ref().and_then(|d| d.as_i64()))
1217                .unwrap_or(0);
1218
1219            let mut state = NumberInputState::new(value, &schema.name);
1220            if let Some(min) = minimum {
1221                state = state.with_min(*min);
1222            }
1223            if let Some(max) = maximum {
1224                state = state.with_max(*max);
1225            }
1226            SettingControl::Number(state)
1227        }
1228
1229        SettingType::Number { minimum, maximum } => {
1230            let value = current_value
1231                .and_then(|v| v.as_f64())
1232                .or_else(|| schema.default.as_ref().and_then(|d| d.as_f64()))
1233                .unwrap_or(0.0);
1234
1235            let int_value = (value * 100.0).round() as i64;
1236            let mut state = NumberInputState::new(int_value, &schema.name).with_percentage();
1237            if let Some(min) = minimum {
1238                state = state.with_min((*min * 100.0) as i64);
1239            }
1240            if let Some(max) = maximum {
1241                state = state.with_max((*max * 100.0) as i64);
1242            }
1243            SettingControl::Number(state)
1244        }
1245
1246        SettingType::String => {
1247            let value = current_value
1248                .and_then(|v| v.as_str())
1249                .or_else(|| schema.default.as_ref().and_then(|d| d.as_str()))
1250                .unwrap_or("");
1251
1252            let state = TextInputState::new(&schema.name).with_value(value);
1253            SettingControl::Text(state)
1254        }
1255
1256        SettingType::Enum { options } => {
1257            // Handle null values in enums (represented as empty string in dropdown values)
1258            let current = if current_value.map(|v| v.is_null()).unwrap_or(false) {
1259                "" // null maps to empty string (Auto-detect option)
1260            } else {
1261                current_value
1262                    .and_then(|v| v.as_str())
1263                    .or_else(|| {
1264                        let default = schema.default.as_ref()?;
1265                        if default.is_null() {
1266                            Some("")
1267                        } else {
1268                            default.as_str()
1269                        }
1270                    })
1271                    .unwrap_or("")
1272            };
1273
1274            let display_names: Vec<String> = options.iter().map(|o| o.name.clone()).collect();
1275            let values: Vec<String> = options.iter().map(|o| o.value.clone()).collect();
1276            let selected = values.iter().position(|v| v == current).unwrap_or(0);
1277            let state = DropdownState::with_values(display_names, values, &schema.name)
1278                .with_selected(selected);
1279            SettingControl::Dropdown(state)
1280        }
1281
1282        SettingType::DualList { options, .. } => {
1283            // Dialog context has no sibling to cross-exclude against
1284            SettingControl::DualList(build_dual_list_state(
1285                schema,
1286                options,
1287                current_value,
1288                vec![],
1289            ))
1290        }
1291
1292        SettingType::StringArray => {
1293            let items: Vec<String> = current_value
1294                .and_then(|v| v.as_array())
1295                .map(|arr| {
1296                    arr.iter()
1297                        .filter_map(|v| v.as_str().map(String::from))
1298                        .collect()
1299                })
1300                .or_else(|| {
1301                    schema.default.as_ref().and_then(|d| {
1302                        d.as_array().map(|arr| {
1303                            arr.iter()
1304                                .filter_map(|v| v.as_str().map(String::from))
1305                                .collect()
1306                        })
1307                    })
1308                })
1309                .unwrap_or_default();
1310
1311            let state = TextListState::new(&schema.name).with_items(items);
1312            SettingControl::TextList(state)
1313        }
1314
1315        SettingType::IntegerArray => {
1316            let items: Vec<String> = current_value
1317                .and_then(|v| v.as_array())
1318                .map(|arr| {
1319                    arr.iter()
1320                        .filter_map(|v| {
1321                            v.as_i64()
1322                                .map(|n| n.to_string())
1323                                .or_else(|| v.as_u64().map(|n| n.to_string()))
1324                                .or_else(|| v.as_f64().map(|n| n.to_string()))
1325                        })
1326                        .collect()
1327                })
1328                .or_else(|| {
1329                    schema.default.as_ref().and_then(|d| {
1330                        d.as_array().map(|arr| {
1331                            arr.iter()
1332                                .filter_map(|v| {
1333                                    v.as_i64()
1334                                        .map(|n| n.to_string())
1335                                        .or_else(|| v.as_u64().map(|n| n.to_string()))
1336                                        .or_else(|| v.as_f64().map(|n| n.to_string()))
1337                                })
1338                                .collect()
1339                        })
1340                    })
1341                })
1342                .unwrap_or_default();
1343
1344            let state = TextListState::new(&schema.name)
1345                .with_items(items)
1346                .with_integer_mode();
1347            SettingControl::TextList(state)
1348        }
1349
1350        SettingType::Object { .. } => {
1351            json_control(&schema.name, current_value, schema.default.as_ref())
1352        }
1353
1354        SettingType::Map {
1355            value_schema,
1356            display_field,
1357            no_add,
1358        } => {
1359            let map_value = current_value
1360                .cloned()
1361                .or_else(|| schema.default.clone())
1362                .unwrap_or_else(|| serde_json::json!({}));
1363
1364            let mut state = MapState::new(&schema.name).with_entries(&map_value);
1365            state = state.with_value_schema((**value_schema).clone());
1366            if let Some(field) = display_field {
1367                state = state.with_display_field(field.clone());
1368            }
1369            if *no_add {
1370                state = state.with_no_add(true);
1371            }
1372            SettingControl::Map(state)
1373        }
1374
1375        SettingType::ObjectArray {
1376            item_schema,
1377            display_field,
1378        } => {
1379            let array_value = current_value
1380                .cloned()
1381                .or_else(|| schema.default.clone())
1382                .unwrap_or_else(|| serde_json::json!([]));
1383
1384            let mut state = KeybindingListState::new(&schema.name).with_bindings(&array_value);
1385            state = state.with_item_schema((**item_schema).clone());
1386            if let Some(field) = display_field {
1387                state = state.with_display_field(field.clone());
1388            }
1389            SettingControl::ObjectArray(state)
1390        }
1391
1392        SettingType::Complex => json_control(&schema.name, current_value, schema.default.as_ref()),
1393    };
1394
1395    // For dialog items, we use the traditional definition of "modified":
1396    // differs from schema default (since we don't have layer context in dialogs)
1397    let modified = match (&current_value, &schema.default) {
1398        (Some(current), Some(default)) => *current != default,
1399        (Some(_), None) => true,
1400        _ => false,
1401    };
1402
1403    // Check if this is an auto-managed map (no_add)
1404    let is_auto_managed = matches!(&schema.setting_type, SettingType::Map { no_add: true, .. });
1405
1406    let is_null = schema.nullable
1407        && current_value
1408            .map(|v| v.is_null())
1409            .unwrap_or(schema.default.as_ref().map(|d| d.is_null()).unwrap_or(true));
1410
1411    SettingItem {
1412        path: schema.path.clone(),
1413        name: schema.name.clone(),
1414        description: schema.description.clone(),
1415        control,
1416        default: schema.default.clone(),
1417        modified,
1418        // For dialogs, we don't track layer source - default to System
1419        layer_source: ConfigLayer::System,
1420        read_only: schema.read_only,
1421        is_auto_managed,
1422        nullable: schema.nullable,
1423        is_null,
1424        section: schema.section.clone(),
1425        is_section_start: false, // Not used in dialogs
1426        style: ItemBoxStyle::default(),
1427        dual_list_sibling: schema.dual_list_sibling.clone(),
1428    }
1429}
1430
1431/// Extract the current value from a control
1432pub fn control_to_value(control: &SettingControl) -> serde_json::Value {
1433    match control {
1434        SettingControl::Toggle(state) => serde_json::Value::Bool(state.checked),
1435
1436        SettingControl::Number(state) => {
1437            if state.is_percentage {
1438                // Convert back to float (divide by 100)
1439                let float_value = state.value as f64 / 100.0;
1440                serde_json::Number::from_f64(float_value)
1441                    .map(serde_json::Value::Number)
1442                    .unwrap_or(serde_json::Value::Number(state.value.into()))
1443            } else {
1444                serde_json::Value::Number(state.value.into())
1445            }
1446        }
1447
1448        SettingControl::Dropdown(state) => state
1449            .selected_value()
1450            .map(|s| {
1451                if s.is_empty() {
1452                    // Empty string represents null in nullable enums
1453                    serde_json::Value::Null
1454                } else {
1455                    serde_json::Value::String(s.to_string())
1456                }
1457            })
1458            .unwrap_or(serde_json::Value::Null),
1459
1460        SettingControl::Text(state) => serde_json::Value::String(state.value.clone()),
1461
1462        SettingControl::TextList(state) => {
1463            let arr: Vec<serde_json::Value> = state
1464                .items
1465                .iter()
1466                .filter_map(|s| {
1467                    if state.is_integer {
1468                        s.parse::<i64>()
1469                            .ok()
1470                            .map(|n| serde_json::Value::Number(n.into()))
1471                    } else {
1472                        Some(serde_json::Value::String(s.clone()))
1473                    }
1474                })
1475                .collect();
1476            serde_json::Value::Array(arr)
1477        }
1478
1479        SettingControl::DualList(state) => {
1480            let arr: Vec<serde_json::Value> = state
1481                .included
1482                .iter()
1483                .map(|s| serde_json::Value::String(s.clone()))
1484                .collect();
1485            serde_json::Value::Array(arr)
1486        }
1487
1488        SettingControl::Map(state) => state.to_value(),
1489
1490        SettingControl::ObjectArray(state) => state.to_value(),
1491
1492        SettingControl::Json(state) => {
1493            // Parse the JSON string back to a value
1494            serde_json::from_str(&state.value()).unwrap_or(serde_json::Value::Null)
1495        }
1496
1497        SettingControl::Complex { .. } => serde_json::Value::Null,
1498    }
1499}
1500
1501#[cfg(test)]
1502mod tests {
1503    use super::*;
1504
1505    fn sample_config() -> serde_json::Value {
1506        serde_json::json!({
1507            "theme": "monokai",
1508            "check_for_updates": false,
1509            "editor": {
1510                "tab_size": 2,
1511                "line_numbers": true
1512            }
1513        })
1514    }
1515
1516    /// Helper to create a BuildContext for testing
1517    fn test_context(config: &serde_json::Value) -> BuildContext<'_> {
1518        // Create static empty HashMap for layer_sources
1519        static EMPTY_SOURCES: std::sync::LazyLock<HashMap<String, ConfigLayer>> =
1520            std::sync::LazyLock::new(HashMap::new);
1521        BuildContext {
1522            config_value: config,
1523            layer_sources: &EMPTY_SOURCES,
1524            target_layer: ConfigLayer::User,
1525        }
1526    }
1527
1528    /// Helper to create a BuildContext with layer sources for testing "modified" behavior
1529    fn test_context_with_sources<'a>(
1530        config: &'a serde_json::Value,
1531        layer_sources: &'a HashMap<String, ConfigLayer>,
1532        target_layer: ConfigLayer,
1533    ) -> BuildContext<'a> {
1534        BuildContext {
1535            config_value: config,
1536            layer_sources,
1537            target_layer,
1538        }
1539    }
1540
1541    #[test]
1542    fn test_build_toggle_item() {
1543        let schema = SettingSchema {
1544            path: "/check_for_updates".to_string(),
1545            name: "Check For Updates".to_string(),
1546            description: Some("Check for updates".to_string()),
1547            setting_type: SettingType::Boolean,
1548            default: Some(serde_json::Value::Bool(true)),
1549            read_only: false,
1550            section: None,
1551            order: None,
1552            nullable: false,
1553            enum_from: None,
1554            dual_list_sibling: None,
1555        };
1556
1557        let config = sample_config();
1558        let ctx = test_context(&config);
1559        let item = build_item(&schema, &ctx);
1560
1561        assert_eq!(item.path, "/check_for_updates");
1562        // With new semantics, modified = false when layer_sources is empty
1563        // (value is not defined in target layer)
1564        assert!(!item.modified);
1565        assert_eq!(item.layer_source, ConfigLayer::System);
1566
1567        if let SettingControl::Toggle(state) = &item.control {
1568            assert!(!state.checked); // Current value is false
1569        } else {
1570            panic!("Expected toggle control");
1571        }
1572    }
1573
1574    #[test]
1575    fn test_build_toggle_item_modified_in_user_layer() {
1576        let schema = SettingSchema {
1577            path: "/check_for_updates".to_string(),
1578            name: "Check For Updates".to_string(),
1579            description: Some("Check for updates".to_string()),
1580            setting_type: SettingType::Boolean,
1581            default: Some(serde_json::Value::Bool(true)),
1582            read_only: false,
1583            section: None,
1584            order: None,
1585            nullable: false,
1586            enum_from: None,
1587            dual_list_sibling: None,
1588        };
1589
1590        let config = sample_config();
1591        let mut layer_sources = HashMap::new();
1592        layer_sources.insert("/check_for_updates".to_string(), ConfigLayer::User);
1593        let ctx = test_context_with_sources(&config, &layer_sources, ConfigLayer::User);
1594        let item = build_item(&schema, &ctx);
1595
1596        // With new semantics: modified = true because value is defined in User layer
1597        // and target_layer is User
1598        assert!(item.modified);
1599        assert_eq!(item.layer_source, ConfigLayer::User);
1600    }
1601
1602    #[test]
1603    fn test_build_number_item() {
1604        let schema = SettingSchema {
1605            path: "/editor/tab_size".to_string(),
1606            name: "Tab Size".to_string(),
1607            description: None,
1608            setting_type: SettingType::Integer {
1609                minimum: Some(1),
1610                maximum: Some(16),
1611            },
1612            default: Some(serde_json::Value::Number(4.into())),
1613            read_only: false,
1614            section: None,
1615            order: None,
1616            nullable: false,
1617            enum_from: None,
1618            dual_list_sibling: None,
1619        };
1620
1621        let config = sample_config();
1622        let ctx = test_context(&config);
1623        let item = build_item(&schema, &ctx);
1624
1625        // With new semantics, modified = false when layer_sources is empty
1626        assert!(!item.modified);
1627
1628        if let SettingControl::Number(state) = &item.control {
1629            assert_eq!(state.value, 2);
1630            assert_eq!(state.min, Some(1));
1631            assert_eq!(state.max, Some(16));
1632        } else {
1633            panic!("Expected number control");
1634        }
1635    }
1636
1637    #[test]
1638    fn test_build_text_item() {
1639        let schema = SettingSchema {
1640            path: "/theme".to_string(),
1641            name: "Theme".to_string(),
1642            description: None,
1643            setting_type: SettingType::String,
1644            default: Some(serde_json::Value::String("high-contrast".to_string())),
1645            read_only: false,
1646            section: None,
1647            order: None,
1648            nullable: false,
1649            enum_from: None,
1650            dual_list_sibling: None,
1651        };
1652
1653        let config = sample_config();
1654        let ctx = test_context(&config);
1655        let item = build_item(&schema, &ctx);
1656
1657        // With new semantics, modified = false when layer_sources is empty
1658        assert!(!item.modified);
1659
1660        if let SettingControl::Text(state) = &item.control {
1661            assert_eq!(state.value, "monokai");
1662        } else {
1663            panic!("Expected text control");
1664        }
1665    }
1666
1667    #[test]
1668    fn test_clean_description_keeps_full_desc_with_new_info() {
1669        // "Tab Size" + "Number of spaces per tab character" -> keeps full desc (has "spaces", "character")
1670        let result = clean_description("Tab Size", Some("Number of spaces per tab character"));
1671        assert!(result.is_some());
1672        let cleaned = result.unwrap();
1673        // Should preserve original casing and contain the full info
1674        assert!(cleaned.starts_with('N')); // uppercase 'N' from "Number"
1675        assert!(cleaned.contains("spaces"));
1676        assert!(cleaned.contains("character"));
1677    }
1678
1679    #[test]
1680    fn test_clean_description_keeps_extra_info() {
1681        // "Line Numbers" + "Show line numbers in the gutter" -> should keep full desc with "gutter"
1682        let result = clean_description("Line Numbers", Some("Show line numbers in the gutter"));
1683        assert!(result.is_some());
1684        let cleaned = result.unwrap();
1685        assert!(cleaned.contains("gutter"));
1686    }
1687
1688    #[test]
1689    fn test_clean_description_returns_none_for_pure_redundancy() {
1690        // If description is just the name repeated, return None
1691        let result = clean_description("Theme", Some("Theme"));
1692        assert!(result.is_none());
1693
1694        // Or only filler words around the name
1695        let result = clean_description("Theme", Some("The theme to use"));
1696        assert!(result.is_none());
1697    }
1698
1699    #[test]
1700    fn test_clean_description_returns_none_for_empty() {
1701        let result = clean_description("Theme", Some(""));
1702        assert!(result.is_none());
1703
1704        let result = clean_description("Theme", None);
1705        assert!(result.is_none());
1706    }
1707
1708    #[test]
1709    fn test_control_to_value() {
1710        let toggle = SettingControl::Toggle(ToggleState::new(true, "Test"));
1711        assert_eq!(control_to_value(&toggle), serde_json::Value::Bool(true));
1712
1713        let number = SettingControl::Number(NumberInputState::new(42, "Test"));
1714        assert_eq!(control_to_value(&number), serde_json::json!(42));
1715
1716        let text = SettingControl::Text(TextInputState::new("Test").with_value("hello"));
1717        assert_eq!(
1718            control_to_value(&text),
1719            serde_json::Value::String("hello".to_string())
1720        );
1721    }
1722}