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