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 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 #[ts(skip)]
117 pub project_directory: Option<std::path::PathBuf>,
118 #[serde(default, skip_serializing_if = "Option::is_none")]
120 #[ts(skip)]
121 pub theme: Option<AppTheme>,
122 #[serde(default, skip_serializing_if = "Option::is_none", alias = "themeColor")]
124 #[ts(skip)]
125 pub theme_color: Option<FloatOrInt>,
126 #[serde(default, alias = "enableSSAO", skip_serializing_if = "Option::is_none")]
128 #[ts(skip)]
129 pub enable_ssao: Option<bool>,
130 #[serde(default, alias = "dismissWebBanner", skip_serializing_if = "is_default")]
133 pub dismiss_web_banner: bool,
134 #[serde(
136 default,
137 deserialize_with = "deserialize_stream_idle_mode",
138 alias = "streamIdleMode",
139 skip_serializing_if = "is_default"
140 )]
141 stream_idle_mode: Option<u32>,
142 #[serde(default, alias = "allowOrbitInSketchMode", skip_serializing_if = "is_default")]
144 pub allow_orbit_in_sketch_mode: bool,
145 #[serde(default, alias = "showDebugPanel", skip_serializing_if = "is_default")]
148 pub show_debug_panel: bool,
149}
150
151fn deserialize_stream_idle_mode<'de, D>(deserializer: D) -> Result<Option<u32>, D::Error>
152where
153 D: Deserializer<'de>,
154{
155 #[derive(Deserialize)]
156 #[serde(untagged)]
157 enum StreamIdleModeValue {
158 Number(u32),
159 String(String),
160 Boolean(bool),
161 }
162
163 const DEFAULT_TIMEOUT: u32 = 1000 * 60 * 5;
164
165 Ok(match StreamIdleModeValue::deserialize(deserializer) {
166 Ok(StreamIdleModeValue::Number(value)) => Some(value),
167 Ok(StreamIdleModeValue::String(value)) => Some(value.parse::<u32>().unwrap_or(DEFAULT_TIMEOUT)),
168 Ok(StreamIdleModeValue::Boolean(true)) => Some(DEFAULT_TIMEOUT),
171 Ok(StreamIdleModeValue::Boolean(false)) => None,
172 _ => None,
173 })
174}
175
176#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq)]
177#[ts(export)]
178#[serde(untagged)]
179pub enum FloatOrInt {
180 String(String),
181 Float(f64),
182 Int(i64),
183}
184
185impl From<FloatOrInt> for f64 {
186 fn from(float_or_int: FloatOrInt) -> Self {
187 match float_or_int {
188 FloatOrInt::String(s) => s.parse().unwrap(),
189 FloatOrInt::Float(f) => f,
190 FloatOrInt::Int(i) => i as f64,
191 }
192 }
193}
194
195impl From<FloatOrInt> for AppColor {
196 fn from(float_or_int: FloatOrInt) -> Self {
197 match float_or_int {
198 FloatOrInt::String(s) => s.parse::<f64>().unwrap().into(),
199 FloatOrInt::Float(f) => f.into(),
200 FloatOrInt::Int(i) => (i as f64).into(),
201 }
202 }
203}
204
205#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Validate)]
207#[ts(export)]
208#[serde(rename_all = "snake_case")]
209pub struct AppearanceSettings {
210 #[serde(default, skip_serializing_if = "is_default")]
212 pub theme: AppTheme,
213 #[serde(default, skip_serializing_if = "is_default")]
215 #[validate(nested)]
216 pub color: AppColor,
217}
218
219#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq)]
220#[ts(export)]
221#[serde(transparent)]
222pub struct AppColor(pub f64);
223
224impl Default for AppColor {
225 fn default() -> Self {
226 Self(DEFAULT_THEME_COLOR)
227 }
228}
229
230impl From<AppColor> for f64 {
231 fn from(color: AppColor) -> Self {
232 color.0
233 }
234}
235
236impl From<f64> for AppColor {
237 fn from(color: f64) -> Self {
238 Self(color)
239 }
240}
241
242impl Validate for AppColor {
243 fn validate(&self) -> Result<(), validator::ValidationErrors> {
244 if !self.0.validate_range(Some(0.0), None, None, Some(360.0)) {
245 let mut errors = validator::ValidationErrors::new();
246 let mut err = validator::ValidationError::new("color");
247 err.add_param(std::borrow::Cow::from("min"), &0.0);
248 err.add_param(std::borrow::Cow::from("exclusive_max"), &360.0);
249 errors.add("color", err);
250 return Err(errors);
251 }
252 Ok(())
253 }
254}
255
256#[derive(
258 Debug, Default, Copy, Clone, Deserialize, Serialize, JsonSchema, Display, FromStr, ts_rs::TS, PartialEq, Eq,
259)]
260#[ts(export)]
261#[serde(rename_all = "snake_case")]
262#[display(style = "snake_case")]
263pub enum AppTheme {
264 Light,
266 Dark,
268 #[default]
271 System,
272}
273
274impl From<AppTheme> for kittycad::types::Color {
275 fn from(theme: AppTheme) -> Self {
276 match theme {
277 AppTheme::Light => kittycad::types::Color {
278 r: 249.0 / 255.0,
279 g: 249.0 / 255.0,
280 b: 249.0 / 255.0,
281 a: 1.0,
282 },
283 AppTheme::Dark => kittycad::types::Color {
284 r: 28.0 / 255.0,
285 g: 28.0 / 255.0,
286 b: 28.0 / 255.0,
287 a: 1.0,
288 },
289 AppTheme::System => {
290 todo!()
292 }
293 }
294 }
295}
296
297#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Eq, Validate)]
299#[serde(rename_all = "snake_case")]
300#[ts(export)]
301pub struct ModelingSettings {
302 #[serde(default, alias = "defaultUnit", skip_serializing_if = "is_default")]
304 pub base_unit: UnitLength,
305 #[serde(default, alias = "cameraProjection", skip_serializing_if = "is_default")]
307 pub camera_projection: CameraProjectionType,
308 #[serde(default, alias = "cameraOrbit", skip_serializing_if = "is_default")]
310 pub camera_orbit: CameraOrbitType,
311 #[serde(default, alias = "mouseControls", skip_serializing_if = "is_default")]
313 pub mouse_controls: MouseControlType,
314 #[serde(default, alias = "highlightEdges", skip_serializing_if = "is_default")]
316 pub highlight_edges: DefaultTrue,
317 #[serde(default, alias = "showDebugPanel", skip_serializing_if = "is_default")]
321 #[ts(skip)]
322 pub show_debug_panel: bool,
323 #[serde(default, skip_serializing_if = "is_default")]
325 pub enable_ssao: DefaultTrue,
326 #[serde(default, alias = "showScaleGrid", skip_serializing_if = "is_default")]
328 pub show_scale_grid: bool,
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(
356 Debug, Default, Eq, PartialEq, Copy, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, Display, FromStr,
357)]
358#[cfg_attr(feature = "pyo3", pyo3::pyclass(eq, eq_int))]
359#[ts(export)]
360#[serde(rename_all = "lowercase")]
361#[display(style = "lowercase")]
362pub enum UnitLength {
363 Cm,
365 Ft,
367 In,
369 M,
371 #[default]
373 Mm,
374 Yd,
376}
377
378impl From<kittycad::types::UnitLength> for UnitLength {
379 fn from(unit: kittycad::types::UnitLength) -> Self {
380 match unit {
381 kittycad::types::UnitLength::Cm => UnitLength::Cm,
382 kittycad::types::UnitLength::Ft => UnitLength::Ft,
383 kittycad::types::UnitLength::In => UnitLength::In,
384 kittycad::types::UnitLength::M => UnitLength::M,
385 kittycad::types::UnitLength::Mm => UnitLength::Mm,
386 kittycad::types::UnitLength::Yd => UnitLength::Yd,
387 }
388 }
389}
390
391impl From<UnitLength> for kittycad::types::UnitLength {
392 fn from(unit: UnitLength) -> Self {
393 match unit {
394 UnitLength::Cm => kittycad::types::UnitLength::Cm,
395 UnitLength::Ft => kittycad::types::UnitLength::Ft,
396 UnitLength::In => kittycad::types::UnitLength::In,
397 UnitLength::M => kittycad::types::UnitLength::M,
398 UnitLength::Mm => kittycad::types::UnitLength::Mm,
399 UnitLength::Yd => kittycad::types::UnitLength::Yd,
400 }
401 }
402}
403
404impl From<kittycad_modeling_cmds::units::UnitLength> for UnitLength {
405 fn from(unit: kittycad_modeling_cmds::units::UnitLength) -> Self {
406 match unit {
407 kittycad_modeling_cmds::units::UnitLength::Centimeters => UnitLength::Cm,
408 kittycad_modeling_cmds::units::UnitLength::Feet => UnitLength::Ft,
409 kittycad_modeling_cmds::units::UnitLength::Inches => UnitLength::In,
410 kittycad_modeling_cmds::units::UnitLength::Meters => UnitLength::M,
411 kittycad_modeling_cmds::units::UnitLength::Millimeters => UnitLength::Mm,
412 kittycad_modeling_cmds::units::UnitLength::Yards => UnitLength::Yd,
413 }
414 }
415}
416
417impl From<UnitLength> for kittycad_modeling_cmds::units::UnitLength {
418 fn from(unit: UnitLength) -> Self {
419 match unit {
420 UnitLength::Cm => kittycad_modeling_cmds::units::UnitLength::Centimeters,
421 UnitLength::Ft => kittycad_modeling_cmds::units::UnitLength::Feet,
422 UnitLength::In => kittycad_modeling_cmds::units::UnitLength::Inches,
423 UnitLength::M => kittycad_modeling_cmds::units::UnitLength::Meters,
424 UnitLength::Mm => kittycad_modeling_cmds::units::UnitLength::Millimeters,
425 UnitLength::Yd => kittycad_modeling_cmds::units::UnitLength::Yards,
426 }
427 }
428}
429
430#[derive(Debug, Default, Eq, PartialEq, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, Display, FromStr)]
432#[ts(export)]
433#[serde(rename_all = "snake_case")]
434#[display(style = "snake_case")]
435pub enum MouseControlType {
436 #[default]
437 #[display("zoo")]
438 #[serde(rename = "zoo", alias = "Zoo", alias = "KittyCAD")]
439 Zoo,
440 #[display("onshape")]
441 #[serde(rename = "onshape", alias = "OnShape")]
442 OnShape,
443 #[serde(alias = "Trackpad Friendly")]
444 TrackpadFriendly,
445 #[serde(alias = "Solidworks")]
446 Solidworks,
447 #[serde(alias = "NX")]
448 Nx,
449 #[serde(alias = "Creo")]
450 Creo,
451 #[display("autocad")]
452 #[serde(rename = "autocad", alias = "AutoCAD")]
453 AutoCad,
454}
455
456#[derive(Debug, Default, Eq, PartialEq, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, Display, FromStr)]
458#[ts(export)]
459#[serde(rename_all = "snake_case")]
460#[display(style = "snake_case")]
461pub enum CameraProjectionType {
462 Perspective,
464 #[default]
466 Orthographic,
467}
468
469#[derive(Debug, Default, Eq, PartialEq, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, Display, FromStr)]
471#[ts(export)]
472#[serde(rename_all = "snake_case")]
473#[display(style = "snake_case")]
474pub enum CameraOrbitType {
475 #[default]
477 #[display("spherical")]
478 Spherical,
479 #[display("trackball")]
481 Trackball,
482}
483
484#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Eq, Validate)]
486#[serde(rename_all = "snake_case")]
487#[ts(export)]
488pub struct TextEditorSettings {
489 #[serde(default, alias = "textWrapping", skip_serializing_if = "is_default")]
491 pub text_wrapping: DefaultTrue,
492 #[serde(default, alias = "blinkingCursor", skip_serializing_if = "is_default")]
494 pub blinking_cursor: DefaultTrue,
495}
496
497#[derive(Debug, Clone, Default, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Eq, Validate)]
499#[serde(rename_all = "snake_case")]
500#[ts(export)]
501pub struct ProjectSettings {
502 #[serde(default, skip_serializing_if = "is_default")]
504 pub directory: std::path::PathBuf,
505 #[serde(default, alias = "defaultProjectName", skip_serializing_if = "is_default")]
507 pub default_project_name: ProjectNameTemplate,
508}
509
510#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Eq)]
511#[ts(export)]
512#[serde(transparent)]
513pub struct ProjectNameTemplate(pub String);
514
515impl Default for ProjectNameTemplate {
516 fn default() -> Self {
517 Self(DEFAULT_PROJECT_NAME_TEMPLATE.to_string())
518 }
519}
520
521impl From<ProjectNameTemplate> for String {
522 fn from(project_name: ProjectNameTemplate) -> Self {
523 project_name.0
524 }
525}
526
527impl From<String> for ProjectNameTemplate {
528 fn from(s: String) -> Self {
529 Self(s)
530 }
531}
532
533#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Eq, Validate)]
535#[serde(rename_all = "snake_case")]
536#[ts(export)]
537pub struct CommandBarSettings {
538 #[serde(default, alias = "includeSettings", skip_serializing_if = "is_default")]
540 pub include_settings: DefaultTrue,
541}
542
543#[derive(Debug, Default, Eq, PartialEq, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, Display, FromStr)]
545#[ts(export)]
546#[serde(rename_all = "snake_case")]
547#[display(style = "snake_case")]
548pub enum OnboardingStatus {
549 #[serde(rename = "")]
551 #[display("")]
552 Unset,
553 Completed,
555 #[default]
557 Incomplete,
558 Dismissed,
560
561 #[serde(rename = "/")]
563 #[display("/")]
564 Index,
565 #[serde(rename = "/camera")]
566 #[display("/camera")]
567 Camera,
568 #[serde(rename = "/streaming")]
569 #[display("/streaming")]
570 Streaming,
571 #[serde(rename = "/editor")]
572 #[display("/editor")]
573 Editor,
574 #[serde(rename = "/parametric-modeling")]
575 #[display("/parametric-modeling")]
576 ParametricModeling,
577 #[serde(rename = "/interactive-numbers")]
578 #[display("/interactive-numbers")]
579 InteractiveNumbers,
580 #[serde(rename = "/command-k")]
581 #[display("/command-k")]
582 CommandK,
583 #[serde(rename = "/user-menu")]
584 #[display("/user-menu")]
585 UserMenu,
586 #[serde(rename = "/project-menu")]
587 #[display("/project-menu")]
588 ProjectMenu,
589 #[serde(rename = "/export")]
590 #[display("/export")]
591 Export,
592 #[serde(rename = "/move")]
593 #[display("/move")]
594 Move,
595 #[serde(rename = "/sketching")]
596 #[display("/sketching")]
597 Sketching,
598 #[serde(rename = "/future-work")]
599 #[display("/future-work")]
600 FutureWork,
601}
602
603fn is_default<T: Default + PartialEq>(t: &T) -> bool {
604 t == &T::default()
605}
606
607#[cfg(test)]
608mod tests {
609 use pretty_assertions::assert_eq;
610 use validator::Validate;
611
612 use super::{
613 AppColor, AppSettings, AppTheme, AppearanceSettings, CameraProjectionType, CommandBarSettings, Configuration,
614 ModelingSettings, OnboardingStatus, ProjectSettings, Settings, TextEditorSettings, UnitLength,
615 };
616 use crate::settings::types::CameraOrbitType;
617
618 #[test]
619 fn test_backwards_compatible_project_settings_file_pw() {
622 let old_project_file = r#"[settings.app]
623theme = "dark"
624onboardingStatus = "dismissed"
625projectDirectory = ""
626enableSSAO = false
627
628[settings.modeling]
629defaultUnit = "in"
630cameraProjection = "orthographic"
631mouseControls = "KittyCAD"
632showDebugPanel = true
633
634[settings.projects]
635defaultProjectName = "untitled"
636
637[settings.textEditor]
638textWrapping = true
639#"#;
640
641 let parsed = Configuration::backwards_compatible_toml_parse(old_project_file).unwrap();
643 assert_eq!(
644 parsed,
645 Configuration {
646 settings: Settings {
647 app: AppSettings {
648 appearance: AppearanceSettings {
649 theme: AppTheme::Dark,
650 color: Default::default()
651 },
652 onboarding_status: OnboardingStatus::Dismissed,
653 project_directory: None,
654 theme: None,
655 theme_color: None,
656 dismiss_web_banner: false,
657 enable_ssao: None,
658 stream_idle_mode: None,
659 allow_orbit_in_sketch_mode: false,
660 show_debug_panel: true,
661 },
662 modeling: ModelingSettings {
663 base_unit: UnitLength::In,
664 camera_projection: CameraProjectionType::Orthographic,
665 camera_orbit: Default::default(),
666 mouse_controls: Default::default(),
667 show_debug_panel: Default::default(),
668 highlight_edges: Default::default(),
669 enable_ssao: false.into(),
670 show_scale_grid: false,
671 },
672 text_editor: TextEditorSettings {
673 text_wrapping: true.into(),
674 blinking_cursor: true.into()
675 },
676 project: Default::default(),
677 command_bar: CommandBarSettings {
678 include_settings: true.into()
679 },
680 }
681 }
682 );
683 }
684
685 #[test]
686 fn test_backwards_compatible_project_settings_file() {
689 let old_project_file = r#"[settings.app]
690theme = "dark"
691themeColor = "138"
692
693[settings.modeling]
694defaultUnit = "yd"
695showDebugPanel = true
696
697[settings.textEditor]
698textWrapping = false
699blinkingCursor = false
700
701[settings.commandBar]
702includeSettings = false
703#"#;
704
705 let parsed = Configuration::backwards_compatible_toml_parse(old_project_file).unwrap();
707 assert_eq!(
708 parsed,
709 Configuration {
710 settings: Settings {
711 app: AppSettings {
712 appearance: AppearanceSettings {
713 theme: AppTheme::Dark,
714 color: 138.0.into()
715 },
716 onboarding_status: Default::default(),
717 project_directory: None,
718 theme: None,
719 theme_color: None,
720 dismiss_web_banner: false,
721 enable_ssao: None,
722 show_debug_panel: true,
723 stream_idle_mode: None,
724 allow_orbit_in_sketch_mode: false,
725 },
726 modeling: ModelingSettings {
727 base_unit: UnitLength::Yd,
728 camera_projection: Default::default(),
729 camera_orbit: Default::default(),
730 mouse_controls: Default::default(),
731 highlight_edges: Default::default(),
732 enable_ssao: true.into(),
733 show_scale_grid: false,
734 show_debug_panel: Default::default(),
735 },
736 text_editor: TextEditorSettings {
737 text_wrapping: false.into(),
738 blinking_cursor: false.into()
739 },
740 project: Default::default(),
741 command_bar: CommandBarSettings {
742 include_settings: false.into()
743 },
744 }
745 }
746 );
747 }
748
749 #[test]
750 fn test_backwards_compatible_app_settings_file() {
753 let old_app_settings_file = r#"[settings.app]
754onboardingStatus = "dismissed"
755projectDirectory = "/Users/macinatormax/Documents/kittycad-modeling-projects"
756theme = "dark"
757themeColor = "138"
758
759[settings.modeling]
760defaultUnit = "yd"
761showDebugPanel = true
762
763[settings.textEditor]
764textWrapping = false
765blinkingCursor = false
766
767[settings.commandBar]
768includeSettings = false
769
770[settings.projects]
771defaultProjectName = "projects-$nnn"
772#"#;
773
774 let parsed = Configuration::backwards_compatible_toml_parse(old_app_settings_file).unwrap();
776 assert_eq!(
777 parsed,
778 Configuration {
779 settings: Settings {
780 app: AppSettings {
781 appearance: AppearanceSettings {
782 theme: AppTheme::Dark,
783 color: 138.0.into()
784 },
785 onboarding_status: OnboardingStatus::Dismissed,
786 project_directory: None,
787 theme: None,
788 theme_color: None,
789 dismiss_web_banner: false,
790 enable_ssao: None,
791 stream_idle_mode: None,
792 allow_orbit_in_sketch_mode: false,
793 show_debug_panel: true,
794 },
795 modeling: ModelingSettings {
796 base_unit: UnitLength::Yd,
797 camera_projection: Default::default(),
798 camera_orbit: CameraOrbitType::Spherical,
799 mouse_controls: Default::default(),
800 highlight_edges: Default::default(),
801 show_debug_panel: Default::default(),
802 enable_ssao: true.into(),
803 show_scale_grid: false,
804 },
805 text_editor: TextEditorSettings {
806 text_wrapping: false.into(),
807 blinking_cursor: false.into()
808 },
809 project: ProjectSettings {
810 directory: "/Users/macinatormax/Documents/kittycad-modeling-projects".into(),
811 default_project_name: "projects-$nnn".to_string().into()
812 },
813 command_bar: CommandBarSettings {
814 include_settings: false.into()
815 },
816 }
817 }
818 );
819
820 let serialized = toml::to_string(&parsed).unwrap();
822 assert_eq!(
823 serialized,
824 r#"[settings.app]
825onboarding_status = "dismissed"
826show_debug_panel = true
827
828[settings.app.appearance]
829theme = "dark"
830color = 138.0
831
832[settings.modeling]
833base_unit = "yd"
834
835[settings.text_editor]
836text_wrapping = false
837blinking_cursor = false
838
839[settings.project]
840directory = "/Users/macinatormax/Documents/kittycad-modeling-projects"
841default_project_name = "projects-$nnn"
842
843[settings.command_bar]
844include_settings = false
845"#
846 );
847 }
848
849 #[test]
850 fn test_settings_backwards_compat_partial() {
851 let partial_settings_file = r#"[settings.app]
852onboardingStatus = "dismissed"
853projectDirectory = "/Users/macinatormax/Documents/kittycad-modeling-projects""#;
854
855 let parsed = Configuration::backwards_compatible_toml_parse(partial_settings_file).unwrap();
857 assert_eq!(
858 parsed,
859 Configuration {
860 settings: Settings {
861 app: AppSettings {
862 appearance: AppearanceSettings {
863 theme: AppTheme::System,
864 color: Default::default()
865 },
866 onboarding_status: OnboardingStatus::Dismissed,
867 project_directory: None,
868 theme: None,
869 theme_color: None,
870 dismiss_web_banner: false,
871 enable_ssao: None,
872 show_debug_panel: false,
873 stream_idle_mode: None,
874 allow_orbit_in_sketch_mode: false,
875 },
876 modeling: ModelingSettings {
877 base_unit: UnitLength::Mm,
878 camera_projection: Default::default(),
879 camera_orbit: Default::default(),
880 mouse_controls: Default::default(),
881 highlight_edges: true.into(),
882 show_debug_panel: Default::default(),
883 enable_ssao: true.into(),
884 show_scale_grid: false,
885 },
886 text_editor: TextEditorSettings {
887 text_wrapping: true.into(),
888 blinking_cursor: true.into()
889 },
890 project: ProjectSettings {
891 directory: "/Users/macinatormax/Documents/kittycad-modeling-projects".into(),
892 default_project_name: "untitled".to_string().into()
893 },
894 command_bar: CommandBarSettings {
895 include_settings: true.into()
896 },
897 }
898 }
899 );
900
901 let serialized = toml::to_string(&parsed).unwrap();
903 assert_eq!(
904 serialized,
905 r#"[settings.app]
906onboarding_status = "dismissed"
907
908[settings.project]
909directory = "/Users/macinatormax/Documents/kittycad-modeling-projects"
910"#
911 );
912 }
913
914 #[test]
915 fn test_settings_empty_file_parses() {
916 let empty_settings_file = r#""#;
917
918 let parsed = toml::from_str::<Configuration>(empty_settings_file).unwrap();
919 assert_eq!(parsed, Configuration::default());
920
921 let serialized = toml::to_string(&parsed).unwrap();
923 assert_eq!(serialized, r#""#);
924
925 let parsed = Configuration::backwards_compatible_toml_parse(empty_settings_file).unwrap();
926 assert_eq!(parsed, Configuration::default());
927 }
928
929 #[test]
930 fn test_color_validation() {
931 let color = AppColor(360.0);
932
933 let result = color.validate();
934 if let Ok(r) = result {
935 panic!("Expected an error, but got success: {:?}", r);
936 }
937 assert!(result.is_err());
938 assert!(result
939 .unwrap_err()
940 .to_string()
941 .contains("color: Validation error: color"));
942
943 let appearance = AppearanceSettings {
944 theme: AppTheme::System,
945 color: AppColor(361.5),
946 };
947 let result = appearance.validate();
948 if let Ok(r) = result {
949 panic!("Expected an error, but got success: {:?}", r);
950 }
951 assert!(result.is_err());
952 assert!(result
953 .unwrap_err()
954 .to_string()
955 .contains("color: Validation error: color"));
956 }
957
958 #[test]
959 fn test_settings_color_validation_error() {
960 let settings_file = r#"[settings.app.appearance]
961color = 1567.4"#;
962
963 let result = Configuration::backwards_compatible_toml_parse(settings_file);
964 if let Ok(r) = result {
965 panic!("Expected an error, but got success: {:?}", r);
966 }
967 assert!(result.is_err());
968
969 assert!(result
970 .unwrap_err()
971 .to_string()
972 .contains("color: Validation error: color"));
973 }
974}