pub mod project;
use anyhow::Result;
use parse_display::{Display, FromStr};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use validator::{Validate, ValidateRange};
const DEFAULT_THEME_COLOR: f64 = 264.5;
const DEFAULT_PROJECT_NAME_TEMPLATE: &str = "project-$nnn";
#[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 backwards_compatible_toml_parse(toml_str: &str) -> Result<Self> {
let mut settings = toml::from_str::<Self>(toml_str)?;
if let Some(project_directory) = &settings.settings.app.project_directory {
if settings.settings.project.directory.to_string_lossy().is_empty() {
settings.settings.project.directory.clone_from(project_directory);
settings.settings.app.project_directory = None;
}
}
if let Some(theme) = &settings.settings.app.theme {
if settings.settings.app.appearance.theme == AppTheme::default() {
settings.settings.app.appearance.theme = *theme;
settings.settings.app.theme = None;
}
}
if let Some(theme_color) = &settings.settings.app.theme_color {
if settings.settings.app.appearance.color == AppColor::default() {
settings.settings.app.appearance.color = theme_color.clone().into();
settings.settings.app.theme_color = None;
}
}
if let Some(enable_ssao) = settings.settings.app.enable_ssao {
if settings.settings.modeling.enable_ssao.into() {
settings.settings.modeling.enable_ssao = enable_ssao.into();
settings.settings.app.enable_ssao = None;
}
}
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, alias = "textEditor", skip_serializing_if = "is_default")]
#[validate(nested)]
pub text_editor: TextEditorSettings,
#[serde(default, alias = "projects", skip_serializing_if = "is_default")]
#[validate(nested)]
pub project: ProjectSettings,
#[serde(default, alias = "commandBar", 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, alias = "onboardingStatus", skip_serializing_if = "is_default")]
pub onboarding_status: OnboardingStatus,
#[serde(default, alias = "projectDirectory", skip_serializing_if = "Option::is_none")]
pub project_directory: Option<std::path::PathBuf>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub theme: Option<AppTheme>,
#[serde(default, skip_serializing_if = "Option::is_none", alias = "themeColor")]
pub theme_color: Option<FloatOrInt>,
#[serde(default, alias = "enableSSAO", skip_serializing_if = "Option::is_none")]
pub enable_ssao: Option<bool>,
#[serde(default, alias = "dismissWebBanner", skip_serializing_if = "is_default")]
pub dismiss_web_banner: bool,
#[serde(default, alias = "streamIdleMode", skip_serializing_if = "is_default")]
stream_idle_mode: bool,
}
#[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,
}
}
}
impl From<FloatOrInt> for AppColor {
fn from(float_or_int: FloatOrInt) -> Self {
match float_or_int {
FloatOrInt::String(s) => s.parse::<f64>().unwrap().into(),
FloatOrInt::Float(f) => f.into(),
FloatOrInt::Int(i) => (i as f64).into(),
}
}
}
#[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,
#[serde(default, skip_serializing_if = "is_default")]
#[validate(nested)]
pub color: AppColor,
}
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq)]
#[ts(export)]
#[serde(transparent)]
pub struct AppColor(pub f64);
impl Default for AppColor {
fn default() -> Self {
Self(DEFAULT_THEME_COLOR)
}
}
impl From<AppColor> for f64 {
fn from(color: AppColor) -> Self {
color.0
}
}
impl From<f64> for AppColor {
fn from(color: f64) -> Self {
Self(color)
}
}
impl Validate for AppColor {
fn validate(&self) -> Result<(), validator::ValidationErrors> {
if !self.0.validate_range(Some(0.0), None, None, Some(360.0)) {
let mut errors = validator::ValidationErrors::new();
let mut err = validator::ValidationError::new("color");
err.add_param(std::borrow::Cow::from("min"), &0.0);
err.add_param(std::borrow::Cow::from("exclusive_max"), &360.0);
errors.add("color", err);
return Err(errors);
}
Ok(())
}
}
#[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, Default, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Eq, Validate)]
#[serde(rename_all = "snake_case")]
#[ts(export)]
pub struct ModelingSettings {
#[serde(default, alias = "defaultUnit", skip_serializing_if = "is_default")]
pub base_unit: UnitLength,
#[serde(default, alias = "cameraProjection", skip_serializing_if = "is_default")]
pub camera_projection: CameraProjectionType,
#[serde(default, alias = "mouseControls", skip_serializing_if = "is_default")]
pub mouse_controls: MouseControlType,
#[serde(default, alias = "highlightEdges", skip_serializing_if = "is_default")]
pub highlight_edges: DefaultTrue,
#[serde(default, alias = "showDebugPanel", skip_serializing_if = "is_default")]
pub show_debug_panel: bool,
#[serde(default, skip_serializing_if = "is_default")]
pub enable_ssao: DefaultTrue,
#[serde(default, alias = "showScaleGrid", skip_serializing_if = "is_default")]
pub show_scale_grid: bool,
}
#[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, Copy, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, Display, FromStr,
)]
#[cfg_attr(feature = "pyo3", pyo3::pyclass(eq, eq_int))]
#[ts(export)]
#[serde(rename_all = "lowercase")]
#[display(style = "lowercase")]
pub enum UnitLength {
Cm,
Ft,
In,
M,
#[default]
Mm,
Yd,
}
impl From<kittycad::types::UnitLength> for UnitLength {
fn from(unit: kittycad::types::UnitLength) -> Self {
match unit {
kittycad::types::UnitLength::Cm => UnitLength::Cm,
kittycad::types::UnitLength::Ft => UnitLength::Ft,
kittycad::types::UnitLength::In => UnitLength::In,
kittycad::types::UnitLength::M => UnitLength::M,
kittycad::types::UnitLength::Mm => UnitLength::Mm,
kittycad::types::UnitLength::Yd => UnitLength::Yd,
}
}
}
impl From<UnitLength> for kittycad::types::UnitLength {
fn from(unit: UnitLength) -> Self {
match unit {
UnitLength::Cm => kittycad::types::UnitLength::Cm,
UnitLength::Ft => kittycad::types::UnitLength::Ft,
UnitLength::In => kittycad::types::UnitLength::In,
UnitLength::M => kittycad::types::UnitLength::M,
UnitLength::Mm => kittycad::types::UnitLength::Mm,
UnitLength::Yd => kittycad::types::UnitLength::Yd,
}
}
}
impl From<kittycad_modeling_cmds::units::UnitLength> for UnitLength {
fn from(unit: kittycad_modeling_cmds::units::UnitLength) -> Self {
match unit {
kittycad_modeling_cmds::units::UnitLength::Centimeters => UnitLength::Cm,
kittycad_modeling_cmds::units::UnitLength::Feet => UnitLength::Ft,
kittycad_modeling_cmds::units::UnitLength::Inches => UnitLength::In,
kittycad_modeling_cmds::units::UnitLength::Meters => UnitLength::M,
kittycad_modeling_cmds::units::UnitLength::Millimeters => UnitLength::Mm,
kittycad_modeling_cmds::units::UnitLength::Yards => UnitLength::Yd,
}
}
}
impl From<UnitLength> for kittycad_modeling_cmds::units::UnitLength {
fn from(unit: UnitLength) -> Self {
match unit {
UnitLength::Cm => kittycad_modeling_cmds::units::UnitLength::Centimeters,
UnitLength::Ft => kittycad_modeling_cmds::units::UnitLength::Feet,
UnitLength::In => kittycad_modeling_cmds::units::UnitLength::Inches,
UnitLength::M => kittycad_modeling_cmds::units::UnitLength::Meters,
UnitLength::Mm => kittycad_modeling_cmds::units::UnitLength::Millimeters,
UnitLength::Yd => kittycad_modeling_cmds::units::UnitLength::Yards,
}
}
}
#[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", alias = "Zoo", alias = "KittyCAD")]
Zoo,
#[display("onshape")]
#[serde(rename = "onshape", alias = "OnShape")]
OnShape,
#[serde(alias = "Trackpad Friendly")]
TrackpadFriendly,
#[serde(alias = "Solidworks")]
Solidworks,
#[serde(alias = "NX")]
Nx,
#[serde(alias = "Creo")]
Creo,
#[display("autocad")]
#[serde(rename = "autocad", alias = "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, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Eq, Validate)]
#[serde(rename_all = "snake_case")]
#[ts(export)]
pub struct TextEditorSettings {
#[serde(default, alias = "textWrapping", skip_serializing_if = "is_default")]
pub text_wrapping: DefaultTrue,
#[serde(default, alias = "blinkingCursor", skip_serializing_if = "is_default")]
pub blinking_cursor: DefaultTrue,
}
#[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, alias = "defaultProjectName", 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, alias = "includeSettings", skip_serializing_if = "is_default")]
pub include_settings: DefaultTrue,
}
#[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 = "/")]
#[display("/")]
Index,
#[serde(rename = "/camera")]
#[display("/camera")]
Camera,
#[serde(rename = "/streaming")]
#[display("/streaming")]
Streaming,
#[serde(rename = "/editor")]
#[display("/editor")]
Editor,
#[serde(rename = "/parametric-modeling")]
#[display("/parametric-modeling")]
ParametricModeling,
#[serde(rename = "/interactive-numbers")]
#[display("/interactive-numbers")]
InteractiveNumbers,
#[serde(rename = "/command-k")]
#[display("/command-k")]
CommandK,
#[serde(rename = "/user-menu")]
#[display("/user-menu")]
UserMenu,
#[serde(rename = "/project-menu")]
#[display("/project-menu")]
ProjectMenu,
#[serde(rename = "/export")]
#[display("/export")]
Export,
#[serde(rename = "/move")]
#[display("/move")]
Move,
#[serde(rename = "/sketching")]
#[display("/sketching")]
Sketching,
#[serde(rename = "/future-work")]
#[display("/future-work")]
FutureWork,
}
fn is_default<T: Default + PartialEq>(t: &T) -> bool {
t == &T::default()
}
#[cfg(test)]
mod tests {
use pretty_assertions::assert_eq;
use validator::Validate;
use super::{
AppColor, AppSettings, AppTheme, AppearanceSettings, CameraProjectionType, CommandBarSettings, Configuration,
ModelingSettings, OnboardingStatus, ProjectSettings, Settings, TextEditorSettings, UnitLength,
};
#[test]
fn test_backwards_compatible_project_settings_file_pw() {
let old_project_file = r#"[settings.app]
theme = "dark"
onboardingStatus = "dismissed"
projectDirectory = ""
enableSSAO = false
[settings.modeling]
defaultUnit = "in"
cameraProjection = "orthographic"
mouseControls = "KittyCAD"
showDebugPanel = true
[settings.projects]
defaultProjectName = "project-$nnn"
[settings.textEditor]
textWrapping = true
#"#;
let parsed = Configuration::backwards_compatible_toml_parse(old_project_file).unwrap();
assert_eq!(
parsed,
Configuration {
settings: Settings {
app: AppSettings {
appearance: AppearanceSettings {
theme: AppTheme::Dark,
color: Default::default()
},
onboarding_status: OnboardingStatus::Dismissed,
project_directory: None,
theme: None,
theme_color: None,
dismiss_web_banner: false,
enable_ssao: None,
stream_idle_mode: false,
},
modeling: ModelingSettings {
base_unit: UnitLength::In,
camera_projection: CameraProjectionType::Orthographic,
mouse_controls: Default::default(),
highlight_edges: Default::default(),
show_debug_panel: true,
enable_ssao: false.into(),
show_scale_grid: false,
},
text_editor: TextEditorSettings {
text_wrapping: true.into(),
blinking_cursor: true.into()
},
project: Default::default(),
command_bar: CommandBarSettings {
include_settings: true.into()
},
}
}
);
}
#[test]
fn test_backwards_compatible_project_settings_file() {
let old_project_file = r#"[settings.app]
theme = "dark"
themeColor = "138"
[settings.modeling]
defaultUnit = "yd"
showDebugPanel = true
[settings.textEditor]
textWrapping = false
blinkingCursor = false
[settings.commandBar]
includeSettings = false
#"#;
let parsed = Configuration::backwards_compatible_toml_parse(old_project_file).unwrap();
assert_eq!(
parsed,
Configuration {
settings: Settings {
app: AppSettings {
appearance: AppearanceSettings {
theme: AppTheme::Dark,
color: 138.0.into()
},
onboarding_status: Default::default(),
project_directory: None,
theme: None,
theme_color: None,
dismiss_web_banner: false,
enable_ssao: None,
stream_idle_mode: false,
},
modeling: ModelingSettings {
base_unit: UnitLength::Yd,
camera_projection: Default::default(),
mouse_controls: Default::default(),
highlight_edges: Default::default(),
show_debug_panel: true,
enable_ssao: true.into(),
show_scale_grid: false,
},
text_editor: TextEditorSettings {
text_wrapping: false.into(),
blinking_cursor: false.into()
},
project: Default::default(),
command_bar: CommandBarSettings {
include_settings: false.into()
},
}
}
);
}
#[test]
fn test_backwards_compatible_app_settings_file() {
let old_app_settings_file = r#"[settings.app]
onboardingStatus = "dismissed"
projectDirectory = "/Users/macinatormax/Documents/kittycad-modeling-projects"
theme = "dark"
themeColor = "138"
[settings.modeling]
defaultUnit = "yd"
showDebugPanel = true
[settings.textEditor]
textWrapping = false
blinkingCursor = false
[settings.commandBar]
includeSettings = false
[settings.projects]
defaultProjectName = "projects-$nnn"
#"#;
let parsed = Configuration::backwards_compatible_toml_parse(old_app_settings_file).unwrap();
assert_eq!(
parsed,
Configuration {
settings: Settings {
app: AppSettings {
appearance: AppearanceSettings {
theme: AppTheme::Dark,
color: 138.0.into()
},
onboarding_status: OnboardingStatus::Dismissed,
project_directory: None,
theme: None,
theme_color: None,
dismiss_web_banner: false,
enable_ssao: None,
stream_idle_mode: false,
},
modeling: ModelingSettings {
base_unit: UnitLength::Yd,
camera_projection: Default::default(),
mouse_controls: Default::default(),
highlight_edges: Default::default(),
show_debug_panel: true,
enable_ssao: true.into(),
show_scale_grid: false,
},
text_editor: TextEditorSettings {
text_wrapping: false.into(),
blinking_cursor: false.into()
},
project: ProjectSettings {
directory: "/Users/macinatormax/Documents/kittycad-modeling-projects".into(),
default_project_name: "projects-$nnn".to_string().into()
},
command_bar: CommandBarSettings {
include_settings: false.into()
},
}
}
);
let serialized = toml::to_string(&parsed).unwrap();
assert_eq!(
serialized,
r#"[settings.app]
onboarding_status = "dismissed"
[settings.app.appearance]
theme = "dark"
color = 138.0
[settings.modeling]
base_unit = "yd"
show_debug_panel = true
[settings.text_editor]
text_wrapping = false
blinking_cursor = false
[settings.project]
directory = "/Users/macinatormax/Documents/kittycad-modeling-projects"
default_project_name = "projects-$nnn"
[settings.command_bar]
include_settings = false
"#
);
}
#[test]
fn test_settings_backwards_compat_partial() {
let partial_settings_file = r#"[settings.app]
onboardingStatus = "dismissed"
projectDirectory = "/Users/macinatormax/Documents/kittycad-modeling-projects""#;
let parsed = Configuration::backwards_compatible_toml_parse(partial_settings_file).unwrap();
assert_eq!(
parsed,
Configuration {
settings: Settings {
app: AppSettings {
appearance: AppearanceSettings {
theme: AppTheme::System,
color: Default::default()
},
onboarding_status: OnboardingStatus::Dismissed,
project_directory: None,
theme: None,
theme_color: None,
dismiss_web_banner: false,
enable_ssao: None,
stream_idle_mode: false,
},
modeling: ModelingSettings {
base_unit: UnitLength::Mm,
camera_projection: Default::default(),
mouse_controls: Default::default(),
highlight_edges: true.into(),
show_debug_panel: false,
enable_ssao: true.into(),
show_scale_grid: false,
},
text_editor: TextEditorSettings {
text_wrapping: true.into(),
blinking_cursor: true.into()
},
project: ProjectSettings {
directory: "/Users/macinatormax/Documents/kittycad-modeling-projects".into(),
default_project_name: "project-$nnn".to_string().into()
},
command_bar: CommandBarSettings {
include_settings: true.into()
},
}
}
);
let serialized = toml::to_string(&parsed).unwrap();
assert_eq!(
serialized,
r#"[settings.app]
onboarding_status = "dismissed"
[settings.project]
directory = "/Users/macinatormax/Documents/kittycad-modeling-projects"
"#
);
}
#[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());
let serialized = toml::to_string(&parsed).unwrap();
assert_eq!(serialized, r#""#);
let parsed = Configuration::backwards_compatible_toml_parse(empty_settings_file).unwrap();
assert_eq!(parsed, Configuration::default());
}
#[test]
fn test_color_validation() {
let color = AppColor(360.0);
let result = color.validate();
if let Ok(r) = result {
panic!("Expected an error, but got success: {:?}", r);
}
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("color: Validation error: color"));
let appearance = AppearanceSettings {
theme: AppTheme::System,
color: AppColor(361.5),
};
let result = appearance.validate();
if let Ok(r) = result {
panic!("Expected an error, but got success: {:?}", r);
}
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("color: Validation error: color"));
}
#[test]
fn test_settings_color_validation_error() {
let settings_file = r#"[settings.app.appearance]
color = 1567.4"#;
let result = Configuration::backwards_compatible_toml_parse(settings_file);
if let Ok(r) = result {
panic!("Expected an error, but got success: {:?}", r);
}
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("color: Validation error: color"));
}
}