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