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