1pub mod project;
4
5use anyhow::Result;
6use parse_display::{Display, FromStr};
7use schemars::JsonSchema;
8use serde::{Deserialize, Serialize};
9use validator::{Validate, ValidateRange};
10
11const DEFAULT_THEME_COLOR: f64 = 264.5;
12const DEFAULT_PROJECT_NAME_TEMPLATE: &str = "project-$nnn";
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 backwards_compatible_toml_parse(toml_str: &str) -> Result<Self> {
32 let mut settings = toml::from_str::<Self>(toml_str)?;
33
34 if let Some(project_directory) = &settings.settings.app.project_directory {
35 if settings.settings.project.directory.to_string_lossy().is_empty() {
36 settings.settings.project.directory.clone_from(project_directory);
37 settings.settings.app.project_directory = None;
38 }
39 }
40
41 if let Some(theme) = &settings.settings.app.theme {
42 if settings.settings.app.appearance.theme == AppTheme::default() {
43 settings.settings.app.appearance.theme = *theme;
44 settings.settings.app.theme = None;
45 }
46 }
47
48 if let Some(theme_color) = &settings.settings.app.theme_color {
49 if settings.settings.app.appearance.color == AppColor::default() {
50 settings.settings.app.appearance.color = theme_color.clone().into();
51 settings.settings.app.theme_color = None;
52 }
53 }
54
55 if let Some(enable_ssao) = settings.settings.app.enable_ssao {
56 if settings.settings.modeling.enable_ssao.into() {
57 settings.settings.modeling.enable_ssao = enable_ssao.into();
58 settings.settings.app.enable_ssao = None;
59 }
60 }
61
62 if settings.settings.modeling.show_debug_panel && !settings.settings.app.show_debug_panel {
63 settings.settings.app.show_debug_panel = settings.settings.modeling.show_debug_panel;
64 settings.settings.modeling.show_debug_panel = Default::default();
65 }
66
67 settings.validate()?;
68
69 Ok(settings)
70 }
71}
72
73#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Validate)]
75#[ts(export)]
76#[serde(rename_all = "snake_case")]
77pub struct Settings {
78 #[serde(default, skip_serializing_if = "is_default")]
80 #[validate(nested)]
81 pub app: AppSettings,
82 #[serde(default, skip_serializing_if = "is_default")]
84 #[validate(nested)]
85 pub modeling: ModelingSettings,
86 #[serde(default, alias = "textEditor", skip_serializing_if = "is_default")]
88 #[validate(nested)]
89 pub text_editor: TextEditorSettings,
90 #[serde(default, alias = "projects", skip_serializing_if = "is_default")]
92 #[validate(nested)]
93 pub project: ProjectSettings,
94 #[serde(default, alias = "commandBar", skip_serializing_if = "is_default")]
96 #[validate(nested)]
97 pub command_bar: CommandBarSettings,
98}
99
100#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Validate)]
104#[ts(export)]
105#[serde(rename_all = "snake_case")]
106pub struct AppSettings {
107 #[serde(default, skip_serializing_if = "is_default")]
109 #[validate(nested)]
110 pub appearance: AppearanceSettings,
111 #[serde(default, alias = "onboardingStatus", skip_serializing_if = "is_default")]
113 pub onboarding_status: OnboardingStatus,
114 #[serde(default, alias = "projectDirectory", skip_serializing_if = "Option::is_none")]
116 pub project_directory: Option<std::path::PathBuf>,
117 #[serde(default, skip_serializing_if = "Option::is_none")]
119 pub theme: Option<AppTheme>,
120 #[serde(default, skip_serializing_if = "Option::is_none", alias = "themeColor")]
122 pub theme_color: Option<FloatOrInt>,
123 #[serde(default, alias = "enableSSAO", skip_serializing_if = "Option::is_none")]
125 pub enable_ssao: Option<bool>,
126 #[serde(default, alias = "dismissWebBanner", skip_serializing_if = "is_default")]
129 pub dismiss_web_banner: bool,
130 #[serde(default, alias = "streamIdleMode", skip_serializing_if = "is_default")]
132 pub stream_idle_mode: bool,
133 #[serde(default, alias = "allowOrbitInSketchMode", skip_serializing_if = "is_default")]
135 pub allow_orbit_in_sketch_mode: bool,
136 #[serde(default, alias = "showDebugPanel", skip_serializing_if = "is_default")]
139 pub show_debug_panel: bool,
140}
141
142#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq)]
144#[ts(export)]
145#[serde(untagged)]
146pub enum FloatOrInt {
147 String(String),
148 Float(f64),
149 Int(i64),
150}
151
152impl From<FloatOrInt> for f64 {
153 fn from(float_or_int: FloatOrInt) -> Self {
154 match float_or_int {
155 FloatOrInt::String(s) => s.parse().unwrap(),
156 FloatOrInt::Float(f) => f,
157 FloatOrInt::Int(i) => i as f64,
158 }
159 }
160}
161
162impl From<FloatOrInt> for AppColor {
163 fn from(float_or_int: FloatOrInt) -> Self {
164 match float_or_int {
165 FloatOrInt::String(s) => s.parse::<f64>().unwrap().into(),
166 FloatOrInt::Float(f) => f.into(),
167 FloatOrInt::Int(i) => (i as f64).into(),
168 }
169 }
170}
171
172#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Validate)]
174#[ts(export)]
175#[serde(rename_all = "snake_case")]
176pub struct AppearanceSettings {
177 #[serde(default, skip_serializing_if = "is_default")]
179 pub theme: AppTheme,
180 #[serde(default, skip_serializing_if = "is_default")]
182 #[validate(nested)]
183 pub color: AppColor,
184}
185
186#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq)]
187#[ts(export)]
188#[serde(transparent)]
189pub struct AppColor(pub f64);
190
191impl Default for AppColor {
192 fn default() -> Self {
193 Self(DEFAULT_THEME_COLOR)
194 }
195}
196
197impl From<AppColor> for f64 {
198 fn from(color: AppColor) -> Self {
199 color.0
200 }
201}
202
203impl From<f64> for AppColor {
204 fn from(color: f64) -> Self {
205 Self(color)
206 }
207}
208
209impl Validate for AppColor {
210 fn validate(&self) -> Result<(), validator::ValidationErrors> {
211 if !self.0.validate_range(Some(0.0), None, None, Some(360.0)) {
212 let mut errors = validator::ValidationErrors::new();
213 let mut err = validator::ValidationError::new("color");
214 err.add_param(std::borrow::Cow::from("min"), &0.0);
215 err.add_param(std::borrow::Cow::from("exclusive_max"), &360.0);
216 errors.add("color", err);
217 return Err(errors);
218 }
219 Ok(())
220 }
221}
222
223#[derive(
225 Debug, Default, Copy, Clone, Deserialize, Serialize, JsonSchema, Display, FromStr, ts_rs::TS, PartialEq, Eq,
226)]
227#[ts(export)]
228#[serde(rename_all = "snake_case")]
229#[display(style = "snake_case")]
230pub enum AppTheme {
231 Light,
233 Dark,
235 #[default]
238 System,
239}
240
241impl From<AppTheme> for kittycad::types::Color {
242 fn from(theme: AppTheme) -> Self {
243 match theme {
244 AppTheme::Light => kittycad::types::Color {
245 r: 249.0 / 255.0,
246 g: 249.0 / 255.0,
247 b: 249.0 / 255.0,
248 a: 1.0,
249 },
250 AppTheme::Dark => kittycad::types::Color {
251 r: 28.0 / 255.0,
252 g: 28.0 / 255.0,
253 b: 28.0 / 255.0,
254 a: 1.0,
255 },
256 AppTheme::System => {
257 todo!()
259 }
260 }
261 }
262}
263
264#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Eq, Validate)]
266#[serde(rename_all = "snake_case")]
267#[ts(export)]
268pub struct ModelingSettings {
269 #[serde(default, alias = "defaultUnit", skip_serializing_if = "is_default")]
271 pub base_unit: UnitLength,
272 #[serde(default, alias = "cameraProjection", skip_serializing_if = "is_default")]
274 pub camera_projection: CameraProjectionType,
275 #[serde(default, alias = "cameraOrbit", skip_serializing_if = "is_default")]
277 pub camera_orbit: CameraOrbitType,
278 #[serde(default, alias = "mouseControls", skip_serializing_if = "is_default")]
280 pub mouse_controls: MouseControlType,
281 #[serde(default, alias = "highlightEdges", skip_serializing_if = "is_default")]
283 pub highlight_edges: DefaultTrue,
284 #[serde(default, alias = "showDebugPanel", skip_serializing_if = "is_default")]
288 pub show_debug_panel: bool,
289 #[serde(default, skip_serializing_if = "is_default")]
291 pub enable_ssao: DefaultTrue,
292 #[serde(default, alias = "showScaleGrid", skip_serializing_if = "is_default")]
294 pub show_scale_grid: bool,
295}
296
297#[derive(Debug, Copy, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Eq)]
298#[ts(export)]
299#[serde(transparent)]
300pub struct DefaultTrue(pub bool);
301
302impl Default for DefaultTrue {
303 fn default() -> Self {
304 Self(true)
305 }
306}
307
308impl From<DefaultTrue> for bool {
309 fn from(default_true: DefaultTrue) -> Self {
310 default_true.0
311 }
312}
313
314impl From<bool> for DefaultTrue {
315 fn from(b: bool) -> Self {
316 Self(b)
317 }
318}
319
320#[derive(
322 Debug, Default, Eq, PartialEq, Copy, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, Display, FromStr,
323)]
324#[cfg_attr(feature = "pyo3", pyo3::pyclass(eq, eq_int))]
325#[ts(export)]
326#[serde(rename_all = "lowercase")]
327#[display(style = "lowercase")]
328pub enum UnitLength {
329 Cm,
331 Ft,
333 In,
335 M,
337 #[default]
339 Mm,
340 Yd,
342}
343
344impl From<kittycad::types::UnitLength> for UnitLength {
345 fn from(unit: kittycad::types::UnitLength) -> Self {
346 match unit {
347 kittycad::types::UnitLength::Cm => UnitLength::Cm,
348 kittycad::types::UnitLength::Ft => UnitLength::Ft,
349 kittycad::types::UnitLength::In => UnitLength::In,
350 kittycad::types::UnitLength::M => UnitLength::M,
351 kittycad::types::UnitLength::Mm => UnitLength::Mm,
352 kittycad::types::UnitLength::Yd => UnitLength::Yd,
353 }
354 }
355}
356
357impl From<UnitLength> for kittycad::types::UnitLength {
358 fn from(unit: UnitLength) -> Self {
359 match unit {
360 UnitLength::Cm => kittycad::types::UnitLength::Cm,
361 UnitLength::Ft => kittycad::types::UnitLength::Ft,
362 UnitLength::In => kittycad::types::UnitLength::In,
363 UnitLength::M => kittycad::types::UnitLength::M,
364 UnitLength::Mm => kittycad::types::UnitLength::Mm,
365 UnitLength::Yd => kittycad::types::UnitLength::Yd,
366 }
367 }
368}
369
370impl From<kittycad_modeling_cmds::units::UnitLength> for UnitLength {
371 fn from(unit: kittycad_modeling_cmds::units::UnitLength) -> Self {
372 match unit {
373 kittycad_modeling_cmds::units::UnitLength::Centimeters => UnitLength::Cm,
374 kittycad_modeling_cmds::units::UnitLength::Feet => UnitLength::Ft,
375 kittycad_modeling_cmds::units::UnitLength::Inches => UnitLength::In,
376 kittycad_modeling_cmds::units::UnitLength::Meters => UnitLength::M,
377 kittycad_modeling_cmds::units::UnitLength::Millimeters => UnitLength::Mm,
378 kittycad_modeling_cmds::units::UnitLength::Yards => UnitLength::Yd,
379 }
380 }
381}
382
383impl From<UnitLength> for kittycad_modeling_cmds::units::UnitLength {
384 fn from(unit: UnitLength) -> Self {
385 match unit {
386 UnitLength::Cm => kittycad_modeling_cmds::units::UnitLength::Centimeters,
387 UnitLength::Ft => kittycad_modeling_cmds::units::UnitLength::Feet,
388 UnitLength::In => kittycad_modeling_cmds::units::UnitLength::Inches,
389 UnitLength::M => kittycad_modeling_cmds::units::UnitLength::Meters,
390 UnitLength::Mm => kittycad_modeling_cmds::units::UnitLength::Millimeters,
391 UnitLength::Yd => kittycad_modeling_cmds::units::UnitLength::Yards,
392 }
393 }
394}
395
396#[derive(Debug, Default, Eq, PartialEq, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, Display, FromStr)]
398#[ts(export)]
399#[serde(rename_all = "snake_case")]
400#[display(style = "snake_case")]
401pub enum MouseControlType {
402 #[default]
403 #[display("zoo")]
404 #[serde(rename = "zoo", alias = "Zoo", alias = "KittyCAD")]
405 Zoo,
406 #[display("onshape")]
407 #[serde(rename = "onshape", alias = "OnShape")]
408 OnShape,
409 #[serde(alias = "Trackpad Friendly")]
410 TrackpadFriendly,
411 #[serde(alias = "Solidworks")]
412 Solidworks,
413 #[serde(alias = "NX")]
414 Nx,
415 #[serde(alias = "Creo")]
416 Creo,
417 #[display("autocad")]
418 #[serde(rename = "autocad", alias = "AutoCAD")]
419 AutoCad,
420}
421
422#[derive(Debug, Default, Eq, PartialEq, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, Display, FromStr)]
424#[ts(export)]
425#[serde(rename_all = "snake_case")]
426#[display(style = "snake_case")]
427pub enum CameraProjectionType {
428 Perspective,
430 #[default]
432 Orthographic,
433}
434
435#[derive(Debug, Default, Eq, PartialEq, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, Display, FromStr)]
437#[ts(export)]
438#[serde(rename_all = "snake_case")]
439#[display(style = "snake_case")]
440pub enum CameraOrbitType {
441 #[default]
443 #[display("spherical")]
444 Spherical,
445 #[display("trackball")]
447 Trackball,
448}
449
450#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Eq, Validate)]
452#[serde(rename_all = "snake_case")]
453#[ts(export)]
454pub struct TextEditorSettings {
455 #[serde(default, alias = "textWrapping", skip_serializing_if = "is_default")]
457 pub text_wrapping: DefaultTrue,
458 #[serde(default, alias = "blinkingCursor", skip_serializing_if = "is_default")]
460 pub blinking_cursor: DefaultTrue,
461}
462
463#[derive(Debug, Clone, Default, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Eq, Validate)]
465#[serde(rename_all = "snake_case")]
466#[ts(export)]
467pub struct ProjectSettings {
468 #[serde(default, skip_serializing_if = "is_default")]
470 pub directory: std::path::PathBuf,
471 #[serde(default, alias = "defaultProjectName", skip_serializing_if = "is_default")]
473 pub default_project_name: ProjectNameTemplate,
474}
475
476#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Eq)]
477#[ts(export)]
478#[serde(transparent)]
479pub struct ProjectNameTemplate(pub String);
480
481impl Default for ProjectNameTemplate {
482 fn default() -> Self {
483 Self(DEFAULT_PROJECT_NAME_TEMPLATE.to_string())
484 }
485}
486
487impl From<ProjectNameTemplate> for String {
488 fn from(project_name: ProjectNameTemplate) -> Self {
489 project_name.0
490 }
491}
492
493impl From<String> for ProjectNameTemplate {
494 fn from(s: String) -> Self {
495 Self(s)
496 }
497}
498
499#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Eq, Validate)]
501#[serde(rename_all = "snake_case")]
502#[ts(export)]
503pub struct CommandBarSettings {
504 #[serde(default, alias = "includeSettings", skip_serializing_if = "is_default")]
506 pub include_settings: DefaultTrue,
507}
508
509#[derive(Debug, Default, Eq, PartialEq, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, Display, FromStr)]
511#[ts(export)]
512#[serde(rename_all = "snake_case")]
513#[display(style = "snake_case")]
514pub enum OnboardingStatus {
515 #[serde(rename = "")]
517 #[display("")]
518 Unset,
519 Completed,
521 #[default]
523 Incomplete,
524 Dismissed,
526
527 #[serde(rename = "/")]
529 #[display("/")]
530 Index,
531 #[serde(rename = "/camera")]
532 #[display("/camera")]
533 Camera,
534 #[serde(rename = "/streaming")]
535 #[display("/streaming")]
536 Streaming,
537 #[serde(rename = "/editor")]
538 #[display("/editor")]
539 Editor,
540 #[serde(rename = "/parametric-modeling")]
541 #[display("/parametric-modeling")]
542 ParametricModeling,
543 #[serde(rename = "/interactive-numbers")]
544 #[display("/interactive-numbers")]
545 InteractiveNumbers,
546 #[serde(rename = "/command-k")]
547 #[display("/command-k")]
548 CommandK,
549 #[serde(rename = "/user-menu")]
550 #[display("/user-menu")]
551 UserMenu,
552 #[serde(rename = "/project-menu")]
553 #[display("/project-menu")]
554 ProjectMenu,
555 #[serde(rename = "/export")]
556 #[display("/export")]
557 Export,
558 #[serde(rename = "/move")]
559 #[display("/move")]
560 Move,
561 #[serde(rename = "/sketching")]
562 #[display("/sketching")]
563 Sketching,
564 #[serde(rename = "/future-work")]
565 #[display("/future-work")]
566 FutureWork,
567}
568
569fn is_default<T: Default + PartialEq>(t: &T) -> bool {
570 t == &T::default()
571}
572
573#[cfg(test)]
574mod tests {
575 use pretty_assertions::assert_eq;
576 use validator::Validate;
577
578 use super::{
579 AppColor, AppSettings, AppTheme, AppearanceSettings, CameraProjectionType, CommandBarSettings, Configuration,
580 ModelingSettings, OnboardingStatus, ProjectSettings, Settings, TextEditorSettings, UnitLength,
581 };
582 use crate::settings::types::CameraOrbitType;
583
584 #[test]
585 fn test_backwards_compatible_project_settings_file_pw() {
588 let old_project_file = r#"[settings.app]
589theme = "dark"
590onboardingStatus = "dismissed"
591projectDirectory = ""
592enableSSAO = false
593
594[settings.modeling]
595defaultUnit = "in"
596cameraProjection = "orthographic"
597mouseControls = "KittyCAD"
598showDebugPanel = true
599
600[settings.projects]
601defaultProjectName = "project-$nnn"
602
603[settings.textEditor]
604textWrapping = true
605#"#;
606
607 let parsed = Configuration::backwards_compatible_toml_parse(old_project_file).unwrap();
609 assert_eq!(
610 parsed,
611 Configuration {
612 settings: Settings {
613 app: AppSettings {
614 appearance: AppearanceSettings {
615 theme: AppTheme::Dark,
616 color: Default::default()
617 },
618 onboarding_status: OnboardingStatus::Dismissed,
619 project_directory: None,
620 theme: None,
621 theme_color: None,
622 dismiss_web_banner: false,
623 enable_ssao: None,
624 stream_idle_mode: false,
625 allow_orbit_in_sketch_mode: false,
626 show_debug_panel: true,
627 },
628 modeling: ModelingSettings {
629 base_unit: UnitLength::In,
630 camera_projection: CameraProjectionType::Orthographic,
631 camera_orbit: Default::default(),
632 mouse_controls: Default::default(),
633 show_debug_panel: Default::default(),
634 highlight_edges: Default::default(),
635 enable_ssao: false.into(),
636 show_scale_grid: false,
637 },
638 text_editor: TextEditorSettings {
639 text_wrapping: true.into(),
640 blinking_cursor: true.into()
641 },
642 project: Default::default(),
643 command_bar: CommandBarSettings {
644 include_settings: true.into()
645 },
646 }
647 }
648 );
649 }
650
651 #[test]
652 fn test_backwards_compatible_project_settings_file() {
655 let old_project_file = r#"[settings.app]
656theme = "dark"
657themeColor = "138"
658
659[settings.modeling]
660defaultUnit = "yd"
661showDebugPanel = true
662
663[settings.textEditor]
664textWrapping = false
665blinkingCursor = false
666
667[settings.commandBar]
668includeSettings = false
669#"#;
670
671 let parsed = Configuration::backwards_compatible_toml_parse(old_project_file).unwrap();
673 assert_eq!(
674 parsed,
675 Configuration {
676 settings: Settings {
677 app: AppSettings {
678 appearance: AppearanceSettings {
679 theme: AppTheme::Dark,
680 color: 138.0.into()
681 },
682 onboarding_status: Default::default(),
683 project_directory: None,
684 theme: None,
685 theme_color: None,
686 dismiss_web_banner: false,
687 enable_ssao: None,
688 show_debug_panel: true,
689 stream_idle_mode: false,
690 allow_orbit_in_sketch_mode: false,
691 },
692 modeling: ModelingSettings {
693 base_unit: UnitLength::Yd,
694 camera_projection: Default::default(),
695 camera_orbit: Default::default(),
696 mouse_controls: Default::default(),
697 highlight_edges: Default::default(),
698 enable_ssao: true.into(),
699 show_scale_grid: false,
700 show_debug_panel: Default::default(),
701 },
702 text_editor: TextEditorSettings {
703 text_wrapping: false.into(),
704 blinking_cursor: false.into()
705 },
706 project: Default::default(),
707 command_bar: CommandBarSettings {
708 include_settings: false.into()
709 },
710 }
711 }
712 );
713 }
714
715 #[test]
716 fn test_backwards_compatible_app_settings_file() {
719 let old_app_settings_file = r#"[settings.app]
720onboardingStatus = "dismissed"
721projectDirectory = "/Users/macinatormax/Documents/kittycad-modeling-projects"
722theme = "dark"
723themeColor = "138"
724
725[settings.modeling]
726defaultUnit = "yd"
727showDebugPanel = true
728
729[settings.textEditor]
730textWrapping = false
731blinkingCursor = false
732
733[settings.commandBar]
734includeSettings = false
735
736[settings.projects]
737defaultProjectName = "projects-$nnn"
738#"#;
739
740 let parsed = Configuration::backwards_compatible_toml_parse(old_app_settings_file).unwrap();
742 assert_eq!(
743 parsed,
744 Configuration {
745 settings: Settings {
746 app: AppSettings {
747 appearance: AppearanceSettings {
748 theme: AppTheme::Dark,
749 color: 138.0.into()
750 },
751 onboarding_status: OnboardingStatus::Dismissed,
752 project_directory: None,
753 theme: None,
754 theme_color: None,
755 dismiss_web_banner: false,
756 enable_ssao: None,
757 stream_idle_mode: false,
758 allow_orbit_in_sketch_mode: false,
759 show_debug_panel: true,
760 },
761 modeling: ModelingSettings {
762 base_unit: UnitLength::Yd,
763 camera_projection: Default::default(),
764 camera_orbit: CameraOrbitType::Spherical,
765 mouse_controls: Default::default(),
766 highlight_edges: Default::default(),
767 show_debug_panel: Default::default(),
768 enable_ssao: true.into(),
769 show_scale_grid: false,
770 },
771 text_editor: TextEditorSettings {
772 text_wrapping: false.into(),
773 blinking_cursor: false.into()
774 },
775 project: ProjectSettings {
776 directory: "/Users/macinatormax/Documents/kittycad-modeling-projects".into(),
777 default_project_name: "projects-$nnn".to_string().into()
778 },
779 command_bar: CommandBarSettings {
780 include_settings: false.into()
781 },
782 }
783 }
784 );
785
786 let serialized = toml::to_string(&parsed).unwrap();
788 assert_eq!(
789 serialized,
790 r#"[settings.app]
791onboarding_status = "dismissed"
792show_debug_panel = true
793
794[settings.app.appearance]
795theme = "dark"
796color = 138.0
797
798[settings.modeling]
799base_unit = "yd"
800
801[settings.text_editor]
802text_wrapping = false
803blinking_cursor = false
804
805[settings.project]
806directory = "/Users/macinatormax/Documents/kittycad-modeling-projects"
807default_project_name = "projects-$nnn"
808
809[settings.command_bar]
810include_settings = false
811"#
812 );
813 }
814
815 #[test]
816 fn test_settings_backwards_compat_partial() {
817 let partial_settings_file = r#"[settings.app]
818onboardingStatus = "dismissed"
819projectDirectory = "/Users/macinatormax/Documents/kittycad-modeling-projects""#;
820
821 let parsed = Configuration::backwards_compatible_toml_parse(partial_settings_file).unwrap();
823 assert_eq!(
824 parsed,
825 Configuration {
826 settings: Settings {
827 app: AppSettings {
828 appearance: AppearanceSettings {
829 theme: AppTheme::System,
830 color: Default::default()
831 },
832 onboarding_status: OnboardingStatus::Dismissed,
833 project_directory: None,
834 theme: None,
835 theme_color: None,
836 dismiss_web_banner: false,
837 enable_ssao: None,
838 show_debug_panel: false,
839 stream_idle_mode: false,
840 allow_orbit_in_sketch_mode: false,
841 },
842 modeling: ModelingSettings {
843 base_unit: UnitLength::Mm,
844 camera_projection: Default::default(),
845 camera_orbit: Default::default(),
846 mouse_controls: Default::default(),
847 highlight_edges: true.into(),
848 show_debug_panel: Default::default(),
849 enable_ssao: true.into(),
850 show_scale_grid: false,
851 },
852 text_editor: TextEditorSettings {
853 text_wrapping: true.into(),
854 blinking_cursor: true.into()
855 },
856 project: ProjectSettings {
857 directory: "/Users/macinatormax/Documents/kittycad-modeling-projects".into(),
858 default_project_name: "project-$nnn".to_string().into()
859 },
860 command_bar: CommandBarSettings {
861 include_settings: true.into()
862 },
863 }
864 }
865 );
866
867 let serialized = toml::to_string(&parsed).unwrap();
869 assert_eq!(
870 serialized,
871 r#"[settings.app]
872onboarding_status = "dismissed"
873
874[settings.project]
875directory = "/Users/macinatormax/Documents/kittycad-modeling-projects"
876"#
877 );
878 }
879
880 #[test]
881 fn test_settings_empty_file_parses() {
882 let empty_settings_file = r#""#;
883
884 let parsed = toml::from_str::<Configuration>(empty_settings_file).unwrap();
885 assert_eq!(parsed, Configuration::default());
886
887 let serialized = toml::to_string(&parsed).unwrap();
889 assert_eq!(serialized, r#""#);
890
891 let parsed = Configuration::backwards_compatible_toml_parse(empty_settings_file).unwrap();
892 assert_eq!(parsed, Configuration::default());
893 }
894
895 #[test]
896 fn test_color_validation() {
897 let color = AppColor(360.0);
898
899 let result = color.validate();
900 if let Ok(r) = result {
901 panic!("Expected an error, but got success: {:?}", r);
902 }
903 assert!(result.is_err());
904 assert!(result
905 .unwrap_err()
906 .to_string()
907 .contains("color: Validation error: color"));
908
909 let appearance = AppearanceSettings {
910 theme: AppTheme::System,
911 color: AppColor(361.5),
912 };
913 let result = appearance.validate();
914 if let Ok(r) = result {
915 panic!("Expected an error, but got success: {:?}", r);
916 }
917 assert!(result.is_err());
918 assert!(result
919 .unwrap_err()
920 .to_string()
921 .contains("color: Validation error: color"));
922 }
923
924 #[test]
925 fn test_settings_color_validation_error() {
926 let settings_file = r#"[settings.app.appearance]
927color = 1567.4"#;
928
929 let result = Configuration::backwards_compatible_toml_parse(settings_file);
930 if let Ok(r) = result {
931 panic!("Expected an error, but got success: {:?}", r);
932 }
933 assert!(result.is_err());
934
935 assert!(result
936 .unwrap_err()
937 .to_string()
938 .contains("color: Validation error: color"));
939 }
940}