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