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