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