1pub 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#[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 #[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#[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 #[serde(default, skip_serializing_if = "Option::is_none")]
47 #[validate(nested)]
48 pub app: Option<AppSettings>,
49 #[serde(default, skip_serializing_if = "Option::is_none")]
51 #[validate(nested)]
52 pub modeling: Option<ModelingSettings>,
53 #[serde(flatten)]
57 pub other: std::collections::HashMap<String, serde_json::Value>,
58}
59
60#[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 #[serde(default, skip_serializing_if = "Option::is_none")]
67 #[validate(nested)]
68 pub appearance: Option<AppearanceSettings>,
69 #[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 #[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 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#[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 #[serde(default, skip_serializing_if = "Option::is_none")]
133 pub theme: Option<AppTheme>,
134 #[serde(flatten)]
136 pub other: std::collections::HashMap<String, serde_json::Value>,
137}
138
139#[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 Light,
149 Dark,
151 #[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!()
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#[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 #[serde(default, skip_serializing_if = "Option::is_none")]
226 pub base_unit: Option<LengthDefaultMm>,
227 #[serde(default, skip_serializing_if = "Option::is_none")]
229 pub camera_projection: Option<CameraProjectionType>,
230 #[serde(default, skip_serializing_if = "Option::is_none")]
232 pub camera_orbit: Option<CameraOrbitType>,
233 #[serde(default, skip_serializing_if = "Option::is_none")]
235 pub highlight_edges: Option<DefaultTrue>,
236 #[serde(default, skip_serializing_if = "Option::is_none")]
238 pub enable_ssao: Option<DefaultTrue>,
239 #[serde(default, skip_serializing_if = "Option::is_none")]
241 pub backface_color: Option<BackfaceDefault>,
242 #[serde(default, skip_serializing_if = "Option::is_none")]
244 pub show_scale_grid: Option<bool>,
245 #[serde(default, skip_serializing_if = "Option::is_none")]
249 pub fixed_size_grid: Option<DefaultTrue>,
250 #[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
259fn 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#[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,
295 #[default]
297 Orthographic,
298}
299
300#[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 #[default]
308 #[display("spherical")]
309 Spherical,
310 #[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 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}