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 enable_copilot: bool,
278 #[serde(default, skip_serializing_if = "is_default")]
280 pub use_new_sketch_mode: bool,
281 #[serde(default, skip_serializing_if = "is_default")]
283 pub highlight_edges: DefaultTrue,
284 #[serde(default, skip_serializing_if = "is_default")]
286 pub enable_ssao: DefaultTrue,
287 #[serde(default, skip_serializing_if = "is_default")]
289 pub show_scale_grid: bool,
290 #[serde(default = "make_it_so", skip_serializing_if = "is_true")]
294 pub fixed_size_grid: bool,
295 #[serde(default, skip_serializing_if = "is_default")]
297 pub snap_to_grid: bool,
298 #[serde(default, skip_serializing_if = "is_default")]
300 pub major_grid_spacing: f64,
301 #[serde(default, skip_serializing_if = "is_default")]
303 pub minor_grids_per_major: f64,
304 #[serde(default, skip_serializing_if = "is_default")]
306 pub snaps_per_minor: f64,
307}
308
309fn default_length_unit_millimeters() -> UnitLength {
310 UnitLength::Millimeters
311}
312
313impl Default for ModelingSettings {
314 fn default() -> Self {
315 Self {
316 base_unit: UnitLength::Millimeters,
317 camera_projection: Default::default(),
318 camera_orbit: Default::default(),
319 mouse_controls: Default::default(),
320 enable_touch_controls: Default::default(),
321 enable_copilot: Default::default(),
322 use_new_sketch_mode: Default::default(),
323 highlight_edges: Default::default(),
324 enable_ssao: Default::default(),
325 show_scale_grid: Default::default(),
326 fixed_size_grid: true,
327 snap_to_grid: Default::default(),
328 major_grid_spacing: Default::default(),
329 minor_grids_per_major: Default::default(),
330 snaps_per_minor: Default::default(),
331 }
332 }
333}
334
335#[derive(Debug, Copy, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Eq)]
336#[ts(export)]
337#[serde(transparent)]
338pub struct DefaultTrue(pub bool);
339
340impl Default for DefaultTrue {
341 fn default() -> Self {
342 Self(true)
343 }
344}
345
346impl From<DefaultTrue> for bool {
347 fn from(default_true: DefaultTrue) -> Self {
348 default_true.0
349 }
350}
351
352impl From<bool> for DefaultTrue {
353 fn from(b: bool) -> Self {
354 Self(b)
355 }
356}
357
358#[derive(Debug, Default, Eq, PartialEq, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, Display, FromStr)]
360#[ts(export)]
361#[serde(rename_all = "snake_case")]
362#[display(style = "snake_case")]
363pub enum MouseControlType {
364 #[default]
365 #[display("zoo")]
366 #[serde(rename = "zoo")]
367 Zoo,
368 #[display("onshape")]
369 #[serde(rename = "onshape")]
370 OnShape,
371 TrackpadFriendly,
372 Solidworks,
373 Nx,
374 Creo,
375 #[display("autocad")]
376 #[serde(rename = "autocad")]
377 AutoCad,
378}
379
380#[derive(Debug, Default, Eq, PartialEq, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, Display, FromStr)]
382#[ts(export)]
383#[serde(rename_all = "snake_case")]
384#[display(style = "snake_case")]
385pub enum CameraProjectionType {
386 Perspective,
388 #[default]
390 Orthographic,
391}
392
393#[derive(Debug, Default, Eq, PartialEq, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, Display, FromStr)]
395#[ts(export)]
396#[serde(rename_all = "snake_case")]
397#[display(style = "snake_case")]
398pub enum CameraOrbitType {
399 #[default]
401 #[display("spherical")]
402 Spherical,
403 #[display("trackball")]
405 Trackball,
406}
407
408#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Eq, Validate)]
410#[serde(rename_all = "snake_case")]
411#[ts(export)]
412pub struct TextEditorSettings {
413 #[serde(default, skip_serializing_if = "is_default")]
415 pub text_wrapping: DefaultTrue,
416 #[serde(default, skip_serializing_if = "is_default")]
418 pub blinking_cursor: DefaultTrue,
419}
420
421#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Eq, Validate)]
423#[serde(rename_all = "snake_case")]
424#[ts(export)]
425pub struct ProjectTextEditorSettings {
426 #[serde(default, skip_serializing_if = "Option::is_none")]
428 pub text_wrapping: Option<bool>,
429 #[serde(default, skip_serializing_if = "Option::is_none")]
431 pub blinking_cursor: Option<bool>,
432}
433
434#[derive(Debug, Clone, Default, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Eq, Validate)]
436#[serde(rename_all = "snake_case")]
437#[ts(export)]
438pub struct ProjectSettings {
439 #[serde(default, skip_serializing_if = "is_default")]
441 pub directory: std::path::PathBuf,
442 #[serde(default, skip_serializing_if = "is_default")]
444 pub default_project_name: ProjectNameTemplate,
445}
446
447#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Eq)]
448#[ts(export)]
449#[serde(transparent)]
450pub struct ProjectNameTemplate(pub String);
451
452impl Default for ProjectNameTemplate {
453 fn default() -> Self {
454 Self(DEFAULT_PROJECT_NAME_TEMPLATE.to_string())
455 }
456}
457
458impl From<ProjectNameTemplate> for String {
459 fn from(project_name: ProjectNameTemplate) -> Self {
460 project_name.0
461 }
462}
463
464impl From<String> for ProjectNameTemplate {
465 fn from(s: String) -> Self {
466 Self(s)
467 }
468}
469
470#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Eq, Validate)]
472#[serde(rename_all = "snake_case")]
473#[ts(export)]
474pub struct CommandBarSettings {
475 #[serde(default, skip_serializing_if = "is_default")]
477 pub include_settings: DefaultTrue,
478}
479
480#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Eq, Validate)]
482#[serde(rename_all = "snake_case")]
483#[ts(export)]
484pub struct ProjectCommandBarSettings {
485 #[serde(default, skip_serializing_if = "Option::is_none")]
487 pub include_settings: Option<bool>,
488}
489
490#[derive(Debug, Default, Eq, PartialEq, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, Display, FromStr)]
492#[ts(export)]
493#[serde(rename_all = "snake_case")]
494#[display(style = "snake_case")]
495pub enum OnboardingStatus {
496 #[serde(rename = "")]
498 #[display("")]
499 Unset,
500 Completed,
502 #[default]
504 Incomplete,
505 Dismissed,
507
508 #[serde(rename = "/desktop")]
510 #[display("/desktop")]
511 DesktopWelcome,
512 #[serde(rename = "/desktop/scene")]
513 #[display("/desktop/scene")]
514 DesktopScene,
515 #[serde(rename = "/desktop/toolbar")]
516 #[display("/desktop/toolbar")]
517 DesktopToolbar,
518 #[serde(rename = "/desktop/text-to-cad")]
519 #[display("/desktop/text-to-cad")]
520 DesktopTextToCadWelcome,
521 #[serde(rename = "/desktop/text-to-cad-prompt")]
522 #[display("/desktop/text-to-cad-prompt")]
523 DesktopTextToCadPrompt,
524 #[serde(rename = "/desktop/feature-tree-pane")]
525 #[display("/desktop/feature-tree-pane")]
526 DesktopFeatureTreePane,
527 #[serde(rename = "/desktop/code-pane")]
528 #[display("/desktop/code-pane")]
529 DesktopCodePane,
530 #[serde(rename = "/desktop/project-pane")]
531 #[display("/desktop/project-pane")]
532 DesktopProjectFilesPane,
533 #[serde(rename = "/desktop/other-panes")]
534 #[display("/desktop/other-panes")]
535 DesktopOtherPanes,
536 #[serde(rename = "/desktop/prompt-to-edit")]
537 #[display("/desktop/prompt-to-edit")]
538 DesktopPromptToEditWelcome,
539 #[serde(rename = "/desktop/prompt-to-edit-prompt")]
540 #[display("/desktop/prompt-to-edit-prompt")]
541 DesktopPromptToEditPrompt,
542 #[serde(rename = "/desktop/prompt-to-edit-result")]
543 #[display("/desktop/prompt-to-edit-result")]
544 DesktopPromptToEditResult,
545 #[serde(rename = "/desktop/imports")]
546 #[display("/desktop/imports")]
547 DesktopImports,
548 #[serde(rename = "/desktop/exports")]
549 #[display("/desktop/exports")]
550 DesktopExports,
551 #[serde(rename = "/desktop/conclusion")]
552 #[display("/desktop/conclusion")]
553 DesktopConclusion,
554
555 #[serde(rename = "/browser")]
557 #[display("/browser")]
558 BrowserWelcome,
559 #[serde(rename = "/browser/scene")]
560 #[display("/browser/scene")]
561 BrowserScene,
562 #[serde(rename = "/browser/toolbar")]
563 #[display("/browser/toolbar")]
564 BrowserToolbar,
565 #[serde(rename = "/browser/text-to-cad")]
566 #[display("/browser/text-to-cad")]
567 BrowserTextToCadWelcome,
568 #[serde(rename = "/browser/text-to-cad-prompt")]
569 #[display("/browser/text-to-cad-prompt")]
570 BrowserTextToCadPrompt,
571 #[serde(rename = "/browser/feature-tree-pane")]
572 #[display("/browser/feature-tree-pane")]
573 BrowserFeatureTreePane,
574 #[serde(rename = "/browser/prompt-to-edit")]
575 #[display("/browser/prompt-to-edit")]
576 BrowserPromptToEditWelcome,
577 #[serde(rename = "/browser/prompt-to-edit-prompt")]
578 #[display("/browser/prompt-to-edit-prompt")]
579 BrowserPromptToEditPrompt,
580 #[serde(rename = "/browser/prompt-to-edit-result")]
581 #[display("/browser/prompt-to-edit-result")]
582 BrowserPromptToEditResult,
583 #[serde(rename = "/browser/conclusion")]
584 #[display("/browser/conclusion")]
585 BrowserConclusion,
586}
587
588fn is_default<T: Default + PartialEq>(t: &T) -> bool {
589 t == &T::default()
590}
591
592#[cfg(test)]
593mod tests {
594 use pretty_assertions::assert_eq;
595 use validator::Validate;
596
597 use super::{
598 AppColor, AppSettings, AppTheme, AppearanceSettings, CameraProjectionType, CommandBarSettings, Configuration,
599 ModelingSettings, MouseControlType, OnboardingStatus, ProjectNameTemplate, ProjectSettings, Settings,
600 TextEditorSettings, UnitLength,
601 };
602
603 #[test]
604 fn test_settings_empty_file_parses() {
605 let empty_settings_file = r#""#;
606
607 let parsed = toml::from_str::<Configuration>(empty_settings_file).unwrap();
608 assert_eq!(parsed, Configuration::default());
609
610 let serialized = toml::to_string(&parsed).unwrap();
612 assert_eq!(serialized, r#""#);
613
614 let parsed = Configuration::parse_and_validate(empty_settings_file).unwrap();
615 assert_eq!(parsed, Configuration::default());
616 }
617
618 #[test]
619 fn test_settings_parse_basic() {
620 let settings_file = r#"[settings.app]
621default_project_name = "untitled"
622directory = ""
623onboarding_status = "dismissed"
624
625 [settings.app.appearance]
626 theme = "dark"
627
628[settings.modeling]
629enable_ssao = false
630base_unit = "in"
631mouse_controls = "zoo"
632camera_projection = "perspective"
633
634[settings.project]
635default_project_name = "untitled"
636directory = ""
637
638[settings.text_editor]
639text_wrapping = true"#;
640
641 let expected = Configuration {
642 settings: Settings {
643 app: AppSettings {
644 onboarding_status: OnboardingStatus::Dismissed,
645 appearance: AppearanceSettings {
646 theme: AppTheme::Dark,
647 color: AppColor(264.5),
648 },
649 ..Default::default()
650 },
651 modeling: ModelingSettings {
652 enable_ssao: false.into(),
653 base_unit: UnitLength::Inches,
654 mouse_controls: MouseControlType::Zoo,
655 camera_projection: CameraProjectionType::Perspective,
656 fixed_size_grid: true,
657 ..Default::default()
658 },
659 project: ProjectSettings {
660 default_project_name: ProjectNameTemplate("untitled".to_string()),
661 directory: "".into(),
662 },
663 text_editor: TextEditorSettings {
664 text_wrapping: true.into(),
665 ..Default::default()
666 },
667 command_bar: CommandBarSettings {
668 include_settings: true.into(),
669 },
670 },
671 };
672 let parsed = toml::from_str::<Configuration>(settings_file).unwrap();
673 assert_eq!(parsed, expected);
674
675 let serialized = toml::to_string(&parsed).unwrap();
677 assert_eq!(
678 serialized,
679 r#"[settings.app]
680onboarding_status = "dismissed"
681
682[settings.app.appearance]
683theme = "dark"
684
685[settings.modeling]
686base_unit = "in"
687camera_projection = "perspective"
688enable_ssao = false
689"#
690 );
691
692 let parsed = Configuration::parse_and_validate(settings_file).unwrap();
693 assert_eq!(parsed, expected);
694 }
695
696 #[test]
697 fn test_color_validation() {
698 let color = AppColor(360.0);
699
700 let result = color.validate();
701 if let Ok(r) = result {
702 panic!("Expected an error, but got success: {r:?}");
703 }
704 assert!(result.is_err());
705 assert!(
706 result
707 .unwrap_err()
708 .to_string()
709 .contains("color: Validation error: color")
710 );
711
712 let appearance = AppearanceSettings {
713 theme: AppTheme::System,
714 color: AppColor(361.5),
715 };
716 let result = appearance.validate();
717 if let Ok(r) = result {
718 panic!("Expected an error, but got success: {r:?}");
719 }
720 assert!(result.is_err());
721 assert!(
722 result
723 .unwrap_err()
724 .to_string()
725 .contains("color: Validation error: color")
726 );
727 }
728
729 #[test]
730 fn test_settings_color_validation_error() {
731 let settings_file = r#"[settings.app.appearance]
732color = 1567.4"#;
733
734 let result = Configuration::parse_and_validate(settings_file);
735 if let Ok(r) = result {
736 panic!("Expected an error, but got success: {r:?}");
737 }
738 assert!(result.is_err());
739
740 assert!(
741 result
742 .unwrap_err()
743 .to_string()
744 .contains("color: Validation error: color")
745 );
746 }
747}