Skip to main content

fresh/view/settings/
items.rs

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