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    /// Toggle touch controls for 3D view navigation
217    #[serde(default, skip_serializing_if = "is_default")]
218    pub enable_touch_controls: DefaultTrue,
219    /// Toggle new sketch mode implementation
220    #[serde(default, skip_serializing_if = "is_default")]
221    pub use_new_sketch_mode: bool,
222    /// Highlight edges of 3D objects?
223    #[serde(default, skip_serializing_if = "is_default")]
224    pub highlight_edges: DefaultTrue,
225    /// Whether or not Screen Space Ambient Occlusion (SSAO) is enabled.
226    #[serde(default, skip_serializing_if = "is_default")]
227    pub enable_ssao: DefaultTrue,
228    /// Whether or not to show a scale grid in the 3D modeling view
229    #[serde(default, skip_serializing_if = "is_default")]
230    pub show_scale_grid: bool,
231    /// When enabled, the grid will use a fixed size based on your selected units rather than automatically scaling with zoom level.
232    /// If true, the grid cells will be fixed-size, where the width is your default length unit.
233    /// If false, the grid will get larger as you zoom out, and smaller as you zoom in.
234    #[serde(default = "make_it_so", skip_serializing_if = "is_true")]
235    pub fixed_size_grid: bool,
236    /// When enabled, tools like line, rectangle, etc. will snap to the grid.
237    #[serde(default, skip_serializing_if = "is_default")]
238    pub snap_to_grid: bool,
239    /// The space between major grid lines, specified in the current unit.
240    #[serde(default, skip_serializing_if = "is_default")]
241    pub major_grid_spacing: f64,
242    /// The number of minor grid lines per major grid line.
243    #[serde(default, skip_serializing_if = "is_default")]
244    pub minor_grids_per_major: f64,
245    /// The number of snaps between minor grid lines. 1 means snapping to each minor grid line.
246    #[serde(default, skip_serializing_if = "is_default")]
247    pub snaps_per_minor: f64,
248}
249
250fn default_length_unit_millimeters() -> UnitLength {
251    UnitLength::Millimeters
252}
253
254impl Default for ModelingSettings {
255    fn default() -> Self {
256        Self {
257            base_unit: UnitLength::Millimeters,
258            camera_projection: Default::default(),
259            camera_orbit: Default::default(),
260            mouse_controls: Default::default(),
261            enable_touch_controls: Default::default(),
262            use_new_sketch_mode: Default::default(),
263            highlight_edges: Default::default(),
264            enable_ssao: Default::default(),
265            show_scale_grid: Default::default(),
266            fixed_size_grid: true,
267            snap_to_grid: Default::default(),
268            major_grid_spacing: Default::default(),
269            minor_grids_per_major: Default::default(),
270            snaps_per_minor: Default::default(),
271        }
272    }
273}
274
275#[derive(Debug, Copy, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Eq)]
276#[ts(export)]
277#[serde(transparent)]
278pub struct DefaultTrue(pub bool);
279
280impl Default for DefaultTrue {
281    fn default() -> Self {
282        Self(true)
283    }
284}
285
286impl From<DefaultTrue> for bool {
287    fn from(default_true: DefaultTrue) -> Self {
288        default_true.0
289    }
290}
291
292impl From<bool> for DefaultTrue {
293    fn from(b: bool) -> Self {
294        Self(b)
295    }
296}
297
298/// The types of controls for how to navigate the 3D view.
299#[derive(Debug, Default, Eq, PartialEq, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, Display, FromStr)]
300#[ts(export)]
301#[serde(rename_all = "snake_case")]
302#[display(style = "snake_case")]
303pub enum MouseControlType {
304    #[default]
305    #[display("zoo")]
306    #[serde(rename = "zoo")]
307    Zoo,
308    #[display("onshape")]
309    #[serde(rename = "onshape")]
310    OnShape,
311    TrackpadFriendly,
312    Solidworks,
313    Nx,
314    Creo,
315    #[display("autocad")]
316    #[serde(rename = "autocad")]
317    AutoCad,
318}
319
320/// The types of camera projection for the 3D view.
321#[derive(Debug, Default, Eq, PartialEq, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, Display, FromStr)]
322#[ts(export)]
323#[serde(rename_all = "snake_case")]
324#[display(style = "snake_case")]
325pub enum CameraProjectionType {
326    /// Perspective projection https://en.wikipedia.org/wiki/3D_projection#Perspective_projection
327    Perspective,
328    /// Orthographic projection https://en.wikipedia.org/wiki/3D_projection#Orthographic_projection
329    #[default]
330    Orthographic,
331}
332
333/// The types of camera orbit methods.
334#[derive(Debug, Default, Eq, PartialEq, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, Display, FromStr)]
335#[ts(export)]
336#[serde(rename_all = "snake_case")]
337#[display(style = "snake_case")]
338pub enum CameraOrbitType {
339    /// Orbit using a spherical camera movement.
340    #[default]
341    #[display("spherical")]
342    Spherical,
343    /// Orbit using a trackball camera movement.
344    #[display("trackball")]
345    Trackball,
346}
347
348/// Settings that affect the behavior of the KCL text editor.
349#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Eq, Validate)]
350#[serde(rename_all = "snake_case")]
351#[ts(export)]
352pub struct TextEditorSettings {
353    /// Whether to wrap text in the editor or overflow with scroll.
354    #[serde(default, skip_serializing_if = "is_default")]
355    pub text_wrapping: DefaultTrue,
356    /// Whether to make the cursor blink in the editor.
357    #[serde(default, skip_serializing_if = "is_default")]
358    pub blinking_cursor: DefaultTrue,
359}
360
361/// Same as TextEditorSettings but applies to a per-project basis.
362#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Eq, Validate)]
363#[serde(rename_all = "snake_case")]
364#[ts(export)]
365pub struct ProjectTextEditorSettings {
366    /// Whether to wrap text in the editor or overflow with scroll.
367    #[serde(default, skip_serializing_if = "Option::is_none")]
368    pub text_wrapping: Option<bool>,
369    /// Whether to make the cursor blink in the editor.
370    #[serde(default, skip_serializing_if = "Option::is_none")]
371    pub blinking_cursor: Option<bool>,
372}
373
374/// Settings that affect the behavior of project management.
375#[derive(Debug, Clone, Default, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Eq, Validate)]
376#[serde(rename_all = "snake_case")]
377#[ts(export)]
378pub struct ProjectSettings {
379    /// The directory to save and load projects from.
380    #[serde(default, skip_serializing_if = "is_default")]
381    pub directory: std::path::PathBuf,
382    /// The default project name to use when creating a new project.
383    #[serde(default, skip_serializing_if = "is_default")]
384    pub default_project_name: ProjectNameTemplate,
385}
386
387#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Eq)]
388#[ts(export)]
389#[serde(transparent)]
390pub struct ProjectNameTemplate(pub String);
391
392impl Default for ProjectNameTemplate {
393    fn default() -> Self {
394        Self(DEFAULT_PROJECT_NAME_TEMPLATE.to_string())
395    }
396}
397
398impl From<ProjectNameTemplate> for String {
399    fn from(project_name: ProjectNameTemplate) -> Self {
400        project_name.0
401    }
402}
403
404impl From<String> for ProjectNameTemplate {
405    fn from(s: String) -> Self {
406        Self(s)
407    }
408}
409
410/// Settings that affect the behavior of the command bar.
411#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Eq, Validate)]
412#[serde(rename_all = "snake_case")]
413#[ts(export)]
414pub struct CommandBarSettings {
415    /// Whether to include settings in the command bar.
416    #[serde(default, skip_serializing_if = "is_default")]
417    pub include_settings: DefaultTrue,
418}
419
420/// Same as CommandBarSettings but applies to a per-project basis.
421#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Eq, Validate)]
422#[serde(rename_all = "snake_case")]
423#[ts(export)]
424pub struct ProjectCommandBarSettings {
425    /// Whether to include settings in the command bar.
426    #[serde(default, skip_serializing_if = "Option::is_none")]
427    pub include_settings: Option<bool>,
428}
429
430/// The types of onboarding status.
431#[derive(Debug, Default, Eq, PartialEq, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, Display, FromStr)]
432#[ts(export)]
433#[serde(rename_all = "snake_case")]
434#[display(style = "snake_case")]
435pub enum OnboardingStatus {
436    /// The unset state.
437    #[serde(rename = "")]
438    #[display("")]
439    Unset,
440    /// The user has completed onboarding.
441    Completed,
442    /// The user has not completed onboarding.
443    #[default]
444    Incomplete,
445    /// The user has dismissed onboarding.
446    Dismissed,
447
448    // Desktop Routes
449    #[serde(rename = "/desktop")]
450    #[display("/desktop")]
451    DesktopWelcome,
452    #[serde(rename = "/desktop/scene")]
453    #[display("/desktop/scene")]
454    DesktopScene,
455    #[serde(rename = "/desktop/toolbar")]
456    #[display("/desktop/toolbar")]
457    DesktopToolbar,
458    #[serde(rename = "/desktop/text-to-cad")]
459    #[display("/desktop/text-to-cad")]
460    DesktopTextToCadWelcome,
461    #[serde(rename = "/desktop/text-to-cad-prompt")]
462    #[display("/desktop/text-to-cad-prompt")]
463    DesktopTextToCadPrompt,
464    #[serde(rename = "/desktop/feature-tree-pane")]
465    #[display("/desktop/feature-tree-pane")]
466    DesktopFeatureTreePane,
467    #[serde(rename = "/desktop/code-pane")]
468    #[display("/desktop/code-pane")]
469    DesktopCodePane,
470    #[serde(rename = "/desktop/project-pane")]
471    #[display("/desktop/project-pane")]
472    DesktopProjectFilesPane,
473    #[serde(rename = "/desktop/other-panes")]
474    #[display("/desktop/other-panes")]
475    DesktopOtherPanes,
476    #[serde(rename = "/desktop/prompt-to-edit")]
477    #[display("/desktop/prompt-to-edit")]
478    DesktopPromptToEditWelcome,
479    #[serde(rename = "/desktop/prompt-to-edit-prompt")]
480    #[display("/desktop/prompt-to-edit-prompt")]
481    DesktopPromptToEditPrompt,
482    #[serde(rename = "/desktop/prompt-to-edit-result")]
483    #[display("/desktop/prompt-to-edit-result")]
484    DesktopPromptToEditResult,
485    #[serde(rename = "/desktop/imports")]
486    #[display("/desktop/imports")]
487    DesktopImports,
488    #[serde(rename = "/desktop/exports")]
489    #[display("/desktop/exports")]
490    DesktopExports,
491    #[serde(rename = "/desktop/conclusion")]
492    #[display("/desktop/conclusion")]
493    DesktopConclusion,
494
495    // Browser Routes
496    #[serde(rename = "/browser")]
497    #[display("/browser")]
498    BrowserWelcome,
499    #[serde(rename = "/browser/scene")]
500    #[display("/browser/scene")]
501    BrowserScene,
502    #[serde(rename = "/browser/toolbar")]
503    #[display("/browser/toolbar")]
504    BrowserToolbar,
505    #[serde(rename = "/browser/text-to-cad")]
506    #[display("/browser/text-to-cad")]
507    BrowserTextToCadWelcome,
508    #[serde(rename = "/browser/text-to-cad-prompt")]
509    #[display("/browser/text-to-cad-prompt")]
510    BrowserTextToCadPrompt,
511    #[serde(rename = "/browser/feature-tree-pane")]
512    #[display("/browser/feature-tree-pane")]
513    BrowserFeatureTreePane,
514    #[serde(rename = "/browser/prompt-to-edit")]
515    #[display("/browser/prompt-to-edit")]
516    BrowserPromptToEditWelcome,
517    #[serde(rename = "/browser/prompt-to-edit-prompt")]
518    #[display("/browser/prompt-to-edit-prompt")]
519    BrowserPromptToEditPrompt,
520    #[serde(rename = "/browser/prompt-to-edit-result")]
521    #[display("/browser/prompt-to-edit-result")]
522    BrowserPromptToEditResult,
523    #[serde(rename = "/browser/conclusion")]
524    #[display("/browser/conclusion")]
525    BrowserConclusion,
526}
527
528fn is_default<T: Default + PartialEq>(t: &T) -> bool {
529    t == &T::default()
530}
531
532#[cfg(test)]
533mod tests {
534    use pretty_assertions::assert_eq;
535
536    use super::{
537        AppSettings, AppTheme, AppearanceSettings, CameraProjectionType, CommandBarSettings, Configuration,
538        ModelingSettings, MouseControlType, OnboardingStatus, ProjectNameTemplate, ProjectSettings, Settings,
539        TextEditorSettings, UnitLength,
540    };
541
542    #[test]
543    fn test_settings_empty_file_parses() {
544        let empty_settings_file = r#""#;
545
546        let parsed = toml::from_str::<Configuration>(empty_settings_file).unwrap();
547        assert_eq!(parsed, Configuration::default());
548
549        // Write the file back out.
550        let serialized = toml::to_string(&parsed).unwrap();
551        assert_eq!(serialized, r#""#);
552
553        let parsed = Configuration::parse_and_validate(empty_settings_file).unwrap();
554        assert_eq!(parsed, Configuration::default());
555    }
556
557    #[test]
558    fn test_settings_parse_basic() {
559        let settings_file = r#"[settings.app]
560default_project_name = "untitled"
561directory = ""
562onboarding_status = "dismissed"
563
564  [settings.app.appearance]
565  theme = "dark"
566
567[settings.modeling]
568enable_ssao = false
569base_unit = "in"
570mouse_controls = "zoo"
571camera_projection = "perspective"
572
573[settings.project]
574default_project_name = "untitled"
575directory = ""
576
577[settings.text_editor]
578text_wrapping = true"#;
579
580        let expected = Configuration {
581            settings: Settings {
582                app: AppSettings {
583                    onboarding_status: OnboardingStatus::Dismissed,
584                    appearance: AppearanceSettings { theme: AppTheme::Dark },
585                    ..Default::default()
586                },
587                modeling: ModelingSettings {
588                    enable_ssao: false.into(),
589                    base_unit: UnitLength::Inches,
590                    mouse_controls: MouseControlType::Zoo,
591                    camera_projection: CameraProjectionType::Perspective,
592                    fixed_size_grid: true,
593                    ..Default::default()
594                },
595                project: ProjectSettings {
596                    default_project_name: ProjectNameTemplate("untitled".to_string()),
597                    directory: "".into(),
598                },
599                text_editor: TextEditorSettings {
600                    text_wrapping: true.into(),
601                    ..Default::default()
602                },
603                command_bar: CommandBarSettings {
604                    include_settings: true.into(),
605                },
606            },
607        };
608        let parsed = toml::from_str::<Configuration>(settings_file).unwrap();
609        assert_eq!(parsed, expected);
610
611        // Write the file back out.
612        let serialized = toml::to_string(&parsed).unwrap();
613        assert_eq!(
614            serialized,
615            r#"[settings.app]
616onboarding_status = "dismissed"
617
618[settings.app.appearance]
619theme = "dark"
620
621[settings.modeling]
622base_unit = "in"
623camera_projection = "perspective"
624enable_ssao = false
625"#
626        );
627
628        let parsed = Configuration::parse_and_validate(settings_file).unwrap();
629        assert_eq!(parsed, expected);
630    }
631}