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