// SPDX-License-Identifier: LGPL-2.1-only // Copyright (C) 2026 Liberux Labs, S. L. //! The slot-typed lookup table that backs the new theme API. //! //! A [`Slot`] is a typed entry in the theme document: it is always either a //! [`Color`], a [`Paint`] (that is a gradient — solid colours live in the //! `Color` variant), an outer [`Shadow`] stack, a composite [`Surface`] or //! a [`TextStyle`]. Widgets resolve them through [`SlotStore`]. //! //! # Promotion //! //! The store offers five accessors (`color`, `paint`, `shadows`, `surface`, //! `text_style`). They promote automatically wherever it makes sense: asking //! for a paint against a colour slot returns `Paint::Solid(c)`; asking for a //! surface against a colour returns a `Surface` with `fill: Solid(c)` and //! empty decorations; asking for a surface against a paint returns a //! `Surface` wrapping that paint. Stricter requests that cannot be satisfied //! (asking for a `TextStyle` against a colour, for example) return `None`. use std::collections::HashMap; use crate::types::Color; use super::paint::Paint; use super::shadow::Shadow; use super::surface::Surface; use super::text_style::TextStyle; // ─── Metadata ──────────────────────────────────────────────────────────────── /// Optional free-form annotations a theme author can attach to any slot. The /// runtime ignores these — inspection tools (and human readers of the JSON) /// use them. /// /// All fields are optional and default to `None`. Missing fields in the JSON /// do not raise an error. #[ derive( Debug, Clone, Default, PartialEq, Eq ) ] pub struct Metadata { /// Canonical name of the token in the design system /// (e.g. `"primary/500"`). pub semantic: Option, /// Equivalent name in another system (e.g. `"NeutralColors.white"` for /// Fluent). Useful when migrating from or cross-referencing other kits. pub fluent: Option, /// Human-readable guidance on where to use this slot. pub usage: Option, /// Free-form note, typically for quirks worth flagging in the JSON. pub note: Option, } // ─── Slot ──────────────────────────────────────────────────────────────────── /// One typed entry of the theme. #[ derive( Debug, Clone, PartialEq ) ] pub enum Slot { /// A single opaque or translucent colour. Color { value: Color, meta: Metadata }, /// A gradient (linear or radial). Solid colours are kept in /// [`Slot::Color`] — they do not round-trip through this variant. Paint { value: Paint, meta: Metadata }, /// An ordered stack of outer shadows, typically an elevation level. Shadows { value: Vec, meta: Metadata }, /// A composite surface: fill, outer shadows (ref or inline), inset /// shadows and an optional backdrop. Surface { value: Surface, meta: Metadata }, /// A resolved text style (family, weight, size, line-height, …). TextStyle { value: TextStyle, meta: Metadata }, } impl Slot { /// Annotations attached to the slot, if any. pub fn metadata( &self ) -> &Metadata { match self { Slot::Color { meta, .. } => meta, Slot::Paint { meta, .. } => meta, Slot::Shadows { meta, .. } => meta, Slot::Surface { meta, .. } => meta, Slot::TextStyle { meta, .. } => meta, } } /// Short tag identifying the slot's kind. Useful in error messages. pub fn kind_tag( &self ) -> &'static str { match self { Slot::Color { .. } => "color", Slot::Paint { .. } => "paint", Slot::Shadows { .. } => "shadows", Slot::Surface { .. } => "surface", Slot::TextStyle { .. } => "typography", } } } // ─── Store ─────────────────────────────────────────────────────────────────── /// Lookup table indexed by slot id. /// /// The store is immutable from a consumer's standpoint: themes are built /// once at load time and handed to the renderer. All accessors return /// references bound to the store's lifetime. #[ derive( Debug, Clone, Default ) ] pub struct SlotStore { entries: HashMap, } impl SlotStore { /// Build an empty store. Used by tests and by the JSON loader. pub fn new() -> Self { Self { entries: HashMap::new() } } /// Insert a slot under `id`. Returns the previous slot under that id if /// there was one. Used by the JSON loader; not typically called from /// widget code. pub fn insert( &mut self, id: impl Into, slot: Slot ) -> Option { self.entries.insert( id.into(), slot ) } /// The raw entry, if present. pub fn get( &self, id: &str ) -> Option<&Slot> { self.entries.get( id ) } /// Number of slots in the store. pub fn len( &self ) -> usize { self.entries.len() } /// Whether the store is empty. pub fn is_empty( &self ) -> bool { self.entries.is_empty() } // ─── Typed accessors ───────────────────────────────────────────────── /// Resolve `id` to a [`Color`]. Only `Slot::Color` matches — gradients /// do not promote down to a single colour. pub fn color( &self, id: &str ) -> Option { match self.entries.get( id )? { Slot::Color { value, .. } => Some( *value ), _ => None, } } /// Resolve `id` to a [`Paint`]. A colour slot promotes to /// [`Paint::Solid`]; a paint slot returns directly. pub fn paint( &self, id: &str ) -> Option { match self.entries.get( id )? { Slot::Color { value, .. } => Some( Paint::Solid( *value ) ), Slot::Paint { value, .. } => Some( value.clone() ), _ => None, } } /// Resolve `id` to an outer-shadow stack. Only `Slot::Shadows` matches. pub fn shadows( &self, id: &str ) -> Option<&[Shadow]> { match self.entries.get( id )? { Slot::Shadows { value, .. } => Some( value.as_slice() ), _ => None, } } /// Resolve `id` to a [`Surface`]. A colour slot promotes to a surface /// with a solid fill and no decorations; a paint slot promotes to a /// surface with that paint as fill and no decorations; a surface slot /// returns directly. pub fn surface( &self, id: &str ) -> Option { match self.entries.get( id )? { Slot::Color { value, .. } => Some( Surface::from_paint( Paint::Solid( *value ) ) ), Slot::Paint { value, .. } => Some( Surface::from_paint( value.clone() ) ), Slot::Surface { value, .. } => Some( value.clone() ), _ => None, } } /// Resolve `id` to a [`TextStyle`]. Only `Slot::TextStyle` matches — /// typography does not promote from anything else. pub fn text_style( &self, id: &str ) -> Option<&TextStyle> { match self.entries.get( id )? { Slot::TextStyle { value, .. } => Some( value ), _ => None, } } } // ─── Tests ─────────────────────────────────────────────────────────────────── #[ cfg( test ) ] mod tests { use super::*; use crate::theme::paint::{ ColorStop, GradientSpace, LinearGradient }; use crate::theme::shadow::{ BlendMode }; use crate::theme::text_style::{ FontRef, LineHeight }; fn sample_color_slot() -> ( &'static str, Slot ) { ( "primary-500", Slot::Color { value: Color::hex( 0x04, 0xD9, 0xFE ), meta: Metadata { semantic: Some( "primary/500".to_string() ), ..Metadata::default() }, }, ) } fn sample_gradient_slot() -> ( &'static str, Slot ) { ( "gradient-error-light", Slot::Paint { value: Paint::Linear( LinearGradient { angle_deg: 152.77, stops: vec! [ ColorStop { position: -1.1654, color: Color::hex( 0xFF, 0x93, 0xA9 ) }, ColorStop { position: 1.2332, color: Color::WHITE }, ], space: GradientSpace::LinearRgb, }), meta: Metadata::default(), }, ) } #[ test ] fn color_slot_promotes_to_paint_and_surface() { let mut store = SlotStore::new(); let ( id, slot ) = sample_color_slot(); store.insert( id, slot ); assert!( store.color( id ).is_some() ); assert_eq!( store.color( id ).unwrap(), Color::hex( 0x04, 0xD9, 0xFE ) ); assert!( matches!( store.paint( id ).unwrap(), Paint::Solid( _ ) ) ); let surface = store.surface( id ).unwrap(); assert!( matches!( surface.fill, Paint::Solid( _ ) ) ); assert!( surface.inset_shadows.is_empty() ); } #[ test ] fn paint_slot_does_not_downgrade_to_color() { let mut store = SlotStore::new(); let ( id, slot ) = sample_gradient_slot(); store.insert( id, slot ); assert!( store.color( id ).is_none(), "gradient must not answer as color" ); assert!( matches!( store.paint( id ).unwrap(), Paint::Linear( _ ) ) ); assert!( matches!( store.surface( id ).unwrap().fill, Paint::Linear( _ ) ) ); } #[ test ] fn shadows_and_text_style_are_strict() { let mut store = SlotStore::new(); let ( c_id, c_slot ) = sample_color_slot(); store.insert( c_id, c_slot ); // Color does not promote into shadows or text_style. assert!( store.shadows( c_id ).is_none() ); assert!( store.text_style( c_id ).is_none() ); // A text-style slot answers as such. store.insert ( "body-m", Slot::TextStyle { value: TextStyle::new ( FontRef::Named( "sora".to_string() ), 400, 16.0, LineHeight::Px( 24.0 ), ), meta: Metadata::default(), }, ); let ts = store.text_style( "body-m" ).unwrap(); assert_eq!( ts.size, 16.0 ); } #[ test ] fn missing_id_returns_none_everywhere() { let store = SlotStore::new(); assert!( store.color ( "nope" ).is_none() ); assert!( store.paint ( "nope" ).is_none() ); assert!( store.shadows ( "nope" ).is_none() ); assert!( store.surface ( "nope" ).is_none() ); assert!( store.text_style( "nope" ).is_none() ); } #[ test ] fn metadata_accessor_is_uniform_across_variants() { let ( id, slot ) = sample_color_slot(); assert_eq!( slot.metadata().semantic.as_deref(), Some( "primary/500" ) ); assert_eq!( slot.kind_tag(), "color" ); let shadow_slot = Slot::Shadows { value: vec! [ Shadow { offset: [ 0.0, 2.0 ], blur: 4.0, spread: 0.0, color: Color::rgba( 0.0, 0.0, 0.0, 0.04 ), blend: BlendMode::Normal, }, ], meta: Metadata { note: Some( "elevation 1 - topmost layer".to_string() ), ..Default::default() }, }; assert_eq!( shadow_slot.kind_tag(), "shadows" ); assert!( shadow_slot.metadata().note.is_some() ); let _ = id; } }