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, FocusState, KeybindingListState, MapState, NumberInputState, TextInputState,
9    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/// A renderable setting item
219#[derive(Debug, Clone)]
220pub struct SettingItem {
221    /// JSON pointer path
222    pub path: String,
223    /// Display name
224    pub name: String,
225    /// Description
226    pub description: Option<String>,
227    /// The control for this setting
228    pub control: SettingControl,
229    /// Default value (for reset)
230    pub default: Option<serde_json::Value>,
231    /// Whether this setting is defined in the current target layer.
232    /// This is the new semantic: modified means "explicitly set in target layer",
233    /// not "differs from schema default".
234    pub modified: bool,
235    /// Which layer this setting's current value comes from.
236    /// System means it's using the schema default.
237    pub layer_source: ConfigLayer,
238    /// Whether this field is read-only (cannot be edited)
239    pub read_only: bool,
240    /// Whether this is an auto-managed map (no_add) that should never show as modified
241    pub is_auto_managed: bool,
242    /// Section/group within the category (from x-section)
243    pub section: Option<String>,
244    /// Whether this item is the first in its section (for rendering section headers)
245    pub is_section_start: bool,
246}
247
248/// The type of control to render for a setting
249#[derive(Debug, Clone)]
250pub enum SettingControl {
251    Toggle(ToggleState),
252    Number(NumberInputState),
253    Dropdown(DropdownState),
254    Text(TextInputState),
255    TextList(TextListState),
256    /// Map/dictionary control for key-value pairs
257    Map(MapState),
258    /// Array of objects control (for keybindings, etc.)
259    ObjectArray(KeybindingListState),
260    /// Multiline JSON editor
261    Json(JsonEditState),
262    /// Complex settings that can't be edited inline
263    Complex {
264        type_name: String,
265    },
266}
267
268impl SettingControl {
269    /// Calculate the height needed for this control (in lines)
270    pub fn control_height(&self) -> u16 {
271        match self {
272            // TextList needs: 1 label line + items + 1 "add new" row
273            Self::TextList(state) => {
274                // 1 for label + items count + 1 for add-new row
275                (state.items.len() + 2) as u16
276            }
277            // Map needs: 1 label + 1 header (if display_field) + entries + expanded content + 1 add-new row (if allowed)
278            Self::Map(state) => {
279                let header_row = if state.display_field.is_some() { 1 } else { 0 };
280                let add_new_row = if state.no_add { 0 } else { 1 };
281                let base = 1 + header_row + state.entries.len() + add_new_row; // label + header? + entries + add-new?
282                                                                               // Add extra height for expanded entries (up to 6 lines each)
283                let expanded_height: usize = state
284                    .expanded
285                    .iter()
286                    .filter_map(|&idx| state.entries.get(idx))
287                    .map(|(_, v)| {
288                        if let Some(obj) = v.as_object() {
289                            obj.len().min(5) + if obj.len() > 5 { 1 } else { 0 }
290                        } else {
291                            0
292                        }
293                    })
294                    .sum();
295                (base + expanded_height) as u16
296            }
297            // Dropdown needs extra height when open to show options
298            Self::Dropdown(state) => {
299                if state.open {
300                    // 1 for label/button + number of options (max 8 visible)
301                    1 + state.options.len().min(8) as u16
302                } else {
303                    1
304                }
305            }
306            // KeybindingList needs: 1 label + bindings + 1 add-new row
307            Self::ObjectArray(state) => {
308                // 1 for label + bindings count + 1 for add-new row
309                (state.bindings.len() + 2) as u16
310            }
311            // Json needs: 1 label + visible lines
312            Self::Json(state) => {
313                // 1 for label + displayed lines
314                1 + state.display_height() as u16
315            }
316            // All other controls fit in 1 line
317            _ => 1,
318        }
319    }
320}
321
322/// Height of a section header (header line + gap)
323pub const SECTION_HEADER_HEIGHT: u16 = 2;
324
325impl SettingItem {
326    /// Calculate the total height needed for this item (control + description + spacing + section header if applicable)
327    pub fn item_height(&self) -> u16 {
328        // Height = section header (if first in section) + control + description (if any) + spacing
329        let section_height = if self.is_section_start {
330            SECTION_HEADER_HEIGHT
331        } else {
332            0
333        };
334        let description_height = if self.description.is_some() { 1 } else { 0 };
335        section_height + self.control.control_height() + description_height + 1
336    }
337
338    /// Calculate height with expanded description when focused
339    pub fn item_height_expanded(&self, width: u16) -> u16 {
340        let section_height = if self.is_section_start {
341            SECTION_HEADER_HEIGHT
342        } else {
343            0
344        };
345        let description_height = self.description_height_expanded(width);
346        section_height + self.control.control_height() + description_height + 1
347    }
348
349    /// Calculate description height when expanded (wrapped to width)
350    pub fn description_height_expanded(&self, width: u16) -> u16 {
351        if let Some(ref desc) = self.description {
352            if desc.is_empty() || width == 0 {
353                return 0;
354            }
355            // Calculate number of lines needed for wrapped description
356            let chars_per_line = width.saturating_sub(2) as usize; // Leave some margin
357            if chars_per_line == 0 {
358                return 1;
359            }
360            desc.len().div_ceil(chars_per_line) as u16
361        } else {
362            0
363        }
364    }
365
366    /// Calculate the content height (control + description, excluding spacing)
367    pub fn content_height(&self) -> u16 {
368        let description_height = if self.description.is_some() { 1 } else { 0 };
369        self.control.control_height() + description_height
370    }
371
372    /// Calculate content height with expanded description
373    pub fn content_height_expanded(&self, width: u16) -> u16 {
374        let description_height = self.description_height_expanded(width);
375        self.control.control_height() + description_height
376    }
377}
378
379/// Clean a description to remove redundancy with the name.
380/// Returns None if the description is empty or essentially just repeats the name.
381pub fn clean_description(name: &str, description: Option<&str>) -> Option<String> {
382    let desc = description?;
383    if desc.is_empty() {
384        return None;
385    }
386
387    // Build a set of significant words from the name (lowercase for comparison)
388    let name_words: HashSet<String> = name
389        .to_lowercase()
390        .split(|c: char| !c.is_alphanumeric())
391        .filter(|w| !w.is_empty() && w.len() > 2)
392        .map(String::from)
393        .collect();
394
395    // Common filler words to ignore when checking for new info
396    let filler_words: HashSet<&str> = [
397        "the", "a", "an", "to", "for", "of", "in", "on", "is", "are", "be", "and", "or", "when",
398        "whether", "if", "this", "that", "with", "from", "by", "as", "at", "show", "enable",
399        "disable", "set", "use", "allow", "default", "true", "false",
400    ]
401    .into_iter()
402    .collect();
403
404    // Split description into words
405    let desc_words: Vec<&str> = desc
406        .split(|c: char| !c.is_alphanumeric())
407        .filter(|w| !w.is_empty())
408        .collect();
409
410    // Check if description has any meaningful new information
411    let has_new_info = desc_words.iter().any(|word| {
412        let lower = word.to_lowercase();
413        lower.len() > 2 && !name_words.contains(&lower) && !filler_words.contains(lower.as_str())
414    });
415
416    if !has_new_info {
417        return None;
418    }
419
420    Some(desc.to_string())
421}
422
423impl ScrollItem for SettingItem {
424    fn height(&self) -> u16 {
425        self.item_height()
426    }
427
428    fn focus_regions(&self) -> Vec<FocusRegion> {
429        match &self.control {
430            // TextList: each row is a focus region
431            SettingControl::TextList(state) => {
432                let mut regions = Vec::new();
433                // Label row
434                regions.push(FocusRegion {
435                    id: 0,
436                    y_offset: 0,
437                    height: 1,
438                });
439                // Each item row (id = 1 + row_index)
440                for i in 0..state.items.len() {
441                    regions.push(FocusRegion {
442                        id: 1 + i,
443                        y_offset: 1 + i as u16,
444                        height: 1,
445                    });
446                }
447                // Add-new row
448                regions.push(FocusRegion {
449                    id: 1 + state.items.len(),
450                    y_offset: 1 + state.items.len() as u16,
451                    height: 1,
452                });
453                regions
454            }
455            // Map: each entry row is a focus region
456            SettingControl::Map(state) => {
457                let mut regions = Vec::new();
458                let mut y = 0u16;
459
460                // Label row
461                regions.push(FocusRegion {
462                    id: 0,
463                    y_offset: y,
464                    height: 1,
465                });
466                y += 1;
467
468                // Column header row (if display_field is set)
469                if state.display_field.is_some() {
470                    y += 1;
471                }
472
473                // Each entry (id = 1 + entry_index)
474                for (i, (_, v)) in state.entries.iter().enumerate() {
475                    let mut entry_height = 1u16;
476                    // Add expanded content height if expanded
477                    if state.expanded.contains(&i) {
478                        if let Some(obj) = v.as_object() {
479                            entry_height += obj.len().min(5) as u16;
480                            if obj.len() > 5 {
481                                entry_height += 1;
482                            }
483                        }
484                    }
485                    regions.push(FocusRegion {
486                        id: 1 + i,
487                        y_offset: y,
488                        height: entry_height,
489                    });
490                    y += entry_height;
491                }
492
493                // Add-new row
494                regions.push(FocusRegion {
495                    id: 1 + state.entries.len(),
496                    y_offset: y,
497                    height: 1,
498                });
499                regions
500            }
501            // KeybindingList: each entry row is a focus region
502            SettingControl::ObjectArray(state) => {
503                let mut regions = Vec::new();
504                // Label row
505                regions.push(FocusRegion {
506                    id: 0,
507                    y_offset: 0,
508                    height: 1,
509                });
510                // Each binding (id = 1 + index)
511                for i in 0..state.bindings.len() {
512                    regions.push(FocusRegion {
513                        id: 1 + i,
514                        y_offset: 1 + i as u16,
515                        height: 1,
516                    });
517                }
518                // Add-new row
519                regions.push(FocusRegion {
520                    id: 1 + state.bindings.len(),
521                    y_offset: 1 + state.bindings.len() as u16,
522                    height: 1,
523                });
524                regions
525            }
526            // Other controls: single region covering the whole item
527            _ => {
528                vec![FocusRegion {
529                    id: 0,
530                    y_offset: 0,
531                    height: self.item_height().saturating_sub(1), // Exclude spacing
532                }]
533            }
534        }
535    }
536}
537
538/// A page of settings (corresponds to a category)
539#[derive(Debug, Clone)]
540pub struct SettingsPage {
541    /// Page name
542    pub name: String,
543    /// JSON path prefix
544    pub path: String,
545    /// Description
546    pub description: Option<String>,
547    /// Settings on this page
548    pub items: Vec<SettingItem>,
549    /// Subpages
550    pub subpages: Vec<SettingsPage>,
551}
552
553/// Context for building setting items with layer awareness
554pub struct BuildContext<'a> {
555    /// The merged config value (effective values)
556    pub config_value: &'a serde_json::Value,
557    /// Map of paths to their source layer
558    pub layer_sources: &'a HashMap<String, ConfigLayer>,
559    /// The layer currently being edited
560    pub target_layer: ConfigLayer,
561}
562
563/// Convert a category tree into pages with control states
564pub fn build_pages(
565    categories: &[SettingCategory],
566    config_value: &serde_json::Value,
567    layer_sources: &HashMap<String, ConfigLayer>,
568    target_layer: ConfigLayer,
569) -> Vec<SettingsPage> {
570    let ctx = BuildContext {
571        config_value,
572        layer_sources,
573        target_layer,
574    };
575    categories.iter().map(|cat| build_page(cat, &ctx)).collect()
576}
577
578/// Build a single page from a category
579fn build_page(category: &SettingCategory, ctx: &BuildContext) -> SettingsPage {
580    let mut items: Vec<SettingItem> = category
581        .settings
582        .iter()
583        .map(|s| build_item(s, ctx))
584        .collect();
585
586    // Sort items: by section first (None comes last), then alphabetically by name
587    items.sort_by(|a, b| match (&a.section, &b.section) {
588        (Some(sec_a), Some(sec_b)) => sec_a.cmp(sec_b).then_with(|| a.name.cmp(&b.name)),
589        (Some(_), None) => std::cmp::Ordering::Less,
590        (None, Some(_)) => std::cmp::Ordering::Greater,
591        (None, None) => a.name.cmp(&b.name),
592    });
593
594    // Mark items that start a new section
595    let mut prev_section: Option<&String> = None;
596    for item in &mut items {
597        let is_new_section = match (&item.section, prev_section) {
598            (Some(sec), Some(prev)) => sec != prev,
599            (Some(_), None) => true,
600            (None, Some(_)) => false, // Unsectioned items after sectioned ones don't start a section
601            (None, None) => false,
602        };
603        item.is_section_start = is_new_section;
604        prev_section = item.section.as_ref();
605    }
606
607    let subpages = category
608        .subcategories
609        .iter()
610        .map(|sub| build_page(sub, ctx))
611        .collect();
612
613    SettingsPage {
614        name: category.name.clone(),
615        path: category.path.clone(),
616        description: category.description.clone(),
617        items,
618        subpages,
619    }
620}
621
622/// Build a setting item with its control state initialized from current config
623pub fn build_item(schema: &SettingSchema, ctx: &BuildContext) -> SettingItem {
624    // Get current value from config
625    let current_value = ctx.config_value.pointer(&schema.path);
626
627    // Check if this is an auto-managed map (no_add)
628    let is_auto_managed = matches!(&schema.setting_type, SettingType::Map { no_add: true, .. });
629
630    // Create control based on type
631    let control = match &schema.setting_type {
632        SettingType::Boolean => {
633            let checked = current_value
634                .and_then(|v| v.as_bool())
635                .or_else(|| schema.default.as_ref().and_then(|d| d.as_bool()))
636                .unwrap_or(false);
637            SettingControl::Toggle(ToggleState::new(checked, &schema.name))
638        }
639
640        SettingType::Integer { minimum, maximum } => {
641            let value = current_value
642                .and_then(|v| v.as_i64())
643                .or_else(|| schema.default.as_ref().and_then(|d| d.as_i64()))
644                .unwrap_or(0);
645
646            let mut state = NumberInputState::new(value, &schema.name);
647            if let Some(min) = minimum {
648                state = state.with_min(*min);
649            }
650            if let Some(max) = maximum {
651                state = state.with_max(*max);
652            }
653            SettingControl::Number(state)
654        }
655
656        SettingType::Number { minimum, maximum } => {
657            // For floats, we display as integers (multiply by 100 for percentages)
658            let value = current_value
659                .and_then(|v| v.as_f64())
660                .or_else(|| schema.default.as_ref().and_then(|d| d.as_f64()))
661                .unwrap_or(0.0);
662
663            // Convert to integer representation
664            let int_value = (value * 100.0).round() as i64;
665            let mut state = NumberInputState::new(int_value, &schema.name).with_percentage();
666            if let Some(min) = minimum {
667                state = state.with_min((*min * 100.0) as i64);
668            }
669            if let Some(max) = maximum {
670                state = state.with_max((*max * 100.0) as i64);
671            }
672            SettingControl::Number(state)
673        }
674
675        SettingType::String => {
676            let value = current_value
677                .and_then(|v| v.as_str())
678                .or_else(|| schema.default.as_ref().and_then(|d| d.as_str()))
679                .unwrap_or("");
680
681            let state = TextInputState::new(&schema.name).with_value(value);
682            SettingControl::Text(state)
683        }
684
685        SettingType::Enum { options } => {
686            // Handle null values in enums (represented as empty string in dropdown values)
687            let current = if current_value.map(|v| v.is_null()).unwrap_or(false) {
688                "" // null maps to empty string (Auto-detect option)
689            } else {
690                current_value
691                    .and_then(|v| v.as_str())
692                    .or_else(|| {
693                        let default = schema.default.as_ref()?;
694                        if default.is_null() {
695                            Some("")
696                        } else {
697                            default.as_str()
698                        }
699                    })
700                    .unwrap_or("")
701            };
702
703            let display_names: Vec<String> = options.iter().map(|o| o.name.clone()).collect();
704            let values: Vec<String> = options.iter().map(|o| o.value.clone()).collect();
705            let selected = values.iter().position(|v| v == current).unwrap_or(0);
706            let state = DropdownState::with_values(display_names, values, &schema.name)
707                .with_selected(selected);
708            SettingControl::Dropdown(state)
709        }
710
711        SettingType::StringArray => {
712            let items: Vec<String> = current_value
713                .and_then(|v| v.as_array())
714                .map(|arr| {
715                    arr.iter()
716                        .filter_map(|v| v.as_str().map(String::from))
717                        .collect()
718                })
719                .or_else(|| {
720                    schema.default.as_ref().and_then(|d| {
721                        d.as_array().map(|arr| {
722                            arr.iter()
723                                .filter_map(|v| v.as_str().map(String::from))
724                                .collect()
725                        })
726                    })
727                })
728                .unwrap_or_default();
729
730            let state = TextListState::new(&schema.name).with_items(items);
731            SettingControl::TextList(state)
732        }
733
734        SettingType::Object { .. } => {
735            json_control(&schema.name, current_value, schema.default.as_ref())
736        }
737
738        SettingType::Map {
739            value_schema,
740            display_field,
741            no_add,
742        } => {
743            // Get current map value or default
744            let map_value = current_value
745                .cloned()
746                .or_else(|| schema.default.clone())
747                .unwrap_or_else(|| serde_json::json!({}));
748
749            let mut state = MapState::new(&schema.name).with_entries(&map_value);
750            state = state.with_value_schema((**value_schema).clone());
751            if let Some(field) = display_field {
752                state = state.with_display_field(field.clone());
753            }
754            if *no_add {
755                state = state.with_no_add(true);
756            }
757            SettingControl::Map(state)
758        }
759
760        SettingType::ObjectArray {
761            item_schema,
762            display_field,
763        } => {
764            // Get current array or default
765            let array_value = current_value
766                .cloned()
767                .or_else(|| schema.default.clone())
768                .unwrap_or_else(|| serde_json::json!([]));
769
770            let mut state = KeybindingListState::new(&schema.name).with_bindings(&array_value);
771            state = state.with_item_schema((**item_schema).clone());
772            if let Some(field) = display_field {
773                state = state.with_display_field(field.clone());
774            }
775            SettingControl::ObjectArray(state)
776        }
777
778        SettingType::Complex => json_control(&schema.name, current_value, schema.default.as_ref()),
779    };
780
781    // Determine layer source for this setting
782    let layer_source = ctx
783        .layer_sources
784        .get(&schema.path)
785        .copied()
786        .unwrap_or(ConfigLayer::System);
787
788    // NEW SEMANTICS: "modified" means the value is defined in the target layer being edited.
789    // Auto-managed maps (no_add like plugins/languages) are never "modified" at the container level.
790    let modified = if is_auto_managed {
791        false // Auto-managed content never shows as modified
792    } else {
793        layer_source == ctx.target_layer
794    };
795
796    // Clean description to remove redundancy with name
797    let cleaned_description = clean_description(&schema.name, schema.description.as_deref());
798
799    SettingItem {
800        path: schema.path.clone(),
801        name: schema.name.clone(),
802        description: cleaned_description,
803        control,
804        default: schema.default.clone(),
805        modified,
806        layer_source,
807        read_only: schema.read_only,
808        is_auto_managed,
809        section: schema.section.clone(),
810        is_section_start: false, // Set later in build_page after sorting
811    }
812}
813
814/// Build a setting item with a value provided directly (for dialogs)
815pub fn build_item_from_value(
816    schema: &SettingSchema,
817    current_value: Option<&serde_json::Value>,
818) -> SettingItem {
819    // Create control based on type
820    let control = match &schema.setting_type {
821        SettingType::Boolean => {
822            let checked = current_value
823                .and_then(|v| v.as_bool())
824                .or_else(|| schema.default.as_ref().and_then(|d| d.as_bool()))
825                .unwrap_or(false);
826            SettingControl::Toggle(ToggleState::new(checked, &schema.name))
827        }
828
829        SettingType::Integer { minimum, maximum } => {
830            let value = current_value
831                .and_then(|v| v.as_i64())
832                .or_else(|| schema.default.as_ref().and_then(|d| d.as_i64()))
833                .unwrap_or(0);
834
835            let mut state = NumberInputState::new(value, &schema.name);
836            if let Some(min) = minimum {
837                state = state.with_min(*min);
838            }
839            if let Some(max) = maximum {
840                state = state.with_max(*max);
841            }
842            SettingControl::Number(state)
843        }
844
845        SettingType::Number { minimum, maximum } => {
846            let value = current_value
847                .and_then(|v| v.as_f64())
848                .or_else(|| schema.default.as_ref().and_then(|d| d.as_f64()))
849                .unwrap_or(0.0);
850
851            let int_value = (value * 100.0).round() as i64;
852            let mut state = NumberInputState::new(int_value, &schema.name).with_percentage();
853            if let Some(min) = minimum {
854                state = state.with_min((*min * 100.0) as i64);
855            }
856            if let Some(max) = maximum {
857                state = state.with_max((*max * 100.0) as i64);
858            }
859            SettingControl::Number(state)
860        }
861
862        SettingType::String => {
863            let value = current_value
864                .and_then(|v| v.as_str())
865                .or_else(|| schema.default.as_ref().and_then(|d| d.as_str()))
866                .unwrap_or("");
867
868            let state = TextInputState::new(&schema.name).with_value(value);
869            SettingControl::Text(state)
870        }
871
872        SettingType::Enum { options } => {
873            // Handle null values in enums (represented as empty string in dropdown values)
874            let current = if current_value.map(|v| v.is_null()).unwrap_or(false) {
875                "" // null maps to empty string (Auto-detect option)
876            } else {
877                current_value
878                    .and_then(|v| v.as_str())
879                    .or_else(|| {
880                        let default = schema.default.as_ref()?;
881                        if default.is_null() {
882                            Some("")
883                        } else {
884                            default.as_str()
885                        }
886                    })
887                    .unwrap_or("")
888            };
889
890            let display_names: Vec<String> = options.iter().map(|o| o.name.clone()).collect();
891            let values: Vec<String> = options.iter().map(|o| o.value.clone()).collect();
892            let selected = values.iter().position(|v| v == current).unwrap_or(0);
893            let state = DropdownState::with_values(display_names, values, &schema.name)
894                .with_selected(selected);
895            SettingControl::Dropdown(state)
896        }
897
898        SettingType::StringArray => {
899            let items: Vec<String> = current_value
900                .and_then(|v| v.as_array())
901                .map(|arr| {
902                    arr.iter()
903                        .filter_map(|v| v.as_str().map(String::from))
904                        .collect()
905                })
906                .or_else(|| {
907                    schema.default.as_ref().and_then(|d| {
908                        d.as_array().map(|arr| {
909                            arr.iter()
910                                .filter_map(|v| v.as_str().map(String::from))
911                                .collect()
912                        })
913                    })
914                })
915                .unwrap_or_default();
916
917            let state = TextListState::new(&schema.name).with_items(items);
918            SettingControl::TextList(state)
919        }
920
921        SettingType::Object { .. } => {
922            json_control(&schema.name, current_value, schema.default.as_ref())
923        }
924
925        SettingType::Map {
926            value_schema,
927            display_field,
928            no_add,
929        } => {
930            let map_value = current_value
931                .cloned()
932                .or_else(|| schema.default.clone())
933                .unwrap_or_else(|| serde_json::json!({}));
934
935            let mut state = MapState::new(&schema.name).with_entries(&map_value);
936            state = state.with_value_schema((**value_schema).clone());
937            if let Some(field) = display_field {
938                state = state.with_display_field(field.clone());
939            }
940            if *no_add {
941                state = state.with_no_add(true);
942            }
943            SettingControl::Map(state)
944        }
945
946        SettingType::ObjectArray {
947            item_schema,
948            display_field,
949        } => {
950            let array_value = current_value
951                .cloned()
952                .or_else(|| schema.default.clone())
953                .unwrap_or_else(|| serde_json::json!([]));
954
955            let mut state = KeybindingListState::new(&schema.name).with_bindings(&array_value);
956            state = state.with_item_schema((**item_schema).clone());
957            if let Some(field) = display_field {
958                state = state.with_display_field(field.clone());
959            }
960            SettingControl::ObjectArray(state)
961        }
962
963        SettingType::Complex => json_control(&schema.name, current_value, schema.default.as_ref()),
964    };
965
966    // For dialog items, we use the traditional definition of "modified":
967    // differs from schema default (since we don't have layer context in dialogs)
968    let modified = match (&current_value, &schema.default) {
969        (Some(current), Some(default)) => *current != default,
970        (Some(_), None) => true,
971        _ => false,
972    };
973
974    // Check if this is an auto-managed map (no_add)
975    let is_auto_managed = matches!(&schema.setting_type, SettingType::Map { no_add: true, .. });
976
977    SettingItem {
978        path: schema.path.clone(),
979        name: schema.name.clone(),
980        description: schema.description.clone(),
981        control,
982        default: schema.default.clone(),
983        modified,
984        // For dialogs, we don't track layer source - default to System
985        layer_source: ConfigLayer::System,
986        read_only: schema.read_only,
987        is_auto_managed,
988        section: schema.section.clone(),
989        is_section_start: false, // Not used in dialogs
990    }
991}
992
993/// Extract the current value from a control
994pub fn control_to_value(control: &SettingControl) -> serde_json::Value {
995    match control {
996        SettingControl::Toggle(state) => serde_json::Value::Bool(state.checked),
997
998        SettingControl::Number(state) => {
999            if state.is_percentage {
1000                // Convert back to float (divide by 100)
1001                let float_value = state.value as f64 / 100.0;
1002                serde_json::Number::from_f64(float_value)
1003                    .map(serde_json::Value::Number)
1004                    .unwrap_or(serde_json::Value::Number(state.value.into()))
1005            } else {
1006                serde_json::Value::Number(state.value.into())
1007            }
1008        }
1009
1010        SettingControl::Dropdown(state) => state
1011            .selected_value()
1012            .map(|s| {
1013                if s.is_empty() {
1014                    // Empty string represents null in nullable enums
1015                    serde_json::Value::Null
1016                } else {
1017                    serde_json::Value::String(s.to_string())
1018                }
1019            })
1020            .unwrap_or(serde_json::Value::Null),
1021
1022        SettingControl::Text(state) => serde_json::Value::String(state.value.clone()),
1023
1024        SettingControl::TextList(state) => {
1025            let arr: Vec<serde_json::Value> = state
1026                .items
1027                .iter()
1028                .map(|s| serde_json::Value::String(s.clone()))
1029                .collect();
1030            serde_json::Value::Array(arr)
1031        }
1032
1033        SettingControl::Map(state) => state.to_value(),
1034
1035        SettingControl::ObjectArray(state) => state.to_value(),
1036
1037        SettingControl::Json(state) => {
1038            // Parse the JSON string back to a value
1039            serde_json::from_str(&state.value()).unwrap_or(serde_json::Value::Null)
1040        }
1041
1042        SettingControl::Complex { .. } => serde_json::Value::Null,
1043    }
1044}
1045
1046#[cfg(test)]
1047mod tests {
1048    use super::*;
1049
1050    fn sample_config() -> serde_json::Value {
1051        serde_json::json!({
1052            "theme": "monokai",
1053            "check_for_updates": false,
1054            "editor": {
1055                "tab_size": 2,
1056                "line_numbers": true
1057            }
1058        })
1059    }
1060
1061    /// Helper to create a BuildContext for testing
1062    fn test_context(config: &serde_json::Value) -> BuildContext<'_> {
1063        // Create static empty HashMap for layer_sources
1064        static EMPTY_SOURCES: std::sync::LazyLock<HashMap<String, ConfigLayer>> =
1065            std::sync::LazyLock::new(HashMap::new);
1066        BuildContext {
1067            config_value: config,
1068            layer_sources: &EMPTY_SOURCES,
1069            target_layer: ConfigLayer::User,
1070        }
1071    }
1072
1073    /// Helper to create a BuildContext with layer sources for testing "modified" behavior
1074    fn test_context_with_sources<'a>(
1075        config: &'a serde_json::Value,
1076        layer_sources: &'a HashMap<String, ConfigLayer>,
1077        target_layer: ConfigLayer,
1078    ) -> BuildContext<'a> {
1079        BuildContext {
1080            config_value: config,
1081            layer_sources,
1082            target_layer,
1083        }
1084    }
1085
1086    #[test]
1087    fn test_build_toggle_item() {
1088        let schema = SettingSchema {
1089            path: "/check_for_updates".to_string(),
1090            name: "Check For Updates".to_string(),
1091            description: Some("Check for updates".to_string()),
1092            setting_type: SettingType::Boolean,
1093            default: Some(serde_json::Value::Bool(true)),
1094            read_only: false,
1095            section: None,
1096        };
1097
1098        let config = sample_config();
1099        let ctx = test_context(&config);
1100        let item = build_item(&schema, &ctx);
1101
1102        assert_eq!(item.path, "/check_for_updates");
1103        // With new semantics, modified = false when layer_sources is empty
1104        // (value is not defined in target layer)
1105        assert!(!item.modified);
1106        assert_eq!(item.layer_source, ConfigLayer::System);
1107
1108        if let SettingControl::Toggle(state) = &item.control {
1109            assert!(!state.checked); // Current value is false
1110        } else {
1111            panic!("Expected toggle control");
1112        }
1113    }
1114
1115    #[test]
1116    fn test_build_toggle_item_modified_in_user_layer() {
1117        let schema = SettingSchema {
1118            path: "/check_for_updates".to_string(),
1119            name: "Check For Updates".to_string(),
1120            description: Some("Check for updates".to_string()),
1121            setting_type: SettingType::Boolean,
1122            default: Some(serde_json::Value::Bool(true)),
1123            read_only: false,
1124            section: None,
1125        };
1126
1127        let config = sample_config();
1128        let mut layer_sources = HashMap::new();
1129        layer_sources.insert("/check_for_updates".to_string(), ConfigLayer::User);
1130        let ctx = test_context_with_sources(&config, &layer_sources, ConfigLayer::User);
1131        let item = build_item(&schema, &ctx);
1132
1133        // With new semantics: modified = true because value is defined in User layer
1134        // and target_layer is User
1135        assert!(item.modified);
1136        assert_eq!(item.layer_source, ConfigLayer::User);
1137    }
1138
1139    #[test]
1140    fn test_build_number_item() {
1141        let schema = SettingSchema {
1142            path: "/editor/tab_size".to_string(),
1143            name: "Tab Size".to_string(),
1144            description: None,
1145            setting_type: SettingType::Integer {
1146                minimum: Some(1),
1147                maximum: Some(16),
1148            },
1149            default: Some(serde_json::Value::Number(4.into())),
1150            read_only: false,
1151            section: None,
1152        };
1153
1154        let config = sample_config();
1155        let ctx = test_context(&config);
1156        let item = build_item(&schema, &ctx);
1157
1158        // With new semantics, modified = false when layer_sources is empty
1159        assert!(!item.modified);
1160
1161        if let SettingControl::Number(state) = &item.control {
1162            assert_eq!(state.value, 2);
1163            assert_eq!(state.min, Some(1));
1164            assert_eq!(state.max, Some(16));
1165        } else {
1166            panic!("Expected number control");
1167        }
1168    }
1169
1170    #[test]
1171    fn test_build_text_item() {
1172        let schema = SettingSchema {
1173            path: "/theme".to_string(),
1174            name: "Theme".to_string(),
1175            description: None,
1176            setting_type: SettingType::String,
1177            default: Some(serde_json::Value::String("high-contrast".to_string())),
1178            read_only: false,
1179            section: None,
1180        };
1181
1182        let config = sample_config();
1183        let ctx = test_context(&config);
1184        let item = build_item(&schema, &ctx);
1185
1186        // With new semantics, modified = false when layer_sources is empty
1187        assert!(!item.modified);
1188
1189        if let SettingControl::Text(state) = &item.control {
1190            assert_eq!(state.value, "monokai");
1191        } else {
1192            panic!("Expected text control");
1193        }
1194    }
1195
1196    #[test]
1197    fn test_clean_description_keeps_full_desc_with_new_info() {
1198        // "Tab Size" + "Number of spaces per tab character" -> keeps full desc (has "spaces", "character")
1199        let result = clean_description("Tab Size", Some("Number of spaces per tab character"));
1200        assert!(result.is_some());
1201        let cleaned = result.unwrap();
1202        // Should preserve original casing and contain the full info
1203        assert!(cleaned.starts_with('N')); // uppercase 'N' from "Number"
1204        assert!(cleaned.contains("spaces"));
1205        assert!(cleaned.contains("character"));
1206    }
1207
1208    #[test]
1209    fn test_clean_description_keeps_extra_info() {
1210        // "Line Numbers" + "Show line numbers in the gutter" -> should keep full desc with "gutter"
1211        let result = clean_description("Line Numbers", Some("Show line numbers in the gutter"));
1212        assert!(result.is_some());
1213        let cleaned = result.unwrap();
1214        assert!(cleaned.contains("gutter"));
1215    }
1216
1217    #[test]
1218    fn test_clean_description_returns_none_for_pure_redundancy() {
1219        // If description is just the name repeated, return None
1220        let result = clean_description("Theme", Some("Theme"));
1221        assert!(result.is_none());
1222
1223        // Or only filler words around the name
1224        let result = clean_description("Theme", Some("The theme to use"));
1225        assert!(result.is_none());
1226    }
1227
1228    #[test]
1229    fn test_clean_description_returns_none_for_empty() {
1230        let result = clean_description("Theme", Some(""));
1231        assert!(result.is_none());
1232
1233        let result = clean_description("Theme", None);
1234        assert!(result.is_none());
1235    }
1236
1237    #[test]
1238    fn test_control_to_value() {
1239        let toggle = SettingControl::Toggle(ToggleState::new(true, "Test"));
1240        assert_eq!(control_to_value(&toggle), serde_json::Value::Bool(true));
1241
1242        let number = SettingControl::Number(NumberInputState::new(42, "Test"));
1243        assert_eq!(control_to_value(&number), serde_json::json!(42));
1244
1245        let text = SettingControl::Text(TextInputState::new("Test").with_value("hello"));
1246        assert_eq!(
1247            control_to_value(&text),
1248            serde_json::Value::String("hello".to_string())
1249        );
1250    }
1251}