1pub mod project;
4
5use anyhow::Result;
6use kittycad_modeling_cmds::units::UnitLength;
7use parse_display::Display;
8use parse_display::FromStr;
9use schemars::JsonSchema;
10use serde::Deserialize;
11use serde::Deserializer;
12use serde::Serialize;
13use validator::Validate;
14
15const DEFAULT_PROJECT_NAME_TEMPLATE: &str = "untitled";
16
17#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Validate)]
23#[ts(export)]
24#[serde(rename_all = "snake_case")]
25pub struct Configuration {
26 #[serde(default, skip_serializing_if = "is_default")]
28 #[validate(nested)]
29 pub settings: Settings,
30}
31
32impl Configuration {
33 pub fn parse_and_validate(toml_str: &str) -> Result<Self> {
34 let settings = toml::from_str::<Self>(toml_str)?;
35
36 settings.validate()?;
37
38 Ok(settings)
39 }
40}
41
42#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Validate)]
44#[ts(export)]
45#[serde(rename_all = "snake_case")]
46pub struct Settings {
47 #[serde(default, skip_serializing_if = "is_default")]
49 #[validate(nested)]
50 pub app: AppSettings,
51 #[serde(default, skip_serializing_if = "is_default")]
53 #[validate(nested)]
54 pub modeling: ModelingSettings,
55 #[serde(default, skip_serializing_if = "is_default")]
57 #[validate(nested)]
58 pub text_editor: TextEditorSettings,
59 #[serde(default, skip_serializing_if = "is_default")]
61 #[validate(nested)]
62 pub project: ProjectSettings,
63 #[serde(default, skip_serializing_if = "is_default")]
65 #[validate(nested)]
66 pub command_bar: CommandBarSettings,
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 AppSettings {
74 #[serde(default, skip_serializing_if = "is_default")]
76 #[validate(nested)]
77 pub appearance: AppearanceSettings,
78 #[serde(default, skip_serializing_if = "is_default")]
80 pub onboarding_status: OnboardingStatus,
81 #[serde(
83 default,
84 deserialize_with = "deserialize_stream_idle_mode",
85 alias = "streamIdleMode",
86 skip_serializing_if = "is_default"
87 )]
88 stream_idle_mode: Option<u32>,
89 #[serde(default, skip_serializing_if = "is_default")]
91 pub allow_orbit_in_sketch_mode: bool,
92 #[serde(default, skip_serializing_if = "is_default")]
95 pub show_debug_panel: bool,
96 #[serde(default, skip_serializing_if = "is_default")]
98 pub machine_api: bool,
99}
100
101fn make_it_so() -> bool {
103 true
104}
105
106fn is_true(b: &bool) -> bool {
107 *b
108}
109
110fn deserialize_stream_idle_mode<'de, D>(deserializer: D) -> Result<Option<u32>, D::Error>
111where
112 D: Deserializer<'de>,
113{
114 #[derive(Deserialize)]
115 #[serde(untagged)]
116 enum StreamIdleModeValue {
117 Number(u32),
118 String(String),
119 Boolean(bool),
120 }
121
122 const DEFAULT_TIMEOUT: u32 = 1000 * 60 * 5;
123
124 Ok(match StreamIdleModeValue::deserialize(deserializer) {
125 Ok(StreamIdleModeValue::Number(value)) => Some(value),
126 Ok(StreamIdleModeValue::String(value)) => Some(value.parse::<u32>().unwrap_or(DEFAULT_TIMEOUT)),
127 Ok(StreamIdleModeValue::Boolean(true)) => Some(DEFAULT_TIMEOUT),
130 Ok(StreamIdleModeValue::Boolean(false)) => None,
131 _ => None,
132 })
133}
134
135#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq)]
136#[ts(export)]
137#[serde(untagged)]
138pub enum FloatOrInt {
139 String(String),
140 Float(f64),
141 Int(i64),
142}
143
144impl From<FloatOrInt> for f64 {
145 fn from(float_or_int: FloatOrInt) -> Self {
146 match float_or_int {
147 FloatOrInt::String(s) => s.parse().unwrap(),
148 FloatOrInt::Float(f) => f,
149 FloatOrInt::Int(i) => i as f64,
150 }
151 }
152}
153
154#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Validate)]
156#[ts(export)]
157#[serde(rename_all = "snake_case")]
158pub struct AppearanceSettings {
159 #[serde(default, skip_serializing_if = "is_default")]
161 pub theme: AppTheme,
162}
163
164#[derive(
166 Debug, Default, Copy, Clone, Deserialize, Serialize, JsonSchema, Display, FromStr, ts_rs::TS, PartialEq, Eq,
167)]
168#[ts(export)]
169#[serde(rename_all = "snake_case")]
170#[display(style = "snake_case")]
171pub enum AppTheme {
172 Light,
174 Dark,
176 #[default]
179 System,
180}
181
182impl From<AppTheme> for kittycad::types::Color {
183 fn from(theme: AppTheme) -> Self {
184 match theme {
185 AppTheme::Light => kittycad::types::Color {
186 r: 249.0 / 255.0,
187 g: 249.0 / 255.0,
188 b: 249.0 / 255.0,
189 a: 1.0,
190 },
191 AppTheme::Dark => kittycad::types::Color {
192 r: 28.0 / 255.0,
193 g: 28.0 / 255.0,
194 b: 28.0 / 255.0,
195 a: 1.0,
196 },
197 AppTheme::System => {
198 todo!()
200 }
201 }
202 }
203}
204
205#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Validate)]
207#[serde(rename_all = "snake_case")]
208#[ts(export)]
209pub struct ModelingSettings {
210 #[serde(default = "default_length_unit_millimeters", skip_serializing_if = "is_default")]
212 pub base_unit: UnitLength,
213 #[serde(default, skip_serializing_if = "is_default")]
215 pub camera_projection: CameraProjectionType,
216 #[serde(default, skip_serializing_if = "is_default")]
218 pub camera_orbit: CameraOrbitType,
219 #[serde(default, skip_serializing_if = "is_default")]
221 pub mouse_controls: MouseControlType,
222 #[serde(default, skip_serializing_if = "is_default")]
224 pub gizmo_type: GizmoType,
225 #[serde(default, skip_serializing_if = "is_default")]
227 pub enable_touch_controls: DefaultTrue,
228 #[serde(default, skip_serializing_if = "is_default")]
230 pub use_sketch_solve_mode: bool,
231 #[serde(default, skip_serializing_if = "is_default")]
233 pub highlight_edges: DefaultTrue,
234 #[serde(default, skip_serializing_if = "is_default")]
236 pub enable_ssao: DefaultTrue,
237 #[serde(
239 default = "default_backface_color",
240 skip_serializing_if = "is_default_backface_color"
241 )]
242 pub backface_color: String,
243 #[serde(default, skip_serializing_if = "is_default")]
245 pub show_scale_grid: bool,
246 #[serde(default = "make_it_so", skip_serializing_if = "is_true")]
250 pub fixed_size_grid: bool,
251 #[serde(default, skip_serializing_if = "is_default")]
253 pub snap_to_grid: bool,
254 #[serde(default, skip_serializing_if = "is_default")]
256 pub major_grid_spacing: f64,
257 #[serde(default, skip_serializing_if = "is_default")]
259 pub minor_grids_per_major: f64,
260 #[serde(default, skip_serializing_if = "is_default")]
262 pub snaps_per_minor: f64,
263}
264
265fn default_length_unit_millimeters() -> UnitLength {
266 UnitLength::Millimeters
267}
268
269fn default_backface_color() -> String {
271 "#00D5FF".to_string()
272}
273
274fn is_default_backface_color(color: &String) -> bool {
275 *color == default_backface_color()
276}
277
278impl Default for ModelingSettings {
279 fn default() -> Self {
280 Self {
281 base_unit: UnitLength::Millimeters,
282 camera_projection: Default::default(),
283 camera_orbit: Default::default(),
284 mouse_controls: Default::default(),
285 gizmo_type: Default::default(),
286 enable_touch_controls: Default::default(),
287 use_sketch_solve_mode: Default::default(),
288 highlight_edges: Default::default(),
289 enable_ssao: Default::default(),
290 backface_color: default_backface_color(),
291 show_scale_grid: Default::default(),
292 fixed_size_grid: true,
293 snap_to_grid: Default::default(),
294 major_grid_spacing: Default::default(),
295 minor_grids_per_major: Default::default(),
296 snaps_per_minor: Default::default(),
297 }
298 }
299}
300
301#[derive(Debug, Copy, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Eq)]
302#[ts(export)]
303#[serde(transparent)]
304pub struct DefaultTrue(pub bool);
305
306impl Default for DefaultTrue {
307 fn default() -> Self {
308 Self(true)
309 }
310}
311
312impl From<DefaultTrue> for bool {
313 fn from(default_true: DefaultTrue) -> Self {
314 default_true.0
315 }
316}
317
318impl From<bool> for DefaultTrue {
319 fn from(b: bool) -> Self {
320 Self(b)
321 }
322}
323
324#[derive(Debug, Default, Eq, PartialEq, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, Display, FromStr)]
326#[ts(export)]
327#[serde(rename_all = "snake_case")]
328#[display(style = "snake_case")]
329pub enum MouseControlType {
330 #[default]
331 #[display("zoo")]
332 #[serde(rename = "zoo")]
333 Zoo,
334 #[display("onshape")]
335 #[serde(rename = "onshape")]
336 OnShape,
337 TrackpadFriendly,
338 Solidworks,
339 Nx,
340 Creo,
341 #[display("autocad")]
342 #[serde(rename = "autocad")]
343 AutoCad,
344}
345
346#[derive(Debug, Default, Eq, PartialEq, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, Display, FromStr)]
348#[ts(export)]
349#[serde(rename_all = "snake_case")]
350#[display(style = "snake_case")]
351pub enum CameraProjectionType {
352 Perspective,
354 #[default]
356 Orthographic,
357}
358
359#[derive(Debug, Default, Eq, PartialEq, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, Display, FromStr)]
361#[ts(export)]
362#[serde(rename_all = "snake_case")]
363#[display(style = "snake_case")]
364pub enum CameraOrbitType {
365 #[default]
367 #[display("spherical")]
368 Spherical,
369 #[display("trackball")]
371 Trackball,
372}
373
374#[derive(Debug, Default, Eq, PartialEq, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, Display, FromStr)]
376#[ts(export)]
377#[serde(rename_all = "snake_case")]
378#[display(style = "snake_case")]
379pub enum GizmoType {
380 #[default]
382 Cube,
383 Axis,
385}
386
387#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Eq, Validate)]
389#[serde(rename_all = "snake_case")]
390#[ts(export)]
391pub struct TextEditorSettings {
392 #[serde(default, skip_serializing_if = "is_default")]
394 pub text_wrapping: DefaultTrue,
395 #[serde(default, skip_serializing_if = "is_default")]
397 pub blinking_cursor: DefaultTrue,
398}
399
400#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Eq, Validate)]
402#[serde(rename_all = "snake_case")]
403#[ts(export)]
404pub struct ProjectTextEditorSettings {
405 #[serde(default, skip_serializing_if = "Option::is_none")]
407 pub text_wrapping: Option<bool>,
408 #[serde(default, skip_serializing_if = "Option::is_none")]
410 pub blinking_cursor: Option<bool>,
411}
412
413#[derive(Debug, Clone, Default, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Eq, Validate)]
415#[serde(rename_all = "snake_case")]
416#[ts(export)]
417pub struct ProjectSettings {
418 #[serde(default, skip_serializing_if = "is_default")]
420 pub directory: std::path::PathBuf,
421 #[serde(default, skip_serializing_if = "is_default")]
423 pub default_project_name: ProjectNameTemplate,
424}
425
426#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Eq)]
427#[ts(export)]
428#[serde(transparent)]
429pub struct ProjectNameTemplate(pub String);
430
431impl Default for ProjectNameTemplate {
432 fn default() -> Self {
433 Self(DEFAULT_PROJECT_NAME_TEMPLATE.to_string())
434 }
435}
436
437impl From<ProjectNameTemplate> for String {
438 fn from(project_name: ProjectNameTemplate) -> Self {
439 project_name.0
440 }
441}
442
443impl From<String> for ProjectNameTemplate {
444 fn from(s: String) -> Self {
445 Self(s)
446 }
447}
448
449#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Eq, Validate)]
451#[serde(rename_all = "snake_case")]
452#[ts(export)]
453pub struct CommandBarSettings {
454 #[serde(default, skip_serializing_if = "is_default")]
456 pub include_settings: DefaultTrue,
457}
458
459#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Eq, Validate)]
461#[serde(rename_all = "snake_case")]
462#[ts(export)]
463pub struct ProjectCommandBarSettings {
464 #[serde(default, skip_serializing_if = "Option::is_none")]
466 pub include_settings: Option<bool>,
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 OnboardingStatus {
475 #[serde(rename = "")]
477 #[display("")]
478 Unset,
479 Completed,
481 #[default]
483 Incomplete,
484 Dismissed,
486
487 #[serde(rename = "/desktop")]
489 #[display("/desktop")]
490 DesktopWelcome,
491 #[serde(rename = "/desktop/scene")]
492 #[display("/desktop/scene")]
493 DesktopScene,
494 #[serde(rename = "/desktop/toolbar")]
495 #[display("/desktop/toolbar")]
496 DesktopToolbar,
497 #[serde(rename = "/desktop/text-to-cad")]
498 #[display("/desktop/text-to-cad")]
499 DesktopTextToCadWelcome,
500 #[serde(rename = "/desktop/text-to-cad-prompt")]
501 #[display("/desktop/text-to-cad-prompt")]
502 DesktopTextToCadPrompt,
503 #[serde(rename = "/desktop/feature-tree-pane")]
504 #[display("/desktop/feature-tree-pane")]
505 DesktopFeatureTreePane,
506 #[serde(rename = "/desktop/code-pane")]
507 #[display("/desktop/code-pane")]
508 DesktopCodePane,
509 #[serde(rename = "/desktop/project-pane")]
510 #[display("/desktop/project-pane")]
511 DesktopProjectFilesPane,
512 #[serde(rename = "/desktop/other-panes")]
513 #[display("/desktop/other-panes")]
514 DesktopOtherPanes,
515 #[serde(rename = "/desktop/prompt-to-edit")]
516 #[display("/desktop/prompt-to-edit")]
517 DesktopPromptToEditWelcome,
518 #[serde(rename = "/desktop/prompt-to-edit-prompt")]
519 #[display("/desktop/prompt-to-edit-prompt")]
520 DesktopPromptToEditPrompt,
521 #[serde(rename = "/desktop/prompt-to-edit-result")]
522 #[display("/desktop/prompt-to-edit-result")]
523 DesktopPromptToEditResult,
524 #[serde(rename = "/desktop/imports")]
525 #[display("/desktop/imports")]
526 DesktopImports,
527 #[serde(rename = "/desktop/exports")]
528 #[display("/desktop/exports")]
529 DesktopExports,
530 #[serde(rename = "/desktop/conclusion")]
531 #[display("/desktop/conclusion")]
532 DesktopConclusion,
533}
534
535fn is_default<T: Default + PartialEq>(t: &T) -> bool {
536 t == &T::default()
537}
538
539#[cfg(test)]
540mod tests {
541 use pretty_assertions::assert_eq;
542
543 use super::AppSettings;
544 use super::AppTheme;
545 use super::AppearanceSettings;
546 use super::CameraProjectionType;
547 use super::CommandBarSettings;
548 use super::Configuration;
549 use super::ModelingSettings;
550 use super::MouseControlType;
551 use super::OnboardingStatus;
552 use super::ProjectNameTemplate;
553 use super::ProjectSettings;
554 use super::Settings;
555 use super::TextEditorSettings;
556 use super::UnitLength;
557 use super::default_backface_color;
558
559 #[test]
560 fn test_settings_empty_file_parses() {
561 let empty_settings_file = r#""#;
562
563 let parsed = toml::from_str::<Configuration>(empty_settings_file).unwrap();
564 assert_eq!(parsed, Configuration::default());
565 assert_eq!(parsed.settings.modeling.backface_color, default_backface_color());
566
567 let serialized = toml::to_string(&parsed).unwrap();
569 assert_eq!(serialized, r#""#);
570
571 let parsed = Configuration::parse_and_validate(empty_settings_file).unwrap();
572 assert_eq!(parsed, Configuration::default());
573 assert_eq!(parsed.settings.modeling.backface_color, default_backface_color());
574 }
575
576 #[test]
577 fn test_settings_parse_basic() {
578 let settings_file = r#"[settings.app]
579default_project_name = "untitled"
580directory = ""
581onboarding_status = "dismissed"
582
583 [settings.app.appearance]
584 theme = "dark"
585
586[settings.modeling]
587enable_ssao = false
588base_unit = "in"
589mouse_controls = "zoo"
590camera_projection = "perspective"
591
592[settings.project]
593default_project_name = "untitled"
594directory = ""
595
596[settings.text_editor]
597text_wrapping = true"#;
598
599 let expected = Configuration {
600 settings: Settings {
601 app: AppSettings {
602 onboarding_status: OnboardingStatus::Dismissed,
603 appearance: AppearanceSettings { theme: AppTheme::Dark },
604 ..Default::default()
605 },
606 modeling: ModelingSettings {
607 enable_ssao: false.into(),
608 base_unit: UnitLength::Inches,
609 mouse_controls: MouseControlType::Zoo,
610 camera_projection: CameraProjectionType::Perspective,
611 fixed_size_grid: true,
612 ..Default::default()
613 },
614 project: ProjectSettings {
615 default_project_name: ProjectNameTemplate("untitled".to_string()),
616 directory: "".into(),
617 },
618 text_editor: TextEditorSettings {
619 text_wrapping: true.into(),
620 ..Default::default()
621 },
622 command_bar: CommandBarSettings {
623 include_settings: true.into(),
624 },
625 },
626 };
627 let parsed = toml::from_str::<Configuration>(settings_file).unwrap();
628 assert_eq!(parsed, expected);
629
630 let serialized = toml::to_string(&parsed).unwrap();
632 assert_eq!(
633 serialized,
634 r#"[settings.app]
635onboarding_status = "dismissed"
636
637[settings.app.appearance]
638theme = "dark"
639
640[settings.modeling]
641base_unit = "in"
642camera_projection = "perspective"
643enable_ssao = false
644"#
645 );
646
647 let parsed = Configuration::parse_and_validate(settings_file).unwrap();
648 assert_eq!(parsed, expected);
649 }
650
651 #[test]
652 fn test_settings_backface_color_roundtrip() {
653 let settings_file = r##"[settings.modeling]
654backface_color = "#112233"
655"##;
656
657 let parsed = toml::from_str::<Configuration>(settings_file).unwrap();
658 assert_eq!(parsed.settings.modeling.backface_color, "#112233");
659
660 let serialized = toml::to_string(&parsed).unwrap();
661 let reparsed = toml::from_str::<Configuration>(&serialized).unwrap();
662 assert_eq!(reparsed, parsed);
663 assert!(serialized.contains("backface_color = \"#112233\""));
664 }
665}