1use 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#[derive(Debug, Clone)]
16pub struct JsonEditState {
17 pub editor: TextEdit,
19 pub original_text: String,
21 pub label: String,
23 pub focus: FocusState,
25 pub scroll_offset: usize,
27 pub max_visible_lines: usize,
29}
30
31impl JsonEditState {
32 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 pub fn revert(&mut self) {
50 self.editor.set_value(&self.original_text);
51 self.scroll_offset = 0;
52 }
53
54 pub fn commit(&mut self) {
56 self.original_text = self.editor.value();
57 }
58
59 pub fn value(&self) -> String {
61 self.editor.value()
62 }
63
64 pub fn is_valid(&self) -> bool {
66 serde_json::from_str::<serde_json::Value>(&self.value()).is_ok()
67 }
68
69 pub fn display_height(&self) -> usize {
71 self.editor.line_count()
72 }
73
74 pub fn display_height_capped(&self) -> usize {
76 self.editor.line_count().min(self.max_visible_lines)
77 }
78
79 pub fn lines(&self) -> &[String] {
81 &self.editor.lines
82 }
83
84 pub fn cursor_pos(&self) -> (usize, usize) {
86 (self.editor.cursor_row, self.editor.cursor_col)
87 }
88
89 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 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
208fn 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#[derive(Debug, Clone)]
220pub struct SettingItem {
221 pub path: String,
223 pub name: String,
225 pub description: Option<String>,
227 pub control: SettingControl,
229 pub default: Option<serde_json::Value>,
231 pub modified: bool,
235 pub layer_source: ConfigLayer,
238 pub read_only: bool,
240 pub is_auto_managed: bool,
242 pub section: Option<String>,
244 pub is_section_start: bool,
246 pub layout_width: u16,
248}
249
250#[derive(Debug, Clone)]
252pub enum SettingControl {
253 Toggle(ToggleState),
254 Number(NumberInputState),
255 Dropdown(DropdownState),
256 Text(TextInputState),
257 TextList(TextListState),
258 Map(MapState),
260 ObjectArray(KeybindingListState),
262 Json(JsonEditState),
264 Complex {
266 type_name: String,
267 },
268}
269
270impl SettingControl {
271 pub fn control_height(&self) -> u16 {
273 match self {
274 Self::TextList(state) => {
276 (state.items.len() + 2) as u16
278 }
279 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; 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 Self::Dropdown(state) => {
301 if state.open {
302 1 + state.options.len().min(8) as u16
304 } else {
305 1
306 }
307 }
308 Self::ObjectArray(state) => {
310 (state.bindings.len() + 2) as u16
312 }
313 Self::Json(state) => {
315 1 + state.display_height() as u16
317 }
318 _ => 1,
320 }
321 }
322}
323
324pub const SECTION_HEADER_HEIGHT: u16 = 2;
326
327impl SettingItem {
328 pub fn item_height(&self) -> u16 {
331 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 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 let chars_per_line = width.saturating_sub(2) as usize; 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 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
370pub 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 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 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 let desc_words: Vec<&str> = desc
397 .split(|c: char| !c.is_alphanumeric())
398 .filter(|w| !w.is_empty())
399 .collect();
400
401 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 SettingControl::TextList(state) => {
423 let mut regions = Vec::new();
424 regions.push(FocusRegion {
426 id: 0,
427 y_offset: 0,
428 height: 1,
429 });
430 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 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 SettingControl::Map(state) => {
448 let mut regions = Vec::new();
449 let mut y = 0u16;
450
451 regions.push(FocusRegion {
453 id: 0,
454 y_offset: y,
455 height: 1,
456 });
457 y += 1;
458
459 if state.display_field.is_some() {
461 y += 1;
462 }
463
464 for (i, (_, v)) in state.entries.iter().enumerate() {
466 let mut entry_height = 1u16;
467 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 regions.push(FocusRegion {
486 id: 1 + state.entries.len(),
487 y_offset: y,
488 height: 1,
489 });
490 regions
491 }
492 SettingControl::ObjectArray(state) => {
494 let mut regions = Vec::new();
495 regions.push(FocusRegion {
497 id: 0,
498 y_offset: 0,
499 height: 1,
500 });
501 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 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 _ => {
519 vec![FocusRegion {
520 id: 0,
521 y_offset: 0,
522 height: self.item_height().saturating_sub(1), }]
524 }
525 }
526 }
527}
528
529#[derive(Debug, Clone)]
531pub struct SettingsPage {
532 pub name: String,
534 pub path: String,
536 pub description: Option<String>,
538 pub items: Vec<SettingItem>,
540 pub subpages: Vec<SettingsPage>,
542}
543
544pub struct BuildContext<'a> {
546 pub config_value: &'a serde_json::Value,
548 pub layer_sources: &'a HashMap<String, ConfigLayer>,
550 pub target_layer: ConfigLayer,
552}
553
554pub 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
569fn 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 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 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, (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
613pub fn build_item(schema: &SettingSchema, ctx: &BuildContext) -> SettingItem {
615 let current_value = ctx.config_value.pointer(&schema.path);
617
618 let is_auto_managed = matches!(&schema.setting_type, SettingType::Map { no_add: true, .. });
620
621 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 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 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 let current = if current_value.map(|v| v.is_null()).unwrap_or(false) {
679 "" } 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 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 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 let layer_source = ctx
809 .layer_sources
810 .get(&schema.path)
811 .copied()
812 .unwrap_or(ConfigLayer::System);
813
814 let modified = if is_auto_managed {
817 false } else {
819 layer_source == ctx.target_layer
820 };
821
822 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, layout_width: 0,
838 }
839}
840
841pub fn build_item_from_value(
843 schema: &SettingSchema,
844 current_value: Option<&serde_json::Value>,
845) -> SettingItem {
846 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 let current = if current_value.map(|v| v.is_null()).unwrap_or(false) {
902 "" } 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 let modified = match (¤t_value, &schema.default) {
1031 (Some(current), Some(default)) => *current != default,
1032 (Some(_), None) => true,
1033 _ => false,
1034 };
1035
1036 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 layer_source: ConfigLayer::System,
1048 read_only: schema.read_only,
1049 is_auto_managed,
1050 section: schema.section.clone(),
1051 is_section_start: false, layout_width: 0,
1053 }
1054}
1055
1056pub 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 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 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 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 fn test_context(config: &serde_json::Value) -> BuildContext<'_> {
1134 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 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 assert!(!item.modified);
1177 assert_eq!(item.layer_source, ConfigLayer::System);
1178
1179 if let SettingControl::Toggle(state) = &item.control {
1180 assert!(!state.checked); } 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 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 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 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 let result = clean_description("Tab Size", Some("Number of spaces per tab character"));
1271 assert!(result.is_some());
1272 let cleaned = result.unwrap();
1273 assert!(cleaned.starts_with('N')); assert!(cleaned.contains("spaces"));
1276 assert!(cleaned.contains("character"));
1277 }
1278
1279 #[test]
1280 fn test_clean_description_keeps_extra_info() {
1281 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 let result = clean_description("Theme", Some("Theme"));
1292 assert!(result.is_none());
1293
1294 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}