Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 2 additions & 13 deletions e2e/app/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -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 || ''));
Expand Down Expand Up @@ -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);
Expand All @@ -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;
Expand Down
157 changes: 154 additions & 3 deletions rusty/src/shared/color.rs
Original file line number Diff line number Diff line change
@@ -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),
Expand All @@ -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<NamedColor> {
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())
Expand All @@ -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<Color> {
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<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(&self.to_css())
}
}

impl<'de> Deserialize<'de> for Color {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
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<E: de::Error>(self, value: &str) -> Result<Color, E> {
Color::parse_css(value)
.ok_or_else(|| de::Error::invalid_value(de::Unexpected::Str(value), &self))
}
}

deserializer.deserialize_str(ColorVisitor)
}
}

impl From<NamedColor> for Color {
Expand Down Expand Up @@ -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::<Color>(&json).unwrap(), named);

let hex = Color::Hex("#ff0000".to_string());
let json = serde_json::to_string(&hex).unwrap();
assert_eq!(serde_json::from_str::<Color>(&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::<Color>(&json).unwrap(), rgba);
}

#[test]
fn test_color_deserialize_rejects_object_and_garbage() {
assert!(serde_json::from_str::<Color>("{\"r\":1,\"g\":2,\"b\":3,\"a\":1.0}").is_err());
assert!(serde_json::from_str::<Color>("\"notacolor\"").is_err());
}
}
104 changes: 87 additions & 17 deletions rusty/src/shared/types.rs
Original file line number Diff line number Diff line change
@@ -1,40 +1,80 @@
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),
Auto,
}

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),
Size::Percent(pct) => format!("{}%", pct),
Size::Auto => "auto".to_string(),
}
}

/// Parse a CSS length string produced by [`Size::to_css`].
pub fn parse_css(value: &str) -> Option<Size> {
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::<f64>().ok().map(Size::Percent);
}
if let Some(num) = trimmed.strip_suffix("px") {
return num.trim().parse::<f64>().ok().map(Size::Px);
}
None
}
}

impl Serialize for Size {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(&self.to_css())
}
}

impl<'de> Deserialize<'de> for Size {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
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<E: de::Error>(self, value: &str) -> Result<Size, E> {
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<Size>) -> Option<String> {
size.as_ref().map(Size::to_css)
}

/// Density level for widget rendering.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
Expand Down Expand Up @@ -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::<Size>(&json).unwrap(), px);

let percent = Size::Percent(50.0);
let json = serde_json::to_string(&percent).unwrap();
assert_eq!(serde_json::from_str::<Size>(&json).unwrap(), percent);

let auto = Size::Auto;
let json = serde_json::to_string(&auto).unwrap();
assert_eq!(serde_json::from_str::<Size>(&json).unwrap(), auto);
}

#[test]
fn test_size_deserialize_rejects_bare_number_and_unknown_unit() {
assert!(serde_json::from_str::<Size>("8.0").is_err());
assert!(serde_json::from_str::<Size>("null").is_err());
assert!(serde_json::from_str::<Size>("\"8em\"").is_err());
}

#[test]
fn test_size_option_none_is_null() {
let none: Option<Size> = None;
assert_eq!(serde_json::to_string(&none).unwrap(), "null");
}
}
4 changes: 2 additions & 2 deletions rusty/src/widgets/container.rs
Original file line number Diff line number Diff line change
Expand Up @@ -88,8 +88,8 @@ impl WidgetData for Container {
.map(|c| serde_json::to_value(c).unwrap_or_default())
.collect::<Vec<_>>(),
"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,
Expand Down
4 changes: 2 additions & 2 deletions rusty/src/widgets/image.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
})
}

Expand Down
4 changes: 2 additions & 2 deletions rusty/src/widgets/layout.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,10 +36,10 @@ pub struct Layout {
#[prop]
#[serde(skip_serializing_if = "Option::is_none")]
pub columns: Option<usize>,
#[prop(with = "crate::shared::size_css")]
#[prop]
#[serde(skip_serializing_if = "Option::is_none")]
pub width: Option<Size>,
#[prop(with = "crate::shared::size_css")]
#[prop]
#[serde(skip_serializing_if = "Option::is_none")]
pub height: Option<Size>,
#[prop]
Expand Down
Loading