pub mod project;
use anyhow::Result;
use kittycad_modeling_cmds::units::UnitLength;
use parse_display::Display;
use parse_display::FromStr;
use schemars::JsonSchema;
use serde::Deserialize;
use serde::Deserializer;
use serde::Serialize;
use validator::Validate;
const DEFAULT_PROJECT_NAME_TEMPLATE: &str = "untitled";
#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Validate)]
#[ts(export)]
#[serde(rename_all = "snake_case")]
pub struct Configuration {
#[serde(default, skip_serializing_if = "is_default")]
#[validate(nested)]
pub settings: Settings,
}
impl Configuration {
pub fn parse_and_validate(toml_str: &str) -> Result<Self> {
let settings = toml::from_str::<Self>(toml_str)?;
settings.validate()?;
Ok(settings)
}
}
#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Validate)]
#[ts(export)]
#[serde(rename_all = "snake_case")]
pub struct Settings {
#[serde(default, skip_serializing_if = "is_default")]
#[validate(nested)]
pub app: AppSettings,
#[serde(default, skip_serializing_if = "is_default")]
#[validate(nested)]
pub modeling: ModelingSettings,
#[serde(default, skip_serializing_if = "is_default")]
#[validate(nested)]
pub text_editor: TextEditorSettings,
#[serde(default, skip_serializing_if = "is_default")]
#[validate(nested)]
pub project: ProjectSettings,
#[serde(default, skip_serializing_if = "is_default")]
#[validate(nested)]
pub command_bar: CommandBarSettings,
}
#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Validate)]
#[ts(export)]
#[serde(rename_all = "snake_case")]
pub struct AppSettings {
#[serde(default, skip_serializing_if = "is_default")]
#[validate(nested)]
pub appearance: AppearanceSettings,
#[serde(default, skip_serializing_if = "is_default")]
pub onboarding_status: OnboardingStatus,
#[serde(
default,
deserialize_with = "deserialize_stream_idle_mode",
alias = "streamIdleMode",
skip_serializing_if = "is_default"
)]
stream_idle_mode: Option<u32>,
#[serde(default, skip_serializing_if = "is_default")]
pub allow_orbit_in_sketch_mode: bool,
#[serde(default, skip_serializing_if = "is_default")]
pub show_debug_panel: bool,
#[serde(default, skip_serializing_if = "is_default")]
pub machine_api: bool,
}
fn make_it_so() -> bool {
true
}
fn is_true(b: &bool) -> bool {
*b
}
fn deserialize_stream_idle_mode<'de, D>(deserializer: D) -> Result<Option<u32>, D::Error>
where
D: Deserializer<'de>,
{
#[derive(Deserialize)]
#[serde(untagged)]
enum StreamIdleModeValue {
Number(u32),
String(String),
Boolean(bool),
}
const DEFAULT_TIMEOUT: u32 = 1000 * 60 * 5;
Ok(match StreamIdleModeValue::deserialize(deserializer) {
Ok(StreamIdleModeValue::Number(value)) => Some(value),
Ok(StreamIdleModeValue::String(value)) => Some(value.parse::<u32>().unwrap_or(DEFAULT_TIMEOUT)),
Ok(StreamIdleModeValue::Boolean(true)) => Some(DEFAULT_TIMEOUT),
Ok(StreamIdleModeValue::Boolean(false)) => None,
_ => None,
})
}
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq)]
#[ts(export)]
#[serde(untagged)]
pub enum FloatOrInt {
String(String),
Float(f64),
Int(i64),
}
impl From<FloatOrInt> for f64 {
fn from(float_or_int: FloatOrInt) -> Self {
match float_or_int {
FloatOrInt::String(s) => s.parse().unwrap(),
FloatOrInt::Float(f) => f,
FloatOrInt::Int(i) => i as f64,
}
}
}
#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Validate)]
#[ts(export)]
#[serde(rename_all = "snake_case")]
pub struct AppearanceSettings {
#[serde(default, skip_serializing_if = "is_default")]
pub theme: AppTheme,
}
#[derive(
Debug, Default, Copy, Clone, Deserialize, Serialize, JsonSchema, Display, FromStr, ts_rs::TS, PartialEq, Eq,
)]
#[ts(export)]
#[serde(rename_all = "snake_case")]
#[display(style = "snake_case")]
pub enum AppTheme {
Light,
Dark,
#[default]
System,
}
impl From<AppTheme> for kittycad::types::Color {
fn from(theme: AppTheme) -> Self {
match theme {
AppTheme::Light => kittycad::types::Color {
r: 249.0 / 255.0,
g: 249.0 / 255.0,
b: 249.0 / 255.0,
a: 1.0,
},
AppTheme::Dark => kittycad::types::Color {
r: 28.0 / 255.0,
g: 28.0 / 255.0,
b: 28.0 / 255.0,
a: 1.0,
},
AppTheme::System => {
todo!()
}
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Validate)]
#[serde(rename_all = "snake_case")]
#[ts(export)]
pub struct ModelingSettings {
#[serde(default = "default_length_unit_millimeters", skip_serializing_if = "is_default")]
pub base_unit: UnitLength,
#[serde(default, skip_serializing_if = "is_default")]
pub camera_projection: CameraProjectionType,
#[serde(default, skip_serializing_if = "is_default")]
pub camera_orbit: CameraOrbitType,
#[serde(default, skip_serializing_if = "is_default")]
pub mouse_controls: MouseControlType,
#[serde(default, skip_serializing_if = "is_default")]
pub gizmo_type: GizmoType,
#[serde(default, skip_serializing_if = "is_default")]
pub enable_touch_controls: DefaultTrue,
#[serde(default, skip_serializing_if = "is_default")]
pub use_sketch_solve_mode: bool,
#[serde(default, skip_serializing_if = "is_default")]
pub highlight_edges: DefaultTrue,
#[serde(default, skip_serializing_if = "is_default")]
pub enable_ssao: DefaultTrue,
#[serde(
default = "default_backface_color",
skip_serializing_if = "is_default_backface_color"
)]
pub backface_color: String,
#[serde(default, skip_serializing_if = "is_default")]
pub show_scale_grid: bool,
#[serde(default = "make_it_so", skip_serializing_if = "is_true")]
pub fixed_size_grid: bool,
#[serde(default, skip_serializing_if = "is_default")]
pub snap_to_grid: bool,
#[serde(default, skip_serializing_if = "is_default")]
pub major_grid_spacing: f64,
#[serde(default, skip_serializing_if = "is_default")]
pub minor_grids_per_major: f64,
#[serde(default, skip_serializing_if = "is_default")]
pub snaps_per_minor: f64,
}
fn default_length_unit_millimeters() -> UnitLength {
UnitLength::Millimeters
}
fn default_backface_color() -> String {
"#00D5FF".to_string()
}
fn is_default_backface_color(color: &String) -> bool {
*color == default_backface_color()
}
impl Default for ModelingSettings {
fn default() -> Self {
Self {
base_unit: UnitLength::Millimeters,
camera_projection: Default::default(),
camera_orbit: Default::default(),
mouse_controls: Default::default(),
gizmo_type: Default::default(),
enable_touch_controls: Default::default(),
use_sketch_solve_mode: Default::default(),
highlight_edges: Default::default(),
enable_ssao: Default::default(),
backface_color: default_backface_color(),
show_scale_grid: Default::default(),
fixed_size_grid: true,
snap_to_grid: Default::default(),
major_grid_spacing: Default::default(),
minor_grids_per_major: Default::default(),
snaps_per_minor: Default::default(),
}
}
}
#[derive(Debug, Copy, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Eq)]
#[ts(export)]
#[serde(transparent)]
pub struct DefaultTrue(pub bool);
impl Default for DefaultTrue {
fn default() -> Self {
Self(true)
}
}
impl From<DefaultTrue> for bool {
fn from(default_true: DefaultTrue) -> Self {
default_true.0
}
}
impl From<bool> for DefaultTrue {
fn from(b: bool) -> Self {
Self(b)
}
}
#[derive(Debug, Default, Eq, PartialEq, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, Display, FromStr)]
#[ts(export)]
#[serde(rename_all = "snake_case")]
#[display(style = "snake_case")]
pub enum MouseControlType {
#[default]
#[display("zoo")]
#[serde(rename = "zoo")]
Zoo,
#[display("onshape")]
#[serde(rename = "onshape")]
OnShape,
TrackpadFriendly,
Solidworks,
Nx,
Creo,
#[display("autocad")]
#[serde(rename = "autocad")]
AutoCad,
}
#[derive(Debug, Default, Eq, PartialEq, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, Display, FromStr)]
#[ts(export)]
#[serde(rename_all = "snake_case")]
#[display(style = "snake_case")]
pub enum CameraProjectionType {
Perspective,
#[default]
Orthographic,
}
#[derive(Debug, Default, Eq, PartialEq, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, Display, FromStr)]
#[ts(export)]
#[serde(rename_all = "snake_case")]
#[display(style = "snake_case")]
pub enum CameraOrbitType {
#[default]
#[display("spherical")]
Spherical,
#[display("trackball")]
Trackball,
}
#[derive(Debug, Default, Eq, PartialEq, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, Display, FromStr)]
#[ts(export)]
#[serde(rename_all = "snake_case")]
#[display(style = "snake_case")]
pub enum GizmoType {
#[default]
Cube,
Axis,
}
#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Eq, Validate)]
#[serde(rename_all = "snake_case")]
#[ts(export)]
pub struct TextEditorSettings {
#[serde(default, skip_serializing_if = "is_default")]
pub text_wrapping: DefaultTrue,
#[serde(default, skip_serializing_if = "is_default")]
pub blinking_cursor: DefaultTrue,
}
#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Eq, Validate)]
#[serde(rename_all = "snake_case")]
#[ts(export)]
pub struct ProjectTextEditorSettings {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub text_wrapping: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub blinking_cursor: Option<bool>,
}
#[derive(Debug, Clone, Default, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Eq, Validate)]
#[serde(rename_all = "snake_case")]
#[ts(export)]
pub struct ProjectSettings {
#[serde(default, skip_serializing_if = "is_default")]
pub directory: std::path::PathBuf,
#[serde(default, skip_serializing_if = "is_default")]
pub default_project_name: ProjectNameTemplate,
}
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Eq)]
#[ts(export)]
#[serde(transparent)]
pub struct ProjectNameTemplate(pub String);
impl Default for ProjectNameTemplate {
fn default() -> Self {
Self(DEFAULT_PROJECT_NAME_TEMPLATE.to_string())
}
}
impl From<ProjectNameTemplate> for String {
fn from(project_name: ProjectNameTemplate) -> Self {
project_name.0
}
}
impl From<String> for ProjectNameTemplate {
fn from(s: String) -> Self {
Self(s)
}
}
#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Eq, Validate)]
#[serde(rename_all = "snake_case")]
#[ts(export)]
pub struct CommandBarSettings {
#[serde(default, skip_serializing_if = "is_default")]
pub include_settings: DefaultTrue,
}
#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Eq, Validate)]
#[serde(rename_all = "snake_case")]
#[ts(export)]
pub struct ProjectCommandBarSettings {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub include_settings: Option<bool>,
}
#[derive(Debug, Default, Eq, PartialEq, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, Display, FromStr)]
#[ts(export)]
#[serde(rename_all = "snake_case")]
#[display(style = "snake_case")]
pub enum OnboardingStatus {
#[serde(rename = "")]
#[display("")]
Unset,
Completed,
#[default]
Incomplete,
Dismissed,
#[serde(rename = "/desktop")]
#[display("/desktop")]
DesktopWelcome,
#[serde(rename = "/desktop/scene")]
#[display("/desktop/scene")]
DesktopScene,
#[serde(rename = "/desktop/toolbar")]
#[display("/desktop/toolbar")]
DesktopToolbar,
#[serde(rename = "/desktop/text-to-cad")]
#[display("/desktop/text-to-cad")]
DesktopTextToCadWelcome,
#[serde(rename = "/desktop/text-to-cad-prompt")]
#[display("/desktop/text-to-cad-prompt")]
DesktopTextToCadPrompt,
#[serde(rename = "/desktop/feature-tree-pane")]
#[display("/desktop/feature-tree-pane")]
DesktopFeatureTreePane,
#[serde(rename = "/desktop/code-pane")]
#[display("/desktop/code-pane")]
DesktopCodePane,
#[serde(rename = "/desktop/project-pane")]
#[display("/desktop/project-pane")]
DesktopProjectFilesPane,
#[serde(rename = "/desktop/other-panes")]
#[display("/desktop/other-panes")]
DesktopOtherPanes,
#[serde(rename = "/desktop/prompt-to-edit")]
#[display("/desktop/prompt-to-edit")]
DesktopPromptToEditWelcome,
#[serde(rename = "/desktop/prompt-to-edit-prompt")]
#[display("/desktop/prompt-to-edit-prompt")]
DesktopPromptToEditPrompt,
#[serde(rename = "/desktop/prompt-to-edit-result")]
#[display("/desktop/prompt-to-edit-result")]
DesktopPromptToEditResult,
#[serde(rename = "/desktop/imports")]
#[display("/desktop/imports")]
DesktopImports,
#[serde(rename = "/desktop/exports")]
#[display("/desktop/exports")]
DesktopExports,
#[serde(rename = "/desktop/conclusion")]
#[display("/desktop/conclusion")]
DesktopConclusion,
}
fn is_default<T: Default + PartialEq>(t: &T) -> bool {
t == &T::default()
}
#[cfg(test)]
mod tests {
use pretty_assertions::assert_eq;
use super::AppSettings;
use super::AppTheme;
use super::AppearanceSettings;
use super::CameraProjectionType;
use super::CommandBarSettings;
use super::Configuration;
use super::ModelingSettings;
use super::MouseControlType;
use super::OnboardingStatus;
use super::ProjectNameTemplate;
use super::ProjectSettings;
use super::Settings;
use super::TextEditorSettings;
use super::UnitLength;
use super::default_backface_color;
#[test]
fn test_settings_empty_file_parses() {
let empty_settings_file = r#""#;
let parsed = toml::from_str::<Configuration>(empty_settings_file).unwrap();
assert_eq!(parsed, Configuration::default());
assert_eq!(parsed.settings.modeling.backface_color, default_backface_color());
let serialized = toml::to_string(&parsed).unwrap();
assert_eq!(serialized, r#""#);
let parsed = Configuration::parse_and_validate(empty_settings_file).unwrap();
assert_eq!(parsed, Configuration::default());
assert_eq!(parsed.settings.modeling.backface_color, default_backface_color());
}
#[test]
fn test_settings_parse_basic() {
let settings_file = r#"[settings.app]
default_project_name = "untitled"
directory = ""
onboarding_status = "dismissed"
[settings.app.appearance]
theme = "dark"
[settings.modeling]
enable_ssao = false
base_unit = "in"
mouse_controls = "zoo"
camera_projection = "perspective"
[settings.project]
default_project_name = "untitled"
directory = ""
[settings.text_editor]
text_wrapping = true"#;
let expected = Configuration {
settings: Settings {
app: AppSettings {
onboarding_status: OnboardingStatus::Dismissed,
appearance: AppearanceSettings { theme: AppTheme::Dark },
..Default::default()
},
modeling: ModelingSettings {
enable_ssao: false.into(),
base_unit: UnitLength::Inches,
mouse_controls: MouseControlType::Zoo,
camera_projection: CameraProjectionType::Perspective,
fixed_size_grid: true,
..Default::default()
},
project: ProjectSettings {
default_project_name: ProjectNameTemplate("untitled".to_string()),
directory: "".into(),
},
text_editor: TextEditorSettings {
text_wrapping: true.into(),
..Default::default()
},
command_bar: CommandBarSettings {
include_settings: true.into(),
},
},
};
let parsed = toml::from_str::<Configuration>(settings_file).unwrap();
assert_eq!(parsed, expected);
let serialized = toml::to_string(&parsed).unwrap();
assert_eq!(
serialized,
r#"[settings.app]
onboarding_status = "dismissed"
[settings.app.appearance]
theme = "dark"
[settings.modeling]
base_unit = "in"
camera_projection = "perspective"
enable_ssao = false
"#
);
let parsed = Configuration::parse_and_validate(settings_file).unwrap();
assert_eq!(parsed, expected);
}
#[test]
fn test_settings_backface_color_roundtrip() {
let settings_file = r##"[settings.modeling]
backface_color = "#112233"
"##;
let parsed = toml::from_str::<Configuration>(settings_file).unwrap();
assert_eq!(parsed.settings.modeling.backface_color, "#112233");
let serialized = toml::to_string(&parsed).unwrap();
let reparsed = toml::from_str::<Configuration>(&serialized).unwrap();
assert_eq!(reparsed, parsed);
assert!(serialized.contains("backface_color = \"#112233\""));
}
}