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