kcl_lib/settings/types/
mod.rs

1//! Types for kcl project and modeling-app settings.
2
3pub mod project;
4
5use anyhow::Result;
6use kittycad_modeling_cmds::units::UnitLength;
7use parse_display::{Display, FromStr};
8use schemars::JsonSchema;
9use serde::{Deserialize, Deserializer, Serialize};
10use validator::{Validate, ValidateRange};
11
12const DEFAULT_THEME_COLOR: f64 = 264.5;
13const DEFAULT_PROJECT_NAME_TEMPLATE: &str = "untitled";
14
15/// User specific settings for the app.
16/// These live in `user.toml` in the app's configuration directory.
17/// Updating the settings in the app will update this file automatically.
18/// Do not edit this file manually, as it may be overwritten by the app.
19/// Manual edits can cause corruption of the settings file.
20#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Validate)]
21#[ts(export)]
22#[serde(rename_all = "snake_case")]
23pub struct Configuration {
24    /// The settings for the Design Studio.
25    #[serde(default, skip_serializing_if = "is_default")]
26    #[validate(nested)]
27    pub settings: Settings,
28}
29
30impl Configuration {
31    pub fn parse_and_validate(toml_str: &str) -> Result<Self> {
32        let settings = toml::from_str::<Self>(toml_str)?;
33
34        settings.validate()?;
35
36        Ok(settings)
37    }
38}
39
40/// High level settings.
41#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Validate)]
42#[ts(export)]
43#[serde(rename_all = "snake_case")]
44pub struct Settings {
45    /// The settings for the Design Studio.
46    #[serde(default, skip_serializing_if = "is_default")]
47    #[validate(nested)]
48    pub app: AppSettings,
49    /// Settings that affect the behavior while modeling.
50    #[serde(default, skip_serializing_if = "is_default")]
51    #[validate(nested)]
52    pub modeling: ModelingSettings,
53    /// Settings that affect the behavior of the KCL text editor.
54    #[serde(default, skip_serializing_if = "is_default")]
55    #[validate(nested)]
56    pub text_editor: TextEditorSettings,
57    /// Settings that affect the behavior of project management.
58    #[serde(default, skip_serializing_if = "is_default")]
59    #[validate(nested)]
60    pub project: ProjectSettings,
61    /// Settings that affect the behavior of the command bar.
62    #[serde(default, skip_serializing_if = "is_default")]
63    #[validate(nested)]
64    pub command_bar: CommandBarSettings,
65}
66
67/// Application wide settings.
68#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Validate)]
69#[ts(export)]
70#[serde(rename_all = "snake_case")]
71pub struct AppSettings {
72    /// The settings for the appearance of the app.
73    #[serde(default, skip_serializing_if = "is_default")]
74    #[validate(nested)]
75    pub appearance: AppearanceSettings,
76    /// The onboarding status of the app.
77    #[serde(default, skip_serializing_if = "is_default")]
78    pub onboarding_status: OnboardingStatus,
79    /// Permanently dismiss the banner warning to download the desktop app.
80    /// This setting only applies to the web app. And is temporary until we have Linux support.
81    #[serde(default, skip_serializing_if = "is_default")]
82    pub dismiss_web_banner: bool,
83    /// When the user is idle, teardown the stream after some time.
84    #[serde(
85        default,
86        deserialize_with = "deserialize_stream_idle_mode",
87        alias = "streamIdleMode",
88        skip_serializing_if = "is_default"
89    )]
90    stream_idle_mode: Option<u32>,
91    /// Allow orbiting in sketch mode.
92    #[serde(default, skip_serializing_if = "is_default")]
93    pub allow_orbit_in_sketch_mode: bool,
94    /// Whether to show the debug panel, which lets you see various states
95    /// of the app to aid in development.
96    #[serde(default, skip_serializing_if = "is_default")]
97    pub show_debug_panel: bool,
98}
99
100/// Default to true.
101fn make_it_so() -> bool {
102    true
103}
104
105fn is_true(b: &bool) -> bool {
106    *b
107}
108
109fn deserialize_stream_idle_mode<'de, D>(deserializer: D) -> Result<Option<u32>, D::Error>
110where
111    D: Deserializer<'de>,
112{
113    #[derive(Deserialize)]
114    #[serde(untagged)]
115    enum StreamIdleModeValue {
116        Number(u32),
117        String(String),
118        Boolean(bool),
119    }
120
121    const DEFAULT_TIMEOUT: u32 = 1000 * 60 * 5;
122
123    Ok(match StreamIdleModeValue::deserialize(deserializer) {
124        Ok(StreamIdleModeValue::Number(value)) => Some(value),
125        Ok(StreamIdleModeValue::String(value)) => Some(value.parse::<u32>().unwrap_or(DEFAULT_TIMEOUT)),
126        // The old type of this value. I'm willing to say no one used it but
127        // we can never guarantee it.
128        Ok(StreamIdleModeValue::Boolean(true)) => Some(DEFAULT_TIMEOUT),
129        Ok(StreamIdleModeValue::Boolean(false)) => None,
130        _ => None,
131    })
132}
133
134#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq)]
135#[ts(export)]
136#[serde(untagged)]
137pub enum FloatOrInt {
138    String(String),
139    Float(f64),
140    Int(i64),
141}
142
143impl From<FloatOrInt> for f64 {
144    fn from(float_or_int: FloatOrInt) -> Self {
145        match float_or_int {
146            FloatOrInt::String(s) => s.parse().unwrap(),
147            FloatOrInt::Float(f) => f,
148            FloatOrInt::Int(i) => i as f64,
149        }
150    }
151}
152
153impl From<FloatOrInt> for AppColor {
154    fn from(float_or_int: FloatOrInt) -> Self {
155        match float_or_int {
156            FloatOrInt::String(s) => s.parse::<f64>().unwrap().into(),
157            FloatOrInt::Float(f) => f.into(),
158            FloatOrInt::Int(i) => (i as f64).into(),
159        }
160    }
161}
162
163/// The settings for the theme of the app.
164#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Validate)]
165#[ts(export)]
166#[serde(rename_all = "snake_case")]
167pub struct AppearanceSettings {
168    /// The overall theme of the app.
169    #[serde(default, skip_serializing_if = "is_default")]
170    pub theme: AppTheme,
171    /// The hue of the primary theme color for the app.
172    #[serde(default, skip_serializing_if = "is_default")]
173    #[validate(nested)]
174    pub color: AppColor,
175}
176
177#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq)]
178#[ts(export)]
179#[serde(transparent)]
180pub struct AppColor(pub f64);
181
182impl Default for AppColor {
183    fn default() -> Self {
184        Self(DEFAULT_THEME_COLOR)
185    }
186}
187
188impl From<AppColor> for f64 {
189    fn from(color: AppColor) -> Self {
190        color.0
191    }
192}
193
194impl From<f64> for AppColor {
195    fn from(color: f64) -> Self {
196        Self(color)
197    }
198}
199
200impl Validate for AppColor {
201    fn validate(&self) -> Result<(), validator::ValidationErrors> {
202        if !self.0.validate_range(Some(0.0), None, None, Some(360.0)) {
203            let mut errors = validator::ValidationErrors::new();
204            let mut err = validator::ValidationError::new("color");
205            err.add_param(std::borrow::Cow::from("min"), &0.0);
206            err.add_param(std::borrow::Cow::from("exclusive_max"), &360.0);
207            errors.add("color", err);
208            return Err(errors);
209        }
210        Ok(())
211    }
212}
213
214/// The overall appearance of the app.
215#[derive(
216    Debug, Default, Copy, Clone, Deserialize, Serialize, JsonSchema, Display, FromStr, ts_rs::TS, PartialEq, Eq,
217)]
218#[ts(export)]
219#[serde(rename_all = "snake_case")]
220#[display(style = "snake_case")]
221pub enum AppTheme {
222    /// A light theme.
223    Light,
224    /// A dark theme.
225    Dark,
226    /// Use the system theme.
227    /// This will use dark theme if the system theme is dark, and light theme if the system theme is light.
228    #[default]
229    System,
230}
231
232impl From<AppTheme> for kittycad::types::Color {
233    fn from(theme: AppTheme) -> Self {
234        match theme {
235            AppTheme::Light => kittycad::types::Color {
236                r: 249.0 / 255.0,
237                g: 249.0 / 255.0,
238                b: 249.0 / 255.0,
239                a: 1.0,
240            },
241            AppTheme::Dark => kittycad::types::Color {
242                r: 28.0 / 255.0,
243                g: 28.0 / 255.0,
244                b: 28.0 / 255.0,
245                a: 1.0,
246            },
247            AppTheme::System => {
248                // TODO: Check the system setting for the user.
249                todo!()
250            }
251        }
252    }
253}
254
255/// Settings that affect the behavior while modeling.
256#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Validate)]
257#[serde(rename_all = "snake_case")]
258#[ts(export)]
259pub struct ModelingSettings {
260    /// The default unit to use in modeling dimensions.
261    #[serde(default = "default_length_unit_millimeters", skip_serializing_if = "is_default")]
262    pub base_unit: UnitLength,
263    /// The projection mode the camera should use while modeling.
264    #[serde(default, skip_serializing_if = "is_default")]
265    pub camera_projection: CameraProjectionType,
266    /// The methodology the camera should use to orbit around the model.
267    #[serde(default, skip_serializing_if = "is_default")]
268    pub camera_orbit: CameraOrbitType,
269    /// The controls for how to navigate the 3D view.
270    #[serde(default, skip_serializing_if = "is_default")]
271    pub mouse_controls: MouseControlType,
272    /// Toggle touch controls for 3D view navigation
273    #[serde(default, skip_serializing_if = "is_default")]
274    pub enable_touch_controls: DefaultTrue,
275    /// Toggle new sketch mode implementation
276    #[serde(default, skip_serializing_if = "is_default")]
277    pub use_new_sketch_mode: bool,
278    /// Highlight edges of 3D objects?
279    #[serde(default, skip_serializing_if = "is_default")]
280    pub highlight_edges: DefaultTrue,
281    /// Whether or not Screen Space Ambient Occlusion (SSAO) is enabled.
282    #[serde(default, skip_serializing_if = "is_default")]
283    pub enable_ssao: DefaultTrue,
284    /// Whether or not to show a scale grid in the 3D modeling view
285    #[serde(default, skip_serializing_if = "is_default")]
286    pub show_scale_grid: bool,
287    /// When enabled, the grid will use a fixed size based on your selected units rather than automatically scaling with zoom level.
288    /// If true, the grid cells will be fixed-size, where the width is your default length unit.
289    /// If false, the grid will get larger as you zoom out, and smaller as you zoom in.
290    #[serde(default = "make_it_so", skip_serializing_if = "is_true")]
291    pub fixed_size_grid: bool,
292    /// When enabled, tools like line, rectangle, etc. will snap to the grid.
293    #[serde(default, skip_serializing_if = "is_default")]
294    pub snap_to_grid: bool,
295    /// The space between major grid lines, specified in the current unit.
296    #[serde(default, skip_serializing_if = "is_default")]
297    pub major_grid_spacing: f64,
298    /// The number of minor grid lines per major grid line.
299    #[serde(default, skip_serializing_if = "is_default")]
300    pub minor_grids_per_major: f64,
301    /// The number of snaps between minor grid lines. 1 means snapping to each minor grid line.
302    #[serde(default, skip_serializing_if = "is_default")]
303    pub snaps_per_minor: f64,
304}
305
306fn default_length_unit_millimeters() -> UnitLength {
307    UnitLength::Millimeters
308}
309
310impl Default for ModelingSettings {
311    fn default() -> Self {
312        Self {
313            base_unit: UnitLength::Millimeters,
314            camera_projection: Default::default(),
315            camera_orbit: Default::default(),
316            mouse_controls: Default::default(),
317            enable_touch_controls: Default::default(),
318            use_new_sketch_mode: Default::default(),
319            highlight_edges: Default::default(),
320            enable_ssao: Default::default(),
321            show_scale_grid: Default::default(),
322            fixed_size_grid: true,
323            snap_to_grid: Default::default(),
324            major_grid_spacing: Default::default(),
325            minor_grids_per_major: Default::default(),
326            snaps_per_minor: Default::default(),
327        }
328    }
329}
330
331#[derive(Debug, Copy, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Eq)]
332#[ts(export)]
333#[serde(transparent)]
334pub struct DefaultTrue(pub bool);
335
336impl Default for DefaultTrue {
337    fn default() -> Self {
338        Self(true)
339    }
340}
341
342impl From<DefaultTrue> for bool {
343    fn from(default_true: DefaultTrue) -> Self {
344        default_true.0
345    }
346}
347
348impl From<bool> for DefaultTrue {
349    fn from(b: bool) -> Self {
350        Self(b)
351    }
352}
353
354/// The types of controls for how to navigate the 3D view.
355#[derive(Debug, Default, Eq, PartialEq, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, Display, FromStr)]
356#[ts(export)]
357#[serde(rename_all = "snake_case")]
358#[display(style = "snake_case")]
359pub enum MouseControlType {
360    #[default]
361    #[display("zoo")]
362    #[serde(rename = "zoo")]
363    Zoo,
364    #[display("onshape")]
365    #[serde(rename = "onshape")]
366    OnShape,
367    TrackpadFriendly,
368    Solidworks,
369    Nx,
370    Creo,
371    #[display("autocad")]
372    #[serde(rename = "autocad")]
373    AutoCad,
374}
375
376/// The types of camera projection for the 3D view.
377#[derive(Debug, Default, Eq, PartialEq, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, Display, FromStr)]
378#[ts(export)]
379#[serde(rename_all = "snake_case")]
380#[display(style = "snake_case")]
381pub enum CameraProjectionType {
382    /// Perspective projection https://en.wikipedia.org/wiki/3D_projection#Perspective_projection
383    Perspective,
384    /// Orthographic projection https://en.wikipedia.org/wiki/3D_projection#Orthographic_projection
385    #[default]
386    Orthographic,
387}
388
389/// The types of camera orbit methods.
390#[derive(Debug, Default, Eq, PartialEq, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, Display, FromStr)]
391#[ts(export)]
392#[serde(rename_all = "snake_case")]
393#[display(style = "snake_case")]
394pub enum CameraOrbitType {
395    /// Orbit using a spherical camera movement.
396    #[default]
397    #[display("spherical")]
398    Spherical,
399    /// Orbit using a trackball camera movement.
400    #[display("trackball")]
401    Trackball,
402}
403
404/// Settings that affect the behavior of the KCL text editor.
405#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Eq, Validate)]
406#[serde(rename_all = "snake_case")]
407#[ts(export)]
408pub struct TextEditorSettings {
409    /// Whether to wrap text in the editor or overflow with scroll.
410    #[serde(default, skip_serializing_if = "is_default")]
411    pub text_wrapping: DefaultTrue,
412    /// Whether to make the cursor blink in the editor.
413    #[serde(default, skip_serializing_if = "is_default")]
414    pub blinking_cursor: DefaultTrue,
415}
416
417/// Same as TextEditorSettings but applies to a per-project basis.
418#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Eq, Validate)]
419#[serde(rename_all = "snake_case")]
420#[ts(export)]
421pub struct ProjectTextEditorSettings {
422    /// Whether to wrap text in the editor or overflow with scroll.
423    #[serde(default, skip_serializing_if = "Option::is_none")]
424    pub text_wrapping: Option<bool>,
425    /// Whether to make the cursor blink in the editor.
426    #[serde(default, skip_serializing_if = "Option::is_none")]
427    pub blinking_cursor: Option<bool>,
428}
429
430/// Settings that affect the behavior of project management.
431#[derive(Debug, Clone, Default, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Eq, Validate)]
432#[serde(rename_all = "snake_case")]
433#[ts(export)]
434pub struct ProjectSettings {
435    /// The directory to save and load projects from.
436    #[serde(default, skip_serializing_if = "is_default")]
437    pub directory: std::path::PathBuf,
438    /// The default project name to use when creating a new project.
439    #[serde(default, skip_serializing_if = "is_default")]
440    pub default_project_name: ProjectNameTemplate,
441}
442
443#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Eq)]
444#[ts(export)]
445#[serde(transparent)]
446pub struct ProjectNameTemplate(pub String);
447
448impl Default for ProjectNameTemplate {
449    fn default() -> Self {
450        Self(DEFAULT_PROJECT_NAME_TEMPLATE.to_string())
451    }
452}
453
454impl From<ProjectNameTemplate> for String {
455    fn from(project_name: ProjectNameTemplate) -> Self {
456        project_name.0
457    }
458}
459
460impl From<String> for ProjectNameTemplate {
461    fn from(s: String) -> Self {
462        Self(s)
463    }
464}
465
466/// Settings that affect the behavior of the command bar.
467#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Eq, Validate)]
468#[serde(rename_all = "snake_case")]
469#[ts(export)]
470pub struct CommandBarSettings {
471    /// Whether to include settings in the command bar.
472    #[serde(default, skip_serializing_if = "is_default")]
473    pub include_settings: DefaultTrue,
474}
475
476/// Same as CommandBarSettings but applies to a per-project basis.
477#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Eq, Validate)]
478#[serde(rename_all = "snake_case")]
479#[ts(export)]
480pub struct ProjectCommandBarSettings {
481    /// Whether to include settings in the command bar.
482    #[serde(default, skip_serializing_if = "Option::is_none")]
483    pub include_settings: Option<bool>,
484}
485
486/// The types of onboarding status.
487#[derive(Debug, Default, Eq, PartialEq, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, Display, FromStr)]
488#[ts(export)]
489#[serde(rename_all = "snake_case")]
490#[display(style = "snake_case")]
491pub enum OnboardingStatus {
492    /// The unset state.
493    #[serde(rename = "")]
494    #[display("")]
495    Unset,
496    /// The user has completed onboarding.
497    Completed,
498    /// The user has not completed onboarding.
499    #[default]
500    Incomplete,
501    /// The user has dismissed onboarding.
502    Dismissed,
503
504    // Desktop Routes
505    #[serde(rename = "/desktop")]
506    #[display("/desktop")]
507    DesktopWelcome,
508    #[serde(rename = "/desktop/scene")]
509    #[display("/desktop/scene")]
510    DesktopScene,
511    #[serde(rename = "/desktop/toolbar")]
512    #[display("/desktop/toolbar")]
513    DesktopToolbar,
514    #[serde(rename = "/desktop/text-to-cad")]
515    #[display("/desktop/text-to-cad")]
516    DesktopTextToCadWelcome,
517    #[serde(rename = "/desktop/text-to-cad-prompt")]
518    #[display("/desktop/text-to-cad-prompt")]
519    DesktopTextToCadPrompt,
520    #[serde(rename = "/desktop/feature-tree-pane")]
521    #[display("/desktop/feature-tree-pane")]
522    DesktopFeatureTreePane,
523    #[serde(rename = "/desktop/code-pane")]
524    #[display("/desktop/code-pane")]
525    DesktopCodePane,
526    #[serde(rename = "/desktop/project-pane")]
527    #[display("/desktop/project-pane")]
528    DesktopProjectFilesPane,
529    #[serde(rename = "/desktop/other-panes")]
530    #[display("/desktop/other-panes")]
531    DesktopOtherPanes,
532    #[serde(rename = "/desktop/prompt-to-edit")]
533    #[display("/desktop/prompt-to-edit")]
534    DesktopPromptToEditWelcome,
535    #[serde(rename = "/desktop/prompt-to-edit-prompt")]
536    #[display("/desktop/prompt-to-edit-prompt")]
537    DesktopPromptToEditPrompt,
538    #[serde(rename = "/desktop/prompt-to-edit-result")]
539    #[display("/desktop/prompt-to-edit-result")]
540    DesktopPromptToEditResult,
541    #[serde(rename = "/desktop/imports")]
542    #[display("/desktop/imports")]
543    DesktopImports,
544    #[serde(rename = "/desktop/exports")]
545    #[display("/desktop/exports")]
546    DesktopExports,
547    #[serde(rename = "/desktop/conclusion")]
548    #[display("/desktop/conclusion")]
549    DesktopConclusion,
550
551    // Browser Routes
552    #[serde(rename = "/browser")]
553    #[display("/browser")]
554    BrowserWelcome,
555    #[serde(rename = "/browser/scene")]
556    #[display("/browser/scene")]
557    BrowserScene,
558    #[serde(rename = "/browser/toolbar")]
559    #[display("/browser/toolbar")]
560    BrowserToolbar,
561    #[serde(rename = "/browser/text-to-cad")]
562    #[display("/browser/text-to-cad")]
563    BrowserTextToCadWelcome,
564    #[serde(rename = "/browser/text-to-cad-prompt")]
565    #[display("/browser/text-to-cad-prompt")]
566    BrowserTextToCadPrompt,
567    #[serde(rename = "/browser/feature-tree-pane")]
568    #[display("/browser/feature-tree-pane")]
569    BrowserFeatureTreePane,
570    #[serde(rename = "/browser/prompt-to-edit")]
571    #[display("/browser/prompt-to-edit")]
572    BrowserPromptToEditWelcome,
573    #[serde(rename = "/browser/prompt-to-edit-prompt")]
574    #[display("/browser/prompt-to-edit-prompt")]
575    BrowserPromptToEditPrompt,
576    #[serde(rename = "/browser/prompt-to-edit-result")]
577    #[display("/browser/prompt-to-edit-result")]
578    BrowserPromptToEditResult,
579    #[serde(rename = "/browser/conclusion")]
580    #[display("/browser/conclusion")]
581    BrowserConclusion,
582}
583
584fn is_default<T: Default + PartialEq>(t: &T) -> bool {
585    t == &T::default()
586}
587
588#[cfg(test)]
589mod tests {
590    use pretty_assertions::assert_eq;
591    use validator::Validate;
592
593    use super::{
594        AppColor, AppSettings, AppTheme, AppearanceSettings, CameraProjectionType, CommandBarSettings, Configuration,
595        ModelingSettings, MouseControlType, OnboardingStatus, ProjectNameTemplate, ProjectSettings, Settings,
596        TextEditorSettings, UnitLength,
597    };
598
599    #[test]
600    fn test_settings_empty_file_parses() {
601        let empty_settings_file = r#""#;
602
603        let parsed = toml::from_str::<Configuration>(empty_settings_file).unwrap();
604        assert_eq!(parsed, Configuration::default());
605
606        // Write the file back out.
607        let serialized = toml::to_string(&parsed).unwrap();
608        assert_eq!(serialized, r#""#);
609
610        let parsed = Configuration::parse_and_validate(empty_settings_file).unwrap();
611        assert_eq!(parsed, Configuration::default());
612    }
613
614    #[test]
615    fn test_settings_parse_basic() {
616        let settings_file = r#"[settings.app]
617default_project_name = "untitled"
618directory = ""
619onboarding_status = "dismissed"
620
621  [settings.app.appearance]
622  theme = "dark"
623
624[settings.modeling]
625enable_ssao = false
626base_unit = "in"
627mouse_controls = "zoo"
628camera_projection = "perspective"
629
630[settings.project]
631default_project_name = "untitled"
632directory = ""
633
634[settings.text_editor]
635text_wrapping = true"#;
636
637        let expected = Configuration {
638            settings: Settings {
639                app: AppSettings {
640                    onboarding_status: OnboardingStatus::Dismissed,
641                    appearance: AppearanceSettings {
642                        theme: AppTheme::Dark,
643                        color: AppColor(264.5),
644                    },
645                    ..Default::default()
646                },
647                modeling: ModelingSettings {
648                    enable_ssao: false.into(),
649                    base_unit: UnitLength::Inches,
650                    mouse_controls: MouseControlType::Zoo,
651                    camera_projection: CameraProjectionType::Perspective,
652                    fixed_size_grid: true,
653                    ..Default::default()
654                },
655                project: ProjectSettings {
656                    default_project_name: ProjectNameTemplate("untitled".to_string()),
657                    directory: "".into(),
658                },
659                text_editor: TextEditorSettings {
660                    text_wrapping: true.into(),
661                    ..Default::default()
662                },
663                command_bar: CommandBarSettings {
664                    include_settings: true.into(),
665                },
666            },
667        };
668        let parsed = toml::from_str::<Configuration>(settings_file).unwrap();
669        assert_eq!(parsed, expected);
670
671        // Write the file back out.
672        let serialized = toml::to_string(&parsed).unwrap();
673        assert_eq!(
674            serialized,
675            r#"[settings.app]
676onboarding_status = "dismissed"
677
678[settings.app.appearance]
679theme = "dark"
680
681[settings.modeling]
682base_unit = "in"
683camera_projection = "perspective"
684enable_ssao = false
685"#
686        );
687
688        let parsed = Configuration::parse_and_validate(settings_file).unwrap();
689        assert_eq!(parsed, expected);
690    }
691
692    #[test]
693    fn test_color_validation() {
694        let color = AppColor(360.0);
695
696        let result = color.validate();
697        if let Ok(r) = result {
698            panic!("Expected an error, but got success: {r:?}");
699        }
700        assert!(result.is_err());
701        assert!(
702            result
703                .unwrap_err()
704                .to_string()
705                .contains("color: Validation error: color")
706        );
707
708        let appearance = AppearanceSettings {
709            theme: AppTheme::System,
710            color: AppColor(361.5),
711        };
712        let result = appearance.validate();
713        if let Ok(r) = result {
714            panic!("Expected an error, but got success: {r:?}");
715        }
716        assert!(result.is_err());
717        assert!(
718            result
719                .unwrap_err()
720                .to_string()
721                .contains("color: Validation error: color")
722        );
723    }
724
725    #[test]
726    fn test_settings_color_validation_error() {
727        let settings_file = r#"[settings.app.appearance]
728color = 1567.4"#;
729
730        let result = Configuration::parse_and_validate(settings_file);
731        if let Ok(r) = result {
732            panic!("Expected an error, but got success: {r:?}");
733        }
734        assert!(result.is_err());
735
736        assert!(
737            result
738                .unwrap_err()
739                .to_string()
740                .contains("color: Validation error: color")
741        );
742    }
743}