Files
ltk/src/theme/schema.rs
Pedro M. de Echanove Pasquin c3839060cc theme, event_loop: window_controls.bar_bg slot + graceful exit on compositor disconnect
`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.
2026-05-13 10:17:36 +02:00

1453 lines
41 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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() );
}
}