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
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}