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
375machine_api = true
376foo = "bar"
377
378[settings.app.appearance]
379theme = "dark"
380
381[settings.modeling]
382base_unit = "in"
383camera_projection = "perspective"
384mouse_controls = "zoo"
385gizmo_type = "axis"
386enable_touch_controls = false
387use_sketch_solve_mode = true
388enable_ssao = false
389snap_to_grid = true
390major_grid_spacing = 2.5
391minor_grids_per_major = 5
392snaps_per_minor = 3
393
394[settings.project]
395directory = ""
396default_project_name = "untitled"
397
398[settings.command_bar]
399include_settings = false
400
401[settings.text_editor]
402text_wrapping = true
403"#;
404
405 let expected = Configuration {
406 settings: Settings {
407 app: Some(AppSettings {
408 appearance: Some(AppearanceSettings {
409 theme: Some(AppTheme::Dark),
410 other: Default::default(),
411 }),
412 other: std::collections::HashMap::from([
413 ("allow_orbit_in_sketch_mode".to_owned(), true.into()),
414 ("foo".to_owned(), "bar".into()),
415 ("machine_api".to_owned(), true.into()),
416 ("onboarding_status".to_owned(), "dismissed".into()),
417 ]),
418 ..Default::default()
419 }),
420 modeling: Some(ModelingSettings {
421 enable_ssao: Some(false.into()),
422 base_unit: Some(From::from(UnitLength::Inches)),
423 camera_projection: Some(CameraProjectionType::Perspective),
424 fixed_size_grid: None,
425 other: std::collections::HashMap::from([
426 ("enable_touch_controls".to_owned(), false.into()),
427 ("gizmo_type".to_owned(), "axis".into()),
428 ("major_grid_spacing".to_owned(), json!(2.5)),
429 ("minor_grids_per_major".to_owned(), json!(5)),
430 ("mouse_controls".to_owned(), "zoo".into()),
431 ("snap_to_grid".to_owned(), true.into()),
432 ("snaps_per_minor".to_owned(), json!(3)),
433 ("use_sketch_solve_mode".to_owned(), true.into()),
434 ]),
435 ..Default::default()
436 }),
437 other: std::collections::HashMap::from([
438 (
439 "command_bar".to_owned(),
440 json!({
441 "include_settings": false,
442 }),
443 ),
444 (
445 "project".to_owned(),
446 json!({
447 "default_project_name": "untitled",
448 "directory": "",
449 }),
450 ),
451 (
452 "text_editor".to_owned(),
453 json!({
454 "text_wrapping": true,
455 }),
456 ),
457 ]),
458 },
459 };
460 let parsed = toml::from_str::<Configuration>(settings_file).unwrap();
461 assert_eq!(parsed, expected);
462
463 let serialized = toml::to_string(&parsed).unwrap();
464 assert!(serialized.contains("[settings.app]"));
465 assert!(serialized.contains("onboarding_status = \"dismissed\""));
466 assert!(serialized.contains("allow_orbit_in_sketch_mode = true"));
467 assert!(serialized.contains("machine_api = true"));
468 assert!(serialized.contains("foo = \"bar\""));
469 assert!(serialized.contains("[settings.modeling]"));
470 assert!(serialized.contains("mouse_controls = \"zoo\""));
471 assert!(serialized.contains("gizmo_type = \"axis\""));
472 assert!(serialized.contains("enable_touch_controls = false"));
473 assert!(serialized.contains("use_sketch_solve_mode = true"));
474 assert!(serialized.contains("snap_to_grid = true"));
475 assert!(serialized.contains("major_grid_spacing = 2.5"));
476 assert!(serialized.contains("minor_grids_per_major = 5"));
477 assert!(serialized.contains("snaps_per_minor = 3"));
478 assert!(serialized.contains("[settings.project]"));
479 assert!(serialized.contains("directory = \"\""));
480 assert!(serialized.contains("default_project_name = \"untitled\""));
481 assert!(serialized.contains("[settings.command_bar]"));
482 assert!(serialized.contains("include_settings = false"));
483 assert!(serialized.contains("[settings.text_editor]"));
484 assert!(serialized.contains("text_wrapping = true"));
485 let reparsed = toml::from_str::<Configuration>(&serialized).unwrap();
486 assert_eq!(reparsed, expected);
487
488 let parsed = Configuration::parse_and_validate(settings_file).unwrap();
489 assert_eq!(parsed, expected);
490 }
491
492 #[test]
493 fn test_settings_backface_color_roundtrip() {
494 let settings_file = r##"[settings.modeling]
495backface_color = "#112233"
496"##;
497
498 let parsed = toml::from_str::<Configuration>(settings_file).unwrap();
499 assert_eq!(
500 parsed
501 .clone()
502 .settings
503 .modeling
504 .unwrap_or_default()
505 .backface_color
506 .unwrap_or_default()
507 .0,
508 "#112233"
509 );
510
511 let serialized = toml::to_string(&parsed).unwrap();
512 let reparsed = toml::from_str::<Configuration>(&serialized).unwrap();
513 assert_eq!(reparsed, parsed);
514 assert!(serialized.contains("backface_color = \"#112233\""));
515 }
516}