1pub 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#[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 #[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#[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 #[serde(default, skip_serializing_if = "Option::is_none")]
48 #[validate(nested)]
49 pub app: Option<AppSettings>,
50 #[serde(default, skip_serializing_if = "Option::is_none")]
52 #[validate(nested)]
53 pub modeling: Option<ModelingSettings>,
54 #[serde(flatten)]
58 pub other: std::collections::HashMap<String, serde_json::Value>,
59}
60
61#[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 #[serde(default, skip_serializing_if = "Option::is_none")]
68 #[validate(nested)]
69 pub appearance: Option<AppearanceSettings>,
70 #[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 #[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 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#[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 #[serde(default, skip_serializing_if = "Option::is_none")]
134 pub theme: Option<AppTheme>,
135 #[serde(flatten)]
137 pub other: std::collections::HashMap<String, serde_json::Value>,
138}
139
140#[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 Light,
150 Dark,
152 #[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!()
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 Err(_) => default_backface_color_struct(),
226 }
227 }
228}
229
230#[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 #[serde(default, skip_serializing_if = "Option::is_none")]
238 pub base_unit: Option<LengthDefaultMm>,
239 #[serde(default, skip_serializing_if = "Option::is_none")]
241 pub camera_projection: Option<CameraProjectionType>,
242 #[serde(default, skip_serializing_if = "Option::is_none")]
244 pub camera_orbit: Option<CameraOrbitType>,
245 #[serde(default, skip_serializing_if = "Option::is_none")]
247 pub highlight_edges: Option<DefaultTrue>,
248 #[serde(default, skip_serializing_if = "Option::is_none")]
250 pub enable_ssao: Option<DefaultTrue>,
251 #[serde(default, skip_serializing_if = "Option::is_none")]
253 pub backface_color: Option<BackfaceDefault>,
254 #[serde(default, skip_serializing_if = "Option::is_none")]
256 pub show_scale_grid: Option<bool>,
257 #[serde(default, skip_serializing_if = "Option::is_none")]
261 pub fixed_size_grid: Option<DefaultTrue>,
262 #[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
271pub fn default_backface_color() -> String {
273 "#00D5FF".to_string()
275}
276pub 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#[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,
312 #[default]
314 Orthographic,
315}
316
317#[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 #[default]
325 #[display("spherical")]
326 Spherical,
327 #[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 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
404enable_ssao = false
405snap_to_grid = true
406major_grid_spacing = 2.5
407minor_grids_per_major = 5
408snaps_per_minor = 3
409
410[settings.project]
411directory = ""
412default_project_name = "untitled"
413
414[settings.command_bar]
415include_settings = false
416
417[settings.text_editor]
418text_wrapping = true
419"#;
420
421 let expected = Configuration {
422 settings: Settings {
423 app: Some(AppSettings {
424 appearance: Some(AppearanceSettings {
425 theme: Some(AppTheme::Dark),
426 other: Default::default(),
427 }),
428 other: std::collections::HashMap::from([
429 ("allow_orbit_in_sketch_mode".to_owned(), true.into()),
430 ("foo".to_owned(), "bar".into()),
431 ("machine_api".to_owned(), true.into()),
432 ("onboarding_status".to_owned(), "dismissed".into()),
433 ]),
434 ..Default::default()
435 }),
436 modeling: Some(ModelingSettings {
437 enable_ssao: Some(false.into()),
438 base_unit: Some(From::from(UnitLength::Inches)),
439 camera_projection: Some(CameraProjectionType::Perspective),
440 fixed_size_grid: None,
441 other: std::collections::HashMap::from([
442 ("enable_touch_controls".to_owned(), false.into()),
443 ("gizmo_type".to_owned(), "axis".into()),
444 ("major_grid_spacing".to_owned(), json!(2.5)),
445 ("minor_grids_per_major".to_owned(), json!(5)),
446 ("mouse_controls".to_owned(), "zoo".into()),
447 ("snap_to_grid".to_owned(), true.into()),
448 ("snaps_per_minor".to_owned(), json!(3)),
449 ]),
450 ..Default::default()
451 }),
452 other: std::collections::HashMap::from([
453 (
454 "command_bar".to_owned(),
455 json!({
456 "include_settings": false,
457 }),
458 ),
459 (
460 "project".to_owned(),
461 json!({
462 "default_project_name": "untitled",
463 "directory": "",
464 }),
465 ),
466 (
467 "text_editor".to_owned(),
468 json!({
469 "text_wrapping": true,
470 }),
471 ),
472 ]),
473 },
474 };
475 let parsed = toml::from_str::<Configuration>(settings_file).unwrap();
476 assert_eq!(parsed, expected);
477
478 let serialized = toml::to_string(&parsed).unwrap();
479 assert!(serialized.contains("[settings.app]"));
480 assert!(serialized.contains("onboarding_status = \"dismissed\""));
481 assert!(serialized.contains("allow_orbit_in_sketch_mode = true"));
482 assert!(serialized.contains("machine_api = true"));
483 assert!(serialized.contains("foo = \"bar\""));
484 assert!(serialized.contains("[settings.modeling]"));
485 assert!(serialized.contains("mouse_controls = \"zoo\""));
486 assert!(serialized.contains("gizmo_type = \"axis\""));
487 assert!(serialized.contains("enable_touch_controls = false"));
488 assert!(serialized.contains("snap_to_grid = true"));
489 assert!(serialized.contains("major_grid_spacing = 2.5"));
490 assert!(serialized.contains("minor_grids_per_major = 5"));
491 assert!(serialized.contains("snaps_per_minor = 3"));
492 assert!(serialized.contains("[settings.project]"));
493 assert!(serialized.contains("directory = \"\""));
494 assert!(serialized.contains("default_project_name = \"untitled\""));
495 assert!(serialized.contains("[settings.command_bar]"));
496 assert!(serialized.contains("include_settings = false"));
497 assert!(serialized.contains("[settings.text_editor]"));
498 assert!(serialized.contains("text_wrapping = true"));
499 let reparsed = toml::from_str::<Configuration>(&serialized).unwrap();
500 assert_eq!(reparsed, expected);
501
502 let parsed = Configuration::parse_and_validate(settings_file).unwrap();
503 assert_eq!(parsed, expected);
504 }
505
506 #[test]
507 fn test_settings_backface_color_roundtrip() {
508 let settings_file = r##"[settings.modeling]
509backface_color = "#112233"
510"##;
511
512 let parsed = toml::from_str::<Configuration>(settings_file).unwrap();
513 assert_eq!(
514 parsed
515 .clone()
516 .settings
517 .modeling
518 .unwrap_or_default()
519 .backface_color
520 .unwrap_or_default()
521 .0,
522 "#112233"
523 );
524
525 let serialized = toml::to_string(&parsed).unwrap();
526 let reparsed = toml::from_str::<Configuration>(&serialized).unwrap();
527 assert_eq!(reparsed, parsed);
528 assert!(serialized.contains("backface_color = \"#112233\""));
529 }
530}