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