1pub mod project;
4
5use anyhow::Result;
6use kittycad_modeling_cmds::units::UnitLength;
7use parse_display::{Display, FromStr};
8use schemars::JsonSchema;
9use serde::{Deserialize, Deserializer, Serialize};
10use validator::Validate;
11
12const DEFAULT_PROJECT_NAME_TEMPLATE: &str = "untitled";
13
14#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Validate)]
20#[ts(export)]
21#[serde(rename_all = "snake_case")]
22pub struct Configuration {
23 #[serde(default, skip_serializing_if = "is_default")]
25 #[validate(nested)]
26 pub settings: Settings,
27}
28
29impl Configuration {
30 pub fn parse_and_validate(toml_str: &str) -> Result<Self> {
31 let settings = toml::from_str::<Self>(toml_str)?;
32
33 settings.validate()?;
34
35 Ok(settings)
36 }
37}
38
39#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Validate)]
41#[ts(export)]
42#[serde(rename_all = "snake_case")]
43pub struct Settings {
44 #[serde(default, skip_serializing_if = "is_default")]
46 #[validate(nested)]
47 pub app: AppSettings,
48 #[serde(default, skip_serializing_if = "is_default")]
50 #[validate(nested)]
51 pub modeling: ModelingSettings,
52 #[serde(default, skip_serializing_if = "is_default")]
54 #[validate(nested)]
55 pub text_editor: TextEditorSettings,
56 #[serde(default, skip_serializing_if = "is_default")]
58 #[validate(nested)]
59 pub project: ProjectSettings,
60 #[serde(default, skip_serializing_if = "is_default")]
62 #[validate(nested)]
63 pub command_bar: CommandBarSettings,
64}
65
66#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Validate)]
68#[ts(export)]
69#[serde(rename_all = "snake_case")]
70pub struct AppSettings {
71 #[serde(default, skip_serializing_if = "is_default")]
73 #[validate(nested)]
74 pub appearance: AppearanceSettings,
75 #[serde(default, skip_serializing_if = "is_default")]
77 pub onboarding_status: OnboardingStatus,
78 #[serde(
80 default,
81 deserialize_with = "deserialize_stream_idle_mode",
82 alias = "streamIdleMode",
83 skip_serializing_if = "is_default"
84 )]
85 stream_idle_mode: Option<u32>,
86 #[serde(default, skip_serializing_if = "is_default")]
88 pub allow_orbit_in_sketch_mode: bool,
89 #[serde(default, skip_serializing_if = "is_default")]
92 pub show_debug_panel: bool,
93}
94
95fn make_it_so() -> bool {
97 true
98}
99
100fn is_true(b: &bool) -> bool {
101 *b
102}
103
104fn deserialize_stream_idle_mode<'de, D>(deserializer: D) -> Result<Option<u32>, D::Error>
105where
106 D: Deserializer<'de>,
107{
108 #[derive(Deserialize)]
109 #[serde(untagged)]
110 enum StreamIdleModeValue {
111 Number(u32),
112 String(String),
113 Boolean(bool),
114 }
115
116 const DEFAULT_TIMEOUT: u32 = 1000 * 60 * 5;
117
118 Ok(match StreamIdleModeValue::deserialize(deserializer) {
119 Ok(StreamIdleModeValue::Number(value)) => Some(value),
120 Ok(StreamIdleModeValue::String(value)) => Some(value.parse::<u32>().unwrap_or(DEFAULT_TIMEOUT)),
121 Ok(StreamIdleModeValue::Boolean(true)) => Some(DEFAULT_TIMEOUT),
124 Ok(StreamIdleModeValue::Boolean(false)) => None,
125 _ => None,
126 })
127}
128
129#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq)]
130#[ts(export)]
131#[serde(untagged)]
132pub enum FloatOrInt {
133 String(String),
134 Float(f64),
135 Int(i64),
136}
137
138impl From<FloatOrInt> for f64 {
139 fn from(float_or_int: FloatOrInt) -> Self {
140 match float_or_int {
141 FloatOrInt::String(s) => s.parse().unwrap(),
142 FloatOrInt::Float(f) => f,
143 FloatOrInt::Int(i) => i as f64,
144 }
145 }
146}
147
148#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Validate)]
150#[ts(export)]
151#[serde(rename_all = "snake_case")]
152pub struct AppearanceSettings {
153 #[serde(default, skip_serializing_if = "is_default")]
155 pub theme: AppTheme,
156}
157
158#[derive(
160 Debug, Default, Copy, Clone, Deserialize, Serialize, JsonSchema, Display, FromStr, ts_rs::TS, PartialEq, Eq,
161)]
162#[ts(export)]
163#[serde(rename_all = "snake_case")]
164#[display(style = "snake_case")]
165pub enum AppTheme {
166 Light,
168 Dark,
170 #[default]
173 System,
174}
175
176impl From<AppTheme> for kittycad::types::Color {
177 fn from(theme: AppTheme) -> Self {
178 match theme {
179 AppTheme::Light => kittycad::types::Color {
180 r: 249.0 / 255.0,
181 g: 249.0 / 255.0,
182 b: 249.0 / 255.0,
183 a: 1.0,
184 },
185 AppTheme::Dark => kittycad::types::Color {
186 r: 28.0 / 255.0,
187 g: 28.0 / 255.0,
188 b: 28.0 / 255.0,
189 a: 1.0,
190 },
191 AppTheme::System => {
192 todo!()
194 }
195 }
196 }
197}
198
199#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Validate)]
201#[serde(rename_all = "snake_case")]
202#[ts(export)]
203pub struct ModelingSettings {
204 #[serde(default = "default_length_unit_millimeters", skip_serializing_if = "is_default")]
206 pub base_unit: UnitLength,
207 #[serde(default, skip_serializing_if = "is_default")]
209 pub camera_projection: CameraProjectionType,
210 #[serde(default, skip_serializing_if = "is_default")]
212 pub camera_orbit: CameraOrbitType,
213 #[serde(default, skip_serializing_if = "is_default")]
215 pub mouse_controls: MouseControlType,
216 #[serde(default, skip_serializing_if = "is_default")]
218 pub gizmo_type: GizmoType,
219 #[serde(default, skip_serializing_if = "is_default")]
221 pub enable_touch_controls: DefaultTrue,
222 #[serde(default, skip_serializing_if = "is_default")]
224 pub use_new_sketch_mode: bool,
225 #[serde(default, skip_serializing_if = "is_default")]
227 pub highlight_edges: DefaultTrue,
228 #[serde(default, skip_serializing_if = "is_default")]
230 pub enable_ssao: DefaultTrue,
231 #[serde(default, skip_serializing_if = "is_default")]
233 pub show_scale_grid: bool,
234 #[serde(default = "make_it_so", skip_serializing_if = "is_true")]
238 pub fixed_size_grid: bool,
239 #[serde(default, skip_serializing_if = "is_default")]
241 pub snap_to_grid: bool,
242 #[serde(default, skip_serializing_if = "is_default")]
244 pub major_grid_spacing: f64,
245 #[serde(default, skip_serializing_if = "is_default")]
247 pub minor_grids_per_major: f64,
248 #[serde(default, skip_serializing_if = "is_default")]
250 pub snaps_per_minor: f64,
251}
252
253fn default_length_unit_millimeters() -> UnitLength {
254 UnitLength::Millimeters
255}
256
257impl Default for ModelingSettings {
258 fn default() -> Self {
259 Self {
260 base_unit: UnitLength::Millimeters,
261 camera_projection: Default::default(),
262 camera_orbit: Default::default(),
263 mouse_controls: Default::default(),
264 gizmo_type: Default::default(),
265 enable_touch_controls: Default::default(),
266 use_new_sketch_mode: Default::default(),
267 highlight_edges: Default::default(),
268 enable_ssao: Default::default(),
269 show_scale_grid: Default::default(),
270 fixed_size_grid: true,
271 snap_to_grid: Default::default(),
272 major_grid_spacing: Default::default(),
273 minor_grids_per_major: Default::default(),
274 snaps_per_minor: Default::default(),
275 }
276 }
277}
278
279#[derive(Debug, Copy, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Eq)]
280#[ts(export)]
281#[serde(transparent)]
282pub struct DefaultTrue(pub bool);
283
284impl Default for DefaultTrue {
285 fn default() -> Self {
286 Self(true)
287 }
288}
289
290impl From<DefaultTrue> for bool {
291 fn from(default_true: DefaultTrue) -> Self {
292 default_true.0
293 }
294}
295
296impl From<bool> for DefaultTrue {
297 fn from(b: bool) -> Self {
298 Self(b)
299 }
300}
301
302#[derive(Debug, Default, Eq, PartialEq, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, Display, FromStr)]
304#[ts(export)]
305#[serde(rename_all = "snake_case")]
306#[display(style = "snake_case")]
307pub enum MouseControlType {
308 #[default]
309 #[display("zoo")]
310 #[serde(rename = "zoo")]
311 Zoo,
312 #[display("onshape")]
313 #[serde(rename = "onshape")]
314 OnShape,
315 TrackpadFriendly,
316 Solidworks,
317 Nx,
318 Creo,
319 #[display("autocad")]
320 #[serde(rename = "autocad")]
321 AutoCad,
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 CameraProjectionType {
330 Perspective,
332 #[default]
334 Orthographic,
335}
336
337#[derive(Debug, Default, Eq, PartialEq, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, Display, FromStr)]
339#[ts(export)]
340#[serde(rename_all = "snake_case")]
341#[display(style = "snake_case")]
342pub enum CameraOrbitType {
343 #[default]
345 #[display("spherical")]
346 Spherical,
347 #[display("trackball")]
349 Trackball,
350}
351
352#[derive(Debug, Default, Eq, PartialEq, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, Display, FromStr)]
354#[ts(export)]
355#[serde(rename_all = "snake_case")]
356#[display(style = "snake_case")]
357pub enum GizmoType {
358 #[default]
360 Cube,
361 Axis,
363}
364
365#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Eq, Validate)]
367#[serde(rename_all = "snake_case")]
368#[ts(export)]
369pub struct TextEditorSettings {
370 #[serde(default, skip_serializing_if = "is_default")]
372 pub text_wrapping: DefaultTrue,
373 #[serde(default, skip_serializing_if = "is_default")]
375 pub blinking_cursor: DefaultTrue,
376}
377
378#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Eq, Validate)]
380#[serde(rename_all = "snake_case")]
381#[ts(export)]
382pub struct ProjectTextEditorSettings {
383 #[serde(default, skip_serializing_if = "Option::is_none")]
385 pub text_wrapping: Option<bool>,
386 #[serde(default, skip_serializing_if = "Option::is_none")]
388 pub blinking_cursor: Option<bool>,
389}
390
391#[derive(Debug, Clone, Default, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Eq, Validate)]
393#[serde(rename_all = "snake_case")]
394#[ts(export)]
395pub struct ProjectSettings {
396 #[serde(default, skip_serializing_if = "is_default")]
398 pub directory: std::path::PathBuf,
399 #[serde(default, skip_serializing_if = "is_default")]
401 pub default_project_name: ProjectNameTemplate,
402}
403
404#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Eq)]
405#[ts(export)]
406#[serde(transparent)]
407pub struct ProjectNameTemplate(pub String);
408
409impl Default for ProjectNameTemplate {
410 fn default() -> Self {
411 Self(DEFAULT_PROJECT_NAME_TEMPLATE.to_string())
412 }
413}
414
415impl From<ProjectNameTemplate> for String {
416 fn from(project_name: ProjectNameTemplate) -> Self {
417 project_name.0
418 }
419}
420
421impl From<String> for ProjectNameTemplate {
422 fn from(s: String) -> Self {
423 Self(s)
424 }
425}
426
427#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Eq, Validate)]
429#[serde(rename_all = "snake_case")]
430#[ts(export)]
431pub struct CommandBarSettings {
432 #[serde(default, skip_serializing_if = "is_default")]
434 pub include_settings: DefaultTrue,
435}
436
437#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Eq, Validate)]
439#[serde(rename_all = "snake_case")]
440#[ts(export)]
441pub struct ProjectCommandBarSettings {
442 #[serde(default, skip_serializing_if = "Option::is_none")]
444 pub include_settings: Option<bool>,
445}
446
447#[derive(Debug, Default, Eq, PartialEq, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, Display, FromStr)]
449#[ts(export)]
450#[serde(rename_all = "snake_case")]
451#[display(style = "snake_case")]
452pub enum OnboardingStatus {
453 #[serde(rename = "")]
455 #[display("")]
456 Unset,
457 Completed,
459 #[default]
461 Incomplete,
462 Dismissed,
464
465 #[serde(rename = "/desktop")]
467 #[display("/desktop")]
468 DesktopWelcome,
469 #[serde(rename = "/desktop/scene")]
470 #[display("/desktop/scene")]
471 DesktopScene,
472 #[serde(rename = "/desktop/toolbar")]
473 #[display("/desktop/toolbar")]
474 DesktopToolbar,
475 #[serde(rename = "/desktop/text-to-cad")]
476 #[display("/desktop/text-to-cad")]
477 DesktopTextToCadWelcome,
478 #[serde(rename = "/desktop/text-to-cad-prompt")]
479 #[display("/desktop/text-to-cad-prompt")]
480 DesktopTextToCadPrompt,
481 #[serde(rename = "/desktop/feature-tree-pane")]
482 #[display("/desktop/feature-tree-pane")]
483 DesktopFeatureTreePane,
484 #[serde(rename = "/desktop/code-pane")]
485 #[display("/desktop/code-pane")]
486 DesktopCodePane,
487 #[serde(rename = "/desktop/project-pane")]
488 #[display("/desktop/project-pane")]
489 DesktopProjectFilesPane,
490 #[serde(rename = "/desktop/other-panes")]
491 #[display("/desktop/other-panes")]
492 DesktopOtherPanes,
493 #[serde(rename = "/desktop/prompt-to-edit")]
494 #[display("/desktop/prompt-to-edit")]
495 DesktopPromptToEditWelcome,
496 #[serde(rename = "/desktop/prompt-to-edit-prompt")]
497 #[display("/desktop/prompt-to-edit-prompt")]
498 DesktopPromptToEditPrompt,
499 #[serde(rename = "/desktop/prompt-to-edit-result")]
500 #[display("/desktop/prompt-to-edit-result")]
501 DesktopPromptToEditResult,
502 #[serde(rename = "/desktop/imports")]
503 #[display("/desktop/imports")]
504 DesktopImports,
505 #[serde(rename = "/desktop/exports")]
506 #[display("/desktop/exports")]
507 DesktopExports,
508 #[serde(rename = "/desktop/conclusion")]
509 #[display("/desktop/conclusion")]
510 DesktopConclusion,
511
512 #[serde(rename = "/browser")]
514 #[display("/browser")]
515 BrowserWelcome,
516 #[serde(rename = "/browser/scene")]
517 #[display("/browser/scene")]
518 BrowserScene,
519 #[serde(rename = "/browser/toolbar")]
520 #[display("/browser/toolbar")]
521 BrowserToolbar,
522 #[serde(rename = "/browser/text-to-cad")]
523 #[display("/browser/text-to-cad")]
524 BrowserTextToCadWelcome,
525 #[serde(rename = "/browser/text-to-cad-prompt")]
526 #[display("/browser/text-to-cad-prompt")]
527 BrowserTextToCadPrompt,
528 #[serde(rename = "/browser/feature-tree-pane")]
529 #[display("/browser/feature-tree-pane")]
530 BrowserFeatureTreePane,
531 #[serde(rename = "/browser/prompt-to-edit")]
532 #[display("/browser/prompt-to-edit")]
533 BrowserPromptToEditWelcome,
534 #[serde(rename = "/browser/prompt-to-edit-prompt")]
535 #[display("/browser/prompt-to-edit-prompt")]
536 BrowserPromptToEditPrompt,
537 #[serde(rename = "/browser/prompt-to-edit-result")]
538 #[display("/browser/prompt-to-edit-result")]
539 BrowserPromptToEditResult,
540 #[serde(rename = "/browser/conclusion")]
541 #[display("/browser/conclusion")]
542 BrowserConclusion,
543}
544
545fn is_default<T: Default + PartialEq>(t: &T) -> bool {
546 t == &T::default()
547}
548
549#[cfg(test)]
550mod tests {
551 use pretty_assertions::assert_eq;
552
553 use super::{
554 AppSettings, AppTheme, AppearanceSettings, CameraProjectionType, CommandBarSettings, Configuration,
555 ModelingSettings, MouseControlType, OnboardingStatus, ProjectNameTemplate, ProjectSettings, Settings,
556 TextEditorSettings, UnitLength,
557 };
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
566 let serialized = toml::to_string(&parsed).unwrap();
568 assert_eq!(serialized, r#""#);
569
570 let parsed = Configuration::parse_and_validate(empty_settings_file).unwrap();
571 assert_eq!(parsed, Configuration::default());
572 }
573
574 #[test]
575 fn test_settings_parse_basic() {
576 let settings_file = r#"[settings.app]
577default_project_name = "untitled"
578directory = ""
579onboarding_status = "dismissed"
580
581 [settings.app.appearance]
582 theme = "dark"
583
584[settings.modeling]
585enable_ssao = false
586base_unit = "in"
587mouse_controls = "zoo"
588camera_projection = "perspective"
589
590[settings.project]
591default_project_name = "untitled"
592directory = ""
593
594[settings.text_editor]
595text_wrapping = true"#;
596
597 let expected = Configuration {
598 settings: Settings {
599 app: AppSettings {
600 onboarding_status: OnboardingStatus::Dismissed,
601 appearance: AppearanceSettings { theme: AppTheme::Dark },
602 ..Default::default()
603 },
604 modeling: ModelingSettings {
605 enable_ssao: false.into(),
606 base_unit: UnitLength::Inches,
607 mouse_controls: MouseControlType::Zoo,
608 camera_projection: CameraProjectionType::Perspective,
609 fixed_size_grid: true,
610 ..Default::default()
611 },
612 project: ProjectSettings {
613 default_project_name: ProjectNameTemplate("untitled".to_string()),
614 directory: "".into(),
615 },
616 text_editor: TextEditorSettings {
617 text_wrapping: true.into(),
618 ..Default::default()
619 },
620 command_bar: CommandBarSettings {
621 include_settings: true.into(),
622 },
623 },
624 };
625 let parsed = toml::from_str::<Configuration>(settings_file).unwrap();
626 assert_eq!(parsed, expected);
627
628 let serialized = toml::to_string(&parsed).unwrap();
630 assert_eq!(
631 serialized,
632 r#"[settings.app]
633onboarding_status = "dismissed"
634
635[settings.app.appearance]
636theme = "dark"
637
638[settings.modeling]
639base_unit = "in"
640camera_projection = "perspective"
641enable_ssao = false
642"#
643 );
644
645 let parsed = Configuration::parse_and_validate(settings_file).unwrap();
646 assert_eq!(parsed, expected);
647 }
648}