diff --git a/e2e/app/index.html b/e2e/app/index.html index fe1fe10..996a579 100644 --- a/e2e/app/index.html +++ b/e2e/app/index.html @@ -103,17 +103,6 @@ } }; - // `Color` serializes untagged: a named colour or hex arrives as a string, - // an rgba arrives as `{r, g, b, a}`. - function cssColor(value) { - if (value == null) return null; - if (typeof value === 'string') return value; - if (typeof value === 'object' && value.r != null) { - return `rgba(${value.r}, ${value.g}, ${value.b}, ${value.a})`; - } - return String(value); - } - function renderWidget(node) { if (!node || typeof node !== 'object') { return document.createTextNode(String(node || '')); @@ -576,7 +565,7 @@ if (node.padding != null) el.style.padding = node.padding + 'px'; if (node.width) el.style.width = node.width; if (node.height) el.style.height = node.height; - const background = cssColor(node.background); + const background = node.background; if (background) { el.style.background = background; el.setAttribute('data-background', background); @@ -594,7 +583,7 @@ el.setAttribute('data-size', String(node.size)); el.style.fontSize = node.size + 'px'; } - const color = cssColor(node.color); + const color = node.color; if (color) { el.setAttribute('data-color', color); el.style.color = color; diff --git a/rusty/src/shared/color.rs b/rusty/src/shared/color.rs index 7944e16..4960ee0 100644 --- a/rusty/src/shared/color.rs +++ b/rusty/src/shared/color.rs @@ -1,8 +1,13 @@ -use serde::{Deserialize, Serialize}; +use serde::de::{self, Visitor}; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; /// A color value supporting named colors, hex, and RGBA. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(untagged)] +/// +/// Serializes to a CSS color string (`"primary"`, `"#ff0000"`, `"rgba(1, 2, 3, 0.5)"`) and +/// deserializes back from one, so `Serialize` and [`Color::to_css`] agree and the +/// value round-trips. A `#[serde(untagged)]` derive would emit named/hex as strings +/// but rgba as an object `{"r":1,"g":2,"b":3,"a":0.5}`, forcing clients to sniff the type. +#[derive(Debug, Clone, PartialEq)] pub enum Color { Named(NamedColor), Hex(String), @@ -23,6 +28,39 @@ pub enum NamedColor { Black, } +impl NamedColor { + /// The camelCase wire name, matching the `rename_all` derive. + pub fn as_str(&self) -> &'static str { + match self { + NamedColor::Primary => "primary", + NamedColor::Secondary => "secondary", + NamedColor::Success => "success", + NamedColor::Warning => "warning", + NamedColor::Danger => "danger", + NamedColor::Info => "info", + NamedColor::Muted => "muted", + NamedColor::White => "white", + NamedColor::Black => "black", + } + } + + /// Parse a camelCase wire name back into a variant. + pub fn parse(value: &str) -> Option { + match value { + "primary" => Some(NamedColor::Primary), + "secondary" => Some(NamedColor::Secondary), + "success" => Some(NamedColor::Success), + "warning" => Some(NamedColor::Warning), + "danger" => Some(NamedColor::Danger), + "info" => Some(NamedColor::Info), + "muted" => Some(NamedColor::Muted), + "white" => Some(NamedColor::White), + "black" => Some(NamedColor::Black), + _ => None, + } + } +} + impl Color { pub fn hex(value: &str) -> Self { Color::Hex(value.to_string()) @@ -31,6 +69,71 @@ impl Color { pub fn rgba(r: u8, g: u8, b: u8, a: f32) -> Self { Color::Rgba { r, g, b, a } } + + /// Render as a CSS color. This is the wire form: `Serialize` emits exactly + /// this string. + pub fn to_css(&self) -> String { + match self { + Color::Named(named) => named.as_str().to_string(), + Color::Hex(hex) => hex.clone(), + Color::Rgba { r, g, b, a } => format!("rgba({}, {}, {}, {})", r, g, b, a), + } + } + + /// Parse a CSS color string produced by [`Color::to_css`]. + pub fn parse_css(value: &str) -> Option { + let trimmed = value.trim(); + if let Some(named) = NamedColor::parse(trimmed) { + return Some(Color::Named(named)); + } + if trimmed.starts_with('#') { + return Some(Color::Hex(trimmed.to_string())); + } + if let Some(args) = trimmed + .strip_prefix("rgba(") + .and_then(|rest| rest.strip_suffix(')')) + { + let parts: Vec<&str> = args.split(',').map(str::trim).collect(); + if parts.len() == 4 { + return Some(Color::Rgba { + r: parts[0].parse().ok()?, + g: parts[1].parse().ok()?, + b: parts[2].parse().ok()?, + a: parts[3].parse().ok()?, + }); + } + } + None + } +} + +impl Serialize for Color { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_str(&self.to_css()) + } +} + +impl<'de> Deserialize<'de> for Color { + fn deserialize>(deserializer: D) -> Result { + struct ColorVisitor; + + impl Visitor<'_> for ColorVisitor { + type Value = Color; + + fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + f.write_str( + "a CSS color such as \"primary\", \"#ff0000\" or \"rgba(1, 2, 3, 0.5)\"", + ) + } + + fn visit_str(self, value: &str) -> Result { + Color::parse_css(value) + .ok_or_else(|| de::Error::invalid_value(de::Unexpected::Str(value), &self)) + } + } + + deserializer.deserialize_str(ColorVisitor) + } } impl From for Color { @@ -59,4 +162,52 @@ mod tests { panic!("Expected hex color"); } } + + #[test] + fn test_color_serialize_matches_to_css() { + assert_eq!( + serde_json::to_string(&Color::Named(NamedColor::Primary)).unwrap(), + "\"primary\"" + ); + assert_eq!( + serde_json::to_string(&Color::Hex("#ff0000".to_string())).unwrap(), + "\"#ff0000\"" + ); + assert_eq!( + serde_json::to_string(&Color::Rgba { + r: 1, + g: 2, + b: 3, + a: 0.5 + }) + .unwrap(), + "\"rgba(1, 2, 3, 0.5)\"" + ); + } + + #[test] + fn test_color_round_trips() { + let named = Color::Named(NamedColor::Primary); + let json = serde_json::to_string(&named).unwrap(); + assert_eq!(serde_json::from_str::(&json).unwrap(), named); + + let hex = Color::Hex("#ff0000".to_string()); + let json = serde_json::to_string(&hex).unwrap(); + assert_eq!(serde_json::from_str::(&json).unwrap(), hex); + + let rgba = Color::Rgba { + r: 1, + g: 2, + b: 3, + a: 0.5, + }; + let json = serde_json::to_string(&rgba).unwrap(); + assert_eq!(serde_json::from_str::(&json).unwrap(), rgba); + } + + #[test] + fn test_color_deserialize_rejects_object_and_garbage() { + assert!(serde_json::from_str::("{\"r\":1,\"g\":2,\"b\":3,\"a\":1.0}").is_err()); + assert!(serde_json::from_str::("\"notacolor\"").is_err()); + } } diff --git a/rusty/src/shared/types.rs b/rusty/src/shared/types.rs index 5507157..f1e474c 100644 --- a/rusty/src/shared/types.rs +++ b/rusty/src/shared/types.rs @@ -1,12 +1,17 @@ -use serde::{Deserialize, Serialize}; +use serde::de::{self, Visitor}; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; /// Unique identifier for views and widgets. pub type ViewId = uuid::Uuid; pub type WidgetId = uuid::Uuid; /// Size specification for widgets. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(untagged)] +/// +/// Serializes to a CSS length string (`"8px"`, `"50%"`, `"auto"`) and +/// deserializes back from one, so `Serialize` and [`Size::to_css`] agree and the +/// value round-trips. A `#[serde(untagged)]` derive would collapse `Px(8.0)` and +/// `Percent(8.0)` to a bare `8.0` and `Auto` to `null`. +#[derive(Debug, Clone, PartialEq)] pub enum Size { Px(f64), Percent(f64), @@ -14,10 +19,8 @@ pub enum Size { } impl Size { - /// Render as a CSS length. Widgets serialize sizes through this rather than - /// through `Serialize`: the derive is `untagged`, so `Px(8.0)` and - /// `Percent(8.0)` both emit a bare `8.0` and `Auto` emits `null`, leaving a - /// client unable to tell pixels from percent or `Auto` from unset. + /// Render as a CSS length. This is the wire form: `Serialize` emits exactly + /// this string. pub fn to_css(&self) -> String { match self { Size::Px(px) => format!("{}px", px), @@ -25,16 +28,53 @@ impl Size { Size::Auto => "auto".to_string(), } } + + /// Parse a CSS length string produced by [`Size::to_css`]. + pub fn parse_css(value: &str) -> Option { + let trimmed = value.trim(); + if trimmed.eq_ignore_ascii_case("auto") { + return Some(Size::Auto); + } + if let Some(num) = trimmed.strip_suffix('%') { + return num.trim().parse::().ok().map(Size::Percent); + } + if let Some(num) = trimmed.strip_suffix("px") { + return num.trim().parse::().ok().map(Size::Px); + } + None + } +} + +impl Serialize for Size { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_str(&self.to_css()) + } +} + +impl<'de> Deserialize<'de> for Size { + fn deserialize>(deserializer: D) -> Result { + struct SizeVisitor; + + impl Visitor<'_> for SizeVisitor { + type Value = Size; + + fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + f.write_str("a CSS length such as \"8px\", \"50%\" or \"auto\"") + } + + fn visit_str(self, value: &str) -> Result { + Size::parse_css(value) + .ok_or_else(|| de::Error::invalid_value(de::Unexpected::Str(value), &self)) + } + } + + deserializer.deserialize_str(SizeVisitor) + } } /// Render an optional [`Size`] as its CSS string, for `#[prop(with = ...)]`. /// /// `Size`'s derived `Serialize` is `untagged` and therefore lossy on the wire -/// (`Px(8.0)` and `Percent(8.0)` both become `8.0`), so widgets emit the CSS -/// form instead. See [`Size::to_css`]. -pub fn size_css(size: &Option) -> Option { - size.as_ref().map(Size::to_css) -} /// Density level for widget rendering. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] @@ -80,10 +120,40 @@ mod tests { } #[test] - fn test_size_serialize_is_lossy_so_widgets_use_to_css() { - // Documents why `to_css` exists: `untagged` collapses the variants. - assert_eq!(serde_json::to_string(&Size::Px(8.0)).unwrap(), "8.0"); - assert_eq!(serde_json::to_string(&Size::Percent(8.0)).unwrap(), "8.0"); - assert_eq!(serde_json::to_string(&Size::Auto).unwrap(), "null"); + fn test_size_serialize_matches_to_css() { + assert_eq!(serde_json::to_string(&Size::Px(8.0)).unwrap(), "\"8px\""); + assert_eq!( + serde_json::to_string(&Size::Percent(8.0)).unwrap(), + "\"8%\"" + ); + assert_eq!(serde_json::to_string(&Size::Auto).unwrap(), "\"auto\""); + } + + #[test] + fn test_size_round_trips() { + let px = Size::Px(8.0); + let json = serde_json::to_string(&px).unwrap(); + assert_eq!(serde_json::from_str::(&json).unwrap(), px); + + let percent = Size::Percent(50.0); + let json = serde_json::to_string(&percent).unwrap(); + assert_eq!(serde_json::from_str::(&json).unwrap(), percent); + + let auto = Size::Auto; + let json = serde_json::to_string(&auto).unwrap(); + assert_eq!(serde_json::from_str::(&json).unwrap(), auto); + } + + #[test] + fn test_size_deserialize_rejects_bare_number_and_unknown_unit() { + assert!(serde_json::from_str::("8.0").is_err()); + assert!(serde_json::from_str::("null").is_err()); + assert!(serde_json::from_str::("\"8em\"").is_err()); + } + + #[test] + fn test_size_option_none_is_null() { + let none: Option = None; + assert_eq!(serde_json::to_string(&none).unwrap(), "null"); } } diff --git a/rusty/src/widgets/container.rs b/rusty/src/widgets/container.rs index fc0bb39..b69d1b0 100644 --- a/rusty/src/widgets/container.rs +++ b/rusty/src/widgets/container.rs @@ -88,8 +88,8 @@ impl WidgetData for Container { .map(|c| serde_json::to_value(c).unwrap_or_default()) .collect::>(), "padding": self.padding, - "width": self.width.as_ref().map(Size::to_css), - "height": self.height.as_ref().map(Size::to_css), + "width": self.width, + "height": self.height, "background": self.background, "border": self.border, "rounded": self.rounded, diff --git a/rusty/src/widgets/image.rs b/rusty/src/widgets/image.rs index 402fc32..e69b286 100644 --- a/rusty/src/widgets/image.rs +++ b/rusty/src/widgets/image.rs @@ -59,8 +59,8 @@ impl WidgetData for Image { "id": self.id, "src": self.src, "alt": self.alt, - "width": self.width.as_ref().map(Size::to_css), - "height": self.height.as_ref().map(Size::to_css), + "width": self.width, + "height": self.height, }) } diff --git a/rusty/src/widgets/layout.rs b/rusty/src/widgets/layout.rs index 4f18519..ab5b66d 100644 --- a/rusty/src/widgets/layout.rs +++ b/rusty/src/widgets/layout.rs @@ -36,10 +36,10 @@ pub struct Layout { #[prop] #[serde(skip_serializing_if = "Option::is_none")] pub columns: Option, - #[prop(with = "crate::shared::size_css")] + #[prop] #[serde(skip_serializing_if = "Option::is_none")] pub width: Option, - #[prop(with = "crate::shared::size_css")] + #[prop] #[serde(skip_serializing_if = "Option::is_none")] pub height: Option, #[prop] diff --git a/rusty/src/widgets/skeleton.rs b/rusty/src/widgets/skeleton.rs index 5ca1997..bf4f4c2 100644 --- a/rusty/src/widgets/skeleton.rs +++ b/rusty/src/widgets/skeleton.rs @@ -8,10 +8,10 @@ use serde::{Deserialize, Serialize}; pub struct Skeleton { #[serde(skip_serializing_if = "Option::is_none")] pub id: Option, - #[prop(with = "crate::shared::size_css")] + #[prop] #[serde(skip_serializing_if = "Option::is_none")] pub width: Option, - #[prop(with = "crate::shared::size_css")] + #[prop] #[serde(skip_serializing_if = "Option::is_none")] pub height: Option, }