`WindowControlsSpec` gains `bar_bg`, the background fill of the SSD title-bar strip the close / maximize / minimize controls sit on. Forge used to paint that strip with `palette.surface` clamped to alpha 1.0 — a hack on a translucent panel token. With a dedicated slot the theme decides directly: `@off-white` in light and a new `@window-bar-dark` shade in dark. Schema gets the matching `Option<String>` field (defaulting to `palette.surface` to keep existing themes rendering the same) and the fallback `WindowControlsSpec` seeds `Color::WHITE` so a missing theme still draws something readable.
`try_run`'s dispatch loop previously `.expect("dispatch")`-ed every calloop error. When the compositor closes the wayland socket — `wl_display.error`, forge crashing, the user logging out — the loop saw a `BrokenPipe` / `ConnectionReset` and panicked, which polluted the user's stderr with a backtrace for what is just "the session ended". The match now treats those two `IoError` kinds as a clean shutdown: it prints `Connection::protocol_error()` (the typed `wl_display.error` if the server sent one), sets `exit_requested = true`, and lets the loop drain out normally. Any other dispatch error keeps panicking — those are still genuine bugs.
1453 lines
41 KiB
Rust
1453 lines
41 KiB
Rust
// SPDX-License-Identifier: LGPL-2.1-only
|
||
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
|
||
|
||
//! JSON schema for the theme document: raw deserialisation types, colour
|
||
//! (de)serialiser, and conversion to the public runtime types.
|
||
//!
|
||
//! This module is the single place where impedance between "JSON as designers
|
||
//! and tools write it" and "the Rust types widgets consume" lives. Every
|
||
//! public type in `theme::paint`, `theme::shadow`, `theme::surface`,
|
||
//! `theme::text_style` and `theme::slots` gets a private `Raw*` counterpart
|
||
//! here with the minimum serde annotations to match the JSON shape, and a
|
||
//! `From<Raw*> for *` conversion that builds the public value.
|
||
//!
|
||
//! Keeping the derives off the public types means the public API stays
|
||
//! free of serde dependencies and the JSON format can evolve (add fields,
|
||
//! accept aliases, reject unknown keys) without touching consumer code.
|
||
|
||
use std::collections::HashMap;
|
||
use std::path::{ Path, PathBuf };
|
||
|
||
use serde::{ Deserialize, Deserializer, Serialize, Serializer };
|
||
use serde::de::Error as DeError;
|
||
|
||
use crate::types::Color;
|
||
|
||
use super::document::{ Mode, ThemeDocument };
|
||
use super::fonts::{ FontFamilyDef, FontSource };
|
||
use super::paint::{ ColorStop, GradientSpace, LinearGradient, Paint, RadialGradient };
|
||
use super::shadow::{ BlendMode, InsetShadow, Shadow, ShadowsRef };
|
||
use super::slots::{ Metadata, Slot, SlotStore };
|
||
use super::surface::Surface;
|
||
use super::text_style::
|
||
{
|
||
FontRef, FontStyle, LineHeight, TextDecoration, TextStyle, TextTransform,
|
||
};
|
||
use super::
|
||
{
|
||
default_window_controls, LauncherSpec, ThemeError, ThemeMode, WallpaperFit,
|
||
WallpaperSpec, WindowControlsSpec,
|
||
};
|
||
|
||
// ─── Color serialiser ────────────────────────────────────────────────────────
|
||
|
||
/// Encode / decode [`Color`] as a hex or `rgba(…)` string.
|
||
///
|
||
/// Accepted input forms:
|
||
///
|
||
/// * `#RRGGBB` or `RRGGBB` — opaque, 8 bits per channel.
|
||
/// * `#RRGGBBAA` or `RRGGBBAA` — with straight alpha, 8 bits per channel.
|
||
/// * `rgba(R, G, B, A)` — R/G/B as integers `0..=255` (or floats), A as a
|
||
/// float in `0.0..=1.0`. Whitespace around commas is tolerated.
|
||
/// * `rgb(R, G, B)` — same as above with implicit A = 1.0.
|
||
///
|
||
/// Output form (serialisation): `#RRGGBB` when alpha is 1.0, `#RRGGBBAA`
|
||
/// otherwise.
|
||
pub mod color_serde
|
||
{
|
||
use super::*;
|
||
|
||
/// Parse one of the accepted color syntaxes into a [`Color`].
|
||
pub fn parse( s: &str ) -> Result<Color, String>
|
||
{
|
||
let t = s.trim();
|
||
if t.starts_with( "rgba(" ) || t.starts_with( "rgb(" )
|
||
{
|
||
parse_functional( t )
|
||
}
|
||
else
|
||
{
|
||
parse_hex( t )
|
||
}
|
||
}
|
||
|
||
fn parse_hex( s: &str ) -> Result<Color, String>
|
||
{
|
||
let h = s.trim_start_matches( '#' );
|
||
let ( r, g, b, a ) = match h.len()
|
||
{
|
||
6 =>
|
||
{
|
||
let r = u8::from_str_radix( &h[0..2], 16 ).map_err( |_| bad( s ) )?;
|
||
let g = u8::from_str_radix( &h[2..4], 16 ).map_err( |_| bad( s ) )?;
|
||
let b = u8::from_str_radix( &h[4..6], 16 ).map_err( |_| bad( s ) )?;
|
||
( r, g, b, 0xFF )
|
||
}
|
||
8 =>
|
||
{
|
||
let r = u8::from_str_radix( &h[0..2], 16 ).map_err( |_| bad( s ) )?;
|
||
let g = u8::from_str_radix( &h[2..4], 16 ).map_err( |_| bad( s ) )?;
|
||
let b = u8::from_str_radix( &h[4..6], 16 ).map_err( |_| bad( s ) )?;
|
||
let a = u8::from_str_radix( &h[6..8], 16 ).map_err( |_| bad( s ) )?;
|
||
( r, g, b, a )
|
||
}
|
||
_ => return Err( bad( s ) ),
|
||
};
|
||
Ok( Color
|
||
{
|
||
r: r as f32 / 255.0,
|
||
g: g as f32 / 255.0,
|
||
b: b as f32 / 255.0,
|
||
a: a as f32 / 255.0,
|
||
})
|
||
}
|
||
|
||
fn parse_functional( s: &str ) -> Result<Color, String>
|
||
{
|
||
let ( with_alpha, inner ) = if let Some( rest ) = s.strip_prefix( "rgba(" )
|
||
{
|
||
( true, rest.strip_suffix( ')' ).ok_or_else( || bad( s ) )? )
|
||
}
|
||
else if let Some( rest ) = s.strip_prefix( "rgb(" )
|
||
{
|
||
( false, rest.strip_suffix( ')' ).ok_or_else( || bad( s ) )? )
|
||
}
|
||
else
|
||
{
|
||
return Err( bad( s ) );
|
||
};
|
||
let parts: Vec<&str> = inner.split( ',' ).map( str::trim ).collect();
|
||
let expected = if with_alpha { 4 } else { 3 };
|
||
if parts.len() != expected { return Err( bad( s ) ); }
|
||
|
||
let r = parse_channel( parts[0] ).ok_or_else( || bad( s ) )?;
|
||
let g = parse_channel( parts[1] ).ok_or_else( || bad( s ) )?;
|
||
let b = parse_channel( parts[2] ).ok_or_else( || bad( s ) )?;
|
||
let a = if with_alpha
|
||
{
|
||
parts[3].parse::<f32>().map_err( |_| bad( s ) )?.clamp( 0.0, 1.0 )
|
||
}
|
||
else
|
||
{
|
||
1.0
|
||
};
|
||
|
||
Ok( Color { r: r / 255.0, g: g / 255.0, b: b / 255.0, a })
|
||
}
|
||
|
||
fn parse_channel( s: &str ) -> Option<f32>
|
||
{
|
||
// Integer 0..=255 or float 0..=255.
|
||
if let Ok( n ) = s.parse::<u16>()
|
||
{
|
||
if n <= 255 { return Some( n as f32 ); }
|
||
return None;
|
||
}
|
||
if let Ok( f ) = s.parse::<f32>()
|
||
{
|
||
if ( 0.0..=255.0 ).contains( &f ) { return Some( f ); }
|
||
}
|
||
None
|
||
}
|
||
|
||
fn bad( s: &str ) -> String
|
||
{
|
||
format!
|
||
(
|
||
"invalid colour `{}` (expected `#RRGGBB`, `#RRGGBBAA` or `rgb[a](…)`)",
|
||
s
|
||
)
|
||
}
|
||
|
||
/// Canonical string form: `#RRGGBB` when opaque, `#RRGGBBAA` otherwise.
|
||
pub fn format( c: Color ) -> String
|
||
{
|
||
let r = (c.r.clamp( 0.0, 1.0 ) * 255.0).round() as u8;
|
||
let g = (c.g.clamp( 0.0, 1.0 ) * 255.0).round() as u8;
|
||
let b = (c.b.clamp( 0.0, 1.0 ) * 255.0).round() as u8;
|
||
let a = (c.a.clamp( 0.0, 1.0 ) * 255.0).round() as u8;
|
||
if a == 0xFF
|
||
{
|
||
format!( "#{:02X}{:02X}{:02X}", r, g, b )
|
||
}
|
||
else
|
||
{
|
||
format!( "#{:02X}{:02X}{:02X}{:02X}", r, g, b, a )
|
||
}
|
||
}
|
||
|
||
pub fn serialize<S>( c: &Color, ser: S ) -> Result<S::Ok, S::Error>
|
||
where S: Serializer
|
||
{
|
||
ser.serialize_str( &format( *c ) )
|
||
}
|
||
|
||
pub fn deserialize<'de, D>( de: D ) -> Result<Color, D::Error>
|
||
where D: Deserializer<'de>
|
||
{
|
||
let s = String::deserialize( de )?;
|
||
parse( &s ).map_err( D::Error::custom )
|
||
}
|
||
}
|
||
|
||
/// Expose the colour parser so error messages and other loaders can share
|
||
/// the same syntax understanding.
|
||
pub fn parse_color_str( s: &str ) -> Result<Color, String>
|
||
{
|
||
color_serde::parse( s )
|
||
}
|
||
|
||
// ─── Enum mirrors with defaults ──────────────────────────────────────────────
|
||
|
||
#[ derive( Debug, Clone, Copy, Serialize, Deserialize ) ]
|
||
#[ serde( rename_all = "kebab-case" ) ]
|
||
enum RawGradientSpace
|
||
{
|
||
Srgb,
|
||
LinearRgb,
|
||
Oklab,
|
||
}
|
||
|
||
impl Default for RawGradientSpace { fn default() -> Self { RawGradientSpace::LinearRgb } }
|
||
|
||
impl From<RawGradientSpace> for GradientSpace
|
||
{
|
||
fn from( r: RawGradientSpace ) -> Self
|
||
{
|
||
match r
|
||
{
|
||
RawGradientSpace::Srgb => GradientSpace::Srgb,
|
||
RawGradientSpace::LinearRgb => GradientSpace::LinearRgb,
|
||
RawGradientSpace::Oklab => GradientSpace::Oklab,
|
||
}
|
||
}
|
||
}
|
||
|
||
#[ derive( Debug, Clone, Copy, Serialize, Deserialize ) ]
|
||
#[ serde( rename_all = "kebab-case" ) ]
|
||
enum RawBlendMode
|
||
{
|
||
Normal,
|
||
PlusLighter,
|
||
Overlay,
|
||
Multiply,
|
||
Screen,
|
||
}
|
||
|
||
impl Default for RawBlendMode { fn default() -> Self { RawBlendMode::Normal } }
|
||
|
||
impl From<RawBlendMode> for BlendMode
|
||
{
|
||
fn from( r: RawBlendMode ) -> Self
|
||
{
|
||
match r
|
||
{
|
||
RawBlendMode::Normal => BlendMode::Normal,
|
||
RawBlendMode::PlusLighter => BlendMode::PlusLighter,
|
||
RawBlendMode::Overlay => BlendMode::Overlay,
|
||
RawBlendMode::Multiply => BlendMode::Multiply,
|
||
RawBlendMode::Screen => BlendMode::Screen,
|
||
}
|
||
}
|
||
}
|
||
|
||
#[ derive( Debug, Clone, Copy, Serialize, Deserialize ) ]
|
||
#[ serde( rename_all = "kebab-case" ) ]
|
||
enum RawFontStyle { Normal, Italic }
|
||
|
||
impl Default for RawFontStyle { fn default() -> Self { RawFontStyle::Normal } }
|
||
|
||
impl From<RawFontStyle> for FontStyle
|
||
{
|
||
fn from( r: RawFontStyle ) -> Self
|
||
{
|
||
match r
|
||
{
|
||
RawFontStyle::Normal => FontStyle::Normal,
|
||
RawFontStyle::Italic => FontStyle::Italic,
|
||
}
|
||
}
|
||
}
|
||
|
||
#[ derive( Debug, Clone, Copy, Serialize, Deserialize ) ]
|
||
#[ serde( rename_all = "kebab-case" ) ]
|
||
enum RawTextTransform { None, Uppercase, Lowercase, Capitalize }
|
||
|
||
impl Default for RawTextTransform { fn default() -> Self { RawTextTransform::None } }
|
||
|
||
impl From<RawTextTransform> for TextTransform
|
||
{
|
||
fn from( r: RawTextTransform ) -> Self
|
||
{
|
||
match r
|
||
{
|
||
RawTextTransform::None => TextTransform::None,
|
||
RawTextTransform::Uppercase => TextTransform::Uppercase,
|
||
RawTextTransform::Lowercase => TextTransform::Lowercase,
|
||
RawTextTransform::Capitalize => TextTransform::Capitalize,
|
||
}
|
||
}
|
||
}
|
||
|
||
#[ derive( Debug, Clone, Copy, Serialize, Deserialize ) ]
|
||
#[ serde( rename_all = "kebab-case" ) ]
|
||
enum RawTextDecoration { None, Underline, Strikethrough }
|
||
|
||
impl Default for RawTextDecoration { fn default() -> Self { RawTextDecoration::None } }
|
||
|
||
impl From<RawTextDecoration> for TextDecoration
|
||
{
|
||
fn from( r: RawTextDecoration ) -> Self
|
||
{
|
||
match r
|
||
{
|
||
RawTextDecoration::None => TextDecoration::None,
|
||
RawTextDecoration::Underline => TextDecoration::Underline,
|
||
RawTextDecoration::Strikethrough => TextDecoration::Strikethrough,
|
||
}
|
||
}
|
||
}
|
||
|
||
// ─── Metadata ────────────────────────────────────────────────────────────────
|
||
|
||
#[ derive( Debug, Clone, Default, Serialize, Deserialize ) ]
|
||
struct RawMetadata
|
||
{
|
||
#[ serde( default, skip_serializing_if = "Option::is_none" ) ]
|
||
semantic: Option<String>,
|
||
#[ serde( default, skip_serializing_if = "Option::is_none" ) ]
|
||
fluent: Option<String>,
|
||
#[ serde( default, skip_serializing_if = "Option::is_none" ) ]
|
||
usage: Option<String>,
|
||
#[ serde( default, skip_serializing_if = "Option::is_none" ) ]
|
||
note: Option<String>,
|
||
}
|
||
|
||
impl From<RawMetadata> for Metadata
|
||
{
|
||
fn from( r: RawMetadata ) -> Self
|
||
{
|
||
Metadata { semantic: r.semantic, fluent: r.fluent, usage: r.usage, note: r.note }
|
||
}
|
||
}
|
||
|
||
// ─── Gradient stops and gradients ────────────────────────────────────────────
|
||
|
||
#[ derive( Debug, Clone, Serialize, Deserialize ) ]
|
||
#[ serde( deny_unknown_fields ) ]
|
||
struct RawColorStop
|
||
{
|
||
#[ serde( alias = "position" ) ]
|
||
pos: f32,
|
||
#[ serde( with = "color_serde" ) ]
|
||
color: Color,
|
||
}
|
||
|
||
impl From<RawColorStop> for ColorStop
|
||
{
|
||
fn from( r: RawColorStop ) -> Self { ColorStop { position: r.pos, color: r.color } }
|
||
}
|
||
|
||
// ─── Paint (for `surface.fill`) ──────────────────────────────────────────────
|
||
|
||
#[ derive( Debug, Clone, Serialize, Deserialize ) ]
|
||
#[ serde( tag = "type", rename_all = "kebab-case", deny_unknown_fields ) ]
|
||
enum RawPaint
|
||
{
|
||
Solid
|
||
{
|
||
#[ serde( with = "color_serde" ) ]
|
||
color: Color,
|
||
},
|
||
Linear
|
||
{
|
||
angle_deg: f32,
|
||
#[ serde( default ) ]
|
||
space: RawGradientSpace,
|
||
stops: Vec<RawColorStop>,
|
||
},
|
||
Radial
|
||
{
|
||
center: [f32; 2],
|
||
radius: f32,
|
||
#[ serde( default ) ]
|
||
space: RawGradientSpace,
|
||
stops: Vec<RawColorStop>,
|
||
},
|
||
}
|
||
|
||
impl From<RawPaint> for Paint
|
||
{
|
||
fn from( r: RawPaint ) -> Self
|
||
{
|
||
match r
|
||
{
|
||
RawPaint::Solid { color } => Paint::Solid( color ),
|
||
RawPaint::Linear { angle_deg, space, stops } => Paint::Linear( LinearGradient
|
||
{
|
||
angle_deg,
|
||
space: space.into(),
|
||
stops: collect_capped_stops( stops ),
|
||
}),
|
||
RawPaint::Radial { center, radius, space, stops } => Paint::Radial( RadialGradient
|
||
{
|
||
center,
|
||
radius,
|
||
space: space.into(),
|
||
stops: collect_capped_stops( stops ),
|
||
}),
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Soft cap on the number of color stops a gradient may carry. Realistic
|
||
/// designs use 2 – 6 stops, the shipped theme tops out at 4, and
|
||
/// 64 leaves comfortable headroom for hand-tuned tonemap palettes. The
|
||
/// cap exists to bound CPU + memory under a hostile theme document — a
|
||
/// 200 000-stop linear gradient would otherwise build a 6 MB
|
||
/// `Vec<ColorStop>` per slot at parse time. Exceeding the cap truncates
|
||
/// the tail and logs once via stderr.
|
||
const MAX_GRADIENT_STOPS: usize = 64;
|
||
|
||
fn collect_capped_stops( raw: Vec<RawColorStop> ) -> Vec<ColorStop>
|
||
{
|
||
if raw.len() > MAX_GRADIENT_STOPS
|
||
{
|
||
eprintln!(
|
||
"[ltk theme] gradient declares {} stops, truncating to {}",
|
||
raw.len(), MAX_GRADIENT_STOPS,
|
||
);
|
||
raw.into_iter().take( MAX_GRADIENT_STOPS ).map( Into::into ).collect()
|
||
}
|
||
else
|
||
{
|
||
raw.into_iter().map( Into::into ).collect()
|
||
}
|
||
}
|
||
|
||
// ─── Shadows ─────────────────────────────────────────────────────────────────
|
||
|
||
#[ derive( Debug, Clone, Serialize, Deserialize ) ]
|
||
#[ serde( deny_unknown_fields ) ]
|
||
struct RawShadow
|
||
{
|
||
offset: [f32; 2],
|
||
blur: f32,
|
||
#[ serde( default ) ]
|
||
spread: f32,
|
||
#[ serde( with = "color_serde" ) ]
|
||
color: Color,
|
||
#[ serde( default ) ]
|
||
blend: RawBlendMode,
|
||
}
|
||
|
||
impl From<RawShadow> for Shadow
|
||
{
|
||
fn from( r: RawShadow ) -> Self
|
||
{
|
||
Shadow { offset: r.offset, blur: r.blur, spread: r.spread, color: r.color, blend: r.blend.into() }
|
||
}
|
||
}
|
||
|
||
impl From<RawShadow> for InsetShadow
|
||
{
|
||
fn from( r: RawShadow ) -> Self
|
||
{
|
||
InsetShadow { offset: r.offset, blur: r.blur, spread: r.spread, color: r.color, blend: r.blend.into() }
|
||
}
|
||
}
|
||
|
||
/// Reference to a shadow stack inside a surface: either a string id
|
||
/// pointing at a `shadows` slot, or a literal list.
|
||
#[ derive( Debug, Clone, Serialize, Deserialize ) ]
|
||
#[ serde( untagged ) ]
|
||
enum RawShadowsRef
|
||
{
|
||
Named( String ),
|
||
Inline( Vec<RawShadow> ),
|
||
}
|
||
|
||
impl From<RawShadowsRef> for ShadowsRef
|
||
{
|
||
fn from( r: RawShadowsRef ) -> Self
|
||
{
|
||
match r
|
||
{
|
||
RawShadowsRef::Named( id ) => ShadowsRef::Named( id ),
|
||
RawShadowsRef::Inline( items ) => ShadowsRef::Inline( items.into_iter().map( Into::into ).collect() ),
|
||
}
|
||
}
|
||
}
|
||
|
||
// ─── Surface ─────────────────────────────────────────────────────────────────
|
||
|
||
#[ derive( Debug, Clone, Serialize, Deserialize ) ]
|
||
#[ serde( deny_unknown_fields ) ]
|
||
struct RawSurfaceBody
|
||
{
|
||
fill: Box<RawPaint>,
|
||
#[ serde( default, skip_serializing_if = "Option::is_none" ) ]
|
||
shadows: Option<RawShadowsRef>,
|
||
#[ serde( default ) ]
|
||
inset_shadows: Vec<RawShadow>,
|
||
}
|
||
|
||
impl From<RawSurfaceBody> for Surface
|
||
{
|
||
fn from( r: RawSurfaceBody ) -> Self
|
||
{
|
||
Surface
|
||
{
|
||
fill: Paint::from( *r.fill ),
|
||
shadows: r.shadows.map( Into::into ),
|
||
inset_shadows: r.inset_shadows.into_iter().map( Into::into ).collect(),
|
||
}
|
||
}
|
||
}
|
||
|
||
// ─── Text style ──────────────────────────────────────────────────────────────
|
||
|
||
#[ derive( Debug, Clone, Serialize, Deserialize ) ]
|
||
#[ serde( untagged ) ]
|
||
enum RawLineHeight
|
||
{
|
||
Px { px: f32 },
|
||
Multiplier { mul: f32 },
|
||
}
|
||
|
||
impl From<RawLineHeight> for LineHeight
|
||
{
|
||
fn from( r: RawLineHeight ) -> Self
|
||
{
|
||
match r
|
||
{
|
||
RawLineHeight::Px { px } => LineHeight::Px( px ),
|
||
RawLineHeight::Multiplier { mul } => LineHeight::Multiplier( mul ),
|
||
}
|
||
}
|
||
}
|
||
|
||
#[ derive( Debug, Clone, Serialize, Deserialize ) ]
|
||
#[ serde( deny_unknown_fields ) ]
|
||
struct RawFontRef
|
||
{
|
||
r#ref: String,
|
||
}
|
||
|
||
impl From<RawFontRef> for FontRef
|
||
{
|
||
fn from( r: RawFontRef ) -> Self { FontRef::Named( r.r#ref ) }
|
||
}
|
||
|
||
#[ derive( Debug, Clone, Serialize, Deserialize ) ]
|
||
#[ serde( deny_unknown_fields ) ]
|
||
struct RawTextStyleBody
|
||
{
|
||
family: RawFontRef,
|
||
weight: u16,
|
||
#[ serde( default ) ]
|
||
style: RawFontStyle,
|
||
size: f32,
|
||
line_height: RawLineHeight,
|
||
#[ serde( default ) ]
|
||
letter_spacing: f32,
|
||
#[ serde( default ) ]
|
||
transform: RawTextTransform,
|
||
#[ serde( default ) ]
|
||
decoration: RawTextDecoration,
|
||
#[ serde( default, skip_serializing_if = "Option::is_none" ) ]
|
||
color: Option<String>,
|
||
}
|
||
|
||
impl From<RawTextStyleBody> for TextStyle
|
||
{
|
||
fn from( r: RawTextStyleBody ) -> Self
|
||
{
|
||
TextStyle
|
||
{
|
||
family: r.family.into(),
|
||
weight: r.weight,
|
||
style: r.style.into(),
|
||
size: r.size,
|
||
line_height: r.line_height.into(),
|
||
letter_spacing: r.letter_spacing,
|
||
transform: r.transform.into(),
|
||
decoration: r.decoration.into(),
|
||
color: r.color,
|
||
}
|
||
}
|
||
}
|
||
|
||
// ─── Slot ────────────────────────────────────────────────────────────────────
|
||
|
||
#[ derive( Debug, Clone, Serialize, Deserialize ) ]
|
||
#[ serde( tag = "type", rename_all = "kebab-case" ) ]
|
||
enum RawSlot
|
||
{
|
||
Color
|
||
{
|
||
#[ serde( with = "color_serde" ) ]
|
||
value: Color,
|
||
#[ serde( default ) ]
|
||
meta: RawMetadata,
|
||
},
|
||
Linear
|
||
{
|
||
angle_deg: f32,
|
||
#[ serde( default ) ]
|
||
space: RawGradientSpace,
|
||
stops: Vec<RawColorStop>,
|
||
#[ serde( default ) ]
|
||
meta: RawMetadata,
|
||
},
|
||
Radial
|
||
{
|
||
center: [f32; 2],
|
||
radius: f32,
|
||
#[ serde( default ) ]
|
||
space: RawGradientSpace,
|
||
stops: Vec<RawColorStop>,
|
||
#[ serde( default ) ]
|
||
meta: RawMetadata,
|
||
},
|
||
Shadows
|
||
{
|
||
shadows: Vec<RawShadow>,
|
||
#[ serde( default ) ]
|
||
meta: RawMetadata,
|
||
},
|
||
Surface
|
||
{
|
||
#[ serde( flatten ) ]
|
||
body: RawSurfaceBody,
|
||
#[ serde( default ) ]
|
||
meta: RawMetadata,
|
||
},
|
||
Typography
|
||
{
|
||
#[ serde( flatten ) ]
|
||
body: RawTextStyleBody,
|
||
#[ serde( default ) ]
|
||
meta: RawMetadata,
|
||
},
|
||
}
|
||
|
||
impl From<RawSlot> for Slot
|
||
{
|
||
fn from( r: RawSlot ) -> Self
|
||
{
|
||
match r
|
||
{
|
||
RawSlot::Color { value, meta } => Slot::Color { value, meta: meta.into() },
|
||
RawSlot::Linear { angle_deg, space, stops, meta } => Slot::Paint
|
||
{
|
||
value: Paint::Linear( LinearGradient
|
||
{
|
||
angle_deg,
|
||
space: space.into(),
|
||
stops: collect_capped_stops( stops ),
|
||
}),
|
||
meta: meta.into(),
|
||
},
|
||
RawSlot::Radial { center, radius, space, stops, meta } => Slot::Paint
|
||
{
|
||
value: Paint::Radial( RadialGradient
|
||
{
|
||
center,
|
||
radius,
|
||
space: space.into(),
|
||
stops: collect_capped_stops( stops ),
|
||
}),
|
||
meta: meta.into(),
|
||
},
|
||
RawSlot::Shadows { shadows, meta } => Slot::Shadows
|
||
{
|
||
value: shadows.into_iter().map( Into::into ).collect(),
|
||
meta: meta.into(),
|
||
},
|
||
RawSlot::Surface { body, meta } => Slot::Surface
|
||
{
|
||
value: body.into(),
|
||
meta: meta.into(),
|
||
},
|
||
RawSlot::Typography { body, meta } => Slot::TextStyle
|
||
{
|
||
value: body.into(),
|
||
meta: meta.into(),
|
||
},
|
||
}
|
||
}
|
||
}
|
||
|
||
// ─── Fonts block ─────────────────────────────────────────────────────────────
|
||
|
||
#[ derive( Debug, Clone, Serialize, Deserialize ) ]
|
||
#[ serde( deny_unknown_fields ) ]
|
||
struct RawFontSource
|
||
{
|
||
weight: u16,
|
||
#[ serde( default ) ]
|
||
style: RawFontStyle,
|
||
path: String,
|
||
}
|
||
|
||
#[ derive( Debug, Clone, Serialize, Deserialize ) ]
|
||
#[ serde( deny_unknown_fields ) ]
|
||
struct RawFontFamily
|
||
{
|
||
name: String,
|
||
#[ serde( default ) ]
|
||
fallbacks: Vec<String>,
|
||
#[ serde( default ) ]
|
||
sources: Vec<RawFontSource>,
|
||
}
|
||
|
||
fn family_from_raw( root: Option<&Path>, r: RawFontFamily ) -> FontFamilyDef
|
||
{
|
||
FontFamilyDef
|
||
{
|
||
name: r.name,
|
||
fallbacks: r.fallbacks,
|
||
sources: r.sources.into_iter().map( |s| FontSource
|
||
{
|
||
weight: s.weight,
|
||
style: s.style.into(),
|
||
path: resolve_relative( root, &s.path ),
|
||
}).collect(),
|
||
}
|
||
}
|
||
|
||
// ─── Wallpaper / window_controls ─────────────────────────────────────────────
|
||
|
||
#[ derive( Debug, Clone, Serialize, Deserialize ) ]
|
||
#[ serde( deny_unknown_fields ) ]
|
||
struct RawWallpaper
|
||
{
|
||
path: String,
|
||
#[ serde( default ) ]
|
||
fit: WallpaperFit,
|
||
}
|
||
|
||
#[ derive( Debug, Clone, Serialize, Deserialize ) ]
|
||
#[ serde( deny_unknown_fields ) ]
|
||
struct RawLauncher
|
||
{
|
||
background: String,
|
||
border_radius: f32,
|
||
}
|
||
|
||
#[ derive( Debug, Clone, Serialize, Deserialize, Default ) ]
|
||
#[ serde( deny_unknown_fields ) ]
|
||
struct RawWindowControls
|
||
{
|
||
#[ serde( default ) ] bar_bg: Option<String>,
|
||
#[ serde( default ) ] icon: Option<String>,
|
||
#[ serde( default ) ] hover_bg: Option<String>,
|
||
#[ serde( default ) ] pressed_bg: Option<String>,
|
||
#[ serde( default ) ] close_hover_bg: Option<String>,
|
||
#[ serde( default ) ] close_icon: Option<String>,
|
||
#[ serde( default ) ] focus_ring: Option<String>,
|
||
}
|
||
|
||
// ─── Mode / Modes / Document ─────────────────────────────────────────────────
|
||
|
||
#[ derive( Debug, Clone, Serialize, Deserialize ) ]
|
||
#[ serde( deny_unknown_fields ) ]
|
||
struct RawMode
|
||
{
|
||
#[ serde( default, skip_serializing_if = "Option::is_none" ) ]
|
||
wallpaper: Option<RawWallpaper>,
|
||
#[ serde( default, skip_serializing_if = "Option::is_none" ) ]
|
||
lockscreen: Option<RawWallpaper>,
|
||
#[ serde( default, skip_serializing_if = "Option::is_none" ) ]
|
||
launcher: Option<RawLauncher>,
|
||
#[ serde( default, skip_serializing_if = "Option::is_none" ) ]
|
||
window_controls: Option<RawWindowControls>,
|
||
#[ serde( default ) ]
|
||
slots: HashMap<String, RawSlot>,
|
||
}
|
||
|
||
#[ derive( Debug, Clone, Serialize, Deserialize ) ]
|
||
#[ serde( deny_unknown_fields ) ]
|
||
struct RawModes
|
||
{
|
||
light: RawMode,
|
||
dark: RawMode,
|
||
}
|
||
|
||
#[ derive( Debug, Clone, Serialize, Deserialize ) ]
|
||
#[ serde( deny_unknown_fields ) ]
|
||
struct RawThemeMeta
|
||
{
|
||
id: String,
|
||
name: String,
|
||
}
|
||
|
||
#[ derive( Debug, Clone, Serialize, Deserialize ) ]
|
||
#[ serde( deny_unknown_fields ) ]
|
||
struct RawThemeDocument
|
||
{
|
||
theme: RawThemeMeta,
|
||
#[ serde( default ) ]
|
||
fonts: HashMap<String, RawFontFamily>,
|
||
modes: RawModes,
|
||
}
|
||
|
||
// ─── Conversion from raw to runtime types ────────────────────────────────────
|
||
|
||
fn wallpaper_from_raw( root: Option<&Path>, r: RawWallpaper ) -> WallpaperSpec
|
||
{
|
||
WallpaperSpec { path: Some( resolve_relative( root, &r.path ) ), fit: r.fit }
|
||
}
|
||
|
||
fn window_controls_from_raw
|
||
(
|
||
fallback_palette: Option<&super::Palette>,
|
||
_mode: ThemeMode,
|
||
raw: RawWindowControls,
|
||
) -> Result<WindowControlsSpec, ThemeError>
|
||
{
|
||
// When the mode has no palette to derive sensible defaults from, we fall
|
||
// back to neutral black-on-white defaults for the fields the author did
|
||
// not override.
|
||
let fallback = match fallback_palette
|
||
{
|
||
Some( p ) => default_window_controls( *p ),
|
||
None => WindowControlsSpec
|
||
{
|
||
bar_bg: Color::WHITE,
|
||
icon: Color::BLACK,
|
||
hover_bg: Color::rgba( 0.0, 0.0, 0.0, 0.08 ),
|
||
pressed_bg: Color::rgba( 0.0, 0.0, 0.0, 0.12 ),
|
||
close_hover_bg: Color::rgba( 0.92, 0.18, 0.18, 0.90 ),
|
||
close_icon: Color::WHITE,
|
||
focus_ring: Color::hex( 0x04, 0xD9, 0xFE ),
|
||
},
|
||
};
|
||
Ok( WindowControlsSpec
|
||
{
|
||
bar_bg: parse_opt( raw.bar_bg.as_deref(), fallback.bar_bg )?,
|
||
icon: parse_opt( raw.icon.as_deref(), fallback.icon )?,
|
||
hover_bg: parse_opt( raw.hover_bg.as_deref(), fallback.hover_bg )?,
|
||
pressed_bg: parse_opt( raw.pressed_bg.as_deref(), fallback.pressed_bg )?,
|
||
close_hover_bg: parse_opt( raw.close_hover_bg.as_deref(), fallback.close_hover_bg )?,
|
||
close_icon: parse_opt( raw.close_icon.as_deref(), fallback.close_icon )?,
|
||
focus_ring: parse_opt( raw.focus_ring.as_deref(), fallback.focus_ring )?,
|
||
})
|
||
}
|
||
|
||
fn parse_opt( s: Option<&str>, fallback: Color ) -> Result<Color, ThemeError>
|
||
{
|
||
match s
|
||
{
|
||
Some( v ) => parse_color_str( v ).map_err( ThemeError::InvalidColor ),
|
||
None => Ok( fallback ),
|
||
}
|
||
}
|
||
|
||
fn mode_from_raw( root: Option<&Path>, r: RawMode ) -> Result<Mode, ThemeError>
|
||
{
|
||
let mut store = SlotStore::new();
|
||
for ( id, raw ) in r.slots
|
||
{
|
||
store.insert( id, Slot::from( raw ) );
|
||
}
|
||
let wallpaper = r.wallpaper.map( |w| wallpaper_from_raw( root, w ) );
|
||
let lockscreen = r.lockscreen.map( |w| wallpaper_from_raw( root, w ) );
|
||
let launcher = match r.launcher
|
||
{
|
||
Some( l ) => Some( LauncherSpec
|
||
{
|
||
background: parse_color_str( &l.background ).map_err( ThemeError::InvalidColor )?,
|
||
border_radius: l.border_radius,
|
||
}),
|
||
None => None,
|
||
};
|
||
let window_controls = match r.window_controls
|
||
{
|
||
Some( raw ) => Some( window_controls_from_raw( None, ThemeMode::Light, raw )? ),
|
||
None => None,
|
||
};
|
||
Ok( Mode { wallpaper, lockscreen, launcher, window_controls, slots: store } )
|
||
}
|
||
|
||
// ─── Public entry points ─────────────────────────────────────────────────────
|
||
|
||
/// Parse a theme document from its JSON source text.
|
||
///
|
||
/// `root` is the directory the document was read from, used to resolve
|
||
/// relative paths (wallpaper images, font files) to absolute ones. Pass
|
||
/// `None` when parsing in-memory fixtures from tests.
|
||
///
|
||
/// Performs the colour-reference resolution pass before structural
|
||
/// deserialisation: any string of the form `@name` or `@name/AA` (where
|
||
/// `AA` is a two-digit hex alpha override) is rewritten to its literal
|
||
/// hex form by looking `name` up in the top-level `colors` object. The
|
||
/// rewritten JSON is then deserialised into [`RawThemeDocument`] and
|
||
/// converted to the runtime types as before.
|
||
pub fn parse_document_json( text: &str, root: Option<&Path> )
|
||
-> Result<ThemeDocument, ThemeError>
|
||
{
|
||
let mut value: serde_json::Value = serde_json::from_str( text ).map_err( |e|
|
||
ThemeError::ParseJson( root.map( Path::to_path_buf ).unwrap_or_default(), e )
|
||
)?;
|
||
let colors = extract_colors_map( &value )?;
|
||
let gradients = extract_gradients_map( &value )?;
|
||
let inset_stacks = extract_inset_stacks_map( &value )?;
|
||
// Gradients live in objects (`{ "type": "linear", … }`) and inset stacks
|
||
// live in arrays (`[ { offset, blur, … }, … ]`); the resolver does not
|
||
// care about the shape, so the two sections share a single internal
|
||
// lookup. Names must therefore be unique across both — a collision is
|
||
// rejected up front rather than letting `@foo` resolve ambiguously.
|
||
let mut tokens = gradients;
|
||
for ( k, v ) in inset_stacks
|
||
{
|
||
if tokens.contains_key( &k )
|
||
{
|
||
return Err( ThemeError::InvalidColor( format!(
|
||
"name `{}` is defined in both `gradients` and `inset_stacks`", k
|
||
)));
|
||
}
|
||
tokens.insert( k, v );
|
||
}
|
||
// Pre-resolve `@color` references that live inside token bodies so
|
||
// subsequent substitutions are flat clones with no recursion. Tokens
|
||
// cannot reference each other, so we resolve against an empty token
|
||
// map here.
|
||
let empty_tokens = HashMap::new();
|
||
for ( _, t ) in tokens.iter_mut()
|
||
{
|
||
resolve_refs( t, &colors, &empty_tokens )?;
|
||
}
|
||
// Drop the top-level palette sections before resolving the rest, so the
|
||
// walk does not waste cycles on entries that are about to be discarded
|
||
// and `RawThemeDocument`'s `deny_unknown_fields` does not reject them.
|
||
if let serde_json::Value::Object( ref mut map ) = value
|
||
{
|
||
map.remove( "colors" );
|
||
map.remove( "gradients" );
|
||
map.remove( "inset_stacks" );
|
||
}
|
||
resolve_refs( &mut value, &colors, &tokens )?;
|
||
|
||
let raw: RawThemeDocument = serde_json::from_value( value ).map_err( |e|
|
||
ThemeError::ParseJson( root.map( Path::to_path_buf ).unwrap_or_default(), e )
|
||
)?;
|
||
let fonts = raw.fonts
|
||
.into_iter()
|
||
.map( |( k, v )| ( k, family_from_raw( root, v ) ) )
|
||
.collect();
|
||
let light = mode_from_raw( root, raw.modes.light )?;
|
||
let dark = mode_from_raw( root, raw.modes.dark )?;
|
||
Ok( ThemeDocument
|
||
{
|
||
id: raw.theme.id,
|
||
name: raw.theme.name,
|
||
root: root.map( Path::to_path_buf ),
|
||
fonts,
|
||
light,
|
||
dark,
|
||
})
|
||
}
|
||
|
||
/// Read the top-level `colors` object from the raw JSON value and validate
|
||
/// each entry is a literal hex string. Returns an empty map if no `colors`
|
||
/// section is present.
|
||
fn extract_colors_map( value: &serde_json::Value )
|
||
-> Result<HashMap<String, String>, ThemeError>
|
||
{
|
||
let mut out = HashMap::new();
|
||
let raw = match value.get( "colors" )
|
||
{
|
||
Some( c ) => c,
|
||
None => return Ok( out ),
|
||
};
|
||
let map = raw.as_object().ok_or_else( ||
|
||
ThemeError::InvalidColor( "`colors` must be an object".to_string() )
|
||
)?;
|
||
for ( k, v ) in map
|
||
{
|
||
let s = v.as_str().ok_or_else( ||
|
||
ThemeError::InvalidColor( format!( "`colors.{}` must be a hex string", k ) )
|
||
)?;
|
||
// References inside `colors` itself are not supported — every entry
|
||
// must be a literal hex value the rest of the document can point at.
|
||
let h = s.trim_start_matches( '#' );
|
||
let valid = ( h.len() == 6 || h.len() == 8 ) && h.chars().all( |c| c.is_ascii_hexdigit() );
|
||
if !valid
|
||
{
|
||
return Err( ThemeError::InvalidColor(
|
||
format!( "`colors.{}` = `{}` (expected #RRGGBB or #RRGGBBAA)", k, s )
|
||
));
|
||
}
|
||
out.insert( k.clone(), s.to_string() );
|
||
}
|
||
Ok( out )
|
||
}
|
||
|
||
/// Read the top-level `gradients` object from the raw JSON value. Each
|
||
/// entry must be a paint object (`{ "type": "linear", "angle_deg": …,
|
||
/// "stops": [ … ] }`); shape correctness beyond "is an object" is left
|
||
/// for downstream serde deserialisation to enforce when the gradient is
|
||
/// substituted into a paint position.
|
||
fn extract_gradients_map( value: &serde_json::Value )
|
||
-> Result<HashMap<String, serde_json::Value>, ThemeError>
|
||
{
|
||
extract_token_map( value, "gradients", "paint object", serde_json::Value::is_object )
|
||
}
|
||
|
||
/// Read the top-level `inset_stacks` object from the raw JSON value. Each
|
||
/// entry must be an array of inset-shadow definitions — substituted
|
||
/// wholesale into an `inset_shadows` field on a surface.
|
||
fn extract_inset_stacks_map( value: &serde_json::Value )
|
||
-> Result<HashMap<String, serde_json::Value>, ThemeError>
|
||
{
|
||
extract_token_map( value, "inset_stacks", "array of inset-shadow definitions", serde_json::Value::is_array )
|
||
}
|
||
|
||
/// Generic helper for the `gradients` / `inset_stacks` extractors above:
|
||
/// reads a top-level object, validates each entry against `valid`, and
|
||
/// returns the entries cloned into a `HashMap`. Returns an empty map if
|
||
/// `section` is absent.
|
||
fn extract_token_map(
|
||
value: &serde_json::Value,
|
||
section: &str,
|
||
expected_kind: &str,
|
||
valid: impl Fn( &serde_json::Value ) -> bool,
|
||
) -> Result<HashMap<String, serde_json::Value>, ThemeError>
|
||
{
|
||
let mut out = HashMap::new();
|
||
let raw = match value.get( section )
|
||
{
|
||
Some( c ) => c,
|
||
None => return Ok( out ),
|
||
};
|
||
let map = raw.as_object().ok_or_else( ||
|
||
ThemeError::InvalidColor( format!( "`{}` must be an object", section ) )
|
||
)?;
|
||
for ( k, v ) in map
|
||
{
|
||
if !valid( v )
|
||
{
|
||
return Err( ThemeError::InvalidColor(
|
||
format!( "`{}.{}` must be {}", section, k, expected_kind )
|
||
));
|
||
}
|
||
out.insert( k.clone(), v.clone() );
|
||
}
|
||
Ok( out )
|
||
}
|
||
|
||
/// Walk `value` and replace `@name` / `@name/AA` references with their
|
||
/// resolved form. A reference whose name appears in `tokens` (gradient
|
||
/// objects or inset-shadow arrays) is substituted by the cloned token
|
||
/// value; a reference whose name appears in `colors` is substituted by
|
||
/// its hex literal. After a substitution the new node is recursed into
|
||
/// so a gradient that uses `@cyan-soft` in its stops or an inset stack
|
||
/// that uses `@glass-hi` is fully expanded.
|
||
fn resolve_refs(
|
||
value: &mut serde_json::Value,
|
||
colors: &HashMap<String, String>,
|
||
tokens: &HashMap<String, serde_json::Value>,
|
||
) -> Result<(), ThemeError>
|
||
{
|
||
// Step 1: if this node is a `@ref` string, resolve and overwrite.
|
||
let replacement = match value
|
||
{
|
||
serde_json::Value::String( s ) => match s.strip_prefix( '@' )
|
||
{
|
||
Some( rest ) => Some( resolve_one_ref( rest, colors, tokens )? ),
|
||
None => None,
|
||
},
|
||
_ => None,
|
||
};
|
||
if let Some( new_value ) = replacement
|
||
{
|
||
*value = new_value;
|
||
// The replacement may itself contain references (typically a
|
||
// gradient with `@color` stops). Recurse into the new node.
|
||
resolve_refs( value, colors, tokens )?;
|
||
return Ok( () );
|
||
}
|
||
// Step 2: walk children of objects / arrays.
|
||
match value
|
||
{
|
||
serde_json::Value::Object( map ) =>
|
||
{
|
||
for ( _, v ) in map.iter_mut()
|
||
{
|
||
resolve_refs( v, colors, tokens )?;
|
||
}
|
||
}
|
||
serde_json::Value::Array( arr ) =>
|
||
{
|
||
for v in arr.iter_mut()
|
||
{
|
||
resolve_refs( v, colors, tokens )?;
|
||
}
|
||
}
|
||
_ => {}
|
||
}
|
||
Ok( () )
|
||
}
|
||
|
||
/// Resolve a single `@name[/AA]` reference. Looks up `tokens` first
|
||
/// (gradient or inset-stack), then `colors`. The `/AA` alpha override
|
||
/// only applies to colour references; using it on a non-colour token is
|
||
/// rejected.
|
||
fn resolve_one_ref(
|
||
rest: &str,
|
||
colors: &HashMap<String, String>,
|
||
tokens: &HashMap<String, serde_json::Value>,
|
||
) -> Result<serde_json::Value, ThemeError>
|
||
{
|
||
let ( name, alpha_hex ) = match rest.split_once( '/' )
|
||
{
|
||
Some( ( n, a ) ) => ( n, Some( a ) ),
|
||
None => ( rest, None ),
|
||
};
|
||
if let Some( tok ) = tokens.get( name )
|
||
{
|
||
if let Some( a ) = alpha_hex
|
||
{
|
||
return Err( ThemeError::InvalidColor( format!(
|
||
"@{} is a paint/inset token — alpha override `/{}` is not applicable", name, a
|
||
)));
|
||
}
|
||
return Ok( tok.clone() );
|
||
}
|
||
let base = colors.get( name )
|
||
.ok_or_else( || ThemeError::UnknownColorRef( name.to_string() ) )?;
|
||
let h = base.trim_start_matches( '#' );
|
||
// `extract_colors_map` already validated the base is 6 or 8 hex digits.
|
||
let rgb = &h[0..6];
|
||
let s = match alpha_hex
|
||
{
|
||
Some( a ) =>
|
||
{
|
||
if a.len() != 2 || u8::from_str_radix( a, 16 ).is_err()
|
||
{
|
||
return Err( ThemeError::InvalidColor(
|
||
format!( "@{}/{} (alpha must be two hex digits)", name, a )
|
||
));
|
||
}
|
||
format!( "#{}{}", rgb.to_uppercase(), a.to_uppercase() )
|
||
}
|
||
None => format!( "#{}", h.to_uppercase() ),
|
||
};
|
||
Ok( serde_json::Value::String( s ) )
|
||
}
|
||
|
||
/// Load a theme document from a directory containing a `theme.json`.
|
||
pub fn load_document_from_dir( dir: &Path ) -> Result<ThemeDocument, ThemeError>
|
||
{
|
||
let json_path = dir.join( "theme.json" );
|
||
let text = std::fs::read_to_string( &json_path )
|
||
.map_err( |e| ThemeError::Io( json_path.clone(), e ) )?;
|
||
parse_document_json( &text, Some( dir ) )
|
||
}
|
||
|
||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||
|
||
fn resolve_relative( root: Option<&Path>, rel: &str ) -> PathBuf
|
||
{
|
||
let p = Path::new( rel );
|
||
if p.is_absolute() { return p.to_path_buf(); }
|
||
match root
|
||
{
|
||
Some( r ) => r.join( p ),
|
||
None => p.to_path_buf(),
|
||
}
|
||
}
|
||
|
||
// ─── Tests ───────────────────────────────────────────────────────────────────
|
||
|
||
#[ cfg( test ) ]
|
||
mod tests
|
||
{
|
||
use super::*;
|
||
|
||
#[ test ]
|
||
fn color_parser_accepts_hex_and_functional()
|
||
{
|
||
assert_eq!( color_serde::parse( "#04D9FE" ).unwrap(), Color::hex( 0x04, 0xD9, 0xFE ) );
|
||
assert_eq!( color_serde::parse( "04D9FE" ).unwrap(), Color::hex( 0x04, 0xD9, 0xFE ) );
|
||
let with_a = color_serde::parse( "#FFFFFF80" ).unwrap();
|
||
assert!( ( with_a.a - 128.0 / 255.0 ).abs() < 1e-6 );
|
||
let f = color_serde::parse( "rgba(255, 147, 169, 0.8)" ).unwrap();
|
||
assert_eq!( f.r, 1.0 );
|
||
assert!( ( f.g - 147.0 / 255.0 ).abs() < 1e-6 );
|
||
assert!( ( f.a - 0.8 ).abs() < 1e-6 );
|
||
let three = color_serde::parse( "rgb(0, 0, 0)" ).unwrap();
|
||
assert_eq!( three, Color::BLACK );
|
||
}
|
||
|
||
#[ test ]
|
||
fn color_parser_rejects_bad_input()
|
||
{
|
||
assert!( color_serde::parse( "" ).is_err() );
|
||
assert!( color_serde::parse( "#FFF" ).is_err() );
|
||
assert!( color_serde::parse( "rgba(1,2,3)" ).is_err() );
|
||
assert!( color_serde::parse( "rgba(1,2,3,4,5)" ).is_err() );
|
||
assert!( color_serde::parse( "rgba(300, 0, 0, 1)" ).is_err() );
|
||
}
|
||
|
||
#[ test ]
|
||
fn color_formatter_is_canonical()
|
||
{
|
||
assert_eq!( color_serde::format( Color::hex( 0x04, 0xD9, 0xFE ) ), "#04D9FE" );
|
||
let semi = Color { r: 1.0, g: 1.0, b: 1.0, a: 0.5 };
|
||
assert_eq!( color_serde::format( semi ), "#FFFFFF80" );
|
||
}
|
||
|
||
#[ test ]
|
||
fn slot_color_parses_with_meta()
|
||
{
|
||
let json = r##"
|
||
{
|
||
"type": "color",
|
||
"value": "#04D9FE",
|
||
"meta": { "semantic": "primary/500" }
|
||
}
|
||
"##;
|
||
let raw: RawSlot = serde_json::from_str( json ).unwrap();
|
||
let slot = Slot::from( raw );
|
||
match slot
|
||
{
|
||
Slot::Color { value, meta } =>
|
||
{
|
||
assert_eq!( value, Color::hex( 0x04, 0xD9, 0xFE ) );
|
||
assert_eq!( meta.semantic.as_deref(), Some( "primary/500" ) );
|
||
}
|
||
other => panic!( "expected color slot, got {:?}", other ),
|
||
}
|
||
}
|
||
|
||
#[ test ]
|
||
fn gradient_under_cap_is_passed_through_verbatim()
|
||
{
|
||
let raw_stops: Vec<RawColorStop> = ( 0..16 )
|
||
.map( |i| serde_json::from_str(
|
||
&format!( r##"{{ "pos": {}, "color": "#FFFFFFFF" }}"##, i as f32 / 15.0 ),
|
||
).unwrap() )
|
||
.collect();
|
||
let result = collect_capped_stops( raw_stops );
|
||
assert_eq!( result.len(), 16, "stops below the soft cap pass through unchanged" );
|
||
}
|
||
|
||
#[ test ]
|
||
fn gradient_at_exact_cap_is_passed_through()
|
||
{
|
||
let raw_stops: Vec<RawColorStop> = ( 0..MAX_GRADIENT_STOPS )
|
||
.map( |i| serde_json::from_str(
|
||
&format!( r##"{{ "pos": {}, "color": "#FFFFFFFF" }}"##, i as f32 / 63.0 ),
|
||
).unwrap() )
|
||
.collect();
|
||
let result = collect_capped_stops( raw_stops );
|
||
assert_eq!( result.len(), MAX_GRADIENT_STOPS );
|
||
}
|
||
|
||
#[ test ]
|
||
fn gradient_over_cap_is_truncated_to_cap()
|
||
{
|
||
let raw_stops: Vec<RawColorStop> = ( 0..200 )
|
||
.map( |i| serde_json::from_str(
|
||
&format!( r##"{{ "pos": {}, "color": "#000000FF" }}"##, i as f32 ),
|
||
).unwrap() )
|
||
.collect();
|
||
let result = collect_capped_stops( raw_stops );
|
||
assert_eq!( result.len(), MAX_GRADIENT_STOPS, "tail past the cap must be dropped" );
|
||
}
|
||
|
||
#[ test ]
|
||
fn slot_linear_gradient_accepts_extrapolated_stops()
|
||
{
|
||
let json = r##"
|
||
{
|
||
"type": "linear",
|
||
"angle_deg": 152.77,
|
||
"space": "linear-rgb",
|
||
"stops":
|
||
[
|
||
{ "pos": -1.1654, "color": "rgba(255, 147, 169, 0.8)" },
|
||
{ "pos": 1.2332, "color": "rgba(255, 255, 255, 0.8)" }
|
||
]
|
||
}
|
||
"##;
|
||
let raw: RawSlot = serde_json::from_str( json ).unwrap();
|
||
match Slot::from( raw )
|
||
{
|
||
Slot::Paint { value: Paint::Linear( grad ), .. } =>
|
||
{
|
||
assert_eq!( grad.stops.len(), 2 );
|
||
assert!( grad.stops[0].position < 0.0 );
|
||
assert!( grad.stops[1].position > 1.0 );
|
||
assert_eq!( grad.space, GradientSpace::LinearRgb );
|
||
}
|
||
_ => panic!( "expected linear paint" ),
|
||
}
|
||
}
|
||
|
||
#[ test ]
|
||
fn slot_shadows_parses_stack()
|
||
{
|
||
let json = r##"
|
||
{
|
||
"type": "shadows",
|
||
"shadows":
|
||
[
|
||
{ "offset": [0, 4], "blur": 10, "color": "#00000014" },
|
||
{ "offset": [0, 1], "blur": 4, "color": "#0000000A" }
|
||
]
|
||
}
|
||
"##;
|
||
let raw: RawSlot = serde_json::from_str( json ).unwrap();
|
||
match Slot::from( raw )
|
||
{
|
||
Slot::Shadows { value, .. } =>
|
||
{
|
||
assert_eq!( value.len(), 2 );
|
||
assert_eq!( value[0].offset, [ 0.0, 4.0 ] );
|
||
assert_eq!( value[0].blur, 10.0 );
|
||
assert_eq!( value[0].blend, BlendMode::Normal );
|
||
}
|
||
_ => panic!( "expected shadows slot" ),
|
||
}
|
||
}
|
||
|
||
#[ test ]
|
||
fn slot_surface_reproduces_glass_accent()
|
||
{
|
||
let json = r##"
|
||
{
|
||
"type": "surface",
|
||
"fill": { "type": "solid", "color": "#04D9FE" },
|
||
"shadows": "shadows-glass",
|
||
"inset_shadows":
|
||
[
|
||
{ "offset": [-3.6, -3.6], "blur": 13.5, "color": "#555555", "blend": "plus-lighter" },
|
||
{ "offset": [ 1.8, 1.8], "blur": 1.8, "color": "#555555", "blend": "plus-lighter" },
|
||
{ "offset": [ 0.45, 0.45], "blur": 0.9, "color": "#000000", "blend": "overlay" },
|
||
{ "offset": [ 1.8, 1.8], "blur": 7.2, "color": "#00000026", "blend": "normal" }
|
||
],
|
||
"backdrop": { "blur_px": 22.5 }
|
||
}
|
||
"##;
|
||
let raw: RawSlot = serde_json::from_str( json ).unwrap();
|
||
match Slot::from( raw )
|
||
{
|
||
Slot::Surface { value, .. } =>
|
||
{
|
||
assert!( matches!( value.fill, Paint::Solid( _ ) ) );
|
||
assert_eq!( value.inset_shadows.len(), 4 );
|
||
assert_eq!( value.inset_shadows[0].blend, BlendMode::PlusLighter );
|
||
assert_eq!( value.inset_shadows[2].blend, BlendMode::Overlay );
|
||
match value.shadows.as_ref().unwrap()
|
||
{
|
||
ShadowsRef::Named( id ) => assert_eq!( id, "shadows-glass" ),
|
||
other => panic!( "expected named shadow ref, got {:?}", other ),
|
||
}
|
||
}
|
||
_ => panic!( "expected surface slot" ),
|
||
}
|
||
}
|
||
|
||
#[ test ]
|
||
fn slot_typography_parses_body_m()
|
||
{
|
||
let json = r##"
|
||
{
|
||
"type": "typography",
|
||
"family": { "ref": "sora" },
|
||
"weight": 400,
|
||
"size": 16,
|
||
"line_height": { "px": 24 },
|
||
"letter_spacing": 0,
|
||
"meta": { "semantic": "body/m" }
|
||
}
|
||
"##;
|
||
let raw: RawSlot = serde_json::from_str( json ).unwrap();
|
||
match Slot::from( raw )
|
||
{
|
||
Slot::TextStyle { value, meta } =>
|
||
{
|
||
assert_eq!( value.weight, 400 );
|
||
assert_eq!( value.size, 16.0 );
|
||
assert_eq!( value.line_height, LineHeight::Px( 24.0 ) );
|
||
assert_eq!( meta.semantic.as_deref(), Some( "body/m" ) );
|
||
}
|
||
_ => panic!( "expected typography slot" ),
|
||
}
|
||
}
|
||
|
||
#[ test ]
|
||
fn document_parses_minimal_two_modes()
|
||
{
|
||
let json = r##"
|
||
{
|
||
"theme": { "id": "demo", "name": "Demo" },
|
||
"fonts":
|
||
{
|
||
"sora":
|
||
{
|
||
"name": "Sora",
|
||
"fallbacks": ["system-ui", "sans-serif"],
|
||
"sources":
|
||
[
|
||
{ "weight": 400, "path": "fonts/Sora-Regular.ttf" }
|
||
]
|
||
}
|
||
},
|
||
"modes":
|
||
{
|
||
"light":
|
||
{
|
||
"slots":
|
||
{
|
||
"primary-500": { "type": "color", "value": "#04D9FE" },
|
||
"body-m": { "type": "typography", "family": { "ref": "sora" }, "weight": 400, "size": 16, "line_height": { "px": 24 } }
|
||
}
|
||
},
|
||
"dark":
|
||
{
|
||
"slots":
|
||
{
|
||
"primary-500": { "type": "color", "value": "#04D9FE" }
|
||
}
|
||
}
|
||
}
|
||
}
|
||
"##;
|
||
let doc = parse_document_json( json, None ).unwrap();
|
||
assert_eq!( doc.id, "demo" );
|
||
assert_eq!( doc.name, "Demo" );
|
||
assert_eq!( doc.fonts.len(), 1 );
|
||
assert_eq!( doc.fonts["sora"].sources[0].weight, 400 );
|
||
assert_eq!( doc.light.slots.len(), 2 );
|
||
assert_eq!( doc.dark.slots.len(), 1 );
|
||
assert!( doc.light.slots.color( "primary-500" ).is_some() );
|
||
assert!( doc.light.slots.text_style( "body-m" ).is_some() );
|
||
}
|
||
|
||
#[ test ]
|
||
fn unknown_fields_in_stop_are_rejected()
|
||
{
|
||
// `deny_unknown_fields` doesn't cooperate well with
|
||
// `#[ serde( flatten ) ]`, so the slot-level check would be unreliable
|
||
// on variants that flatten a body struct. The safety net lives on
|
||
// concrete nested types like `RawColorStop`, `RawShadow`, and
|
||
// `RawWallpaper`, which reject typos outright.
|
||
let bad = r##"
|
||
{
|
||
"type": "linear",
|
||
"angle_deg": 90,
|
||
"stops":
|
||
[
|
||
{ "pos": 0, "color": "#FFFFFF", "extraneous": true }
|
||
]
|
||
}
|
||
"##;
|
||
assert!( serde_json::from_str::<RawSlot>( bad ).is_err() );
|
||
}
|
||
}
|