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