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