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