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 enable_touch_controls: DefaultTrue,
219 #[serde(default, skip_serializing_if = "is_default")]
221 pub use_new_sketch_mode: bool,
222 #[serde(default, skip_serializing_if = "is_default")]
224 pub highlight_edges: DefaultTrue,
225 #[serde(default, skip_serializing_if = "is_default")]
227 pub enable_ssao: DefaultTrue,
228 #[serde(default, skip_serializing_if = "is_default")]
230 pub show_scale_grid: bool,
231 #[serde(default = "make_it_so", skip_serializing_if = "is_true")]
235 pub fixed_size_grid: bool,
236 #[serde(default, skip_serializing_if = "is_default")]
238 pub snap_to_grid: bool,
239 #[serde(default, skip_serializing_if = "is_default")]
241 pub major_grid_spacing: f64,
242 #[serde(default, skip_serializing_if = "is_default")]
244 pub minor_grids_per_major: f64,
245 #[serde(default, skip_serializing_if = "is_default")]
247 pub snaps_per_minor: f64,
248}
249
250fn default_length_unit_millimeters() -> UnitLength {
251 UnitLength::Millimeters
252}
253
254impl Default for ModelingSettings {
255 fn default() -> Self {
256 Self {
257 base_unit: UnitLength::Millimeters,
258 camera_projection: Default::default(),
259 camera_orbit: Default::default(),
260 mouse_controls: Default::default(),
261 enable_touch_controls: Default::default(),
262 use_new_sketch_mode: Default::default(),
263 highlight_edges: Default::default(),
264 enable_ssao: Default::default(),
265 show_scale_grid: Default::default(),
266 fixed_size_grid: true,
267 snap_to_grid: Default::default(),
268 major_grid_spacing: Default::default(),
269 minor_grids_per_major: Default::default(),
270 snaps_per_minor: Default::default(),
271 }
272 }
273}
274
275#[derive(Debug, Copy, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Eq)]
276#[ts(export)]
277#[serde(transparent)]
278pub struct DefaultTrue(pub bool);
279
280impl Default for DefaultTrue {
281 fn default() -> Self {
282 Self(true)
283 }
284}
285
286impl From<DefaultTrue> for bool {
287 fn from(default_true: DefaultTrue) -> Self {
288 default_true.0
289 }
290}
291
292impl From<bool> for DefaultTrue {
293 fn from(b: bool) -> Self {
294 Self(b)
295 }
296}
297
298#[derive(Debug, Default, Eq, PartialEq, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, Display, FromStr)]
300#[ts(export)]
301#[serde(rename_all = "snake_case")]
302#[display(style = "snake_case")]
303pub enum MouseControlType {
304 #[default]
305 #[display("zoo")]
306 #[serde(rename = "zoo")]
307 Zoo,
308 #[display("onshape")]
309 #[serde(rename = "onshape")]
310 OnShape,
311 TrackpadFriendly,
312 Solidworks,
313 Nx,
314 Creo,
315 #[display("autocad")]
316 #[serde(rename = "autocad")]
317 AutoCad,
318}
319
320#[derive(Debug, Default, Eq, PartialEq, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, Display, FromStr)]
322#[ts(export)]
323#[serde(rename_all = "snake_case")]
324#[display(style = "snake_case")]
325pub enum CameraProjectionType {
326 Perspective,
328 #[default]
330 Orthographic,
331}
332
333#[derive(Debug, Default, Eq, PartialEq, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, Display, FromStr)]
335#[ts(export)]
336#[serde(rename_all = "snake_case")]
337#[display(style = "snake_case")]
338pub enum CameraOrbitType {
339 #[default]
341 #[display("spherical")]
342 Spherical,
343 #[display("trackball")]
345 Trackball,
346}
347
348#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Eq, Validate)]
350#[serde(rename_all = "snake_case")]
351#[ts(export)]
352pub struct TextEditorSettings {
353 #[serde(default, skip_serializing_if = "is_default")]
355 pub text_wrapping: DefaultTrue,
356 #[serde(default, skip_serializing_if = "is_default")]
358 pub blinking_cursor: DefaultTrue,
359}
360
361#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Eq, Validate)]
363#[serde(rename_all = "snake_case")]
364#[ts(export)]
365pub struct ProjectTextEditorSettings {
366 #[serde(default, skip_serializing_if = "Option::is_none")]
368 pub text_wrapping: Option<bool>,
369 #[serde(default, skip_serializing_if = "Option::is_none")]
371 pub blinking_cursor: Option<bool>,
372}
373
374#[derive(Debug, Clone, Default, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Eq, Validate)]
376#[serde(rename_all = "snake_case")]
377#[ts(export)]
378pub struct ProjectSettings {
379 #[serde(default, skip_serializing_if = "is_default")]
381 pub directory: std::path::PathBuf,
382 #[serde(default, skip_serializing_if = "is_default")]
384 pub default_project_name: ProjectNameTemplate,
385}
386
387#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Eq)]
388#[ts(export)]
389#[serde(transparent)]
390pub struct ProjectNameTemplate(pub String);
391
392impl Default for ProjectNameTemplate {
393 fn default() -> Self {
394 Self(DEFAULT_PROJECT_NAME_TEMPLATE.to_string())
395 }
396}
397
398impl From<ProjectNameTemplate> for String {
399 fn from(project_name: ProjectNameTemplate) -> Self {
400 project_name.0
401 }
402}
403
404impl From<String> for ProjectNameTemplate {
405 fn from(s: String) -> Self {
406 Self(s)
407 }
408}
409
410#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Eq, Validate)]
412#[serde(rename_all = "snake_case")]
413#[ts(export)]
414pub struct CommandBarSettings {
415 #[serde(default, skip_serializing_if = "is_default")]
417 pub include_settings: DefaultTrue,
418}
419
420#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Eq, Validate)]
422#[serde(rename_all = "snake_case")]
423#[ts(export)]
424pub struct ProjectCommandBarSettings {
425 #[serde(default, skip_serializing_if = "Option::is_none")]
427 pub include_settings: Option<bool>,
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 OnboardingStatus {
436 #[serde(rename = "")]
438 #[display("")]
439 Unset,
440 Completed,
442 #[default]
444 Incomplete,
445 Dismissed,
447
448 #[serde(rename = "/desktop")]
450 #[display("/desktop")]
451 DesktopWelcome,
452 #[serde(rename = "/desktop/scene")]
453 #[display("/desktop/scene")]
454 DesktopScene,
455 #[serde(rename = "/desktop/toolbar")]
456 #[display("/desktop/toolbar")]
457 DesktopToolbar,
458 #[serde(rename = "/desktop/text-to-cad")]
459 #[display("/desktop/text-to-cad")]
460 DesktopTextToCadWelcome,
461 #[serde(rename = "/desktop/text-to-cad-prompt")]
462 #[display("/desktop/text-to-cad-prompt")]
463 DesktopTextToCadPrompt,
464 #[serde(rename = "/desktop/feature-tree-pane")]
465 #[display("/desktop/feature-tree-pane")]
466 DesktopFeatureTreePane,
467 #[serde(rename = "/desktop/code-pane")]
468 #[display("/desktop/code-pane")]
469 DesktopCodePane,
470 #[serde(rename = "/desktop/project-pane")]
471 #[display("/desktop/project-pane")]
472 DesktopProjectFilesPane,
473 #[serde(rename = "/desktop/other-panes")]
474 #[display("/desktop/other-panes")]
475 DesktopOtherPanes,
476 #[serde(rename = "/desktop/prompt-to-edit")]
477 #[display("/desktop/prompt-to-edit")]
478 DesktopPromptToEditWelcome,
479 #[serde(rename = "/desktop/prompt-to-edit-prompt")]
480 #[display("/desktop/prompt-to-edit-prompt")]
481 DesktopPromptToEditPrompt,
482 #[serde(rename = "/desktop/prompt-to-edit-result")]
483 #[display("/desktop/prompt-to-edit-result")]
484 DesktopPromptToEditResult,
485 #[serde(rename = "/desktop/imports")]
486 #[display("/desktop/imports")]
487 DesktopImports,
488 #[serde(rename = "/desktop/exports")]
489 #[display("/desktop/exports")]
490 DesktopExports,
491 #[serde(rename = "/desktop/conclusion")]
492 #[display("/desktop/conclusion")]
493 DesktopConclusion,
494
495 #[serde(rename = "/browser")]
497 #[display("/browser")]
498 BrowserWelcome,
499 #[serde(rename = "/browser/scene")]
500 #[display("/browser/scene")]
501 BrowserScene,
502 #[serde(rename = "/browser/toolbar")]
503 #[display("/browser/toolbar")]
504 BrowserToolbar,
505 #[serde(rename = "/browser/text-to-cad")]
506 #[display("/browser/text-to-cad")]
507 BrowserTextToCadWelcome,
508 #[serde(rename = "/browser/text-to-cad-prompt")]
509 #[display("/browser/text-to-cad-prompt")]
510 BrowserTextToCadPrompt,
511 #[serde(rename = "/browser/feature-tree-pane")]
512 #[display("/browser/feature-tree-pane")]
513 BrowserFeatureTreePane,
514 #[serde(rename = "/browser/prompt-to-edit")]
515 #[display("/browser/prompt-to-edit")]
516 BrowserPromptToEditWelcome,
517 #[serde(rename = "/browser/prompt-to-edit-prompt")]
518 #[display("/browser/prompt-to-edit-prompt")]
519 BrowserPromptToEditPrompt,
520 #[serde(rename = "/browser/prompt-to-edit-result")]
521 #[display("/browser/prompt-to-edit-result")]
522 BrowserPromptToEditResult,
523 #[serde(rename = "/browser/conclusion")]
524 #[display("/browser/conclusion")]
525 BrowserConclusion,
526}
527
528fn is_default<T: Default + PartialEq>(t: &T) -> bool {
529 t == &T::default()
530}
531
532#[cfg(test)]
533mod tests {
534 use pretty_assertions::assert_eq;
535
536 use super::{
537 AppSettings, AppTheme, AppearanceSettings, CameraProjectionType, CommandBarSettings, Configuration,
538 ModelingSettings, MouseControlType, OnboardingStatus, ProjectNameTemplate, ProjectSettings, Settings,
539 TextEditorSettings, UnitLength,
540 };
541
542 #[test]
543 fn test_settings_empty_file_parses() {
544 let empty_settings_file = r#""#;
545
546 let parsed = toml::from_str::<Configuration>(empty_settings_file).unwrap();
547 assert_eq!(parsed, Configuration::default());
548
549 let serialized = toml::to_string(&parsed).unwrap();
551 assert_eq!(serialized, r#""#);
552
553 let parsed = Configuration::parse_and_validate(empty_settings_file).unwrap();
554 assert_eq!(parsed, Configuration::default());
555 }
556
557 #[test]
558 fn test_settings_parse_basic() {
559 let settings_file = r#"[settings.app]
560default_project_name = "untitled"
561directory = ""
562onboarding_status = "dismissed"
563
564 [settings.app.appearance]
565 theme = "dark"
566
567[settings.modeling]
568enable_ssao = false
569base_unit = "in"
570mouse_controls = "zoo"
571camera_projection = "perspective"
572
573[settings.project]
574default_project_name = "untitled"
575directory = ""
576
577[settings.text_editor]
578text_wrapping = true"#;
579
580 let expected = Configuration {
581 settings: Settings {
582 app: AppSettings {
583 onboarding_status: OnboardingStatus::Dismissed,
584 appearance: AppearanceSettings { theme: AppTheme::Dark },
585 ..Default::default()
586 },
587 modeling: ModelingSettings {
588 enable_ssao: false.into(),
589 base_unit: UnitLength::Inches,
590 mouse_controls: MouseControlType::Zoo,
591 camera_projection: CameraProjectionType::Perspective,
592 fixed_size_grid: true,
593 ..Default::default()
594 },
595 project: ProjectSettings {
596 default_project_name: ProjectNameTemplate("untitled".to_string()),
597 directory: "".into(),
598 },
599 text_editor: TextEditorSettings {
600 text_wrapping: true.into(),
601 ..Default::default()
602 },
603 command_bar: CommandBarSettings {
604 include_settings: true.into(),
605 },
606 },
607 };
608 let parsed = toml::from_str::<Configuration>(settings_file).unwrap();
609 assert_eq!(parsed, expected);
610
611 let serialized = toml::to_string(&parsed).unwrap();
613 assert_eq!(
614 serialized,
615 r#"[settings.app]
616onboarding_status = "dismissed"
617
618[settings.app.appearance]
619theme = "dark"
620
621[settings.modeling]
622base_unit = "in"
623camera_projection = "perspective"
624enable_ssao = false
625"#
626 );
627
628 let parsed = Configuration::parse_and_validate(settings_file).unwrap();
629 assert_eq!(parsed, expected);
630 }
631}