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, ValidateRange};
11
12const DEFAULT_THEME_COLOR: f64 = 264.5;
13const DEFAULT_PROJECT_NAME_TEMPLATE: &str = "untitled";
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 = "is_default")]
47 #[validate(nested)]
48 pub app: AppSettings,
49 #[serde(default, skip_serializing_if = "is_default")]
51 #[validate(nested)]
52 pub modeling: ModelingSettings,
53 #[serde(default, skip_serializing_if = "is_default")]
55 #[validate(nested)]
56 pub text_editor: TextEditorSettings,
57 #[serde(default, skip_serializing_if = "is_default")]
59 #[validate(nested)]
60 pub project: ProjectSettings,
61 #[serde(default, skip_serializing_if = "is_default")]
63 #[validate(nested)]
64 pub command_bar: CommandBarSettings,
65}
66
67#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Validate)]
69#[ts(export)]
70#[serde(rename_all = "snake_case")]
71pub struct AppSettings {
72 #[serde(default, skip_serializing_if = "is_default")]
74 #[validate(nested)]
75 pub appearance: AppearanceSettings,
76 #[serde(default, skip_serializing_if = "is_default")]
78 pub onboarding_status: OnboardingStatus,
79 #[serde(default, skip_serializing_if = "is_default")]
82 pub dismiss_web_banner: bool,
83 #[serde(
85 default,
86 deserialize_with = "deserialize_stream_idle_mode",
87 alias = "streamIdleMode",
88 skip_serializing_if = "is_default"
89 )]
90 stream_idle_mode: Option<u32>,
91 #[serde(default, skip_serializing_if = "is_default")]
93 pub allow_orbit_in_sketch_mode: bool,
94 #[serde(default, skip_serializing_if = "is_default")]
97 pub show_debug_panel: bool,
98}
99
100fn make_it_so() -> bool {
102 true
103}
104
105fn is_true(b: &bool) -> bool {
106 *b
107}
108
109fn deserialize_stream_idle_mode<'de, D>(deserializer: D) -> Result<Option<u32>, D::Error>
110where
111 D: Deserializer<'de>,
112{
113 #[derive(Deserialize)]
114 #[serde(untagged)]
115 enum StreamIdleModeValue {
116 Number(u32),
117 String(String),
118 Boolean(bool),
119 }
120
121 const DEFAULT_TIMEOUT: u32 = 1000 * 60 * 5;
122
123 Ok(match StreamIdleModeValue::deserialize(deserializer) {
124 Ok(StreamIdleModeValue::Number(value)) => Some(value),
125 Ok(StreamIdleModeValue::String(value)) => Some(value.parse::<u32>().unwrap_or(DEFAULT_TIMEOUT)),
126 Ok(StreamIdleModeValue::Boolean(true)) => Some(DEFAULT_TIMEOUT),
129 Ok(StreamIdleModeValue::Boolean(false)) => None,
130 _ => None,
131 })
132}
133
134#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq)]
135#[ts(export)]
136#[serde(untagged)]
137pub enum FloatOrInt {
138 String(String),
139 Float(f64),
140 Int(i64),
141}
142
143impl From<FloatOrInt> for f64 {
144 fn from(float_or_int: FloatOrInt) -> Self {
145 match float_or_int {
146 FloatOrInt::String(s) => s.parse().unwrap(),
147 FloatOrInt::Float(f) => f,
148 FloatOrInt::Int(i) => i as f64,
149 }
150 }
151}
152
153impl From<FloatOrInt> for AppColor {
154 fn from(float_or_int: FloatOrInt) -> Self {
155 match float_or_int {
156 FloatOrInt::String(s) => s.parse::<f64>().unwrap().into(),
157 FloatOrInt::Float(f) => f.into(),
158 FloatOrInt::Int(i) => (i as f64).into(),
159 }
160 }
161}
162
163#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Validate)]
165#[ts(export)]
166#[serde(rename_all = "snake_case")]
167pub struct AppearanceSettings {
168 #[serde(default, skip_serializing_if = "is_default")]
170 pub theme: AppTheme,
171 #[serde(default, skip_serializing_if = "is_default")]
173 #[validate(nested)]
174 pub color: AppColor,
175}
176
177#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq)]
178#[ts(export)]
179#[serde(transparent)]
180pub struct AppColor(pub f64);
181
182impl Default for AppColor {
183 fn default() -> Self {
184 Self(DEFAULT_THEME_COLOR)
185 }
186}
187
188impl From<AppColor> for f64 {
189 fn from(color: AppColor) -> Self {
190 color.0
191 }
192}
193
194impl From<f64> for AppColor {
195 fn from(color: f64) -> Self {
196 Self(color)
197 }
198}
199
200impl Validate for AppColor {
201 fn validate(&self) -> Result<(), validator::ValidationErrors> {
202 if !self.0.validate_range(Some(0.0), None, None, Some(360.0)) {
203 let mut errors = validator::ValidationErrors::new();
204 let mut err = validator::ValidationError::new("color");
205 err.add_param(std::borrow::Cow::from("min"), &0.0);
206 err.add_param(std::borrow::Cow::from("exclusive_max"), &360.0);
207 errors.add("color", err);
208 return Err(errors);
209 }
210 Ok(())
211 }
212}
213
214#[derive(
216 Debug, Default, Copy, Clone, Deserialize, Serialize, JsonSchema, Display, FromStr, ts_rs::TS, PartialEq, Eq,
217)]
218#[ts(export)]
219#[serde(rename_all = "snake_case")]
220#[display(style = "snake_case")]
221pub enum AppTheme {
222 Light,
224 Dark,
226 #[default]
229 System,
230}
231
232impl From<AppTheme> for kittycad::types::Color {
233 fn from(theme: AppTheme) -> Self {
234 match theme {
235 AppTheme::Light => kittycad::types::Color {
236 r: 249.0 / 255.0,
237 g: 249.0 / 255.0,
238 b: 249.0 / 255.0,
239 a: 1.0,
240 },
241 AppTheme::Dark => kittycad::types::Color {
242 r: 28.0 / 255.0,
243 g: 28.0 / 255.0,
244 b: 28.0 / 255.0,
245 a: 1.0,
246 },
247 AppTheme::System => {
248 todo!()
250 }
251 }
252 }
253}
254
255#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Validate)]
257#[serde(rename_all = "snake_case")]
258#[ts(export)]
259pub struct ModelingSettings {
260 #[serde(default = "default_length_unit_millimeters", skip_serializing_if = "is_default")]
262 pub base_unit: UnitLength,
263 #[serde(default, skip_serializing_if = "is_default")]
265 pub camera_projection: CameraProjectionType,
266 #[serde(default, skip_serializing_if = "is_default")]
268 pub camera_orbit: CameraOrbitType,
269 #[serde(default, skip_serializing_if = "is_default")]
271 pub mouse_controls: MouseControlType,
272 #[serde(default, skip_serializing_if = "is_default")]
274 pub enable_touch_controls: DefaultTrue,
275 #[serde(default, skip_serializing_if = "is_default")]
277 pub use_new_sketch_mode: bool,
278 #[serde(default, skip_serializing_if = "is_default")]
280 pub highlight_edges: DefaultTrue,
281 #[serde(default, skip_serializing_if = "is_default")]
283 pub enable_ssao: DefaultTrue,
284 #[serde(default, skip_serializing_if = "is_default")]
286 pub show_scale_grid: bool,
287 #[serde(default = "make_it_so", skip_serializing_if = "is_true")]
291 pub fixed_size_grid: bool,
292 #[serde(default, skip_serializing_if = "is_default")]
294 pub snap_to_grid: bool,
295 #[serde(default, skip_serializing_if = "is_default")]
297 pub major_grid_spacing: f64,
298 #[serde(default, skip_serializing_if = "is_default")]
300 pub minor_grids_per_major: f64,
301 #[serde(default, skip_serializing_if = "is_default")]
303 pub snaps_per_minor: f64,
304}
305
306fn default_length_unit_millimeters() -> UnitLength {
307 UnitLength::Millimeters
308}
309
310impl Default for ModelingSettings {
311 fn default() -> Self {
312 Self {
313 base_unit: UnitLength::Millimeters,
314 camera_projection: Default::default(),
315 camera_orbit: Default::default(),
316 mouse_controls: Default::default(),
317 enable_touch_controls: Default::default(),
318 use_new_sketch_mode: Default::default(),
319 highlight_edges: Default::default(),
320 enable_ssao: Default::default(),
321 show_scale_grid: Default::default(),
322 fixed_size_grid: true,
323 snap_to_grid: Default::default(),
324 major_grid_spacing: Default::default(),
325 minor_grids_per_major: Default::default(),
326 snaps_per_minor: Default::default(),
327 }
328 }
329}
330
331#[derive(Debug, Copy, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Eq)]
332#[ts(export)]
333#[serde(transparent)]
334pub struct DefaultTrue(pub bool);
335
336impl Default for DefaultTrue {
337 fn default() -> Self {
338 Self(true)
339 }
340}
341
342impl From<DefaultTrue> for bool {
343 fn from(default_true: DefaultTrue) -> Self {
344 default_true.0
345 }
346}
347
348impl From<bool> for DefaultTrue {
349 fn from(b: bool) -> Self {
350 Self(b)
351 }
352}
353
354#[derive(Debug, Default, Eq, PartialEq, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, Display, FromStr)]
356#[ts(export)]
357#[serde(rename_all = "snake_case")]
358#[display(style = "snake_case")]
359pub enum MouseControlType {
360 #[default]
361 #[display("zoo")]
362 #[serde(rename = "zoo")]
363 Zoo,
364 #[display("onshape")]
365 #[serde(rename = "onshape")]
366 OnShape,
367 TrackpadFriendly,
368 Solidworks,
369 Nx,
370 Creo,
371 #[display("autocad")]
372 #[serde(rename = "autocad")]
373 AutoCad,
374}
375
376#[derive(Debug, Default, Eq, PartialEq, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, Display, FromStr)]
378#[ts(export)]
379#[serde(rename_all = "snake_case")]
380#[display(style = "snake_case")]
381pub enum CameraProjectionType {
382 Perspective,
384 #[default]
386 Orthographic,
387}
388
389#[derive(Debug, Default, Eq, PartialEq, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, Display, FromStr)]
391#[ts(export)]
392#[serde(rename_all = "snake_case")]
393#[display(style = "snake_case")]
394pub enum CameraOrbitType {
395 #[default]
397 #[display("spherical")]
398 Spherical,
399 #[display("trackball")]
401 Trackball,
402}
403
404#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Eq, Validate)]
406#[serde(rename_all = "snake_case")]
407#[ts(export)]
408pub struct TextEditorSettings {
409 #[serde(default, skip_serializing_if = "is_default")]
411 pub text_wrapping: DefaultTrue,
412 #[serde(default, skip_serializing_if = "is_default")]
414 pub blinking_cursor: DefaultTrue,
415}
416
417#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Eq, Validate)]
419#[serde(rename_all = "snake_case")]
420#[ts(export)]
421pub struct ProjectTextEditorSettings {
422 #[serde(default, skip_serializing_if = "Option::is_none")]
424 pub text_wrapping: Option<bool>,
425 #[serde(default, skip_serializing_if = "Option::is_none")]
427 pub blinking_cursor: Option<bool>,
428}
429
430#[derive(Debug, Clone, Default, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Eq, Validate)]
432#[serde(rename_all = "snake_case")]
433#[ts(export)]
434pub struct ProjectSettings {
435 #[serde(default, skip_serializing_if = "is_default")]
437 pub directory: std::path::PathBuf,
438 #[serde(default, skip_serializing_if = "is_default")]
440 pub default_project_name: ProjectNameTemplate,
441}
442
443#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Eq)]
444#[ts(export)]
445#[serde(transparent)]
446pub struct ProjectNameTemplate(pub String);
447
448impl Default for ProjectNameTemplate {
449 fn default() -> Self {
450 Self(DEFAULT_PROJECT_NAME_TEMPLATE.to_string())
451 }
452}
453
454impl From<ProjectNameTemplate> for String {
455 fn from(project_name: ProjectNameTemplate) -> Self {
456 project_name.0
457 }
458}
459
460impl From<String> for ProjectNameTemplate {
461 fn from(s: String) -> Self {
462 Self(s)
463 }
464}
465
466#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Eq, Validate)]
468#[serde(rename_all = "snake_case")]
469#[ts(export)]
470pub struct CommandBarSettings {
471 #[serde(default, skip_serializing_if = "is_default")]
473 pub include_settings: DefaultTrue,
474}
475
476#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Eq, Validate)]
478#[serde(rename_all = "snake_case")]
479#[ts(export)]
480pub struct ProjectCommandBarSettings {
481 #[serde(default, skip_serializing_if = "Option::is_none")]
483 pub include_settings: Option<bool>,
484}
485
486#[derive(Debug, Default, Eq, PartialEq, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, Display, FromStr)]
488#[ts(export)]
489#[serde(rename_all = "snake_case")]
490#[display(style = "snake_case")]
491pub enum OnboardingStatus {
492 #[serde(rename = "")]
494 #[display("")]
495 Unset,
496 Completed,
498 #[default]
500 Incomplete,
501 Dismissed,
503
504 #[serde(rename = "/desktop")]
506 #[display("/desktop")]
507 DesktopWelcome,
508 #[serde(rename = "/desktop/scene")]
509 #[display("/desktop/scene")]
510 DesktopScene,
511 #[serde(rename = "/desktop/toolbar")]
512 #[display("/desktop/toolbar")]
513 DesktopToolbar,
514 #[serde(rename = "/desktop/text-to-cad")]
515 #[display("/desktop/text-to-cad")]
516 DesktopTextToCadWelcome,
517 #[serde(rename = "/desktop/text-to-cad-prompt")]
518 #[display("/desktop/text-to-cad-prompt")]
519 DesktopTextToCadPrompt,
520 #[serde(rename = "/desktop/feature-tree-pane")]
521 #[display("/desktop/feature-tree-pane")]
522 DesktopFeatureTreePane,
523 #[serde(rename = "/desktop/code-pane")]
524 #[display("/desktop/code-pane")]
525 DesktopCodePane,
526 #[serde(rename = "/desktop/project-pane")]
527 #[display("/desktop/project-pane")]
528 DesktopProjectFilesPane,
529 #[serde(rename = "/desktop/other-panes")]
530 #[display("/desktop/other-panes")]
531 DesktopOtherPanes,
532 #[serde(rename = "/desktop/prompt-to-edit")]
533 #[display("/desktop/prompt-to-edit")]
534 DesktopPromptToEditWelcome,
535 #[serde(rename = "/desktop/prompt-to-edit-prompt")]
536 #[display("/desktop/prompt-to-edit-prompt")]
537 DesktopPromptToEditPrompt,
538 #[serde(rename = "/desktop/prompt-to-edit-result")]
539 #[display("/desktop/prompt-to-edit-result")]
540 DesktopPromptToEditResult,
541 #[serde(rename = "/desktop/imports")]
542 #[display("/desktop/imports")]
543 DesktopImports,
544 #[serde(rename = "/desktop/exports")]
545 #[display("/desktop/exports")]
546 DesktopExports,
547 #[serde(rename = "/desktop/conclusion")]
548 #[display("/desktop/conclusion")]
549 DesktopConclusion,
550
551 #[serde(rename = "/browser")]
553 #[display("/browser")]
554 BrowserWelcome,
555 #[serde(rename = "/browser/scene")]
556 #[display("/browser/scene")]
557 BrowserScene,
558 #[serde(rename = "/browser/toolbar")]
559 #[display("/browser/toolbar")]
560 BrowserToolbar,
561 #[serde(rename = "/browser/text-to-cad")]
562 #[display("/browser/text-to-cad")]
563 BrowserTextToCadWelcome,
564 #[serde(rename = "/browser/text-to-cad-prompt")]
565 #[display("/browser/text-to-cad-prompt")]
566 BrowserTextToCadPrompt,
567 #[serde(rename = "/browser/feature-tree-pane")]
568 #[display("/browser/feature-tree-pane")]
569 BrowserFeatureTreePane,
570 #[serde(rename = "/browser/prompt-to-edit")]
571 #[display("/browser/prompt-to-edit")]
572 BrowserPromptToEditWelcome,
573 #[serde(rename = "/browser/prompt-to-edit-prompt")]
574 #[display("/browser/prompt-to-edit-prompt")]
575 BrowserPromptToEditPrompt,
576 #[serde(rename = "/browser/prompt-to-edit-result")]
577 #[display("/browser/prompt-to-edit-result")]
578 BrowserPromptToEditResult,
579 #[serde(rename = "/browser/conclusion")]
580 #[display("/browser/conclusion")]
581 BrowserConclusion,
582}
583
584fn is_default<T: Default + PartialEq>(t: &T) -> bool {
585 t == &T::default()
586}
587
588#[cfg(test)]
589mod tests {
590 use pretty_assertions::assert_eq;
591 use validator::Validate;
592
593 use super::{
594 AppColor, AppSettings, AppTheme, AppearanceSettings, CameraProjectionType, CommandBarSettings, Configuration,
595 ModelingSettings, MouseControlType, OnboardingStatus, ProjectNameTemplate, ProjectSettings, Settings,
596 TextEditorSettings, UnitLength,
597 };
598
599 #[test]
600 fn test_settings_empty_file_parses() {
601 let empty_settings_file = r#""#;
602
603 let parsed = toml::from_str::<Configuration>(empty_settings_file).unwrap();
604 assert_eq!(parsed, Configuration::default());
605
606 let serialized = toml::to_string(&parsed).unwrap();
608 assert_eq!(serialized, r#""#);
609
610 let parsed = Configuration::parse_and_validate(empty_settings_file).unwrap();
611 assert_eq!(parsed, Configuration::default());
612 }
613
614 #[test]
615 fn test_settings_parse_basic() {
616 let settings_file = r#"[settings.app]
617default_project_name = "untitled"
618directory = ""
619onboarding_status = "dismissed"
620
621 [settings.app.appearance]
622 theme = "dark"
623
624[settings.modeling]
625enable_ssao = false
626base_unit = "in"
627mouse_controls = "zoo"
628camera_projection = "perspective"
629
630[settings.project]
631default_project_name = "untitled"
632directory = ""
633
634[settings.text_editor]
635text_wrapping = true"#;
636
637 let expected = Configuration {
638 settings: Settings {
639 app: AppSettings {
640 onboarding_status: OnboardingStatus::Dismissed,
641 appearance: AppearanceSettings {
642 theme: AppTheme::Dark,
643 color: AppColor(264.5),
644 },
645 ..Default::default()
646 },
647 modeling: ModelingSettings {
648 enable_ssao: false.into(),
649 base_unit: UnitLength::Inches,
650 mouse_controls: MouseControlType::Zoo,
651 camera_projection: CameraProjectionType::Perspective,
652 fixed_size_grid: true,
653 ..Default::default()
654 },
655 project: ProjectSettings {
656 default_project_name: ProjectNameTemplate("untitled".to_string()),
657 directory: "".into(),
658 },
659 text_editor: TextEditorSettings {
660 text_wrapping: true.into(),
661 ..Default::default()
662 },
663 command_bar: CommandBarSettings {
664 include_settings: true.into(),
665 },
666 },
667 };
668 let parsed = toml::from_str::<Configuration>(settings_file).unwrap();
669 assert_eq!(parsed, expected);
670
671 let serialized = toml::to_string(&parsed).unwrap();
673 assert_eq!(
674 serialized,
675 r#"[settings.app]
676onboarding_status = "dismissed"
677
678[settings.app.appearance]
679theme = "dark"
680
681[settings.modeling]
682base_unit = "in"
683camera_projection = "perspective"
684enable_ssao = false
685"#
686 );
687
688 let parsed = Configuration::parse_and_validate(settings_file).unwrap();
689 assert_eq!(parsed, expected);
690 }
691
692 #[test]
693 fn test_color_validation() {
694 let color = AppColor(360.0);
695
696 let result = color.validate();
697 if let Ok(r) = result {
698 panic!("Expected an error, but got success: {r:?}");
699 }
700 assert!(result.is_err());
701 assert!(
702 result
703 .unwrap_err()
704 .to_string()
705 .contains("color: Validation error: color")
706 );
707
708 let appearance = AppearanceSettings {
709 theme: AppTheme::System,
710 color: AppColor(361.5),
711 };
712 let result = appearance.validate();
713 if let Ok(r) = result {
714 panic!("Expected an error, but got success: {r:?}");
715 }
716 assert!(result.is_err());
717 assert!(
718 result
719 .unwrap_err()
720 .to_string()
721 .contains("color: Validation error: color")
722 );
723 }
724
725 #[test]
726 fn test_settings_color_validation_error() {
727 let settings_file = r#"[settings.app.appearance]
728color = 1567.4"#;
729
730 let result = Configuration::parse_and_validate(settings_file);
731 if let Ok(r) = result {
732 panic!("Expected an error, but got success: {r:?}");
733 }
734 assert!(result.is_err());
735
736 assert!(
737 result
738 .unwrap_err()
739 .to_string()
740 .contains("color: Validation error: color")
741 );
742 }
743}