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
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 = "Option::is_none")]
47    #[validate(nested)]
48    pub app: Option<AppSettings>,
49    /// Settings that affect the behavior while modeling.
50    #[serde(default, skip_serializing_if = "Option::is_none")]
51    #[validate(nested)]
52    pub modeling: Option<ModelingSettings>,
53    /// Other fields that weren't recognized by our schema.
54    /// App-owned extension settings can live here without Rust understanding
55    /// their inner structure.
56    #[serde(flatten)]
57    pub other: std::collections::HashMap<String, serde_json::Value>,
58}
59
60/// Application wide settings.
61#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Validate)]
62#[ts(export)]
63#[serde(rename_all = "snake_case")]
64pub struct AppSettings {
65    /// The settings for the appearance of the app.
66    #[serde(default, skip_serializing_if = "Option::is_none")]
67    #[validate(nested)]
68    pub appearance: Option<AppearanceSettings>,
69    /// When the user is idle, teardown the stream after some time.
70    #[serde(
71        default,
72        deserialize_with = "deserialize_stream_idle_mode",
73        alias = "streamIdleMode",
74        skip_serializing_if = "Option::is_none"
75    )]
76    stream_idle_mode: Option<u32>,
77    /// Other fields that weren't recognized by our schema.
78    #[serde(flatten)]
79    pub other: std::collections::HashMap<String, serde_json::Value>,
80}
81
82fn deserialize_stream_idle_mode<'de, D>(deserializer: D) -> Result<Option<u32>, D::Error>
83where
84    D: Deserializer<'de>,
85{
86    #[derive(Deserialize)]
87    #[serde(untagged)]
88    enum StreamIdleModeValue {
89        Number(u32),
90        String(String),
91        Boolean(bool),
92    }
93
94    const DEFAULT_TIMEOUT: u32 = 1000 * 60 * 5;
95
96    Ok(match StreamIdleModeValue::deserialize(deserializer) {
97        Ok(StreamIdleModeValue::Number(value)) => Some(value),
98        Ok(StreamIdleModeValue::String(value)) => Some(value.parse::<u32>().unwrap_or(DEFAULT_TIMEOUT)),
99        // The old type of this value. I'm willing to say no one used it but
100        // we can never guarantee it.
101        Ok(StreamIdleModeValue::Boolean(true)) => Some(DEFAULT_TIMEOUT),
102        Ok(StreamIdleModeValue::Boolean(false)) => None,
103        _ => None,
104    })
105}
106
107#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq)]
108#[ts(export)]
109#[serde(untagged)]
110pub enum FloatOrInt {
111    String(String),
112    Float(f64),
113    Int(i64),
114}
115
116impl From<FloatOrInt> for f64 {
117    fn from(float_or_int: FloatOrInt) -> Self {
118        match float_or_int {
119            FloatOrInt::String(s) => s.parse().unwrap(),
120            FloatOrInt::Float(f) => f,
121            FloatOrInt::Int(i) => i as f64,
122        }
123    }
124}
125
126/// The settings for the theme of the app.
127#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Validate)]
128#[ts(export)]
129#[serde(rename_all = "snake_case")]
130pub struct AppearanceSettings {
131    /// The overall theme of the app.
132    #[serde(default, skip_serializing_if = "Option::is_none")]
133    pub theme: Option<AppTheme>,
134    /// Other fields that weren't recognized by our schema.
135    #[serde(flatten)]
136    pub other: std::collections::HashMap<String, serde_json::Value>,
137}
138
139/// The overall appearance of the app.
140#[derive(
141    Debug, Default, Copy, Clone, Deserialize, Serialize, JsonSchema, Display, FromStr, ts_rs::TS, PartialEq, Eq,
142)]
143#[ts(export)]
144#[serde(rename_all = "snake_case")]
145#[display(style = "snake_case")]
146pub enum AppTheme {
147    /// A light theme.
148    Light,
149    /// A dark theme.
150    Dark,
151    /// Use the system theme.
152    /// This will use dark theme if the system theme is dark, and light theme if the system theme is light.
153    #[default]
154    System,
155}
156
157impl From<AppTheme> for kittycad::types::Color {
158    fn from(theme: AppTheme) -> Self {
159        match theme {
160            AppTheme::Light => kittycad::types::Color {
161                r: 249.0 / 255.0,
162                g: 249.0 / 255.0,
163                b: 249.0 / 255.0,
164                a: 1.0,
165            },
166            AppTheme::Dark => kittycad::types::Color {
167                r: 28.0 / 255.0,
168                g: 28.0 / 255.0,
169                b: 28.0 / 255.0,
170                a: 1.0,
171            },
172            AppTheme::System => {
173                // TODO: Check the system setting for the user.
174                todo!()
175            }
176        }
177    }
178}
179
180#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq)]
181#[serde(transparent)]
182pub struct LengthDefaultMm(pub UnitLength);
183
184impl Default for LengthDefaultMm {
185    fn default() -> Self {
186        Self(default_length_unit_millimeters())
187    }
188}
189
190impl From<LengthDefaultMm> for UnitLength {
191    fn from(val: LengthDefaultMm) -> Self {
192        val.0
193    }
194}
195
196impl From<UnitLength> for LengthDefaultMm {
197    fn from(unit: UnitLength) -> Self {
198        Self(unit)
199    }
200}
201
202#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq)]
203#[serde(transparent)]
204pub struct BackfaceDefault(pub String);
205
206impl Default for BackfaceDefault {
207    fn default() -> Self {
208        Self(default_backface_color())
209    }
210}
211
212impl From<BackfaceDefault> for String {
213    fn from(val: BackfaceDefault) -> Self {
214        val.0
215    }
216}
217
218/// Settings that affect the behavior while modeling.
219#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Validate, Default)]
220#[serde(rename_all = "snake_case")]
221#[ts(export)]
222pub struct ModelingSettings {
223    /// The default unit to use in modeling dimensions.
224    /// If not given, defaults to millimeters.
225    #[serde(default, skip_serializing_if = "Option::is_none")]
226    pub base_unit: Option<LengthDefaultMm>,
227    /// The projection mode the camera should use while modeling.
228    #[serde(default, skip_serializing_if = "Option::is_none")]
229    pub camera_projection: Option<CameraProjectionType>,
230    /// The methodology the camera should use to orbit around the model.
231    #[serde(default, skip_serializing_if = "Option::is_none")]
232    pub camera_orbit: Option<CameraOrbitType>,
233    /// Highlight edges of 3D objects?
234    #[serde(default, skip_serializing_if = "Option::is_none")]
235    pub highlight_edges: Option<DefaultTrue>,
236    /// Whether or not Screen Space Ambient Occlusion (SSAO) is enabled.
237    #[serde(default, skip_serializing_if = "Option::is_none")]
238    pub enable_ssao: Option<DefaultTrue>,
239    /// The default color to use for surface backfaces.
240    #[serde(default, skip_serializing_if = "Option::is_none")]
241    pub backface_color: Option<BackfaceDefault>,
242    /// Whether or not to show a scale grid in the 3D modeling view
243    #[serde(default, skip_serializing_if = "Option::is_none")]
244    pub show_scale_grid: Option<bool>,
245    /// When enabled, the grid will use a fixed size based on your selected units rather than automatically scaling with zoom level.
246    /// If true, the grid cells will be fixed-size, where the width is your default length unit.
247    /// If false, the grid will get larger as you zoom out, and smaller as you zoom in.
248    #[serde(default, skip_serializing_if = "Option::is_none")]
249    pub fixed_size_grid: Option<DefaultTrue>,
250    /// Other fields that weren't recognized by our schema.
251    #[serde(flatten)]
252    pub other: std::collections::HashMap<String, serde_json::Value>,
253}
254
255fn default_length_unit_millimeters() -> UnitLength {
256    UnitLength::Millimeters
257}
258
259// Also defined at src/lib/constants.ts#L333-L335
260fn default_backface_color() -> String {
261    "#00D5FF".to_string()
262}
263
264#[derive(Debug, Copy, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Eq)]
265#[ts(export)]
266#[serde(transparent)]
267pub struct DefaultTrue(pub bool);
268
269impl Default for DefaultTrue {
270    fn default() -> Self {
271        Self(true)
272    }
273}
274
275impl From<DefaultTrue> for bool {
276    fn from(default_true: DefaultTrue) -> Self {
277        default_true.0
278    }
279}
280
281impl From<bool> for DefaultTrue {
282    fn from(b: bool) -> Self {
283        Self(b)
284    }
285}
286
287/// The types of camera projection for the 3D view.
288#[derive(Debug, Default, Eq, PartialEq, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, Display, FromStr)]
289#[ts(export)]
290#[serde(rename_all = "snake_case")]
291#[display(style = "snake_case")]
292pub enum CameraProjectionType {
293    /// Perspective projection https://en.wikipedia.org/wiki/3D_projection#Perspective_projection
294    Perspective,
295    /// Orthographic projection https://en.wikipedia.org/wiki/3D_projection#Orthographic_projection
296    #[default]
297    Orthographic,
298}
299
300/// The types of camera orbit methods.
301#[derive(Debug, Default, Eq, PartialEq, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, Display, FromStr)]
302#[ts(export)]
303#[serde(rename_all = "snake_case")]
304#[display(style = "snake_case")]
305pub enum CameraOrbitType {
306    /// Orbit using a spherical camera movement.
307    #[default]
308    #[display("spherical")]
309    Spherical,
310    /// Orbit using a trackball camera movement.
311    #[display("trackball")]
312    Trackball,
313}
314
315fn is_default<T: Default + PartialEq>(t: &T) -> bool {
316    t == &T::default()
317}
318
319#[cfg(test)]
320mod tests {
321    use pretty_assertions::assert_eq;
322    use serde_json::json;
323
324    use super::AppSettings;
325    use super::AppTheme;
326    use super::AppearanceSettings;
327    use super::CameraProjectionType;
328    use super::Configuration;
329    use super::ModelingSettings;
330    use super::Settings;
331    use super::UnitLength;
332    use super::default_backface_color;
333
334    #[test]
335    fn test_settings_empty_file_parses() {
336        let empty_settings_file = r#""#;
337
338        let parsed = toml::from_str::<Configuration>(empty_settings_file).unwrap();
339        assert_eq!(parsed, Configuration::default());
340        assert_eq!(
341            parsed
342                .clone()
343                .settings
344                .modeling
345                .unwrap_or_default()
346                .backface_color
347                .unwrap_or_default()
348                .0,
349            default_backface_color()
350        );
351
352        // Write the file back out.
353        let serialized = toml::to_string(&parsed).unwrap();
354        assert_eq!(serialized, r#""#);
355
356        let parsed = Configuration::parse_and_validate(empty_settings_file).unwrap();
357        assert_eq!(parsed, Configuration::default());
358        assert_eq!(
359            parsed
360                .settings
361                .modeling
362                .unwrap_or_default()
363                .backface_color
364                .unwrap_or_default()
365                .0,
366            default_backface_color()
367        );
368    }
369
370    #[test]
371    fn test_settings_parse_basic() {
372        let settings_file = r#"[settings.app]
373onboarding_status = "dismissed"
374allow_orbit_in_sketch_mode = true
375show_debug_panel = true
376machine_api = true
377foo = "bar"
378
379[settings.app.appearance]
380theme = "dark"
381
382[settings.modeling]
383base_unit = "in"
384camera_projection = "perspective"
385mouse_controls = "zoo"
386gizmo_type = "axis"
387enable_touch_controls = false
388use_sketch_solve_mode = true
389enable_ssao = false
390snap_to_grid = true
391major_grid_spacing = 2.5
392minor_grids_per_major = 5
393snaps_per_minor = 3
394
395[settings.project]
396directory = ""
397default_project_name = "untitled"
398
399[settings.command_bar]
400include_settings = false
401
402[settings.text_editor]
403text_wrapping = true
404"#;
405
406        let expected = Configuration {
407            settings: Settings {
408                app: Some(AppSettings {
409                    appearance: Some(AppearanceSettings {
410                        theme: Some(AppTheme::Dark),
411                        other: Default::default(),
412                    }),
413                    other: std::collections::HashMap::from([
414                        ("allow_orbit_in_sketch_mode".to_owned(), true.into()),
415                        ("foo".to_owned(), "bar".into()),
416                        ("machine_api".to_owned(), true.into()),
417                        ("onboarding_status".to_owned(), "dismissed".into()),
418                        ("show_debug_panel".to_owned(), true.into()),
419                    ]),
420                    ..Default::default()
421                }),
422                modeling: Some(ModelingSettings {
423                    enable_ssao: Some(false.into()),
424                    base_unit: Some(From::from(UnitLength::Inches)),
425                    camera_projection: Some(CameraProjectionType::Perspective),
426                    fixed_size_grid: None,
427                    other: std::collections::HashMap::from([
428                        ("enable_touch_controls".to_owned(), false.into()),
429                        ("gizmo_type".to_owned(), "axis".into()),
430                        ("major_grid_spacing".to_owned(), json!(2.5)),
431                        ("minor_grids_per_major".to_owned(), json!(5)),
432                        ("mouse_controls".to_owned(), "zoo".into()),
433                        ("snap_to_grid".to_owned(), true.into()),
434                        ("snaps_per_minor".to_owned(), json!(3)),
435                        ("use_sketch_solve_mode".to_owned(), true.into()),
436                    ]),
437                    ..Default::default()
438                }),
439                other: std::collections::HashMap::from([
440                    (
441                        "command_bar".to_owned(),
442                        json!({
443                            "include_settings": false,
444                        }),
445                    ),
446                    (
447                        "project".to_owned(),
448                        json!({
449                            "default_project_name": "untitled",
450                            "directory": "",
451                        }),
452                    ),
453                    (
454                        "text_editor".to_owned(),
455                        json!({
456                            "text_wrapping": true,
457                        }),
458                    ),
459                ]),
460            },
461        };
462        let parsed = toml::from_str::<Configuration>(settings_file).unwrap();
463        assert_eq!(parsed, expected);
464
465        let serialized = toml::to_string(&parsed).unwrap();
466        assert!(serialized.contains("[settings.app]"));
467        assert!(serialized.contains("onboarding_status = \"dismissed\""));
468        assert!(serialized.contains("allow_orbit_in_sketch_mode = true"));
469        assert!(serialized.contains("show_debug_panel = true"));
470        assert!(serialized.contains("machine_api = true"));
471        assert!(serialized.contains("foo = \"bar\""));
472        assert!(serialized.contains("[settings.modeling]"));
473        assert!(serialized.contains("mouse_controls = \"zoo\""));
474        assert!(serialized.contains("gizmo_type = \"axis\""));
475        assert!(serialized.contains("enable_touch_controls = false"));
476        assert!(serialized.contains("use_sketch_solve_mode = true"));
477        assert!(serialized.contains("snap_to_grid = true"));
478        assert!(serialized.contains("major_grid_spacing = 2.5"));
479        assert!(serialized.contains("minor_grids_per_major = 5"));
480        assert!(serialized.contains("snaps_per_minor = 3"));
481        assert!(serialized.contains("[settings.project]"));
482        assert!(serialized.contains("directory = \"\""));
483        assert!(serialized.contains("default_project_name = \"untitled\""));
484        assert!(serialized.contains("[settings.command_bar]"));
485        assert!(serialized.contains("include_settings = false"));
486        assert!(serialized.contains("[settings.text_editor]"));
487        assert!(serialized.contains("text_wrapping = true"));
488        let reparsed = toml::from_str::<Configuration>(&serialized).unwrap();
489        assert_eq!(reparsed, expected);
490
491        let parsed = Configuration::parse_and_validate(settings_file).unwrap();
492        assert_eq!(parsed, expected);
493    }
494
495    #[test]
496    fn test_settings_backface_color_roundtrip() {
497        let settings_file = r##"[settings.modeling]
498backface_color = "#112233"
499"##;
500
501        let parsed = toml::from_str::<Configuration>(settings_file).unwrap();
502        assert_eq!(
503            parsed
504                .clone()
505                .settings
506                .modeling
507                .unwrap_or_default()
508                .backface_color
509                .unwrap_or_default()
510                .0,
511            "#112233"
512        );
513
514        let serialized = toml::to_string(&parsed).unwrap();
515        let reparsed = toml::from_str::<Configuration>(&serialized).unwrap();
516        assert_eq!(reparsed, parsed);
517        assert!(serialized.contains("backface_color = \"#112233\""));
518    }
519}