Skip to main content

kcl_lib/settings/types/
mod.rs

1//! Types for kcl project and modeling-app settings.
2
3pub mod project;
4
5use anyhow::Result;
6use kittycad_modeling_cmds::units::UnitLength;
7use parse_display::{Display, FromStr};
8use schemars::JsonSchema;
9use serde::{Deserialize, Deserializer, Serialize};
10use validator::Validate;
11
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    /// When the user is idle, teardown the stream after some time.
79    #[serde(
80        default,
81        deserialize_with = "deserialize_stream_idle_mode",
82        alias = "streamIdleMode",
83        skip_serializing_if = "is_default"
84    )]
85    stream_idle_mode: Option<u32>,
86    /// Allow orbiting in sketch mode.
87    #[serde(default, skip_serializing_if = "is_default")]
88    pub allow_orbit_in_sketch_mode: bool,
89    /// Whether to show the debug panel, which lets you see various states
90    /// of the app to aid in development.
91    #[serde(default, skip_serializing_if = "is_default")]
92    pub show_debug_panel: bool,
93}
94
95/// Default to true.
96fn make_it_so() -> bool {
97    true
98}
99
100fn is_true(b: &bool) -> bool {
101    *b
102}
103
104fn deserialize_stream_idle_mode<'de, D>(deserializer: D) -> Result<Option<u32>, D::Error>
105where
106    D: Deserializer<'de>,
107{
108    #[derive(Deserialize)]
109    #[serde(untagged)]
110    enum StreamIdleModeValue {
111        Number(u32),
112        String(String),
113        Boolean(bool),
114    }
115
116    const DEFAULT_TIMEOUT: u32 = 1000 * 60 * 5;
117
118    Ok(match StreamIdleModeValue::deserialize(deserializer) {
119        Ok(StreamIdleModeValue::Number(value)) => Some(value),
120        Ok(StreamIdleModeValue::String(value)) => Some(value.parse::<u32>().unwrap_or(DEFAULT_TIMEOUT)),
121        // The old type of this value. I'm willing to say no one used it but
122        // we can never guarantee it.
123        Ok(StreamIdleModeValue::Boolean(true)) => Some(DEFAULT_TIMEOUT),
124        Ok(StreamIdleModeValue::Boolean(false)) => None,
125        _ => None,
126    })
127}
128
129#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq)]
130#[ts(export)]
131#[serde(untagged)]
132pub enum FloatOrInt {
133    String(String),
134    Float(f64),
135    Int(i64),
136}
137
138impl From<FloatOrInt> for f64 {
139    fn from(float_or_int: FloatOrInt) -> Self {
140        match float_or_int {
141            FloatOrInt::String(s) => s.parse().unwrap(),
142            FloatOrInt::Float(f) => f,
143            FloatOrInt::Int(i) => i as f64,
144        }
145    }
146}
147
148/// The settings for the theme of the app.
149#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Validate)]
150#[ts(export)]
151#[serde(rename_all = "snake_case")]
152pub struct AppearanceSettings {
153    /// The overall theme of the app.
154    #[serde(default, skip_serializing_if = "is_default")]
155    pub theme: AppTheme,
156}
157
158/// The overall appearance of the app.
159#[derive(
160    Debug, Default, Copy, Clone, Deserialize, Serialize, JsonSchema, Display, FromStr, ts_rs::TS, PartialEq, Eq,
161)]
162#[ts(export)]
163#[serde(rename_all = "snake_case")]
164#[display(style = "snake_case")]
165pub enum AppTheme {
166    /// A light theme.
167    Light,
168    /// A dark theme.
169    Dark,
170    /// Use the system theme.
171    /// This will use dark theme if the system theme is dark, and light theme if the system theme is light.
172    #[default]
173    System,
174}
175
176impl From<AppTheme> for kittycad::types::Color {
177    fn from(theme: AppTheme) -> Self {
178        match theme {
179            AppTheme::Light => kittycad::types::Color {
180                r: 249.0 / 255.0,
181                g: 249.0 / 255.0,
182                b: 249.0 / 255.0,
183                a: 1.0,
184            },
185            AppTheme::Dark => kittycad::types::Color {
186                r: 28.0 / 255.0,
187                g: 28.0 / 255.0,
188                b: 28.0 / 255.0,
189                a: 1.0,
190            },
191            AppTheme::System => {
192                // TODO: Check the system setting for the user.
193                todo!()
194            }
195        }
196    }
197}
198
199/// Settings that affect the behavior while modeling.
200#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Validate)]
201#[serde(rename_all = "snake_case")]
202#[ts(export)]
203pub struct ModelingSettings {
204    /// The default unit to use in modeling dimensions.
205    #[serde(default = "default_length_unit_millimeters", skip_serializing_if = "is_default")]
206    pub base_unit: UnitLength,
207    /// The projection mode the camera should use while modeling.
208    #[serde(default, skip_serializing_if = "is_default")]
209    pub camera_projection: CameraProjectionType,
210    /// The methodology the camera should use to orbit around the model.
211    #[serde(default, skip_serializing_if = "is_default")]
212    pub camera_orbit: CameraOrbitType,
213    /// The controls for how to navigate the 3D view.
214    #[serde(default, skip_serializing_if = "is_default")]
215    pub mouse_controls: MouseControlType,
216    /// Which type of orientation gizmo to use.
217    #[serde(default, skip_serializing_if = "is_default")]
218    pub gizmo_type: GizmoType,
219    /// Toggle touch controls for 3D view navigation
220    #[serde(default, skip_serializing_if = "is_default")]
221    pub enable_touch_controls: DefaultTrue,
222    /// Default to the experimental solver-based sketch mode for all new sketches.
223    #[serde(default, skip_serializing_if = "is_default")]
224    pub use_sketch_solve_mode: bool,
225    /// Highlight edges of 3D objects?
226    #[serde(default, skip_serializing_if = "is_default")]
227    pub highlight_edges: DefaultTrue,
228    /// Whether or not Screen Space Ambient Occlusion (SSAO) is enabled.
229    #[serde(default, skip_serializing_if = "is_default")]
230    pub enable_ssao: DefaultTrue,
231    /// The default color to use for surface backfaces.
232    #[serde(
233        default = "default_backface_color",
234        skip_serializing_if = "is_default_backface_color"
235    )]
236    pub backface_color: String,
237    /// Whether or not to show a scale grid in the 3D modeling view
238    #[serde(default, skip_serializing_if = "is_default")]
239    pub show_scale_grid: bool,
240    /// When enabled, the grid will use a fixed size based on your selected units rather than automatically scaling with zoom level.
241    /// If true, the grid cells will be fixed-size, where the width is your default length unit.
242    /// If false, the grid will get larger as you zoom out, and smaller as you zoom in.
243    #[serde(default = "make_it_so", skip_serializing_if = "is_true")]
244    pub fixed_size_grid: bool,
245    /// When enabled, tools like line, rectangle, etc. will snap to the grid.
246    #[serde(default, skip_serializing_if = "is_default")]
247    pub snap_to_grid: bool,
248    /// The space between major grid lines, specified in the current unit.
249    #[serde(default, skip_serializing_if = "is_default")]
250    pub major_grid_spacing: f64,
251    /// The number of minor grid lines per major grid line.
252    #[serde(default, skip_serializing_if = "is_default")]
253    pub minor_grids_per_major: f64,
254    /// The number of snaps between minor grid lines. 1 means snapping to each minor grid line.
255    #[serde(default, skip_serializing_if = "is_default")]
256    pub snaps_per_minor: f64,
257}
258
259fn default_length_unit_millimeters() -> UnitLength {
260    UnitLength::Millimeters
261}
262
263// Also defined at src/lib/constants.ts#L333-L335
264fn default_backface_color() -> String {
265    "#F20D0D".to_string()
266}
267
268fn is_default_backface_color(color: &String) -> bool {
269    *color == default_backface_color()
270}
271
272impl Default for ModelingSettings {
273    fn default() -> Self {
274        Self {
275            base_unit: UnitLength::Millimeters,
276            camera_projection: Default::default(),
277            camera_orbit: Default::default(),
278            mouse_controls: Default::default(),
279            gizmo_type: Default::default(),
280            enable_touch_controls: Default::default(),
281            use_sketch_solve_mode: Default::default(),
282            highlight_edges: Default::default(),
283            enable_ssao: Default::default(),
284            backface_color: default_backface_color(),
285            show_scale_grid: Default::default(),
286            fixed_size_grid: true,
287            snap_to_grid: Default::default(),
288            major_grid_spacing: Default::default(),
289            minor_grids_per_major: Default::default(),
290            snaps_per_minor: Default::default(),
291        }
292    }
293}
294
295#[derive(Debug, Copy, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Eq)]
296#[ts(export)]
297#[serde(transparent)]
298pub struct DefaultTrue(pub bool);
299
300impl Default for DefaultTrue {
301    fn default() -> Self {
302        Self(true)
303    }
304}
305
306impl From<DefaultTrue> for bool {
307    fn from(default_true: DefaultTrue) -> Self {
308        default_true.0
309    }
310}
311
312impl From<bool> for DefaultTrue {
313    fn from(b: bool) -> Self {
314        Self(b)
315    }
316}
317
318/// The types of controls for how to navigate the 3D view.
319#[derive(Debug, Default, Eq, PartialEq, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, Display, FromStr)]
320#[ts(export)]
321#[serde(rename_all = "snake_case")]
322#[display(style = "snake_case")]
323pub enum MouseControlType {
324    #[default]
325    #[display("zoo")]
326    #[serde(rename = "zoo")]
327    Zoo,
328    #[display("onshape")]
329    #[serde(rename = "onshape")]
330    OnShape,
331    TrackpadFriendly,
332    Solidworks,
333    Nx,
334    Creo,
335    #[display("autocad")]
336    #[serde(rename = "autocad")]
337    AutoCad,
338}
339
340/// The types of camera projection for the 3D view.
341#[derive(Debug, Default, Eq, PartialEq, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, Display, FromStr)]
342#[ts(export)]
343#[serde(rename_all = "snake_case")]
344#[display(style = "snake_case")]
345pub enum CameraProjectionType {
346    /// Perspective projection https://en.wikipedia.org/wiki/3D_projection#Perspective_projection
347    Perspective,
348    /// Orthographic projection https://en.wikipedia.org/wiki/3D_projection#Orthographic_projection
349    #[default]
350    Orthographic,
351}
352
353/// The types of camera orbit methods.
354#[derive(Debug, Default, Eq, PartialEq, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, Display, FromStr)]
355#[ts(export)]
356#[serde(rename_all = "snake_case")]
357#[display(style = "snake_case")]
358pub enum CameraOrbitType {
359    /// Orbit using a spherical camera movement.
360    #[default]
361    #[display("spherical")]
362    Spherical,
363    /// Orbit using a trackball camera movement.
364    #[display("trackball")]
365    Trackball,
366}
367
368/// Which type of orientation gizmo to use.
369#[derive(Debug, Default, Eq, PartialEq, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, Display, FromStr)]
370#[ts(export)]
371#[serde(rename_all = "snake_case")]
372#[display(style = "snake_case")]
373pub enum GizmoType {
374    /// 3D cube gizmo
375    #[default]
376    Cube,
377    /// 3-axis gizmo
378    Axis,
379}
380
381/// Settings that affect the behavior of the KCL text editor.
382#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Eq, Validate)]
383#[serde(rename_all = "snake_case")]
384#[ts(export)]
385pub struct TextEditorSettings {
386    /// Whether to wrap text in the editor or overflow with scroll.
387    #[serde(default, skip_serializing_if = "is_default")]
388    pub text_wrapping: DefaultTrue,
389    /// Whether to make the cursor blink in the editor.
390    #[serde(default, skip_serializing_if = "is_default")]
391    pub blinking_cursor: DefaultTrue,
392}
393
394/// Same as TextEditorSettings but applies to a per-project basis.
395#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Eq, Validate)]
396#[serde(rename_all = "snake_case")]
397#[ts(export)]
398pub struct ProjectTextEditorSettings {
399    /// Whether to wrap text in the editor or overflow with scroll.
400    #[serde(default, skip_serializing_if = "Option::is_none")]
401    pub text_wrapping: Option<bool>,
402    /// Whether to make the cursor blink in the editor.
403    #[serde(default, skip_serializing_if = "Option::is_none")]
404    pub blinking_cursor: Option<bool>,
405}
406
407/// Settings that affect the behavior of project management.
408#[derive(Debug, Clone, Default, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Eq, Validate)]
409#[serde(rename_all = "snake_case")]
410#[ts(export)]
411pub struct ProjectSettings {
412    /// The directory to save and load projects from.
413    #[serde(default, skip_serializing_if = "is_default")]
414    pub directory: std::path::PathBuf,
415    /// The default project name to use when creating a new project.
416    #[serde(default, skip_serializing_if = "is_default")]
417    pub default_project_name: ProjectNameTemplate,
418}
419
420#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Eq)]
421#[ts(export)]
422#[serde(transparent)]
423pub struct ProjectNameTemplate(pub String);
424
425impl Default for ProjectNameTemplate {
426    fn default() -> Self {
427        Self(DEFAULT_PROJECT_NAME_TEMPLATE.to_string())
428    }
429}
430
431impl From<ProjectNameTemplate> for String {
432    fn from(project_name: ProjectNameTemplate) -> Self {
433        project_name.0
434    }
435}
436
437impl From<String> for ProjectNameTemplate {
438    fn from(s: String) -> Self {
439        Self(s)
440    }
441}
442
443/// Settings that affect the behavior of the command bar.
444#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Eq, Validate)]
445#[serde(rename_all = "snake_case")]
446#[ts(export)]
447pub struct CommandBarSettings {
448    /// Whether to include settings in the command bar.
449    #[serde(default, skip_serializing_if = "is_default")]
450    pub include_settings: DefaultTrue,
451}
452
453/// Same as CommandBarSettings but applies to a per-project basis.
454#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Eq, Validate)]
455#[serde(rename_all = "snake_case")]
456#[ts(export)]
457pub struct ProjectCommandBarSettings {
458    /// Whether to include settings in the command bar.
459    #[serde(default, skip_serializing_if = "Option::is_none")]
460    pub include_settings: Option<bool>,
461}
462
463/// The types of onboarding status.
464#[derive(Debug, Default, Eq, PartialEq, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, Display, FromStr)]
465#[ts(export)]
466#[serde(rename_all = "snake_case")]
467#[display(style = "snake_case")]
468pub enum OnboardingStatus {
469    /// The unset state.
470    #[serde(rename = "")]
471    #[display("")]
472    Unset,
473    /// The user has completed onboarding.
474    Completed,
475    /// The user has not completed onboarding.
476    #[default]
477    Incomplete,
478    /// The user has dismissed onboarding.
479    Dismissed,
480
481    // Desktop Routes
482    #[serde(rename = "/desktop")]
483    #[display("/desktop")]
484    DesktopWelcome,
485    #[serde(rename = "/desktop/scene")]
486    #[display("/desktop/scene")]
487    DesktopScene,
488    #[serde(rename = "/desktop/toolbar")]
489    #[display("/desktop/toolbar")]
490    DesktopToolbar,
491    #[serde(rename = "/desktop/text-to-cad")]
492    #[display("/desktop/text-to-cad")]
493    DesktopTextToCadWelcome,
494    #[serde(rename = "/desktop/text-to-cad-prompt")]
495    #[display("/desktop/text-to-cad-prompt")]
496    DesktopTextToCadPrompt,
497    #[serde(rename = "/desktop/feature-tree-pane")]
498    #[display("/desktop/feature-tree-pane")]
499    DesktopFeatureTreePane,
500    #[serde(rename = "/desktop/code-pane")]
501    #[display("/desktop/code-pane")]
502    DesktopCodePane,
503    #[serde(rename = "/desktop/project-pane")]
504    #[display("/desktop/project-pane")]
505    DesktopProjectFilesPane,
506    #[serde(rename = "/desktop/other-panes")]
507    #[display("/desktop/other-panes")]
508    DesktopOtherPanes,
509    #[serde(rename = "/desktop/prompt-to-edit")]
510    #[display("/desktop/prompt-to-edit")]
511    DesktopPromptToEditWelcome,
512    #[serde(rename = "/desktop/prompt-to-edit-prompt")]
513    #[display("/desktop/prompt-to-edit-prompt")]
514    DesktopPromptToEditPrompt,
515    #[serde(rename = "/desktop/prompt-to-edit-result")]
516    #[display("/desktop/prompt-to-edit-result")]
517    DesktopPromptToEditResult,
518    #[serde(rename = "/desktop/imports")]
519    #[display("/desktop/imports")]
520    DesktopImports,
521    #[serde(rename = "/desktop/exports")]
522    #[display("/desktop/exports")]
523    DesktopExports,
524    #[serde(rename = "/desktop/conclusion")]
525    #[display("/desktop/conclusion")]
526    DesktopConclusion,
527
528    // Browser Routes
529    #[serde(rename = "/browser")]
530    #[display("/browser")]
531    BrowserWelcome,
532    #[serde(rename = "/browser/scene")]
533    #[display("/browser/scene")]
534    BrowserScene,
535    #[serde(rename = "/browser/toolbar")]
536    #[display("/browser/toolbar")]
537    BrowserToolbar,
538    #[serde(rename = "/browser/text-to-cad")]
539    #[display("/browser/text-to-cad")]
540    BrowserTextToCadWelcome,
541    #[serde(rename = "/browser/text-to-cad-prompt")]
542    #[display("/browser/text-to-cad-prompt")]
543    BrowserTextToCadPrompt,
544    #[serde(rename = "/browser/feature-tree-pane")]
545    #[display("/browser/feature-tree-pane")]
546    BrowserFeatureTreePane,
547    #[serde(rename = "/browser/prompt-to-edit")]
548    #[display("/browser/prompt-to-edit")]
549    BrowserPromptToEditWelcome,
550    #[serde(rename = "/browser/prompt-to-edit-prompt")]
551    #[display("/browser/prompt-to-edit-prompt")]
552    BrowserPromptToEditPrompt,
553    #[serde(rename = "/browser/prompt-to-edit-result")]
554    #[display("/browser/prompt-to-edit-result")]
555    BrowserPromptToEditResult,
556    #[serde(rename = "/browser/conclusion")]
557    #[display("/browser/conclusion")]
558    BrowserConclusion,
559}
560
561fn is_default<T: Default + PartialEq>(t: &T) -> bool {
562    t == &T::default()
563}
564
565#[cfg(test)]
566mod tests {
567    use pretty_assertions::assert_eq;
568
569    use super::{
570        AppSettings, AppTheme, AppearanceSettings, CameraProjectionType, CommandBarSettings, Configuration,
571        ModelingSettings, MouseControlType, OnboardingStatus, ProjectNameTemplate, ProjectSettings, Settings,
572        TextEditorSettings, UnitLength, default_backface_color,
573    };
574
575    #[test]
576    fn test_settings_empty_file_parses() {
577        let empty_settings_file = r#""#;
578
579        let parsed = toml::from_str::<Configuration>(empty_settings_file).unwrap();
580        assert_eq!(parsed, Configuration::default());
581        assert_eq!(parsed.settings.modeling.backface_color, default_backface_color());
582
583        // Write the file back out.
584        let serialized = toml::to_string(&parsed).unwrap();
585        assert_eq!(serialized, r#""#);
586
587        let parsed = Configuration::parse_and_validate(empty_settings_file).unwrap();
588        assert_eq!(parsed, Configuration::default());
589        assert_eq!(parsed.settings.modeling.backface_color, default_backface_color());
590    }
591
592    #[test]
593    fn test_settings_parse_basic() {
594        let settings_file = r#"[settings.app]
595default_project_name = "untitled"
596directory = ""
597onboarding_status = "dismissed"
598
599  [settings.app.appearance]
600  theme = "dark"
601
602[settings.modeling]
603enable_ssao = false
604base_unit = "in"
605mouse_controls = "zoo"
606camera_projection = "perspective"
607
608[settings.project]
609default_project_name = "untitled"
610directory = ""
611
612[settings.text_editor]
613text_wrapping = true"#;
614
615        let expected = Configuration {
616            settings: Settings {
617                app: AppSettings {
618                    onboarding_status: OnboardingStatus::Dismissed,
619                    appearance: AppearanceSettings { theme: AppTheme::Dark },
620                    ..Default::default()
621                },
622                modeling: ModelingSettings {
623                    enable_ssao: false.into(),
624                    base_unit: UnitLength::Inches,
625                    mouse_controls: MouseControlType::Zoo,
626                    camera_projection: CameraProjectionType::Perspective,
627                    fixed_size_grid: true,
628                    ..Default::default()
629                },
630                project: ProjectSettings {
631                    default_project_name: ProjectNameTemplate("untitled".to_string()),
632                    directory: "".into(),
633                },
634                text_editor: TextEditorSettings {
635                    text_wrapping: true.into(),
636                    ..Default::default()
637                },
638                command_bar: CommandBarSettings {
639                    include_settings: true.into(),
640                },
641            },
642        };
643        let parsed = toml::from_str::<Configuration>(settings_file).unwrap();
644        assert_eq!(parsed, expected);
645
646        // Write the file back out.
647        let serialized = toml::to_string(&parsed).unwrap();
648        assert_eq!(
649            serialized,
650            r#"[settings.app]
651onboarding_status = "dismissed"
652
653[settings.app.appearance]
654theme = "dark"
655
656[settings.modeling]
657base_unit = "in"
658camera_projection = "perspective"
659enable_ssao = false
660"#
661        );
662
663        let parsed = Configuration::parse_and_validate(settings_file).unwrap();
664        assert_eq!(parsed, expected);
665    }
666
667    #[test]
668    fn test_settings_backface_color_roundtrip() {
669        let settings_file = r##"[settings.modeling]
670backface_color = "#112233"
671"##;
672
673        let parsed = toml::from_str::<Configuration>(settings_file).unwrap();
674        assert_eq!(parsed.settings.modeling.backface_color, "#112233");
675
676        let serialized = toml::to_string(&parsed).unwrap();
677        let reparsed = toml::from_str::<Configuration>(&serialized).unwrap();
678        assert_eq!(reparsed, parsed);
679        assert!(serialized.contains("backface_color = \"#112233\""));
680    }
681}