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            // A nullable boolean with no explicit value is *inherited*: render
1265            // it as a neutral chip rather than a definite off-state so it isn't
1266            // misread as disabled (issue #2345).
1267            let inherited = schema.nullable
1268                && current_value
1269                    .map(|v| v.is_null())
1270                    .unwrap_or(schema.default.as_ref().map(|d| d.is_null()).unwrap_or(true));
1271            SettingControl::Toggle(
1272                ToggleState::new(checked, &schema.name).with_inherited(inherited),
1273            )
1274        }
1275
1276        SettingType::Integer { minimum, maximum } => {
1277            let value = current_value
1278                .and_then(|v| v.as_i64())
1279                .or_else(|| schema.default.as_ref().and_then(|d| d.as_i64()))
1280                .unwrap_or(0);
1281
1282            let mut state = NumberInputState::new(value, &schema.name);
1283            if let Some(min) = minimum {
1284                state = state.with_min(*min);
1285            }
1286            if let Some(max) = maximum {
1287                state = state.with_max(*max);
1288            }
1289            SettingControl::Number(state)
1290        }
1291
1292        SettingType::Number { minimum, maximum } => {
1293            let value = current_value
1294                .and_then(|v| v.as_f64())
1295                .or_else(|| schema.default.as_ref().and_then(|d| d.as_f64()))
1296                .unwrap_or(0.0);
1297
1298            let int_value = (value * 100.0).round() as i64;
1299            let mut state = NumberInputState::new(int_value, &schema.name).with_percentage();
1300            if let Some(min) = minimum {
1301                state = state.with_min((*min * 100.0) as i64);
1302            }
1303            if let Some(max) = maximum {
1304                state = state.with_max((*max * 100.0) as i64);
1305            }
1306            SettingControl::Number(state)
1307        }
1308
1309        SettingType::String => {
1310            let value = current_value
1311                .and_then(|v| v.as_str())
1312                .or_else(|| schema.default.as_ref().and_then(|d| d.as_str()))
1313                .unwrap_or("");
1314
1315            let state = TextInputState::new(&schema.name).with_value(value);
1316            SettingControl::Text(state)
1317        }
1318
1319        SettingType::Enum { options } => {
1320            // Handle null values in enums (represented as empty string in dropdown values)
1321            let current = if current_value.map(|v| v.is_null()).unwrap_or(false) {
1322                "" // null maps to empty string (Auto-detect option)
1323            } else {
1324                current_value
1325                    .and_then(|v| v.as_str())
1326                    .or_else(|| {
1327                        let default = schema.default.as_ref()?;
1328                        if default.is_null() {
1329                            Some("")
1330                        } else {
1331                            default.as_str()
1332                        }
1333                    })
1334                    .unwrap_or("")
1335            };
1336
1337            let display_names: Vec<String> = options.iter().map(|o| o.name.clone()).collect();
1338            let values: Vec<String> = options.iter().map(|o| o.value.clone()).collect();
1339            let selected = values.iter().position(|v| v == current).unwrap_or(0);
1340            let state = DropdownState::with_values(display_names, values, &schema.name)
1341                .with_selected(selected);
1342            SettingControl::Dropdown(state)
1343        }
1344
1345        SettingType::DualList { options, .. } => {
1346            // Dialog context has no sibling to cross-exclude against
1347            SettingControl::DualList(build_dual_list_state(
1348                schema,
1349                options,
1350                current_value,
1351                vec![],
1352                available_status_bar_tokens,
1353            ))
1354        }
1355
1356        SettingType::StringArray => {
1357            let items: Vec<String> = current_value
1358                .and_then(|v| v.as_array())
1359                .map(|arr| {
1360                    arr.iter()
1361                        .filter_map(|v| v.as_str().map(String::from))
1362                        .collect()
1363                })
1364                .or_else(|| {
1365                    schema.default.as_ref().and_then(|d| {
1366                        d.as_array().map(|arr| {
1367                            arr.iter()
1368                                .filter_map(|v| v.as_str().map(String::from))
1369                                .collect()
1370                        })
1371                    })
1372                })
1373                .unwrap_or_default();
1374
1375            let state = TextListState::new(&schema.name).with_items(items);
1376            SettingControl::TextList(state)
1377        }
1378
1379        SettingType::IntegerArray => {
1380            let items: Vec<String> = current_value
1381                .and_then(|v| v.as_array())
1382                .map(|arr| {
1383                    arr.iter()
1384                        .filter_map(|v| {
1385                            v.as_i64()
1386                                .map(|n| n.to_string())
1387                                .or_else(|| v.as_u64().map(|n| n.to_string()))
1388                                .or_else(|| v.as_f64().map(|n| n.to_string()))
1389                        })
1390                        .collect()
1391                })
1392                .or_else(|| {
1393                    schema.default.as_ref().and_then(|d| {
1394                        d.as_array().map(|arr| {
1395                            arr.iter()
1396                                .filter_map(|v| {
1397                                    v.as_i64()
1398                                        .map(|n| n.to_string())
1399                                        .or_else(|| v.as_u64().map(|n| n.to_string()))
1400                                        .or_else(|| v.as_f64().map(|n| n.to_string()))
1401                                })
1402                                .collect()
1403                        })
1404                    })
1405                })
1406                .unwrap_or_default();
1407
1408            let state = TextListState::new(&schema.name)
1409                .with_items(items)
1410                .with_integer_mode();
1411            SettingControl::TextList(state)
1412        }
1413
1414        SettingType::Object { .. } => {
1415            json_control(&schema.name, current_value, schema.default.as_ref())
1416        }
1417
1418        SettingType::Map {
1419            value_schema,
1420            display_field,
1421            no_add,
1422        } => {
1423            let map_value = current_value
1424                .cloned()
1425                .or_else(|| schema.default.clone())
1426                .unwrap_or_else(|| serde_json::json!({}));
1427
1428            let mut state = MapState::new(&schema.name).with_entries(&map_value);
1429            state = state.with_value_schema((**value_schema).clone());
1430            if let Some(field) = display_field {
1431                state = state.with_display_field(field.clone());
1432            }
1433            if *no_add {
1434                state = state.with_no_add(true);
1435            }
1436            SettingControl::Map(state)
1437        }
1438
1439        SettingType::ObjectArray {
1440            item_schema,
1441            display_field,
1442        } => {
1443            let array_value = current_value
1444                .cloned()
1445                .or_else(|| schema.default.clone())
1446                .unwrap_or_else(|| serde_json::json!([]));
1447
1448            let mut state = KeybindingListState::new(&schema.name).with_bindings(&array_value);
1449            state = state.with_item_schema((**item_schema).clone());
1450            if let Some(field) = display_field {
1451                state = state.with_display_field(field.clone());
1452            }
1453            SettingControl::ObjectArray(state)
1454        }
1455
1456        SettingType::Complex => json_control(&schema.name, current_value, schema.default.as_ref()),
1457    };
1458
1459    // For dialog items, we use the traditional definition of "modified":
1460    // differs from schema default (since we don't have layer context in dialogs)
1461    let modified = match (&current_value, &schema.default) {
1462        (Some(current), Some(default)) => *current != default,
1463        (Some(_), None) => true,
1464        _ => false,
1465    };
1466
1467    // Check if this is an auto-managed map (no_add)
1468    let is_auto_managed = matches!(&schema.setting_type, SettingType::Map { no_add: true, .. });
1469
1470    let is_null = schema.nullable
1471        && current_value
1472            .map(|v| v.is_null())
1473            .unwrap_or(schema.default.as_ref().map(|d| d.is_null()).unwrap_or(true));
1474
1475    SettingItem {
1476        path: schema.path.clone(),
1477        name: schema.name.clone(),
1478        description: schema.description.clone(),
1479        control,
1480        default: schema.default.clone(),
1481        modified,
1482        // For dialogs, we don't track layer source - default to System
1483        layer_source: ConfigLayer::System,
1484        read_only: schema.read_only,
1485        is_auto_managed,
1486        nullable: schema.nullable,
1487        is_null,
1488        section: schema.section.clone(),
1489        is_section_start: false, // Not used in dialogs
1490        style: ItemBoxStyle::default(),
1491        dual_list_sibling: schema.dual_list_sibling.clone(),
1492    }
1493}
1494
1495/// Extract the current value from a control
1496pub fn control_to_value(control: &SettingControl) -> serde_json::Value {
1497    match control {
1498        SettingControl::Toggle(state) => serde_json::Value::Bool(state.checked),
1499
1500        SettingControl::Number(state) => {
1501            if state.is_percentage {
1502                // Convert back to float (divide by 100)
1503                let float_value = state.value as f64 / 100.0;
1504                serde_json::Number::from_f64(float_value)
1505                    .map(serde_json::Value::Number)
1506                    .unwrap_or(serde_json::Value::Number(state.value.into()))
1507            } else {
1508                serde_json::Value::Number(state.value.into())
1509            }
1510        }
1511
1512        SettingControl::Dropdown(state) => state
1513            .selected_value()
1514            .map(|s| {
1515                if s.is_empty() {
1516                    // Empty string represents null in nullable enums
1517                    serde_json::Value::Null
1518                } else {
1519                    serde_json::Value::String(s.to_string())
1520                }
1521            })
1522            .unwrap_or(serde_json::Value::Null),
1523
1524        SettingControl::Text(state) => serde_json::Value::String(state.value.clone()),
1525
1526        SettingControl::TextList(state) => {
1527            let arr: Vec<serde_json::Value> = state
1528                .items
1529                .iter()
1530                .filter_map(|s| {
1531                    if state.is_integer {
1532                        s.parse::<i64>()
1533                            .ok()
1534                            .map(|n| serde_json::Value::Number(n.into()))
1535                    } else {
1536                        Some(serde_json::Value::String(s.clone()))
1537                    }
1538                })
1539                .collect();
1540            serde_json::Value::Array(arr)
1541        }
1542
1543        SettingControl::DualList(state) => {
1544            let arr: Vec<serde_json::Value> = state
1545                .included
1546                .iter()
1547                .map(|s| serde_json::Value::String(s.clone()))
1548                .collect();
1549            serde_json::Value::Array(arr)
1550        }
1551
1552        SettingControl::Map(state) => state.to_value(),
1553
1554        SettingControl::ObjectArray(state) => state.to_value(),
1555
1556        SettingControl::Json(state) => {
1557            // Parse the JSON string back to a value
1558            serde_json::from_str(&state.value()).unwrap_or(serde_json::Value::Null)
1559        }
1560
1561        SettingControl::Complex { .. } => serde_json::Value::Null,
1562    }
1563}
1564
1565#[cfg(test)]
1566mod tests {
1567    use super::*;
1568
1569    fn sample_config() -> serde_json::Value {
1570        serde_json::json!({
1571            "theme": "monokai",
1572            "check_for_updates": false,
1573            "editor": {
1574                "tab_size": 2,
1575                "line_numbers": true
1576            }
1577        })
1578    }
1579
1580    /// Helper to create a BuildContext for testing
1581    fn test_context(config: &serde_json::Value) -> BuildContext<'_> {
1582        // Create static empty HashMap for layer_sources
1583        static EMPTY_SOURCES: std::sync::LazyLock<HashMap<String, ConfigLayer>> =
1584            std::sync::LazyLock::new(HashMap::new);
1585        static EMPTY_TOKENS: std::sync::LazyLock<HashMap<String, String>> =
1586            std::sync::LazyLock::new(HashMap::new);
1587        BuildContext {
1588            config_value: config,
1589            layer_sources: &EMPTY_SOURCES,
1590            target_layer: ConfigLayer::User,
1591            available_status_bar_tokens: &EMPTY_TOKENS,
1592        }
1593    }
1594
1595    /// Helper to create a BuildContext with layer sources for testing "modified" behavior
1596    fn test_context_with_sources<'a>(
1597        config: &'a serde_json::Value,
1598        layer_sources: &'a HashMap<String, ConfigLayer>,
1599        target_layer: ConfigLayer,
1600    ) -> BuildContext<'a> {
1601        static EMPTY_TOKENS: std::sync::LazyLock<HashMap<String, String>> =
1602            std::sync::LazyLock::new(HashMap::new);
1603        BuildContext {
1604            config_value: config,
1605            layer_sources,
1606            target_layer,
1607            available_status_bar_tokens: &EMPTY_TOKENS,
1608        }
1609    }
1610
1611    #[test]
1612    fn test_build_toggle_item() {
1613        let schema = SettingSchema {
1614            path: "/check_for_updates".to_string(),
1615            name: "Check For Updates".to_string(),
1616            description: Some("Check for updates".to_string()),
1617            setting_type: SettingType::Boolean,
1618            default: Some(serde_json::Value::Bool(true)),
1619            read_only: false,
1620            section: None,
1621            order: None,
1622            nullable: false,
1623            enum_from: None,
1624            dual_list_sibling: None,
1625            dynamically_extendable_status_bar_elements: false,
1626        };
1627
1628        let config = sample_config();
1629        let ctx = test_context(&config);
1630        let item = build_item(&schema, &ctx);
1631
1632        assert_eq!(item.path, "/check_for_updates");
1633        // With new semantics, modified = false when layer_sources is empty
1634        // (value is not defined in target layer)
1635        assert!(!item.modified);
1636        assert_eq!(item.layer_source, ConfigLayer::System);
1637
1638        if let SettingControl::Toggle(state) = &item.control {
1639            assert!(!state.checked); // Current value is false
1640        } else {
1641            panic!("Expected toggle control");
1642        }
1643    }
1644
1645    #[test]
1646    fn test_build_toggle_item_modified_in_user_layer() {
1647        let schema = SettingSchema {
1648            path: "/check_for_updates".to_string(),
1649            name: "Check For Updates".to_string(),
1650            description: Some("Check for updates".to_string()),
1651            setting_type: SettingType::Boolean,
1652            default: Some(serde_json::Value::Bool(true)),
1653            read_only: false,
1654            section: None,
1655            order: None,
1656            nullable: false,
1657            enum_from: None,
1658            dual_list_sibling: None,
1659            dynamically_extendable_status_bar_elements: false,
1660        };
1661
1662        let config = sample_config();
1663        let mut layer_sources = HashMap::new();
1664        layer_sources.insert("/check_for_updates".to_string(), ConfigLayer::User);
1665        let ctx = test_context_with_sources(&config, &layer_sources, ConfigLayer::User);
1666        let item = build_item(&schema, &ctx);
1667
1668        // With new semantics: modified = true because value is defined in User layer
1669        // and target_layer is User
1670        assert!(item.modified);
1671        assert_eq!(item.layer_source, ConfigLayer::User);
1672    }
1673
1674    #[test]
1675    fn test_build_number_item() {
1676        let schema = SettingSchema {
1677            path: "/editor/tab_size".to_string(),
1678            name: "Tab Size".to_string(),
1679            description: None,
1680            setting_type: SettingType::Integer {
1681                minimum: Some(1),
1682                maximum: Some(16),
1683            },
1684            default: Some(serde_json::Value::Number(4.into())),
1685            read_only: false,
1686            section: None,
1687            order: None,
1688            nullable: false,
1689            enum_from: None,
1690            dual_list_sibling: None,
1691            dynamically_extendable_status_bar_elements: false,
1692        };
1693
1694        let config = sample_config();
1695        let ctx = test_context(&config);
1696        let item = build_item(&schema, &ctx);
1697
1698        // With new semantics, modified = false when layer_sources is empty
1699        assert!(!item.modified);
1700
1701        if let SettingControl::Number(state) = &item.control {
1702            assert_eq!(state.value, 2);
1703            assert_eq!(state.min, Some(1));
1704            assert_eq!(state.max, Some(16));
1705        } else {
1706            panic!("Expected number control");
1707        }
1708    }
1709
1710    #[test]
1711    fn test_build_text_item() {
1712        let schema = SettingSchema {
1713            path: "/theme".to_string(),
1714            name: "Theme".to_string(),
1715            description: None,
1716            setting_type: SettingType::String,
1717            default: Some(serde_json::Value::String("high-contrast".to_string())),
1718            read_only: false,
1719            section: None,
1720            order: None,
1721            nullable: false,
1722            enum_from: None,
1723            dual_list_sibling: None,
1724            dynamically_extendable_status_bar_elements: false,
1725        };
1726
1727        let config = sample_config();
1728        let ctx = test_context(&config);
1729        let item = build_item(&schema, &ctx);
1730
1731        // With new semantics, modified = false when layer_sources is empty
1732        assert!(!item.modified);
1733
1734        if let SettingControl::Text(state) = &item.control {
1735            assert_eq!(state.value, "monokai");
1736        } else {
1737            panic!("Expected text control");
1738        }
1739    }
1740
1741    #[test]
1742    fn test_clean_description_keeps_full_desc_with_new_info() {
1743        // "Tab Size" + "Number of spaces per tab character" -> keeps full desc (has "spaces", "character")
1744        let result = clean_description("Tab Size", Some("Number of spaces per tab character"));
1745        assert!(result.is_some());
1746        let cleaned = result.unwrap();
1747        // Should preserve original casing and contain the full info
1748        assert!(cleaned.starts_with('N')); // uppercase 'N' from "Number"
1749        assert!(cleaned.contains("spaces"));
1750        assert!(cleaned.contains("character"));
1751    }
1752
1753    #[test]
1754    fn test_clean_description_keeps_extra_info() {
1755        // "Line Numbers" + "Show line numbers in the gutter" -> should keep full desc with "gutter"
1756        let result = clean_description("Line Numbers", Some("Show line numbers in the gutter"));
1757        assert!(result.is_some());
1758        let cleaned = result.unwrap();
1759        assert!(cleaned.contains("gutter"));
1760    }
1761
1762    #[test]
1763    fn test_clean_description_returns_none_for_pure_redundancy() {
1764        // If description is just the name repeated, return None
1765        let result = clean_description("Theme", Some("Theme"));
1766        assert!(result.is_none());
1767
1768        // Or only filler words around the name
1769        let result = clean_description("Theme", Some("The theme to use"));
1770        assert!(result.is_none());
1771    }
1772
1773    #[test]
1774    fn test_clean_description_returns_none_for_empty() {
1775        let result = clean_description("Theme", Some(""));
1776        assert!(result.is_none());
1777
1778        let result = clean_description("Theme", None);
1779        assert!(result.is_none());
1780    }
1781
1782    #[test]
1783    fn test_control_to_value() {
1784        let toggle = SettingControl::Toggle(ToggleState::new(true, "Test"));
1785        assert_eq!(control_to_value(&toggle), serde_json::Value::Bool(true));
1786
1787        let number = SettingControl::Number(NumberInputState::new(42, "Test"));
1788        assert_eq!(control_to_value(&number), serde_json::json!(42));
1789
1790        let text = SettingControl::Text(TextInputState::new("Test").with_value("hello"));
1791        assert_eq!(
1792            control_to_value(&text),
1793            serde_json::Value::String("hello".to_string())
1794        );
1795    }
1796}