kcl_lib/settings/types/
mod.rs

1//! Types for kcl project and modeling-app settings.
2
3pub mod project;
4
5use anyhow::Result;
6use parse_display::{Display, FromStr};
7use schemars::JsonSchema;
8use serde::{Deserialize, Serialize};
9use validator::{Validate, ValidateRange};
10
11const DEFAULT_THEME_COLOR: f64 = 264.5;
12const DEFAULT_PROJECT_NAME_TEMPLATE: &str = "project-$nnn";
13
14/// High level configuration.
15#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Validate)]
16#[ts(export)]
17#[serde(rename_all = "snake_case")]
18pub struct Configuration {
19    /// The settings for the modeling app.
20    #[serde(default, skip_serializing_if = "is_default")]
21    #[validate(nested)]
22    pub settings: Settings,
23}
24
25impl Configuration {
26    // TODO: remove this when we remove backwards compatibility with the old settings file.
27    pub fn backwards_compatible_toml_parse(toml_str: &str) -> Result<Self> {
28        let mut settings = toml::from_str::<Self>(toml_str)?;
29
30        if let Some(project_directory) = &settings.settings.app.project_directory {
31            if settings.settings.project.directory.to_string_lossy().is_empty() {
32                settings.settings.project.directory.clone_from(project_directory);
33                settings.settings.app.project_directory = None;
34            }
35        }
36
37        if let Some(theme) = &settings.settings.app.theme {
38            if settings.settings.app.appearance.theme == AppTheme::default() {
39                settings.settings.app.appearance.theme = *theme;
40                settings.settings.app.theme = None;
41            }
42        }
43
44        if let Some(theme_color) = &settings.settings.app.theme_color {
45            if settings.settings.app.appearance.color == AppColor::default() {
46                settings.settings.app.appearance.color = theme_color.clone().into();
47                settings.settings.app.theme_color = None;
48            }
49        }
50
51        if let Some(enable_ssao) = settings.settings.app.enable_ssao {
52            if settings.settings.modeling.enable_ssao.into() {
53                settings.settings.modeling.enable_ssao = enable_ssao.into();
54                settings.settings.app.enable_ssao = None;
55            }
56        }
57
58        if settings.settings.modeling.show_debug_panel && !settings.settings.app.show_debug_panel {
59            settings.settings.app.show_debug_panel = settings.settings.modeling.show_debug_panel;
60            settings.settings.modeling.show_debug_panel = Default::default();
61        }
62
63        settings.validate()?;
64
65        Ok(settings)
66    }
67}
68
69/// High level settings.
70#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Validate)]
71#[ts(export)]
72#[serde(rename_all = "snake_case")]
73pub struct Settings {
74    /// The settings for the modeling app.
75    #[serde(default, skip_serializing_if = "is_default")]
76    #[validate(nested)]
77    pub app: AppSettings,
78    /// Settings that affect the behavior while modeling.
79    #[serde(default, skip_serializing_if = "is_default")]
80    #[validate(nested)]
81    pub modeling: ModelingSettings,
82    /// Settings that affect the behavior of the KCL text editor.
83    #[serde(default, alias = "textEditor", skip_serializing_if = "is_default")]
84    #[validate(nested)]
85    pub text_editor: TextEditorSettings,
86    /// Settings that affect the behavior of project management.
87    #[serde(default, alias = "projects", skip_serializing_if = "is_default")]
88    #[validate(nested)]
89    pub project: ProjectSettings,
90    /// Settings that affect the behavior of the command bar.
91    #[serde(default, alias = "commandBar", skip_serializing_if = "is_default")]
92    #[validate(nested)]
93    pub command_bar: CommandBarSettings,
94}
95
96/// Application wide settings.
97// TODO: When we remove backwards compatibility with the old settings file, we can remove the
98// aliases to camelCase (and projects plural) from everywhere.
99#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Validate)]
100#[ts(export)]
101#[serde(rename_all = "snake_case")]
102pub struct AppSettings {
103    /// The settings for the appearance of the app.
104    #[serde(default, skip_serializing_if = "is_default")]
105    #[validate(nested)]
106    pub appearance: AppearanceSettings,
107    /// The onboarding status of the app.
108    #[serde(default, alias = "onboardingStatus", skip_serializing_if = "is_default")]
109    pub onboarding_status: OnboardingStatus,
110    /// Backwards compatible project directory setting.
111    #[serde(default, alias = "projectDirectory", skip_serializing_if = "Option::is_none")]
112    pub project_directory: Option<std::path::PathBuf>,
113    /// Backwards compatible theme setting.
114    #[serde(default, skip_serializing_if = "Option::is_none")]
115    pub theme: Option<AppTheme>,
116    /// The hue of the primary theme color for the app.
117    #[serde(default, skip_serializing_if = "Option::is_none", alias = "themeColor")]
118    pub theme_color: Option<FloatOrInt>,
119    /// Whether or not Screen Space Ambient Occlusion (SSAO) is enabled.
120    #[serde(default, alias = "enableSSAO", skip_serializing_if = "Option::is_none")]
121    pub enable_ssao: Option<bool>,
122    /// Permanently dismiss the banner warning to download the desktop app.
123    /// This setting only applies to the web app. And is temporary until we have Linux support.
124    #[serde(default, alias = "dismissWebBanner", skip_serializing_if = "is_default")]
125    pub dismiss_web_banner: bool,
126    /// When the user is idle, and this is true, the stream will be torn down.
127    #[serde(default, alias = "streamIdleMode", skip_serializing_if = "is_default")]
128    pub stream_idle_mode: bool,
129    /// When the user is idle, and this is true, the stream will be torn down.
130    #[serde(default, alias = "allowOrbitInSketchMode", skip_serializing_if = "is_default")]
131    pub allow_orbit_in_sketch_mode: bool,
132    /// Whether to show the debug panel, which lets you see various states
133    /// of the app to aid in development.
134    #[serde(default, alias = "showDebugPanel", skip_serializing_if = "is_default")]
135    pub show_debug_panel: bool,
136}
137
138// TODO: When we remove backwards compatibility with the old settings file, we can remove this.
139#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq)]
140#[ts(export)]
141#[serde(untagged)]
142pub enum FloatOrInt {
143    String(String),
144    Float(f64),
145    Int(i64),
146}
147
148impl From<FloatOrInt> for f64 {
149    fn from(float_or_int: FloatOrInt) -> Self {
150        match float_or_int {
151            FloatOrInt::String(s) => s.parse().unwrap(),
152            FloatOrInt::Float(f) => f,
153            FloatOrInt::Int(i) => i as f64,
154        }
155    }
156}
157
158impl From<FloatOrInt> for AppColor {
159    fn from(float_or_int: FloatOrInt) -> Self {
160        match float_or_int {
161            FloatOrInt::String(s) => s.parse::<f64>().unwrap().into(),
162            FloatOrInt::Float(f) => f.into(),
163            FloatOrInt::Int(i) => (i as f64).into(),
164        }
165    }
166}
167
168/// The settings for the theme of the app.
169#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Validate)]
170#[ts(export)]
171#[serde(rename_all = "snake_case")]
172pub struct AppearanceSettings {
173    /// The overall theme of the app.
174    #[serde(default, skip_serializing_if = "is_default")]
175    pub theme: AppTheme,
176    /// The hue of the primary theme color for the app.
177    #[serde(default, skip_serializing_if = "is_default")]
178    #[validate(nested)]
179    pub color: AppColor,
180}
181
182#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq)]
183#[ts(export)]
184#[serde(transparent)]
185pub struct AppColor(pub f64);
186
187impl Default for AppColor {
188    fn default() -> Self {
189        Self(DEFAULT_THEME_COLOR)
190    }
191}
192
193impl From<AppColor> for f64 {
194    fn from(color: AppColor) -> Self {
195        color.0
196    }
197}
198
199impl From<f64> for AppColor {
200    fn from(color: f64) -> Self {
201        Self(color)
202    }
203}
204
205impl Validate for AppColor {
206    fn validate(&self) -> Result<(), validator::ValidationErrors> {
207        if !self.0.validate_range(Some(0.0), None, None, Some(360.0)) {
208            let mut errors = validator::ValidationErrors::new();
209            let mut err = validator::ValidationError::new("color");
210            err.add_param(std::borrow::Cow::from("min"), &0.0);
211            err.add_param(std::borrow::Cow::from("exclusive_max"), &360.0);
212            errors.add("color", err);
213            return Err(errors);
214        }
215        Ok(())
216    }
217}
218
219/// The overall appearance of the app.
220#[derive(
221    Debug, Default, Copy, Clone, Deserialize, Serialize, JsonSchema, Display, FromStr, ts_rs::TS, PartialEq, Eq,
222)]
223#[ts(export)]
224#[serde(rename_all = "snake_case")]
225#[display(style = "snake_case")]
226pub enum AppTheme {
227    /// A light theme.
228    Light,
229    /// A dark theme.
230    Dark,
231    /// Use the system theme.
232    /// This will use dark theme if the system theme is dark, and light theme if the system theme is light.
233    #[default]
234    System,
235}
236
237impl From<AppTheme> for kittycad::types::Color {
238    fn from(theme: AppTheme) -> Self {
239        match theme {
240            AppTheme::Light => kittycad::types::Color {
241                r: 249.0 / 255.0,
242                g: 249.0 / 255.0,
243                b: 249.0 / 255.0,
244                a: 1.0,
245            },
246            AppTheme::Dark => kittycad::types::Color {
247                r: 28.0 / 255.0,
248                g: 28.0 / 255.0,
249                b: 28.0 / 255.0,
250                a: 1.0,
251            },
252            AppTheme::System => {
253                // TODO: Check the system setting for the user.
254                todo!()
255            }
256        }
257    }
258}
259
260/// Settings that affect the behavior while modeling.
261#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Eq, Validate)]
262#[serde(rename_all = "snake_case")]
263#[ts(export)]
264pub struct ModelingSettings {
265    /// The default unit to use in modeling dimensions.
266    #[serde(default, alias = "defaultUnit", skip_serializing_if = "is_default")]
267    pub base_unit: UnitLength,
268    /// The projection mode the camera should use while modeling.
269    #[serde(default, alias = "cameraProjection", skip_serializing_if = "is_default")]
270    pub camera_projection: CameraProjectionType,
271    /// The methodology the camera should use to orbit around the model.
272    #[serde(default, alias = "cameraOrbit", skip_serializing_if = "is_default")]
273    pub camera_orbit: CameraOrbitType,
274    /// The controls for how to navigate the 3D view.
275    #[serde(default, alias = "mouseControls", skip_serializing_if = "is_default")]
276    pub mouse_controls: MouseControlType,
277    /// Highlight edges of 3D objects?
278    #[serde(default, alias = "highlightEdges", skip_serializing_if = "is_default")]
279    pub highlight_edges: DefaultTrue,
280    /// Whether to show the debug panel, which lets you see various states
281    /// of the app to aid in development.
282    /// Remove this when we remove backwards compatibility with the old settings file.
283    #[serde(default, alias = "showDebugPanel", skip_serializing_if = "is_default")]
284    pub show_debug_panel: bool,
285    /// Whether or not Screen Space Ambient Occlusion (SSAO) is enabled.
286    #[serde(default, skip_serializing_if = "is_default")]
287    pub enable_ssao: DefaultTrue,
288    /// Whether or not to show a scale grid in the 3D modeling view
289    #[serde(default, alias = "showScaleGrid", skip_serializing_if = "is_default")]
290    pub show_scale_grid: bool,
291}
292
293#[derive(Debug, Copy, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Eq)]
294#[ts(export)]
295#[serde(transparent)]
296pub struct DefaultTrue(pub bool);
297
298impl Default for DefaultTrue {
299    fn default() -> Self {
300        Self(true)
301    }
302}
303
304impl From<DefaultTrue> for bool {
305    fn from(default_true: DefaultTrue) -> Self {
306        default_true.0
307    }
308}
309
310impl From<bool> for DefaultTrue {
311    fn from(b: bool) -> Self {
312        Self(b)
313    }
314}
315
316/// The valid types of length units.
317#[derive(
318    Debug, Default, Eq, PartialEq, Copy, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, Display, FromStr,
319)]
320#[cfg_attr(feature = "pyo3", pyo3::pyclass(eq, eq_int))]
321#[ts(export)]
322#[serde(rename_all = "lowercase")]
323#[display(style = "lowercase")]
324pub enum UnitLength {
325    /// Centimeters <https://en.wikipedia.org/wiki/Centimeter>
326    Cm,
327    /// Feet <https://en.wikipedia.org/wiki/Foot_(unit)>
328    Ft,
329    /// Inches <https://en.wikipedia.org/wiki/Inch>
330    In,
331    /// Meters <https://en.wikipedia.org/wiki/Meter>
332    M,
333    /// Millimeters <https://en.wikipedia.org/wiki/Millimeter>
334    #[default]
335    Mm,
336    /// Yards <https://en.wikipedia.org/wiki/Yard>
337    Yd,
338}
339
340impl From<kittycad::types::UnitLength> for UnitLength {
341    fn from(unit: kittycad::types::UnitLength) -> Self {
342        match unit {
343            kittycad::types::UnitLength::Cm => UnitLength::Cm,
344            kittycad::types::UnitLength::Ft => UnitLength::Ft,
345            kittycad::types::UnitLength::In => UnitLength::In,
346            kittycad::types::UnitLength::M => UnitLength::M,
347            kittycad::types::UnitLength::Mm => UnitLength::Mm,
348            kittycad::types::UnitLength::Yd => UnitLength::Yd,
349        }
350    }
351}
352
353impl From<UnitLength> for kittycad::types::UnitLength {
354    fn from(unit: UnitLength) -> Self {
355        match unit {
356            UnitLength::Cm => kittycad::types::UnitLength::Cm,
357            UnitLength::Ft => kittycad::types::UnitLength::Ft,
358            UnitLength::In => kittycad::types::UnitLength::In,
359            UnitLength::M => kittycad::types::UnitLength::M,
360            UnitLength::Mm => kittycad::types::UnitLength::Mm,
361            UnitLength::Yd => kittycad::types::UnitLength::Yd,
362        }
363    }
364}
365
366impl From<kittycad_modeling_cmds::units::UnitLength> for UnitLength {
367    fn from(unit: kittycad_modeling_cmds::units::UnitLength) -> Self {
368        match unit {
369            kittycad_modeling_cmds::units::UnitLength::Centimeters => UnitLength::Cm,
370            kittycad_modeling_cmds::units::UnitLength::Feet => UnitLength::Ft,
371            kittycad_modeling_cmds::units::UnitLength::Inches => UnitLength::In,
372            kittycad_modeling_cmds::units::UnitLength::Meters => UnitLength::M,
373            kittycad_modeling_cmds::units::UnitLength::Millimeters => UnitLength::Mm,
374            kittycad_modeling_cmds::units::UnitLength::Yards => UnitLength::Yd,
375        }
376    }
377}
378
379impl From<UnitLength> for kittycad_modeling_cmds::units::UnitLength {
380    fn from(unit: UnitLength) -> Self {
381        match unit {
382            UnitLength::Cm => kittycad_modeling_cmds::units::UnitLength::Centimeters,
383            UnitLength::Ft => kittycad_modeling_cmds::units::UnitLength::Feet,
384            UnitLength::In => kittycad_modeling_cmds::units::UnitLength::Inches,
385            UnitLength::M => kittycad_modeling_cmds::units::UnitLength::Meters,
386            UnitLength::Mm => kittycad_modeling_cmds::units::UnitLength::Millimeters,
387            UnitLength::Yd => kittycad_modeling_cmds::units::UnitLength::Yards,
388        }
389    }
390}
391
392/// The types of controls for how to navigate the 3D view.
393#[derive(Debug, Default, Eq, PartialEq, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, Display, FromStr)]
394#[ts(export)]
395#[serde(rename_all = "snake_case")]
396#[display(style = "snake_case")]
397pub enum MouseControlType {
398    #[default]
399    #[display("zoo")]
400    #[serde(rename = "zoo", alias = "Zoo", alias = "KittyCAD")]
401    Zoo,
402    #[display("onshape")]
403    #[serde(rename = "onshape", alias = "OnShape")]
404    OnShape,
405    #[serde(alias = "Trackpad Friendly")]
406    TrackpadFriendly,
407    #[serde(alias = "Solidworks")]
408    Solidworks,
409    #[serde(alias = "NX")]
410    Nx,
411    #[serde(alias = "Creo")]
412    Creo,
413    #[display("autocad")]
414    #[serde(rename = "autocad", alias = "AutoCAD")]
415    AutoCad,
416}
417
418/// The types of camera projection for the 3D view.
419#[derive(Debug, Default, Eq, PartialEq, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, Display, FromStr)]
420#[ts(export)]
421#[serde(rename_all = "snake_case")]
422#[display(style = "snake_case")]
423pub enum CameraProjectionType {
424    /// Perspective projection https://en.wikipedia.org/wiki/3D_projection#Perspective_projection
425    Perspective,
426    /// Orthographic projection https://en.wikipedia.org/wiki/3D_projection#Orthographic_projection
427    #[default]
428    Orthographic,
429}
430
431/// The types of camera orbit methods.
432#[derive(Debug, Default, Eq, PartialEq, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, Display, FromStr)]
433#[ts(export)]
434#[serde(rename_all = "snake_case")]
435#[display(style = "snake_case")]
436pub enum CameraOrbitType {
437    /// Orbit using a spherical camera movement.
438    #[default]
439    #[display("spherical")]
440    Spherical,
441    /// Orbit using a trackball camera movement.
442    #[display("trackball")]
443    Trackball,
444}
445
446/// Settings that affect the behavior of the KCL text editor.
447#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Eq, Validate)]
448#[serde(rename_all = "snake_case")]
449#[ts(export)]
450pub struct TextEditorSettings {
451    /// Whether to wrap text in the editor or overflow with scroll.
452    #[serde(default, alias = "textWrapping", skip_serializing_if = "is_default")]
453    pub text_wrapping: DefaultTrue,
454    /// Whether to make the cursor blink in the editor.
455    #[serde(default, alias = "blinkingCursor", skip_serializing_if = "is_default")]
456    pub blinking_cursor: DefaultTrue,
457}
458
459/// Settings that affect the behavior of project management.
460#[derive(Debug, Clone, Default, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Eq, Validate)]
461#[serde(rename_all = "snake_case")]
462#[ts(export)]
463pub struct ProjectSettings {
464    /// The directory to save and load projects from.
465    #[serde(default, skip_serializing_if = "is_default")]
466    pub directory: std::path::PathBuf,
467    /// The default project name to use when creating a new project.
468    #[serde(default, alias = "defaultProjectName", skip_serializing_if = "is_default")]
469    pub default_project_name: ProjectNameTemplate,
470}
471
472#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Eq)]
473#[ts(export)]
474#[serde(transparent)]
475pub struct ProjectNameTemplate(pub String);
476
477impl Default for ProjectNameTemplate {
478    fn default() -> Self {
479        Self(DEFAULT_PROJECT_NAME_TEMPLATE.to_string())
480    }
481}
482
483impl From<ProjectNameTemplate> for String {
484    fn from(project_name: ProjectNameTemplate) -> Self {
485        project_name.0
486    }
487}
488
489impl From<String> for ProjectNameTemplate {
490    fn from(s: String) -> Self {
491        Self(s)
492    }
493}
494
495/// Settings that affect the behavior of the command bar.
496#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Eq, Validate)]
497#[serde(rename_all = "snake_case")]
498#[ts(export)]
499pub struct CommandBarSettings {
500    /// Whether to include settings in the command bar.
501    #[serde(default, alias = "includeSettings", skip_serializing_if = "is_default")]
502    pub include_settings: DefaultTrue,
503}
504
505/// The types of onboarding status.
506#[derive(Debug, Default, Eq, PartialEq, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, Display, FromStr)]
507#[ts(export)]
508#[serde(rename_all = "snake_case")]
509#[display(style = "snake_case")]
510pub enum OnboardingStatus {
511    /// The unset state.
512    #[serde(rename = "")]
513    #[display("")]
514    Unset,
515    /// The user has completed onboarding.
516    Completed,
517    /// The user has not completed onboarding.
518    #[default]
519    Incomplete,
520    /// The user has dismissed onboarding.
521    Dismissed,
522
523    // Routes
524    #[serde(rename = "/")]
525    #[display("/")]
526    Index,
527    #[serde(rename = "/camera")]
528    #[display("/camera")]
529    Camera,
530    #[serde(rename = "/streaming")]
531    #[display("/streaming")]
532    Streaming,
533    #[serde(rename = "/editor")]
534    #[display("/editor")]
535    Editor,
536    #[serde(rename = "/parametric-modeling")]
537    #[display("/parametric-modeling")]
538    ParametricModeling,
539    #[serde(rename = "/interactive-numbers")]
540    #[display("/interactive-numbers")]
541    InteractiveNumbers,
542    #[serde(rename = "/command-k")]
543    #[display("/command-k")]
544    CommandK,
545    #[serde(rename = "/user-menu")]
546    #[display("/user-menu")]
547    UserMenu,
548    #[serde(rename = "/project-menu")]
549    #[display("/project-menu")]
550    ProjectMenu,
551    #[serde(rename = "/export")]
552    #[display("/export")]
553    Export,
554    #[serde(rename = "/move")]
555    #[display("/move")]
556    Move,
557    #[serde(rename = "/sketching")]
558    #[display("/sketching")]
559    Sketching,
560    #[serde(rename = "/future-work")]
561    #[display("/future-work")]
562    FutureWork,
563}
564
565fn is_default<T: Default + PartialEq>(t: &T) -> bool {
566    t == &T::default()
567}
568
569#[cfg(test)]
570mod tests {
571    use pretty_assertions::assert_eq;
572    use validator::Validate;
573
574    use super::{
575        AppColor, AppSettings, AppTheme, AppearanceSettings, CameraProjectionType, CommandBarSettings, Configuration,
576        ModelingSettings, OnboardingStatus, ProjectSettings, Settings, TextEditorSettings, UnitLength,
577    };
578    use crate::settings::types::CameraOrbitType;
579
580    #[test]
581    // Test that we can deserialize a project file from the old format.
582    // TODO: We can remove this functionality after a few versions.
583    fn test_backwards_compatible_project_settings_file_pw() {
584        let old_project_file = r#"[settings.app]
585theme = "dark"
586onboardingStatus = "dismissed"
587projectDirectory = ""
588enableSSAO = false
589
590[settings.modeling]
591defaultUnit = "in"
592cameraProjection = "orthographic"
593mouseControls = "KittyCAD"
594showDebugPanel = true
595
596[settings.projects]
597defaultProjectName = "project-$nnn"
598
599[settings.textEditor]
600textWrapping = true
601#"#;
602
603        //let parsed = toml::from_str::<Configuration(old_project_file).unwrap();
604        let parsed = Configuration::backwards_compatible_toml_parse(old_project_file).unwrap();
605        assert_eq!(
606            parsed,
607            Configuration {
608                settings: Settings {
609                    app: AppSettings {
610                        appearance: AppearanceSettings {
611                            theme: AppTheme::Dark,
612                            color: Default::default()
613                        },
614                        onboarding_status: OnboardingStatus::Dismissed,
615                        project_directory: None,
616                        theme: None,
617                        theme_color: None,
618                        dismiss_web_banner: false,
619                        enable_ssao: None,
620                        stream_idle_mode: false,
621                        allow_orbit_in_sketch_mode: false,
622                        show_debug_panel: true,
623                    },
624                    modeling: ModelingSettings {
625                        base_unit: UnitLength::In,
626                        camera_projection: CameraProjectionType::Orthographic,
627                        camera_orbit: Default::default(),
628                        mouse_controls: Default::default(),
629                        show_debug_panel: Default::default(),
630                        highlight_edges: Default::default(),
631                        enable_ssao: false.into(),
632                        show_scale_grid: false,
633                    },
634                    text_editor: TextEditorSettings {
635                        text_wrapping: true.into(),
636                        blinking_cursor: true.into()
637                    },
638                    project: Default::default(),
639                    command_bar: CommandBarSettings {
640                        include_settings: true.into()
641                    },
642                }
643            }
644        );
645    }
646
647    #[test]
648    // Test that we can deserialize a project file from the old format.
649    // TODO: We can remove this functionality after a few versions.
650    fn test_backwards_compatible_project_settings_file() {
651        let old_project_file = r#"[settings.app]
652theme = "dark"
653themeColor = "138"
654
655[settings.modeling]
656defaultUnit = "yd"
657showDebugPanel = true
658
659[settings.textEditor]
660textWrapping = false
661blinkingCursor = false
662
663[settings.commandBar]
664includeSettings = false
665#"#;
666
667        //let parsed = toml::from_str::<Configuration(old_project_file).unwrap();
668        let parsed = Configuration::backwards_compatible_toml_parse(old_project_file).unwrap();
669        assert_eq!(
670            parsed,
671            Configuration {
672                settings: Settings {
673                    app: AppSettings {
674                        appearance: AppearanceSettings {
675                            theme: AppTheme::Dark,
676                            color: 138.0.into()
677                        },
678                        onboarding_status: Default::default(),
679                        project_directory: None,
680                        theme: None,
681                        theme_color: None,
682                        dismiss_web_banner: false,
683                        enable_ssao: None,
684                        show_debug_panel: true,
685                        stream_idle_mode: false,
686                        allow_orbit_in_sketch_mode: false,
687                    },
688                    modeling: ModelingSettings {
689                        base_unit: UnitLength::Yd,
690                        camera_projection: Default::default(),
691                        camera_orbit: Default::default(),
692                        mouse_controls: Default::default(),
693                        highlight_edges: Default::default(),
694                        enable_ssao: true.into(),
695                        show_scale_grid: false,
696                        show_debug_panel: Default::default(),
697                    },
698                    text_editor: TextEditorSettings {
699                        text_wrapping: false.into(),
700                        blinking_cursor: false.into()
701                    },
702                    project: Default::default(),
703                    command_bar: CommandBarSettings {
704                        include_settings: false.into()
705                    },
706                }
707            }
708        );
709    }
710
711    #[test]
712    // Test that we can deserialize a app settings file from the old format.
713    // TODO: We can remove this functionality after a few versions.
714    fn test_backwards_compatible_app_settings_file() {
715        let old_app_settings_file = r#"[settings.app]
716onboardingStatus = "dismissed"
717projectDirectory = "/Users/macinatormax/Documents/kittycad-modeling-projects"
718theme = "dark"
719themeColor = "138"
720
721[settings.modeling]
722defaultUnit = "yd"
723showDebugPanel = true
724
725[settings.textEditor]
726textWrapping = false
727blinkingCursor = false
728
729[settings.commandBar]
730includeSettings = false
731
732[settings.projects]
733defaultProjectName = "projects-$nnn"
734#"#;
735
736        //let parsed = toml::from_str::<Configuration>(old_app_settings_file).unwrap();
737        let parsed = Configuration::backwards_compatible_toml_parse(old_app_settings_file).unwrap();
738        assert_eq!(
739            parsed,
740            Configuration {
741                settings: Settings {
742                    app: AppSettings {
743                        appearance: AppearanceSettings {
744                            theme: AppTheme::Dark,
745                            color: 138.0.into()
746                        },
747                        onboarding_status: OnboardingStatus::Dismissed,
748                        project_directory: None,
749                        theme: None,
750                        theme_color: None,
751                        dismiss_web_banner: false,
752                        enable_ssao: None,
753                        stream_idle_mode: false,
754                        allow_orbit_in_sketch_mode: false,
755                        show_debug_panel: true,
756                    },
757                    modeling: ModelingSettings {
758                        base_unit: UnitLength::Yd,
759                        camera_projection: Default::default(),
760                        camera_orbit: CameraOrbitType::Spherical,
761                        mouse_controls: Default::default(),
762                        highlight_edges: Default::default(),
763                        show_debug_panel: Default::default(),
764                        enable_ssao: true.into(),
765                        show_scale_grid: false,
766                    },
767                    text_editor: TextEditorSettings {
768                        text_wrapping: false.into(),
769                        blinking_cursor: false.into()
770                    },
771                    project: ProjectSettings {
772                        directory: "/Users/macinatormax/Documents/kittycad-modeling-projects".into(),
773                        default_project_name: "projects-$nnn".to_string().into()
774                    },
775                    command_bar: CommandBarSettings {
776                        include_settings: false.into()
777                    },
778                }
779            }
780        );
781
782        // Write the file back out.
783        let serialized = toml::to_string(&parsed).unwrap();
784        assert_eq!(
785            serialized,
786            r#"[settings.app]
787onboarding_status = "dismissed"
788show_debug_panel = true
789
790[settings.app.appearance]
791theme = "dark"
792color = 138.0
793
794[settings.modeling]
795base_unit = "yd"
796
797[settings.text_editor]
798text_wrapping = false
799blinking_cursor = false
800
801[settings.project]
802directory = "/Users/macinatormax/Documents/kittycad-modeling-projects"
803default_project_name = "projects-$nnn"
804
805[settings.command_bar]
806include_settings = false
807"#
808        );
809    }
810
811    #[test]
812    fn test_settings_backwards_compat_partial() {
813        let partial_settings_file = r#"[settings.app]
814onboardingStatus = "dismissed"
815projectDirectory = "/Users/macinatormax/Documents/kittycad-modeling-projects""#;
816
817        //let parsed = toml::from_str::<Configuration>(partial_settings_file).unwrap();
818        let parsed = Configuration::backwards_compatible_toml_parse(partial_settings_file).unwrap();
819        assert_eq!(
820            parsed,
821            Configuration {
822                settings: Settings {
823                    app: AppSettings {
824                        appearance: AppearanceSettings {
825                            theme: AppTheme::System,
826                            color: Default::default()
827                        },
828                        onboarding_status: OnboardingStatus::Dismissed,
829                        project_directory: None,
830                        theme: None,
831                        theme_color: None,
832                        dismiss_web_banner: false,
833                        enable_ssao: None,
834                        show_debug_panel: false,
835                        stream_idle_mode: false,
836                        allow_orbit_in_sketch_mode: false,
837                    },
838                    modeling: ModelingSettings {
839                        base_unit: UnitLength::Mm,
840                        camera_projection: Default::default(),
841                        camera_orbit: Default::default(),
842                        mouse_controls: Default::default(),
843                        highlight_edges: true.into(),
844                        show_debug_panel: Default::default(),
845                        enable_ssao: true.into(),
846                        show_scale_grid: false,
847                    },
848                    text_editor: TextEditorSettings {
849                        text_wrapping: true.into(),
850                        blinking_cursor: true.into()
851                    },
852                    project: ProjectSettings {
853                        directory: "/Users/macinatormax/Documents/kittycad-modeling-projects".into(),
854                        default_project_name: "project-$nnn".to_string().into()
855                    },
856                    command_bar: CommandBarSettings {
857                        include_settings: true.into()
858                    },
859                }
860            }
861        );
862
863        // Write the file back out.
864        let serialized = toml::to_string(&parsed).unwrap();
865        assert_eq!(
866            serialized,
867            r#"[settings.app]
868onboarding_status = "dismissed"
869
870[settings.project]
871directory = "/Users/macinatormax/Documents/kittycad-modeling-projects"
872"#
873        );
874    }
875
876    #[test]
877    fn test_settings_empty_file_parses() {
878        let empty_settings_file = r#""#;
879
880        let parsed = toml::from_str::<Configuration>(empty_settings_file).unwrap();
881        assert_eq!(parsed, Configuration::default());
882
883        // Write the file back out.
884        let serialized = toml::to_string(&parsed).unwrap();
885        assert_eq!(serialized, r#""#);
886
887        let parsed = Configuration::backwards_compatible_toml_parse(empty_settings_file).unwrap();
888        assert_eq!(parsed, Configuration::default());
889    }
890
891    #[test]
892    fn test_color_validation() {
893        let color = AppColor(360.0);
894
895        let result = color.validate();
896        if let Ok(r) = result {
897            panic!("Expected an error, but got success: {:?}", r);
898        }
899        assert!(result.is_err());
900        assert!(result
901            .unwrap_err()
902            .to_string()
903            .contains("color: Validation error: color"));
904
905        let appearance = AppearanceSettings {
906            theme: AppTheme::System,
907            color: AppColor(361.5),
908        };
909        let result = appearance.validate();
910        if let Ok(r) = result {
911            panic!("Expected an error, but got success: {:?}", r);
912        }
913        assert!(result.is_err());
914        assert!(result
915            .unwrap_err()
916            .to_string()
917            .contains("color: Validation error: color"));
918    }
919
920    #[test]
921    fn test_settings_color_validation_error() {
922        let settings_file = r#"[settings.app.appearance]
923color = 1567.4"#;
924
925        let result = Configuration::backwards_compatible_toml_parse(settings_file);
926        if let Ok(r) = result {
927            panic!("Expected an error, but got success: {:?}", r);
928        }
929        assert!(result.is_err());
930
931        assert!(result
932            .unwrap_err()
933            .to_string()
934            .contains("color: Validation error: color"));
935    }
936}