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