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