1pub mod project;
4
5use anyhow::Result;
6use kittycad_modeling_cmds::units::UnitLength;
7use parse_display::{Display, FromStr};
8use schemars::JsonSchema;
9use serde::{Deserialize, Deserializer, Serialize};
10use validator::Validate;
11
12const DEFAULT_PROJECT_NAME_TEMPLATE: &str = "untitled";
13
14#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Validate)]
20#[ts(export)]
21#[serde(rename_all = "snake_case")]
22pub struct Configuration {
23 #[serde(default, skip_serializing_if = "is_default")]
25 #[validate(nested)]
26 pub settings: Settings,
27}
28
29impl Configuration {
30 pub fn parse_and_validate(toml_str: &str) -> Result<Self> {
31 let settings = toml::from_str::<Self>(toml_str)?;
32
33 settings.validate()?;
34
35 Ok(settings)
36 }
37}
38
39#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Validate)]
41#[ts(export)]
42#[serde(rename_all = "snake_case")]
43pub struct Settings {
44 #[serde(default, skip_serializing_if = "is_default")]
46 #[validate(nested)]
47 pub app: AppSettings,
48 #[serde(default, skip_serializing_if = "is_default")]
50 #[validate(nested)]
51 pub modeling: ModelingSettings,
52 #[serde(default, skip_serializing_if = "is_default")]
54 #[validate(nested)]
55 pub text_editor: TextEditorSettings,
56 #[serde(default, skip_serializing_if = "is_default")]
58 #[validate(nested)]
59 pub project: ProjectSettings,
60 #[serde(default, skip_serializing_if = "is_default")]
62 #[validate(nested)]
63 pub command_bar: CommandBarSettings,
64}
65
66#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Validate)]
68#[ts(export)]
69#[serde(rename_all = "snake_case")]
70pub struct AppSettings {
71 #[serde(default, skip_serializing_if = "is_default")]
73 #[validate(nested)]
74 pub appearance: AppearanceSettings,
75 #[serde(default, skip_serializing_if = "is_default")]
77 pub onboarding_status: OnboardingStatus,
78 #[serde(
80 default,
81 deserialize_with = "deserialize_stream_idle_mode",
82 alias = "streamIdleMode",
83 skip_serializing_if = "is_default"
84 )]
85 stream_idle_mode: Option<u32>,
86 #[serde(default, skip_serializing_if = "is_default")]
88 pub allow_orbit_in_sketch_mode: bool,
89 #[serde(default, skip_serializing_if = "is_default")]
92 pub show_debug_panel: bool,
93}
94
95fn make_it_so() -> bool {
97 true
98}
99
100fn is_true(b: &bool) -> bool {
101 *b
102}
103
104fn deserialize_stream_idle_mode<'de, D>(deserializer: D) -> Result<Option<u32>, D::Error>
105where
106 D: Deserializer<'de>,
107{
108 #[derive(Deserialize)]
109 #[serde(untagged)]
110 enum StreamIdleModeValue {
111 Number(u32),
112 String(String),
113 Boolean(bool),
114 }
115
116 const DEFAULT_TIMEOUT: u32 = 1000 * 60 * 5;
117
118 Ok(match StreamIdleModeValue::deserialize(deserializer) {
119 Ok(StreamIdleModeValue::Number(value)) => Some(value),
120 Ok(StreamIdleModeValue::String(value)) => Some(value.parse::<u32>().unwrap_or(DEFAULT_TIMEOUT)),
121 Ok(StreamIdleModeValue::Boolean(true)) => Some(DEFAULT_TIMEOUT),
124 Ok(StreamIdleModeValue::Boolean(false)) => None,
125 _ => None,
126 })
127}
128
129#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq)]
130#[ts(export)]
131#[serde(untagged)]
132pub enum FloatOrInt {
133 String(String),
134 Float(f64),
135 Int(i64),
136}
137
138impl From<FloatOrInt> for f64 {
139 fn from(float_or_int: FloatOrInt) -> Self {
140 match float_or_int {
141 FloatOrInt::String(s) => s.parse().unwrap(),
142 FloatOrInt::Float(f) => f,
143 FloatOrInt::Int(i) => i as f64,
144 }
145 }
146}
147
148#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Validate)]
150#[ts(export)]
151#[serde(rename_all = "snake_case")]
152pub struct AppearanceSettings {
153 #[serde(default, skip_serializing_if = "is_default")]
155 pub theme: AppTheme,
156}
157
158#[derive(
160 Debug, Default, Copy, Clone, Deserialize, Serialize, JsonSchema, Display, FromStr, ts_rs::TS, PartialEq, Eq,
161)]
162#[ts(export)]
163#[serde(rename_all = "snake_case")]
164#[display(style = "snake_case")]
165pub enum AppTheme {
166 Light,
168 Dark,
170 #[default]
173 System,
174}
175
176impl From<AppTheme> for kittycad::types::Color {
177 fn from(theme: AppTheme) -> Self {
178 match theme {
179 AppTheme::Light => kittycad::types::Color {
180 r: 249.0 / 255.0,
181 g: 249.0 / 255.0,
182 b: 249.0 / 255.0,
183 a: 1.0,
184 },
185 AppTheme::Dark => kittycad::types::Color {
186 r: 28.0 / 255.0,
187 g: 28.0 / 255.0,
188 b: 28.0 / 255.0,
189 a: 1.0,
190 },
191 AppTheme::System => {
192 todo!()
194 }
195 }
196 }
197}
198
199#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Validate)]
201#[serde(rename_all = "snake_case")]
202#[ts(export)]
203pub struct ModelingSettings {
204 #[serde(default = "default_length_unit_millimeters", skip_serializing_if = "is_default")]
206 pub base_unit: UnitLength,
207 #[serde(default, skip_serializing_if = "is_default")]
209 pub camera_projection: CameraProjectionType,
210 #[serde(default, skip_serializing_if = "is_default")]
212 pub camera_orbit: CameraOrbitType,
213 #[serde(default, skip_serializing_if = "is_default")]
215 pub mouse_controls: MouseControlType,
216 #[serde(default, skip_serializing_if = "is_default")]
218 pub gizmo_type: GizmoType,
219 #[serde(default, skip_serializing_if = "is_default")]
221 pub enable_touch_controls: DefaultTrue,
222 #[serde(default, skip_serializing_if = "is_default")]
224 pub use_sketch_solve_mode: bool,
225 #[serde(default, skip_serializing_if = "is_default")]
227 pub highlight_edges: DefaultTrue,
228 #[serde(default, skip_serializing_if = "is_default")]
230 pub enable_ssao: DefaultTrue,
231 #[serde(
233 default = "default_backface_color",
234 skip_serializing_if = "is_default_backface_color"
235 )]
236 pub backface_color: String,
237 #[serde(default, skip_serializing_if = "is_default")]
239 pub show_scale_grid: bool,
240 #[serde(default = "make_it_so", skip_serializing_if = "is_true")]
244 pub fixed_size_grid: bool,
245 #[serde(default, skip_serializing_if = "is_default")]
247 pub snap_to_grid: bool,
248 #[serde(default, skip_serializing_if = "is_default")]
250 pub major_grid_spacing: f64,
251 #[serde(default, skip_serializing_if = "is_default")]
253 pub minor_grids_per_major: f64,
254 #[serde(default, skip_serializing_if = "is_default")]
256 pub snaps_per_minor: f64,
257}
258
259fn default_length_unit_millimeters() -> UnitLength {
260 UnitLength::Millimeters
261}
262
263fn default_backface_color() -> String {
265 "#F20D0D".to_string()
266}
267
268fn is_default_backface_color(color: &String) -> bool {
269 *color == default_backface_color()
270}
271
272impl Default for ModelingSettings {
273 fn default() -> Self {
274 Self {
275 base_unit: UnitLength::Millimeters,
276 camera_projection: Default::default(),
277 camera_orbit: Default::default(),
278 mouse_controls: Default::default(),
279 gizmo_type: Default::default(),
280 enable_touch_controls: Default::default(),
281 use_sketch_solve_mode: Default::default(),
282 highlight_edges: Default::default(),
283 enable_ssao: Default::default(),
284 backface_color: default_backface_color(),
285 show_scale_grid: Default::default(),
286 fixed_size_grid: true,
287 snap_to_grid: Default::default(),
288 major_grid_spacing: Default::default(),
289 minor_grids_per_major: Default::default(),
290 snaps_per_minor: Default::default(),
291 }
292 }
293}
294
295#[derive(Debug, Copy, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Eq)]
296#[ts(export)]
297#[serde(transparent)]
298pub struct DefaultTrue(pub bool);
299
300impl Default for DefaultTrue {
301 fn default() -> Self {
302 Self(true)
303 }
304}
305
306impl From<DefaultTrue> for bool {
307 fn from(default_true: DefaultTrue) -> Self {
308 default_true.0
309 }
310}
311
312impl From<bool> for DefaultTrue {
313 fn from(b: bool) -> Self {
314 Self(b)
315 }
316}
317
318#[derive(Debug, Default, Eq, PartialEq, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, Display, FromStr)]
320#[ts(export)]
321#[serde(rename_all = "snake_case")]
322#[display(style = "snake_case")]
323pub enum MouseControlType {
324 #[default]
325 #[display("zoo")]
326 #[serde(rename = "zoo")]
327 Zoo,
328 #[display("onshape")]
329 #[serde(rename = "onshape")]
330 OnShape,
331 TrackpadFriendly,
332 Solidworks,
333 Nx,
334 Creo,
335 #[display("autocad")]
336 #[serde(rename = "autocad")]
337 AutoCad,
338}
339
340#[derive(Debug, Default, Eq, PartialEq, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, Display, FromStr)]
342#[ts(export)]
343#[serde(rename_all = "snake_case")]
344#[display(style = "snake_case")]
345pub enum CameraProjectionType {
346 Perspective,
348 #[default]
350 Orthographic,
351}
352
353#[derive(Debug, Default, Eq, PartialEq, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, Display, FromStr)]
355#[ts(export)]
356#[serde(rename_all = "snake_case")]
357#[display(style = "snake_case")]
358pub enum CameraOrbitType {
359 #[default]
361 #[display("spherical")]
362 Spherical,
363 #[display("trackball")]
365 Trackball,
366}
367
368#[derive(Debug, Default, Eq, PartialEq, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, Display, FromStr)]
370#[ts(export)]
371#[serde(rename_all = "snake_case")]
372#[display(style = "snake_case")]
373pub enum GizmoType {
374 #[default]
376 Cube,
377 Axis,
379}
380
381#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Eq, Validate)]
383#[serde(rename_all = "snake_case")]
384#[ts(export)]
385pub struct TextEditorSettings {
386 #[serde(default, skip_serializing_if = "is_default")]
388 pub text_wrapping: DefaultTrue,
389 #[serde(default, skip_serializing_if = "is_default")]
391 pub blinking_cursor: DefaultTrue,
392}
393
394#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Eq, Validate)]
396#[serde(rename_all = "snake_case")]
397#[ts(export)]
398pub struct ProjectTextEditorSettings {
399 #[serde(default, skip_serializing_if = "Option::is_none")]
401 pub text_wrapping: Option<bool>,
402 #[serde(default, skip_serializing_if = "Option::is_none")]
404 pub blinking_cursor: Option<bool>,
405}
406
407#[derive(Debug, Clone, Default, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Eq, Validate)]
409#[serde(rename_all = "snake_case")]
410#[ts(export)]
411pub struct ProjectSettings {
412 #[serde(default, skip_serializing_if = "is_default")]
414 pub directory: std::path::PathBuf,
415 #[serde(default, skip_serializing_if = "is_default")]
417 pub default_project_name: ProjectNameTemplate,
418}
419
420#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Eq)]
421#[ts(export)]
422#[serde(transparent)]
423pub struct ProjectNameTemplate(pub String);
424
425impl Default for ProjectNameTemplate {
426 fn default() -> Self {
427 Self(DEFAULT_PROJECT_NAME_TEMPLATE.to_string())
428 }
429}
430
431impl From<ProjectNameTemplate> for String {
432 fn from(project_name: ProjectNameTemplate) -> Self {
433 project_name.0
434 }
435}
436
437impl From<String> for ProjectNameTemplate {
438 fn from(s: String) -> Self {
439 Self(s)
440 }
441}
442
443#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Eq, Validate)]
445#[serde(rename_all = "snake_case")]
446#[ts(export)]
447pub struct CommandBarSettings {
448 #[serde(default, skip_serializing_if = "is_default")]
450 pub include_settings: DefaultTrue,
451}
452
453#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Eq, Validate)]
455#[serde(rename_all = "snake_case")]
456#[ts(export)]
457pub struct ProjectCommandBarSettings {
458 #[serde(default, skip_serializing_if = "Option::is_none")]
460 pub include_settings: Option<bool>,
461}
462
463#[derive(Debug, Default, Eq, PartialEq, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, Display, FromStr)]
465#[ts(export)]
466#[serde(rename_all = "snake_case")]
467#[display(style = "snake_case")]
468pub enum OnboardingStatus {
469 #[serde(rename = "")]
471 #[display("")]
472 Unset,
473 Completed,
475 #[default]
477 Incomplete,
478 Dismissed,
480
481 #[serde(rename = "/desktop")]
483 #[display("/desktop")]
484 DesktopWelcome,
485 #[serde(rename = "/desktop/scene")]
486 #[display("/desktop/scene")]
487 DesktopScene,
488 #[serde(rename = "/desktop/toolbar")]
489 #[display("/desktop/toolbar")]
490 DesktopToolbar,
491 #[serde(rename = "/desktop/text-to-cad")]
492 #[display("/desktop/text-to-cad")]
493 DesktopTextToCadWelcome,
494 #[serde(rename = "/desktop/text-to-cad-prompt")]
495 #[display("/desktop/text-to-cad-prompt")]
496 DesktopTextToCadPrompt,
497 #[serde(rename = "/desktop/feature-tree-pane")]
498 #[display("/desktop/feature-tree-pane")]
499 DesktopFeatureTreePane,
500 #[serde(rename = "/desktop/code-pane")]
501 #[display("/desktop/code-pane")]
502 DesktopCodePane,
503 #[serde(rename = "/desktop/project-pane")]
504 #[display("/desktop/project-pane")]
505 DesktopProjectFilesPane,
506 #[serde(rename = "/desktop/other-panes")]
507 #[display("/desktop/other-panes")]
508 DesktopOtherPanes,
509 #[serde(rename = "/desktop/prompt-to-edit")]
510 #[display("/desktop/prompt-to-edit")]
511 DesktopPromptToEditWelcome,
512 #[serde(rename = "/desktop/prompt-to-edit-prompt")]
513 #[display("/desktop/prompt-to-edit-prompt")]
514 DesktopPromptToEditPrompt,
515 #[serde(rename = "/desktop/prompt-to-edit-result")]
516 #[display("/desktop/prompt-to-edit-result")]
517 DesktopPromptToEditResult,
518 #[serde(rename = "/desktop/imports")]
519 #[display("/desktop/imports")]
520 DesktopImports,
521 #[serde(rename = "/desktop/exports")]
522 #[display("/desktop/exports")]
523 DesktopExports,
524 #[serde(rename = "/desktop/conclusion")]
525 #[display("/desktop/conclusion")]
526 DesktopConclusion,
527
528 #[serde(rename = "/browser")]
530 #[display("/browser")]
531 BrowserWelcome,
532 #[serde(rename = "/browser/scene")]
533 #[display("/browser/scene")]
534 BrowserScene,
535 #[serde(rename = "/browser/toolbar")]
536 #[display("/browser/toolbar")]
537 BrowserToolbar,
538 #[serde(rename = "/browser/text-to-cad")]
539 #[display("/browser/text-to-cad")]
540 BrowserTextToCadWelcome,
541 #[serde(rename = "/browser/text-to-cad-prompt")]
542 #[display("/browser/text-to-cad-prompt")]
543 BrowserTextToCadPrompt,
544 #[serde(rename = "/browser/feature-tree-pane")]
545 #[display("/browser/feature-tree-pane")]
546 BrowserFeatureTreePane,
547 #[serde(rename = "/browser/prompt-to-edit")]
548 #[display("/browser/prompt-to-edit")]
549 BrowserPromptToEditWelcome,
550 #[serde(rename = "/browser/prompt-to-edit-prompt")]
551 #[display("/browser/prompt-to-edit-prompt")]
552 BrowserPromptToEditPrompt,
553 #[serde(rename = "/browser/prompt-to-edit-result")]
554 #[display("/browser/prompt-to-edit-result")]
555 BrowserPromptToEditResult,
556 #[serde(rename = "/browser/conclusion")]
557 #[display("/browser/conclusion")]
558 BrowserConclusion,
559}
560
561fn is_default<T: Default + PartialEq>(t: &T) -> bool {
562 t == &T::default()
563}
564
565#[cfg(test)]
566mod tests {
567 use pretty_assertions::assert_eq;
568
569 use super::{
570 AppSettings, AppTheme, AppearanceSettings, CameraProjectionType, CommandBarSettings, Configuration,
571 ModelingSettings, MouseControlType, OnboardingStatus, ProjectNameTemplate, ProjectSettings, Settings,
572 TextEditorSettings, UnitLength, default_backface_color,
573 };
574
575 #[test]
576 fn test_settings_empty_file_parses() {
577 let empty_settings_file = r#""#;
578
579 let parsed = toml::from_str::<Configuration>(empty_settings_file).unwrap();
580 assert_eq!(parsed, Configuration::default());
581 assert_eq!(parsed.settings.modeling.backface_color, default_backface_color());
582
583 let serialized = toml::to_string(&parsed).unwrap();
585 assert_eq!(serialized, r#""#);
586
587 let parsed = Configuration::parse_and_validate(empty_settings_file).unwrap();
588 assert_eq!(parsed, Configuration::default());
589 assert_eq!(parsed.settings.modeling.backface_color, default_backface_color());
590 }
591
592 #[test]
593 fn test_settings_parse_basic() {
594 let settings_file = r#"[settings.app]
595default_project_name = "untitled"
596directory = ""
597onboarding_status = "dismissed"
598
599 [settings.app.appearance]
600 theme = "dark"
601
602[settings.modeling]
603enable_ssao = false
604base_unit = "in"
605mouse_controls = "zoo"
606camera_projection = "perspective"
607
608[settings.project]
609default_project_name = "untitled"
610directory = ""
611
612[settings.text_editor]
613text_wrapping = true"#;
614
615 let expected = Configuration {
616 settings: Settings {
617 app: AppSettings {
618 onboarding_status: OnboardingStatus::Dismissed,
619 appearance: AppearanceSettings { theme: AppTheme::Dark },
620 ..Default::default()
621 },
622 modeling: ModelingSettings {
623 enable_ssao: false.into(),
624 base_unit: UnitLength::Inches,
625 mouse_controls: MouseControlType::Zoo,
626 camera_projection: CameraProjectionType::Perspective,
627 fixed_size_grid: true,
628 ..Default::default()
629 },
630 project: ProjectSettings {
631 default_project_name: ProjectNameTemplate("untitled".to_string()),
632 directory: "".into(),
633 },
634 text_editor: TextEditorSettings {
635 text_wrapping: true.into(),
636 ..Default::default()
637 },
638 command_bar: CommandBarSettings {
639 include_settings: true.into(),
640 },
641 },
642 };
643 let parsed = toml::from_str::<Configuration>(settings_file).unwrap();
644 assert_eq!(parsed, expected);
645
646 let serialized = toml::to_string(&parsed).unwrap();
648 assert_eq!(
649 serialized,
650 r#"[settings.app]
651onboarding_status = "dismissed"
652
653[settings.app.appearance]
654theme = "dark"
655
656[settings.modeling]
657base_unit = "in"
658camera_projection = "perspective"
659enable_ssao = false
660"#
661 );
662
663 let parsed = Configuration::parse_and_validate(settings_file).unwrap();
664 assert_eq!(parsed, expected);
665 }
666
667 #[test]
668 fn test_settings_backface_color_roundtrip() {
669 let settings_file = r##"[settings.modeling]
670backface_color = "#112233"
671"##;
672
673 let parsed = toml::from_str::<Configuration>(settings_file).unwrap();
674 assert_eq!(parsed.settings.modeling.backface_color, "#112233");
675
676 let serialized = toml::to_string(&parsed).unwrap();
677 let reparsed = toml::from_str::<Configuration>(&serialized).unwrap();
678 assert_eq!(reparsed, parsed);
679 assert!(serialized.contains("backface_color = \"#112233\""));
680 }
681}