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;
#[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 = "Option::is_none")]
#[validate(nested)]
pub app: Option<AppSettings>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[validate(nested)]
pub modeling: Option<ModelingSettings>,
#[serde(flatten)]
pub other: std::collections::HashMap<String, serde_json::Value>,
}
#[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 = "Option::is_none")]
#[validate(nested)]
pub appearance: Option<AppearanceSettings>,
#[serde(
default,
deserialize_with = "deserialize_stream_idle_mode",
alias = "streamIdleMode",
skip_serializing_if = "Option::is_none"
)]
stream_idle_mode: Option<u32>,
#[serde(flatten)]
pub other: std::collections::HashMap<String, serde_json::Value>,
}
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 = "Option::is_none")]
pub theme: Option<AppTheme>,
#[serde(flatten)]
pub other: std::collections::HashMap<String, serde_json::Value>,
}
#[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)]
#[serde(transparent)]
pub struct LengthDefaultMm(pub UnitLength);
impl Default for LengthDefaultMm {
fn default() -> Self {
Self(default_length_unit_millimeters())
}
}
impl From<LengthDefaultMm> for UnitLength {
fn from(val: LengthDefaultMm) -> Self {
val.0
}
}
impl From<UnitLength> for LengthDefaultMm {
fn from(unit: UnitLength) -> Self {
Self(unit)
}
}
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq)]
#[serde(transparent)]
pub struct BackfaceDefault(pub String);
impl Default for BackfaceDefault {
fn default() -> Self {
Self(default_backface_color())
}
}
impl From<BackfaceDefault> for String {
fn from(val: BackfaceDefault) -> Self {
val.0
}
}
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Validate, Default)]
#[serde(rename_all = "snake_case")]
#[ts(export)]
pub struct ModelingSettings {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub base_unit: Option<LengthDefaultMm>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub camera_projection: Option<CameraProjectionType>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub camera_orbit: Option<CameraOrbitType>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub highlight_edges: Option<DefaultTrue>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub enable_ssao: Option<DefaultTrue>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub backface_color: Option<BackfaceDefault>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub show_scale_grid: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub fixed_size_grid: Option<DefaultTrue>,
#[serde(flatten)]
pub other: std::collections::HashMap<String, serde_json::Value>,
}
fn default_length_unit_millimeters() -> UnitLength {
UnitLength::Millimeters
}
fn default_backface_color() -> String {
"#00D5FF".to_string()
}
#[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 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,
}
fn is_default<T: Default + PartialEq>(t: &T) -> bool {
t == &T::default()
}
#[cfg(test)]
mod tests {
use pretty_assertions::assert_eq;
use serde_json::json;
use super::AppSettings;
use super::AppTheme;
use super::AppearanceSettings;
use super::CameraProjectionType;
use super::Configuration;
use super::ModelingSettings;
use super::Settings;
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
.clone()
.settings
.modeling
.unwrap_or_default()
.backface_color
.unwrap_or_default()
.0,
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
.unwrap_or_default()
.backface_color
.unwrap_or_default()
.0,
default_backface_color()
);
}
#[test]
fn test_settings_parse_basic() {
let settings_file = r#"[settings.app]
onboarding_status = "dismissed"
allow_orbit_in_sketch_mode = true
show_debug_panel = true
machine_api = true
foo = "bar"
[settings.app.appearance]
theme = "dark"
[settings.modeling]
base_unit = "in"
camera_projection = "perspective"
mouse_controls = "zoo"
gizmo_type = "axis"
enable_touch_controls = false
use_sketch_solve_mode = true
enable_ssao = false
snap_to_grid = true
major_grid_spacing = 2.5
minor_grids_per_major = 5
snaps_per_minor = 3
[settings.project]
directory = ""
default_project_name = "untitled"
[settings.command_bar]
include_settings = false
[settings.text_editor]
text_wrapping = true
"#;
let expected = Configuration {
settings: Settings {
app: Some(AppSettings {
appearance: Some(AppearanceSettings {
theme: Some(AppTheme::Dark),
other: Default::default(),
}),
other: std::collections::HashMap::from([
("allow_orbit_in_sketch_mode".to_owned(), true.into()),
("foo".to_owned(), "bar".into()),
("machine_api".to_owned(), true.into()),
("onboarding_status".to_owned(), "dismissed".into()),
("show_debug_panel".to_owned(), true.into()),
]),
..Default::default()
}),
modeling: Some(ModelingSettings {
enable_ssao: Some(false.into()),
base_unit: Some(From::from(UnitLength::Inches)),
camera_projection: Some(CameraProjectionType::Perspective),
fixed_size_grid: None,
other: std::collections::HashMap::from([
("enable_touch_controls".to_owned(), false.into()),
("gizmo_type".to_owned(), "axis".into()),
("major_grid_spacing".to_owned(), json!(2.5)),
("minor_grids_per_major".to_owned(), json!(5)),
("mouse_controls".to_owned(), "zoo".into()),
("snap_to_grid".to_owned(), true.into()),
("snaps_per_minor".to_owned(), json!(3)),
("use_sketch_solve_mode".to_owned(), true.into()),
]),
..Default::default()
}),
other: std::collections::HashMap::from([
(
"command_bar".to_owned(),
json!({
"include_settings": false,
}),
),
(
"project".to_owned(),
json!({
"default_project_name": "untitled",
"directory": "",
}),
),
(
"text_editor".to_owned(),
json!({
"text_wrapping": true,
}),
),
]),
},
};
let parsed = toml::from_str::<Configuration>(settings_file).unwrap();
assert_eq!(parsed, expected);
let serialized = toml::to_string(&parsed).unwrap();
assert!(serialized.contains("[settings.app]"));
assert!(serialized.contains("onboarding_status = \"dismissed\""));
assert!(serialized.contains("allow_orbit_in_sketch_mode = true"));
assert!(serialized.contains("show_debug_panel = true"));
assert!(serialized.contains("machine_api = true"));
assert!(serialized.contains("foo = \"bar\""));
assert!(serialized.contains("[settings.modeling]"));
assert!(serialized.contains("mouse_controls = \"zoo\""));
assert!(serialized.contains("gizmo_type = \"axis\""));
assert!(serialized.contains("enable_touch_controls = false"));
assert!(serialized.contains("use_sketch_solve_mode = true"));
assert!(serialized.contains("snap_to_grid = true"));
assert!(serialized.contains("major_grid_spacing = 2.5"));
assert!(serialized.contains("minor_grids_per_major = 5"));
assert!(serialized.contains("snaps_per_minor = 3"));
assert!(serialized.contains("[settings.project]"));
assert!(serialized.contains("directory = \"\""));
assert!(serialized.contains("default_project_name = \"untitled\""));
assert!(serialized.contains("[settings.command_bar]"));
assert!(serialized.contains("include_settings = false"));
assert!(serialized.contains("[settings.text_editor]"));
assert!(serialized.contains("text_wrapping = true"));
let reparsed = toml::from_str::<Configuration>(&serialized).unwrap();
assert_eq!(reparsed, expected);
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
.clone()
.settings
.modeling
.unwrap_or_default()
.backface_color
.unwrap_or_default()
.0,
"#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\""));
}
}