First commit. Version 0.1.0

This commit is contained in:
2026-05-10 09:58:23 +02:00
parent af105b7f7d
commit bbab5e238d
635 changed files with 53627 additions and 175 deletions

111
src/theme/document.rs Normal file
View File

@@ -0,0 +1,111 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! The top-level theme document: metadata, fonts block and per-mode slots.
//!
//! This is the runtime representation of a fully parsed `theme.json`,
//! and the single source of truth for the active theme — installed via
//! [`super::set_active_document`] and read back via
//! [`super::active_document`].
//!
//! # Modes
//!
//! `fonts` is shared between light and dark and only what actually differs
//! — slot tables, wallpaper paths, optional window-controls overrides — is
//! nested under `modes.light` / `modes.dark`.
use std::collections::HashMap;
use std::path::{ Path, PathBuf };
use super::fonts::FontFamilyDef;
use super::slots::SlotStore;
use super::{ search_paths, LauncherSpec, ThemeError, WallpaperSpec, WindowControlsSpec };
// ─── Mode ────────────────────────────────────────────────────────────────────
/// Per-variant content: the slot table plus the surfaces that don't fit
/// cleanly in slots (wallpaper image, window-controls payload).
#[ derive( Debug, Clone ) ]
pub struct Mode
{
/// Homescreen / shell wallpaper. Absent means "solid background, no
/// image" (the renderer falls back to whatever the theme's surface slot
/// resolves to for the viewport).
pub wallpaper: Option<WallpaperSpec>,
/// Lockscreen / greeter wallpaper. Distinct from `wallpaper` because the
/// lockscreen is typically quieter (often a darker crop).
pub lockscreen: Option<WallpaperSpec>,
/// Launcher surface styling. Aggregate (background + border_radius)
/// rather than a slot because the radius is a scalar that widgets treat
/// as theme-imposed geometry, not a design-system colour token.
pub launcher: Option<LauncherSpec>,
/// Window-decoration controls payload. Kept out of the slot table
/// because it is a contract with an external app, not a
/// design-system token.
pub window_controls: Option<WindowControlsSpec>,
/// The typed slot table for this mode.
pub slots: SlotStore,
}
// ─── ThemeDocument ───────────────────────────────────────────────────────────
/// A fully parsed theme, as loaded from a `theme.json` on disk.
#[ derive( Debug, Clone ) ]
pub struct ThemeDocument
{
/// Stable identifier used to look up the theme across search paths.
pub id: String,
/// Human-readable display name.
pub name: String,
/// Directory the document was loaded from. `None` for documents built
/// in-memory (e.g. test fixtures).
pub root: Option<PathBuf>,
/// Font families declared in the document. Indexed by the id used by
/// [`super::FontRef::Named`]. The same registry is shared between modes.
pub fonts: HashMap<String, FontFamilyDef>,
/// Light-mode content.
pub light: Mode,
/// Dark-mode content.
pub dark: Mode,
}
impl ThemeDocument
{
/// Return the mode matching `mode`.
pub fn mode( &self, mode: super::ThemeMode ) -> &Mode
{
match mode
{
super::ThemeMode::Light => &self.light,
super::ThemeMode::Dark => &self.dark,
}
}
/// Load a document from a directory containing a `theme.json`.
///
/// Paths inside the document (wallpaper, lockscreen, font sources) are
/// resolved against `dir`.
pub fn load_from_dir( dir: &Path ) -> Result<Self, ThemeError>
{
super::schema::load_document_from_dir( dir )
}
/// Look up a document by id across the standard search paths.
///
/// Order, highest priority first:
/// 1. `$LTK_THEMES_DIR/<id>/` (when the env var is set)
/// 2. `$XDG_DATA_HOME/ltk/themes/<id>/` (defaults to `~/.local/share/...`)
/// 3. `/usr/share/ltk/themes/<id>/`
pub fn find( id: &str ) -> Result<Self, ThemeError>
{
for base in search_paths()
{
let dir = base.join( id );
if dir.join( "theme.json" ).is_file()
{
return Self::load_from_dir( &dir );
}
}
Err( ThemeError::NotFound( id.to_string() ) )
}
}

View File

@@ -0,0 +1,134 @@
Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
Upstream-Name: sora
Upstream-Contact: The Sora Project Authors
Files-Excluded: docs fonts/*
Source: https://github.com/sora-xor/sora-font
Files: *
Copyright: 2019-2020 The Sora Project Authors
2020 Jonathan Barnbrook <jb@barnbrook.net>
2020 Julián Moncada <julian@moncada.work>
License: OFL-1.1
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
.
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
.
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
.
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
.
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Files: sources/sora-stat-table.py
Copyright: 2020 Google Sans Authors
License: Apache-2.0
Licensed under the Apache License, Version 2.0 (the "License"); you
may not use this file except in compliance with the License. You may
obtain a copy of the License at
.
http://www.apache.org/licenses/LICENSE-2.0
.
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied. See the License for the specific language governing
permissions and limitations under the License.
.
On Debian systems, the complete text of the Apache version 2.0
license can be found in "/usr/share/common-licenses/Apache-2.0".
Files: debian/*
Copyright: 2020-2024 Alex Myczko <tar@debian.org>
License: GPL-2+
This package is free software; you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation; either version 2 of the License, or
(at your option) any later version.
.
This package is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>
.
On Debian systems, the complete text of the GNU General
Public License version 2 can be found in "/usr/share/common-licenses/GPL-2".

Binary file not shown.

112
src/theme/fallback/mod.rs Normal file
View File

@@ -0,0 +1,112 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! "Last resort" assets used when the canonical `default` theme or the
//! system font search chain come up empty. Two pieces:
//!
//! * [`document`] builds a black/white [`ThemeDocument`] with the
//! eight canonical palette slots populated — no wallpaper, no
//! launcher spec, no decorated surface slots. Widgets that look up
//! surface or shadow slots fall through to their flat-colour branch;
//! the UI is usable but not decorated.
//! * [`FALLBACK_FONT`] bundles Sora Regular (~50 KB, SIL OFL 1.1) so
//! `Canvas::new` / `Canvas::new_gles` can always load a font even
//! on systems without `fonts-sora`, `fonts-liberation` or
//! `fonts-dejavu`.
//!
//! Both are activated by [`super::ensure_active`] when
//! `ThemeDocument::find("default")` fails. The crate stamps every
//! frame with a red banner so the user can't miss the
//! "install `ltk-theme-default`" signal.
//!
//! # Font licence
//!
//! `Sora-Regular.otf` is distributed under the SIL Open Font Licence
//! 1.1. The full licence text is in
//! [`Sora-LICENSE.txt`](./Sora-LICENSE.txt) next to this source
//! file. A binary-only redistribution must carry the OFL text
//! alongside — see the crate's `debian/copyright`.
use std::collections::HashMap;
use crate::types::Color;
use super::slots::{ Metadata, Slot, SlotStore };
use super::{ Mode, ThemeDocument };
/// Sora Regular, OFL 1.1. Loaded by `Canvas::new` / `Canvas::new_gles`
/// when no system font is found through the candidate chain. ~50 KB,
/// one weight, one style — enough to render the fallback banner and
/// any app that only asks for default typography.
pub( crate ) const FALLBACK_FONT: &[u8] = include_bytes!( "Sora-Regular.otf" );
/// Build the B/W fallback document. Eight canonical palette slots per
/// mode (`bg-page`, `surface`, `surface-alt`, `text-primary`,
/// `text-secondary`, `accent`, `divider`, `icon`), no wallpaper, no
/// launcher, no window_controls, no fonts registered (the default
/// canvas font is used for every widget).
///
/// Light mode: pure white backgrounds, pure black text + accent.
/// Dark mode: pure black backgrounds, pure white text + accent.
/// `accent` doubles as brand colour and focus-ring colour; setting
/// it to black/white keeps the fallback visually consistent with the
/// rest of the palette and gives the focus ring maximum contrast.
pub( super ) fn document() -> ThemeDocument
{
ThemeDocument
{
id: "fallback".to_string(),
name: "Fallback (B/W)".to_string(),
root: None,
fonts: HashMap::new(),
light: light_mode(),
dark: dark_mode(),
}
}
fn mk_color_slot( c: Color ) -> Slot
{
Slot::Color { value: c, meta: Metadata::default() }
}
fn light_mode() -> Mode
{
let mut slots = SlotStore::new();
slots.insert( "bg-page", mk_color_slot( Color::WHITE ) );
slots.insert( "surface", mk_color_slot( Color::WHITE ) );
slots.insert( "surface-alt", mk_color_slot( Color::rgb( 0.95, 0.95, 0.95 ) ) );
slots.insert( "text-primary", mk_color_slot( Color::BLACK ) );
slots.insert( "text-secondary", mk_color_slot( Color::rgba( 0.0, 0.0, 0.0, 0.6 ) ) );
slots.insert( "accent", mk_color_slot( Color::BLACK ) );
slots.insert( "divider", mk_color_slot( Color::rgba( 0.0, 0.0, 0.0, 0.1 ) ) );
slots.insert( "icon", mk_color_slot( Color::BLACK ) );
Mode
{
wallpaper: None,
lockscreen: None,
launcher: None,
window_controls: None,
slots,
}
}
fn dark_mode() -> Mode
{
let mut slots = SlotStore::new();
slots.insert( "bg-page", mk_color_slot( Color::BLACK ) );
slots.insert( "surface", mk_color_slot( Color::BLACK ) );
slots.insert( "surface-alt", mk_color_slot( Color::rgb( 0.1, 0.1, 0.1 ) ) );
slots.insert( "text-primary", mk_color_slot( Color::WHITE ) );
slots.insert( "text-secondary", mk_color_slot( Color::rgba( 1.0, 1.0, 1.0, 0.6 ) ) );
slots.insert( "accent", mk_color_slot( Color::WHITE ) );
slots.insert( "divider", mk_color_slot( Color::rgba( 1.0, 1.0, 1.0, 0.1 ) ) );
slots.insert( "icon", mk_color_slot( Color::WHITE ) );
Mode
{
wallpaper: None,
lockscreen: None,
launcher: None,
window_controls: None,
slots,
}
}

357
src/theme/font_registry.rs Normal file
View File

@@ -0,0 +1,357 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! Runtime font registry: families → (weight, style) → `fontdue::Font`.
//!
//! The registry is the live, loaded-in-memory counterpart of the theme's
//! `fonts` block ([`super::FontFamilyDef`]). It is built once at theme load
//! time by calling [`FontRegistry::from_families`], then handed to the
//! render backends as an `Arc<FontRegistry>` so they can resolve specific
//! sources on demand without re-reading TTFs from disk.
//!
//! # Resolution order
//!
//! [`FontRegistry::resolve`] looks up a family / weight / style triple with
//! the following precedence:
//!
//! 1. Exact `(family, weight, style)` match.
//! 2. Same family and style, weight closest to the request (absolute diff).
//! 3. Same family, any style, weight closest to the request.
//! 4. Walk the family's `fallbacks` chain, recursing into each fallback
//! family with the original weight/style.
//!
//! When nothing matches, [`FontRegistry::resolve`] returns `None`; callers
//! fall back to the canvas' system font.
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use fontdue::{ Font, FontSettings };
use super::fonts::FontFamilyDef;
use super::text_style::FontStyle;
// ─── Key ─────────────────────────────────────────────────────────────────────
/// Composite key identifying a single font source within a family.
///
/// `family` is the id under which the family was declared in the theme's
/// `fonts` block (e.g. `"sora"`), not its human-readable name.
#[ derive( Debug, Clone, PartialEq, Eq, Hash ) ]
pub struct FontKey
{
pub family: String,
pub weight: u16,
pub style: FontStyle,
}
// ─── Errors ──────────────────────────────────────────────────────────────────
/// Error raised when loading a font source fails.
#[ derive( Debug ) ]
pub enum FontLoadError
{
/// The source file could not be read.
Io( PathBuf, std::io::Error ),
/// The source file was read but `fontdue` rejected its contents.
Parse( PathBuf, String ),
}
impl std::fmt::Display for FontLoadError
{
fn fmt( &self, f: &mut std::fmt::Formatter<'_> ) -> std::fmt::Result
{
match self
{
FontLoadError::Io( p, e ) => write!( f, "reading font {}: {}", p.display(), e ),
FontLoadError::Parse( p, m ) => write!( f, "parsing font {}: {}", p.display(), m ),
}
}
}
impl std::error::Error for FontLoadError {}
// ─── Registry ────────────────────────────────────────────────────────────────
/// A loaded font registry: the theme's declared families materialised into
/// live `fontdue::Font` handles, indexed by family id / weight / style.
#[ derive( Debug, Default ) ]
pub struct FontRegistry
{
by_key: HashMap<FontKey, Arc<Font>>,
fallbacks: HashMap<String, Vec<String>>,
}
impl FontRegistry
{
/// Create an empty registry. Use [`Self::insert`] / [`Self::set_fallbacks`]
/// to populate it, or [`Self::from_families`] to load a whole theme in
/// one go.
pub fn new() -> Self
{
Self { by_key: HashMap::new(), fallbacks: HashMap::new() }
}
/// Register a single font source.
pub fn insert
(
&mut self,
family: impl Into<String>,
weight: u16,
style: FontStyle,
font: Arc<Font>,
)
{
self.by_key.insert( FontKey { family: family.into(), weight, style }, font );
}
/// Set the fallback chain for a family.
pub fn set_fallbacks( &mut self, family: impl Into<String>, chain: Vec<String> )
{
self.fallbacks.insert( family.into(), chain );
}
/// Number of loaded sources across all families. Useful in tests.
pub fn len( &self ) -> usize { self.by_key.len() }
/// Whether the registry has no sources loaded.
pub fn is_empty( &self ) -> bool { self.by_key.is_empty() }
/// Resolve a triple to a loaded [`Font`]. See the module docs for the
/// precedence order.
pub fn resolve( &self, family: &str, weight: u16, style: FontStyle ) -> Option<Arc<Font>>
{
// 1. Exact match.
let exact = FontKey { family: family.to_string(), weight, style };
if let Some( f ) = self.by_key.get( &exact )
{
return Some( Arc::clone( f ) );
}
// 2. Same family + style, closest weight.
let best_same_style = self.by_key.iter()
.filter( |( k, _ )| k.family == family && k.style == style )
.min_by_key( |( k, _ )| (k.weight as i32 - weight as i32).abs() );
if let Some( ( _, f ) ) = best_same_style
{
return Some( Arc::clone( f ) );
}
// 3. Same family, any style, closest weight.
let best_same_family = self.by_key.iter()
.filter( |( k, _ )| k.family == family )
.min_by_key( |( k, _ )| (k.weight as i32 - weight as i32).abs() );
if let Some( ( _, f ) ) = best_same_family
{
return Some( Arc::clone( f ) );
}
// 4. Walk fallback chain.
if let Some( chain ) = self.fallbacks.get( family )
{
for fb in chain
{
if let Some( f ) = self.resolve( fb, weight, style )
{
return Some( f );
}
}
}
None
}
/// Load every source declared in `families` into the registry.
///
/// `families` is keyed by family id (the string the theme JSON uses in
/// `fonts.<id>` and that [`super::FontRef::Named`] references). The
/// family's `name` field is carried for human display only; lookups go
/// through the id.
pub fn from_families
(
families: &HashMap<String, FontFamilyDef>,
) -> Result<Self, FontLoadError>
{
let mut reg = Self::new();
for ( id, family ) in families
{
if !family.fallbacks.is_empty()
{
reg.set_fallbacks( id.clone(), family.fallbacks.clone() );
}
for src in &family.sources
{
let bytes = std::fs::read( &src.path )
.map_err( |e| FontLoadError::Io( src.path.clone(), e ) )?;
let font = Font::from_bytes( bytes.as_slice(), FontSettings::default() )
.map_err( |e| FontLoadError::Parse( src.path.clone(), e.to_string() ) )?;
reg.insert( id.clone(), src.weight, src.style, Arc::new( font ) );
}
}
Ok( reg )
}
/// Like [`Self::from_families`] but tolerant: sources that fail to load
/// are logged via `eprintln!` and skipped instead of aborting the whole
/// registry build. Intended for the runtime path where a missing TTF
/// on a user's machine should degrade to "fall back to system font for
/// that weight" rather than crash the shell.
///
/// Returns a registry that may be partial — callers cannot assume any
/// specific weight is loaded, only that what COULD be loaded is
/// loaded. Font resolution's fallback ladder (family → closest weight
/// → fallback chain → `Canvas::font`) handles the gaps gracefully.
pub fn from_families_lenient
(
families: &HashMap<String, FontFamilyDef>,
) -> Self
{
let mut reg = Self::new();
for ( id, family ) in families
{
if !family.fallbacks.is_empty()
{
reg.set_fallbacks( id.clone(), family.fallbacks.clone() );
}
for src in &family.sources
{
let bytes = match std::fs::read( &src.path )
{
Ok( b ) => b,
Err( e ) =>
{
eprintln!
(
"[ltk] skipping font {} (weight {}, {:?}): {}",
src.path.display(), src.weight, src.style, e
);
continue;
}
};
let font = match Font::from_bytes( bytes.as_slice(), FontSettings::default() )
{
Ok( f ) => f,
Err( e ) =>
{
eprintln!
(
"[ltk] skipping font {} (weight {}, {:?}): parse error: {}",
src.path.display(), src.weight, src.style, e
);
continue;
}
};
reg.insert( id.clone(), src.weight, src.style, Arc::new( font ) );
}
}
reg
}
}
// ─── Tests ───────────────────────────────────────────────────────────────────
#[ cfg( test ) ]
mod tests
{
use super::*;
/// Load a real TTF from the system via the same search chain the
/// software canvas uses. Only used by tests that need an actual
/// `Font` — skipped when no font is found (keeps CI green on images
/// without the usual system fonts).
fn system_font() -> Option<Arc<Font>>
{
let path = crate::render::helpers::find_font_opt()?;
let bytes = std::fs::read( path ).ok()?;
let font = Font::from_bytes( bytes.as_slice(), FontSettings::default() ).ok()?;
Some( Arc::new( font ) )
}
#[ test ]
fn empty_registry_resolves_to_none()
{
let reg = FontRegistry::new();
assert!( reg.resolve( "sora", 400, FontStyle::Normal ).is_none() );
assert!( reg.is_empty() );
}
#[ test ]
fn exact_match_wins()
{
let Some( font ) = system_font() else { return; };
let mut reg = FontRegistry::new();
reg.insert( "sora", 400, FontStyle::Normal, Arc::clone( &font ) );
reg.insert( "sora", 700, FontStyle::Normal, Arc::clone( &font ) );
assert!( reg.resolve( "sora", 400, FontStyle::Normal ).is_some() );
assert!( reg.resolve( "sora", 700, FontStyle::Normal ).is_some() );
assert_eq!( reg.len(), 2 );
}
#[ test ]
fn closest_weight_is_picked_when_exact_missing()
{
let Some( font ) = system_font() else { return; };
let mut reg = FontRegistry::new();
reg.insert( "sora", 300, FontStyle::Normal, Arc::clone( &font ) );
reg.insert( "sora", 700, FontStyle::Normal, Arc::clone( &font ) );
// Ask for 400: closer to 300 than to 700. Both rounds 100 away or
// 300 away respectively; 300 wins.
assert!( reg.resolve( "sora", 400, FontStyle::Normal ).is_some() );
// Ask for 800: closer to 700.
assert!( reg.resolve( "sora", 800, FontStyle::Normal ).is_some() );
}
#[ test ]
fn fallback_chain_is_walked_when_family_unknown()
{
let Some( font ) = system_font() else { return; };
let mut reg = FontRegistry::new();
reg.insert( "sora", 400, FontStyle::Normal, Arc::clone( &font ) );
reg.set_fallbacks( "display", vec![ "sora".to_string() ] );
// `display` has no direct entries, but its fallback chain leads to
// `sora` which is loaded.
assert!( reg.resolve( "display", 400, FontStyle::Normal ).is_some() );
}
#[ test ]
fn unreachable_family_returns_none()
{
let Some( font ) = system_font() else { return; };
let mut reg = FontRegistry::new();
reg.insert( "sora", 400, FontStyle::Normal, font );
assert!( reg.resolve( "roboto", 400, FontStyle::Normal ).is_none() );
}
#[ test ]
fn from_families_reports_io_error_for_missing_path()
{
use crate::theme::fonts::FontSource;
let mut fams = HashMap::new();
fams.insert( "sora".to_string(), FontFamilyDef
{
name: "Sora".to_string(),
fallbacks: Vec::new(),
sources: vec!
[
FontSource
{
weight: 400,
style: FontStyle::Normal,
path: "/this/path/does/not/exist.ttf".into(),
},
],
});
match FontRegistry::from_families( &fams )
{
Err( FontLoadError::Io( p, _ ) ) =>
{
assert!( p.to_string_lossy().contains( "does/not/exist" ) );
}
other => panic!( "expected Io error, got {:?}", other ),
}
}
}

77
src/theme/fonts.rs Normal file
View File

@@ -0,0 +1,77 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! Font family definitions as declared by the theme document.
//!
//! This module only models the **declaration**: a family name, a set of
//! source files indexed by weight/style, and a fallback chain. Loading the
//! `.ttf` bytes into `fontdue` and resolving text styles against a live
//! registry is the runtime registry's job (see [`super::FontRegistry`]).
use std::path::PathBuf;
use super::text_style::FontStyle;
// ─── FontFamilyDef ───────────────────────────────────────────────────────────
/// A font family as declared by the theme document.
///
/// `sources` is a flat list indexed by weight + style, not a nested map, so
/// the JSON round-trips without ambiguity on ordering.
#[ derive( Debug, Clone, PartialEq ) ]
pub struct FontFamilyDef
{
/// Human-readable family name (e.g. `"Sora"`). Shown in telemetry and
/// in any eventual font-picker UI.
pub name: String,
/// Fallback chain if the family or a given weight/style is missing.
/// Resolved in order: the first entry that the font registry can
/// satisfy is used.
pub fallbacks: Vec<String>,
/// One source per weight/style the family ships. The runtime font
/// registry registers each source in `fontdue` at load time.
pub sources: Vec<FontSource>,
}
// ─── FontSource ──────────────────────────────────────────────────────────────
/// A single font file, specified by its numeric weight and style.
#[ derive( Debug, Clone, PartialEq, Eq ) ]
pub struct FontSource
{
/// CSS numeric weight (100..=900).
pub weight: u16,
/// Italic vs upright.
pub style: FontStyle,
/// Path to the `.ttf` / `.otf` file. Resolved to absolute at load time
/// (relative paths are taken to be relative to the theme directory root).
pub path: PathBuf,
}
// ─── Tests ───────────────────────────────────────────────────────────────────
#[ cfg( test ) ]
mod tests
{
use super::*;
#[ test ]
fn family_def_holds_multiple_weights_of_same_family()
{
let sora = FontFamilyDef
{
name: "Sora".to_string(),
fallbacks: vec![ "system-ui".to_string(), "sans-serif".to_string() ],
sources: vec!
[
FontSource { weight: 300, style: FontStyle::Normal, path: "fonts/Sora-Light.ttf".into() },
FontSource { weight: 400, style: FontStyle::Normal, path: "fonts/Sora-Regular.ttf".into() },
FontSource { weight: 600, style: FontStyle::Normal, path: "fonts/Sora-SemiBold.ttf".into() },
FontSource { weight: 700, style: FontStyle::Normal, path: "fonts/Sora-Bold.ttf".into() },
],
};
assert_eq!( sora.sources.len(), 4 );
assert_eq!( sora.sources[2].weight, 600 );
assert_eq!( sora.fallbacks[0], "system-ui" );
}
}

304
src/theme/gradient_lut.rs Normal file
View File

@@ -0,0 +1,304 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! CPU-side gradient sampling and LUT baking.
//!
//! The GPU gradient path in `gles_render` shaders samples a 1D lookup
//! texture baked on the CPU: for each gradient we precompute N equally
//! spaced samples across an extended `t` domain (so stops outside
//! `[0, 1]` are covered without extra shader logic), already colour-space
//! converted, and upload those N × 4 bytes as an RGBA8 texture.
//!
//! # Extrapolation
//!
//! Stops whose `position` falls outside `[0, 1]` are supported by
//! **linear extrapolation**: below the first stop we prolong the
//! `(first, second)` slope, above the last stop we prolong the
//! `(last-1, last)` slope. Values are not clamped. This is the
//! physically correct behaviour for CSS `linear-gradient` stops defined
//! with positions outside the visible range.
//!
//! # Colour spaces
//!
//! [`GradientSpace::Srgb`] interpolates raw sRGB channels (cheap, looks
//! muddy on saturated gradients). [`GradientSpace::LinearRgb`] — the
//! default — converts each stop to linear light, interpolates, and
//! converts the result back to sRGB. [`GradientSpace::Oklab`] is not yet
//! implemented and silently falls back to `LinearRgb`.
use crate::types::Color;
use super::paint::{ ColorStop, GradientSpace };
/// How many samples the LUT stores along the `t` axis.
pub const LUT_SAMPLES: usize = 512;
/// Extended `t` domain the LUT covers. Wide enough to comfortably contain
/// the typical out-of-`[0, 1]` stops produced by design-tool exports.
pub const LUT_DOMAIN: ( f32, f32 ) = ( -2.0, 3.0 );
// ─── sRGB ↔ linear ───────────────────────────────────────────────────────────
/// Convert one sRGB gamma-encoded channel to linear light.
#[ inline ]
pub fn srgb_to_linear( x: f32 ) -> f32
{
if x <= 0.04045 { x / 12.92 } else { ((x + 0.055) / 1.055).powf( 2.4 ) }
}
/// Convert one linear-light channel to sRGB gamma-encoded.
#[ inline ]
pub fn linear_to_srgb( x: f32 ) -> f32
{
if x <= 0.0031308 { x * 12.92 } else { 1.055 * x.powf( 1.0 / 2.4 ) - 0.055 }
}
// ─── Sampling ────────────────────────────────────────────────────────────────
/// Sample the stops at position `t` using the requested interpolation
/// space. Stops need not be sorted. `t` may fall outside `[0, 1]`.
pub fn sample_stops( stops: &[ColorStop], t: f32, space: GradientSpace ) -> Color
{
if stops.is_empty() { return Color::TRANSPARENT; }
if stops.len() == 1 { return stops[0].color; }
// Sort by position. We do this each call because gradients hold at
// most a handful of stops and the LUT builder only calls us N times
// per gradient build (not per pixel).
let mut sorted: Vec<&ColorStop> = stops.iter().collect();
sorted.sort_by( |a, b|
a.position.partial_cmp( &b.position ).unwrap_or( std::cmp::Ordering::Equal )
);
// Pick the bracketing pair. Below the first stop we extrapolate from
// `(first, second)`; above the last, from `(last-1, last)`.
let n = sorted.len();
let ( a, b ) = if t <= sorted[0].position
{
( sorted[0], sorted[1] )
}
else if t >= sorted[n - 1].position
{
( sorted[n - 2], sorted[n - 1] )
}
else
{
let mut pair = ( sorted[0], sorted[1] );
for win in sorted.windows( 2 )
{
if t >= win[0].position && t <= win[1].position
{
pair = ( win[0], win[1] );
break;
}
}
pair
};
let dt = b.position - a.position;
let u = if dt.abs() < 1e-6 { 0.0 } else { ( t - a.position ) / dt };
mix_colors( a.color, b.color, u, space )
}
/// Linear mix of two colours at parameter `u` (not clamped — the caller
/// has already chosen the right bracketing pair).
fn mix_colors( a: Color, b: Color, u: f32, space: GradientSpace ) -> Color
{
let alpha = a.a + ( b.a - a.a ) * u;
match space
{
GradientSpace::Srgb => Color
{
r: a.r + ( b.r - a.r ) * u,
g: a.g + ( b.g - a.g ) * u,
b: a.b + ( b.b - a.b ) * u,
a: alpha,
},
GradientSpace::LinearRgb => mix_linear( a, b, u, alpha ),
// TODO: proper Oklab mix. For now fall back to linear-light.
GradientSpace::Oklab => mix_linear( a, b, u, alpha ),
}
}
fn mix_linear( a: Color, b: Color, u: f32, alpha: f32 ) -> Color
{
let ar = srgb_to_linear( a.r );
let ag = srgb_to_linear( a.g );
let ab = srgb_to_linear( a.b );
let br = srgb_to_linear( b.r );
let bg = srgb_to_linear( b.g );
let bb = srgb_to_linear( b.b );
let r = linear_to_srgb( ar + ( br - ar ) * u );
let g = linear_to_srgb( ag + ( bg - ag ) * u );
let b = linear_to_srgb( ab + ( bb - ab ) * u );
Color { r, g, b, a: alpha }
}
// ─── LUT baking ──────────────────────────────────────────────────────────────
/// Build an RGBA8 LUT of `LUT_SAMPLES` equally spaced samples spanning
/// [`LUT_DOMAIN`]. The returned vector has `LUT_SAMPLES * 4` bytes in
/// straight-alpha, row-major. The GPU shader expects this layout and
/// premultiplies at sample time.
pub fn build_lut_bytes( stops: &[ColorStop], space: GradientSpace ) -> Vec<u8>
{
let ( t0, t1 ) = LUT_DOMAIN;
let n = LUT_SAMPLES;
let mut out = Vec::with_capacity( n * 4 );
for i in 0..n
{
let t = t0 + ( t1 - t0 ) * ( i as f32 / ( n - 1 ) as f32 );
let c = sample_stops( stops, t, space );
out.push( ( c.r.clamp( 0.0, 1.0 ) * 255.0 + 0.5 ) as u8 );
out.push( ( c.g.clamp( 0.0, 1.0 ) * 255.0 + 0.5 ) as u8 );
out.push( ( c.b.clamp( 0.0, 1.0 ) * 255.0 + 0.5 ) as u8 );
out.push( ( c.a.clamp( 0.0, 1.0 ) * 255.0 + 0.5 ) as u8 );
}
out
}
// ─── Tests ───────────────────────────────────────────────────────────────────
#[ cfg( test ) ]
mod tests
{
use super::*;
fn approx( a: f32, b: f32 ) -> bool { ( a - b ).abs() < 2e-2 }
#[ test ]
fn srgb_linear_roundtrip_is_stable()
{
for &v in &[ 0.0, 0.04, 0.1, 0.25, 0.5, 0.75, 1.0 ]
{
let back = linear_to_srgb( srgb_to_linear( v ) );
assert!( ( v - back ).abs() < 1e-5, "roundtrip {} -> {}", v, back );
}
}
#[ test ]
fn sample_at_exact_stop_returns_that_stop()
{
let stops = vec!
[
ColorStop { position: 0.0, color: Color::WHITE },
ColorStop { position: 1.0, color: Color::BLACK },
];
let a = sample_stops( &stops, 0.0, GradientSpace::Srgb );
let b = sample_stops( &stops, 1.0, GradientSpace::Srgb );
assert_eq!( a, Color::WHITE );
assert_eq!( b, Color::BLACK );
}
#[ test ]
fn sample_midpoint_srgb_is_halfway()
{
let stops = vec!
[
ColorStop { position: 0.0, color: Color::rgb( 0.0, 0.0, 0.0 ) },
ColorStop { position: 1.0, color: Color::rgb( 1.0, 1.0, 1.0 ) },
];
let m = sample_stops( &stops, 0.5, GradientSpace::Srgb );
assert!( approx( m.r, 0.5 ) && approx( m.g, 0.5 ) && approx( m.b, 0.5 ) );
}
#[ test ]
fn sample_midpoint_linear_rgb_is_brighter_than_srgb()
{
// Classic demonstration that linear-light midpoint is brighter
// than the naive sRGB midpoint when interpolating 0 → 1.
let stops = vec!
[
ColorStop { position: 0.0, color: Color::rgb( 0.0, 0.0, 0.0 ) },
ColorStop { position: 1.0, color: Color::rgb( 1.0, 1.0, 1.0 ) },
];
let m_srgb = sample_stops( &stops, 0.5, GradientSpace::Srgb );
let m_linear = sample_stops( &stops, 0.5, GradientSpace::LinearRgb );
assert!( m_linear.r > m_srgb.r, "linear {:?} should be brighter than srgb {:?}", m_linear, m_srgb );
}
#[ test ]
fn extrapolation_below_first_stop_continues_slope()
{
// stops at 0.0 (white) and 1.0 (black). Extrapolating to -1.0
// along the same slope lands above 1.0 — we do NOT clamp; the
// LUT baker will clip when converting to u8.
let stops = vec!
[
ColorStop { position: 0.0, color: Color::WHITE },
ColorStop { position: 1.0, color: Color::BLACK },
];
let c = sample_stops( &stops, -1.0, GradientSpace::Srgb );
// Slope is (-1, -1, -1) per unit of t; at t=-1 we get rgb=(2,2,2).
assert!( c.r > 1.0, "extrapolation should exceed 1.0 before clamp: {}", c.r );
}
#[ test ]
fn extrapolation_above_last_stop_continues_slope()
{
let stops = vec!
[
ColorStop { position: 0.0, color: Color::rgb( 0.2, 0.2, 0.2 ) },
ColorStop { position: 1.0, color: Color::rgb( 0.8, 0.8, 0.8 ) },
];
let c = sample_stops( &stops, 2.0, GradientSpace::Srgb );
// Slope (0.6, 0.6, 0.6) per unit. At t=2.0, rgb=(1.4, …) — exceeds 1.0.
assert!( c.r > 1.0, "extrapolation should exceed 1.0: {}", c.r );
}
#[ test ]
fn alpha_mixes_linearly_across_spaces()
{
let stops = vec!
[
ColorStop { position: 0.0, color: Color::rgba( 1.0, 0.0, 0.0, 1.0 ) },
ColorStop { position: 1.0, color: Color::rgba( 1.0, 0.0, 0.0, 0.0 ) },
];
for space in [ GradientSpace::Srgb, GradientSpace::LinearRgb, GradientSpace::Oklab ]
{
let m = sample_stops( &stops, 0.5, space );
assert!( approx( m.a, 0.5 ), "alpha should mix linearly in {:?}: got {}", space, m.a );
}
}
#[ test ]
fn unsorted_stops_are_handled()
{
let stops = vec!
[
ColorStop { position: 1.0, color: Color::BLACK },
ColorStop { position: 0.0, color: Color::WHITE },
];
let a = sample_stops( &stops, 0.0, GradientSpace::Srgb );
let b = sample_stops( &stops, 1.0, GradientSpace::Srgb );
assert_eq!( a, Color::WHITE );
assert_eq!( b, Color::BLACK );
}
#[ test ]
fn build_lut_has_expected_length()
{
let stops = vec!
[
ColorStop { position: 0.0, color: Color::WHITE },
ColorStop { position: 1.0, color: Color::BLACK },
];
let bytes = build_lut_bytes( &stops, GradientSpace::LinearRgb );
assert_eq!( bytes.len(), LUT_SAMPLES * 4 );
}
#[ test ]
fn build_lut_clips_extrapolation_to_u8_range()
{
let stops = vec!
[
ColorStop { position: 0.0, color: Color::WHITE },
ColorStop { position: 1.0, color: Color::BLACK },
];
let bytes = build_lut_bytes( &stops, GradientSpace::Srgb );
// All bytes must be in [0, 255] — no panics from out-of-range casts.
for b in &bytes { let _ = *b; }
}
}

1103
src/theme/mod.rs Normal file

File diff suppressed because it is too large Load Diff

207
src/theme/paint.rs Normal file
View File

@@ -0,0 +1,207 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! Paint primitives: the "what do we fill a shape with" side of theming.
//!
//! A [`Paint`] is either a flat [`Color`], a [`LinearGradient`] or a
//! [`RadialGradient`]. Gradients carry their colour stops, a direction (angle
//! for linear, center+radius for radial) and the [`GradientSpace`] in which
//! stops are interpolated. Sampling happens downstream in the renderer; these
//! types are pure data and have no rendering logic of their own.
//!
//! # Stop positions
//!
//! Stops are represented as fractions (`0.0..=1.0` for the caller's mental
//! model) but the [`ColorStop::position`] field accepts **values outside
//! that range**. This is intentional: design-tool exports often emit
//! gradients whose stops fall outside `[0, 1]`, meaning the visible region
//! of the shape only covers a middle slice of the interpolation. The
//! renderer is expected to extrapolate linearly, not clamp.
//!
//! # Colour space
//!
//! Picking the right interpolation space matters for saturated gradients:
//! interpolating `#04D9FE → #8A38F5` in sRGB produces a muddy grey in the
//! middle, while Oklab keeps the chroma. The space is resolved at theme-load
//! time (stops converted once), not per pixel.
use crate::types::Color;
// ─── Gradient space ──────────────────────────────────────────────────────────
/// Colour space in which a gradient's stops are interpolated.
///
/// The default is [`GradientSpace::LinearRgb`]: cheap, physically correct, and
/// a clear win over naive sRGB interpolation. [`GradientSpace::Oklab`] is kept
/// as an opt-in for brand gradients with high-chroma endpoints where even
/// linear-light shows an undesirable darkening in the mid-point. [`GradientSpace::Srgb`]
/// exists primarily to reproduce designs that were authored against it
/// byte-for-byte.
#[ derive( Debug, Clone, Copy, PartialEq, Eq ) ]
pub enum GradientSpace
{
/// Interpolate directly in sRGB gamma-encoded space. Cheap, but midpoints
/// of saturated gradients look muddy.
Srgb,
/// Interpolate in linear-light RGB. Default. Physically correct and fast.
LinearRgb,
/// Interpolate in the Oklab perceptual colour space. Best for saturated
/// brand gradients; slightly more expensive.
Oklab,
}
impl Default for GradientSpace
{
fn default() -> Self { GradientSpace::LinearRgb }
}
// ─── Colour stops ────────────────────────────────────────────────────────────
/// One stop of a gradient: a `position` along the gradient axis (for linear)
/// or along the radius (for radial), plus the [`Color`] at that point.
///
/// `position` is a fraction but **may fall outside `[0.0, 1.0]`**. See the
/// module-level note on extrapolation.
#[ derive( Debug, Clone, Copy, PartialEq ) ]
pub struct ColorStop
{
/// Position along the gradient. `0.0` is the start, `1.0` is the end;
/// values outside this range are allowed and will be extrapolated.
pub position: f32,
/// Colour at this position.
pub color: Color,
}
// ─── Linear gradient ─────────────────────────────────────────────────────────
/// A straight gradient swept along a vector set by an angle.
///
/// The angle convention follows CSS `linear-gradient`: `0deg` points from the
/// bottom edge towards the top, `90deg` from left to right, `180deg` from top
/// to bottom, and so on.
#[ derive( Debug, Clone, PartialEq ) ]
pub struct LinearGradient
{
/// Direction of the gradient, in degrees (CSS convention).
pub angle_deg: f32,
/// Stops along the axis, in source order. The renderer does not require
/// them to be sorted by position — it sorts internally — but keeping them
/// in increasing order is conventional and makes diffs readable.
pub stops: Vec<ColorStop>,
/// Space in which stops are interpolated. See [`GradientSpace`].
pub space: GradientSpace,
}
// ─── Radial gradient ─────────────────────────────────────────────────────────
/// A gradient radiating from a centre point out to a radius.
///
/// `center` and `radius` are expressed as **fractions of the bounding box**
/// (not pixels) so the gradient scales with the widget. `center: [0.5, 0.5]`
/// and `radius: 0.5` describes a circle inscribed in a square widget.
#[ derive( Debug, Clone, PartialEq ) ]
pub struct RadialGradient
{
/// Centre of the gradient in box-relative coordinates, `[0.0, 1.0]`.
pub center: [f32; 2],
/// Radius of the gradient in box-relative units. `0.5` reaches the edge
/// of a square box, `1.0` reaches the corner diagonally.
pub radius: f32,
/// Stops along the radius, in source order.
pub stops: Vec<ColorStop>,
/// Space in which stops are interpolated. See [`GradientSpace`].
pub space: GradientSpace,
}
// ─── Paint ───────────────────────────────────────────────────────────────────
/// How a shape is filled: a flat colour or one of the gradient variants.
///
/// Theming consumers rarely construct [`Paint`] directly: a slot of kind
/// `color` is promoted to [`Paint::Solid`] automatically when the widget asks
/// for a paint. Only slots that actually declare a gradient in the theme JSON
/// round-trip through [`Paint::Linear`] / [`Paint::Radial`].
#[ derive( Debug, Clone, PartialEq ) ]
pub enum Paint
{
/// A uniform fill with a single [`Color`].
Solid( Color ),
/// A linear gradient sweep.
Linear( LinearGradient ),
/// A radial gradient sweep.
Radial( RadialGradient ),
}
impl Paint
{
/// Convenience constructor for the common solid case.
pub fn solid( color: Color ) -> Self
{
Paint::Solid( color )
}
}
impl From<Color> for Paint
{
fn from( c: Color ) -> Self { Paint::Solid( c ) }
}
// ─── Tests ───────────────────────────────────────────────────────────────────
#[ cfg( test ) ]
mod tests
{
use super::*;
#[ test ]
fn stops_positions_outside_unit_range_are_accepted()
{
// Construction must succeed; the renderer will extrapolate.
let grad = 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,
};
assert_eq!( grad.stops.len(), 2 );
assert!( grad.stops[0].position < 0.0 );
assert!( grad.stops[1].position > 1.0 );
}
#[ test ]
fn default_gradient_space_is_linear_rgb()
{
assert_eq!( GradientSpace::default(), GradientSpace::LinearRgb );
}
#[ test ]
fn paint_promotes_from_color()
{
let c = Color::hex( 0x04, 0xD9, 0xFE );
let p: Paint = c.into();
assert_eq!( p, Paint::Solid( c ) );
}
#[ test ]
fn radial_gradient_uses_box_relative_coordinates()
{
// Documenting the contract: center + radius are fractions, not pixels.
let g = RadialGradient
{
center: [ 0.5, 0.5 ],
radius: 0.5,
stops: vec!
[
ColorStop { position: 0.0, color: Color::WHITE },
ColorStop { position: 1.0, color: Color::TRANSPARENT },
],
space: GradientSpace::LinearRgb,
};
assert_eq!( g.center, [ 0.5, 0.5 ] );
assert_eq!( g.radius, 0.5 );
}
}

1449
src/theme/schema.rs Normal file

File diff suppressed because it is too large Load Diff

193
src/theme/shadow.rs Normal file
View File

@@ -0,0 +1,193 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! Shadow primitives: outer drop shadows, inner inset shadows, and the
//! blend modes used to composite them.
//!
//! # Units
//!
//! [`Shadow::blur`] and [`InsetShadow::blur`] store the **CSS blur radius**,
//! not the SVG `stdDeviation`. The relationship is `blur = 2 × stdDeviation`,
//! which is what browsers compute for `box-shadow: … blur …`. The shader
//! integrates against `sigma`, so it applies `sigma = blur / 2` internally
//! (see [`Shadow::sigma`]).
//!
//! # Order
//!
//! A theme's `shadows` list is stored **back-to-front**, mirroring SVG's
//! `feBlend` stacking order. The first entry is painted first (lowest layer),
//! the last entry is painted last (topmost). This is the inverse of CSS
//! `box-shadow` string order. Documented here so the renderer loop (`for
//! shadow in shadows { ... }`) produces the visually correct result without
//! reversing.
use crate::types::Color;
// ─── Blend modes ─────────────────────────────────────────────────────────────
/// How a shadow composites against the layers below it.
///
/// All modes assume **premultiplied** colour and alpha. The GPU pipeline is
/// expected to be premul-correct; the software pipeline must premultiply
/// before applying these formulas.
#[ derive( Debug, Clone, Copy, PartialEq, Eq ) ]
pub enum BlendMode
{
/// Standard `src-over`: `result = src + dst × (1 src.a)`.
Normal,
/// CSS `plus-lighter`: `result = min(1, src + dst)`, channel-wise on
/// premultiplied values. Adds light; never darkens.
PlusLighter,
/// Overlay (multiply on dark base, screen on light base). Preserves the
/// base's luminance while pushing local contrast.
Overlay,
/// Multiplicative blend: `result = src × dst`. Only darkens.
Multiply,
/// Screen blend: `result = 1 (1 src) × (1 dst)`. Only lightens.
Screen,
}
impl Default for BlendMode
{
fn default() -> Self { BlendMode::Normal }
}
// ─── Outer shadow ────────────────────────────────────────────────────────────
/// An outer drop shadow cast by a shape.
///
/// Modelled after CSS `box-shadow`: an offset, a blur radius, an optional
/// spread that dilates (positive) or erodes (negative) the silhouette before
/// blurring, a colour, and a blend mode.
#[ derive( Debug, Clone, Copy, PartialEq ) ]
pub struct Shadow
{
/// `[dx, dy]` offset in CSS pixels. Positive `dy` is downward.
pub offset: [f32; 2],
/// CSS blur radius in pixels (2 × SVG `stdDeviation`).
pub blur: f32,
/// Spread in CSS pixels. Positive values dilate the silhouette before
/// blurring; negative values erode it. Usually `0.0`.
pub spread: f32,
/// Shadow colour, including alpha.
pub color: Color,
/// Compositing mode. Most drop shadows use [`BlendMode::Normal`].
pub blend: BlendMode,
}
impl Shadow
{
/// Gaussian sigma derived from the CSS blur radius.
///
/// The shader integrates Gaussian kernels against `sigma`, but the public
/// field stores the CSS blur radius for parity with CSS `box-shadow`.
/// The relationship is `sigma = blur / 2`.
pub fn sigma( &self ) -> f32 { self.blur * 0.5 }
}
// ─── Inner shadow ────────────────────────────────────────────────────────────
/// An inset shadow: a shadow painted **inside** the shape's silhouette, as
/// opposed to the outer drop shadow cast behind it.
///
/// Structurally identical to [`Shadow`]; kept as a separate type so the
/// renderer and theme JSON can't accidentally treat an inset as an outer
/// (and vice versa) — the dispatch is at the type level.
#[ derive( Debug, Clone, Copy, PartialEq ) ]
pub struct InsetShadow
{
/// `[dx, dy]` offset in CSS pixels. Positive `dy` is downward.
pub offset: [f32; 2],
/// CSS blur radius in pixels (2 × SVG `stdDeviation`).
pub blur: f32,
/// Spread in CSS pixels.
pub spread: f32,
/// Shadow colour, including alpha.
pub color: Color,
/// Compositing mode against the layers below. Insets routinely use
/// non-`Normal` modes (`PlusLighter` for highlights, `Overlay` for rim).
pub blend: BlendMode,
}
impl InsetShadow
{
/// Gaussian sigma derived from the CSS blur radius. See [`Shadow::sigma`].
pub fn sigma( &self ) -> f32 { self.blur * 0.5 }
}
// ─── Shadow reference ────────────────────────────────────────────────────────
/// How a [`crate::theme::Surface`] refers to its outer shadow stack: either
/// by name (reused across several surfaces — the common case for elevation
/// tokens) or inline (one-off, uncommon).
#[ derive( Debug, Clone, PartialEq ) ]
pub enum ShadowsRef
{
/// Reference to another slot in the theme, by id.
Named( String ),
/// The shadow list, carried inline. Used when the surface is exotic
/// enough that the stack isn't worth a dedicated slot.
Inline( Vec<Shadow> ),
}
// ─── Tests ───────────────────────────────────────────────────────────────────
#[ cfg( test ) ]
mod tests
{
use super::*;
#[ test ]
fn sigma_is_half_of_css_blur()
{
// CSS blur 4 → SVG stdDev 2. The shader needs sigma == stdDev == 2.
let s = 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,
};
assert_eq!( s.sigma(), 2.0 );
}
#[ test ]
fn inset_sigma_follows_same_convention()
{
let i = InsetShadow
{
offset: [ -3.6, -3.6 ],
blur: 13.5,
spread: 0.0,
color: Color::hex( 0x55, 0x55, 0x55 ),
blend: BlendMode::PlusLighter,
};
assert!( ( i.sigma() - 6.75 ).abs() < 1e-6 );
}
#[ test ]
fn default_blend_mode_is_normal()
{
assert_eq!( BlendMode::default(), BlendMode::Normal );
}
#[ test ]
fn shadows_ref_distinguishes_named_and_inline()
{
let n = ShadowsRef::Named( "shadows-2".to_string() );
let i = ShadowsRef::Inline( vec!
[
Shadow
{
offset: [ 0.0, 4.0 ],
blur: 10.0,
spread: 0.0,
color: Color::rgba( 0.0, 0.0, 0.0, 0.08 ),
blend: BlendMode::Normal,
},
]);
match n { ShadowsRef::Named( ref s ) => assert_eq!( s, "shadows-2" ), _ => panic!() }
match i { ShadowsRef::Inline( v ) => assert_eq!( v.len(), 1 ), _ => panic!() }
}
}

345
src/theme/slots.rs Normal file
View File

@@ -0,0 +1,345 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! 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<String>,
/// Equivalent name in another system (e.g. `"NeutralColors.white"` for
/// Fluent). Useful when migrating from or cross-referencing other kits.
pub fluent: Option<String>,
/// Human-readable guidance on where to use this slot.
pub usage: Option<String>,
/// Free-form note, typically for quirks worth flagging in the JSON.
pub note: Option<String>,
}
// ─── 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<Shadow>, 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<String, Slot>,
}
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<String>, slot: Slot ) -> Option<Slot>
{
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<Color>
{
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<Paint>
{
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<Surface>
{
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;
}
}

107
src/theme/surface.rs Normal file
View File

@@ -0,0 +1,107 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! Composite surfaces: fill + outer shadows + inset shadows.
//!
//! A [`Surface`] is the richest kind of theme slot. It packages together a
//! fill (possibly a gradient), an elevation stack, and inset shadows with
//! their own blend modes. Widgets that only need a flat colour or a plain
//! gradient do not construct [`Surface`] values directly; slot promotion
//! (in `theme::slots`) hands them a [`Surface`] with empty decorations when
//! they ask for one against a simpler slot.
use super::paint::Paint;
use super::shadow::{ InsetShadow, ShadowsRef };
use crate::types::Color;
// ─── Surface ─────────────────────────────────────────────────────────────────
/// A composite theme surface: fill, elevation, insets.
///
/// All decorations are optional. A surface with `fill: Paint::Solid(...)`,
/// no shadows and no insets behaves exactly like a flat-colour fill, which
/// is why the promotion path from a `color` slot to a [`Surface`] is trivial.
#[ derive( Debug, Clone, PartialEq ) ]
pub struct Surface
{
/// What the surface is filled with.
pub fill: Paint,
/// Outer shadow stack. When `Some`, can reference another slot by name
/// or inline the full list.
pub shadows: Option<ShadowsRef>,
/// Inset shadows, in back-to-front order. Each entry carries its own
/// [`crate::theme::BlendMode`].
pub inset_shadows: Vec<InsetShadow>,
}
impl Surface
{
/// Build a surface from just a [`Paint`], with no shadows or insets.
pub fn from_paint( paint: Paint ) -> Self
{
Self
{
fill: paint,
shadows: None,
inset_shadows: Vec::new(),
}
}
}
impl From<Paint> for Surface
{
fn from( p: Paint ) -> Self { Surface::from_paint( p ) }
}
impl From<Color> for Surface
{
fn from( c: Color ) -> Self { Surface::from_paint( Paint::Solid( c ) ) }
}
// ─── Tests ───────────────────────────────────────────────────────────────────
#[ cfg( test ) ]
mod tests
{
use super::*;
use crate::types::Color;
use crate::theme::shadow::{ BlendMode, InsetShadow, Shadow, ShadowsRef };
#[ test ]
fn surface_promotes_from_color()
{
let s: Surface = Color::hex( 0x04, 0xD9, 0xFE ).into();
assert_eq!( s.fill, Paint::Solid( Color::hex( 0x04, 0xD9, 0xFE ) ) );
assert!( s.shadows.is_none() );
assert!( s.inset_shadows.is_empty() );
}
#[ test ]
fn glass_accent_shape_composes()
{
let s = Surface
{
fill: Paint::Solid( Color::hex( 0x04, 0xD9, 0xFE ) ),
shadows: Some( ShadowsRef::Named( "shadows-glass".to_string() ) ),
inset_shadows: vec!
[
InsetShadow { offset: [ -3.6, -3.6 ], blur: 13.5, spread: 0.0, color: Color::hex( 0x55, 0x55, 0x55 ), blend: BlendMode::PlusLighter },
InsetShadow { offset: [ 1.8, 1.8 ], blur: 1.8, spread: 0.0, color: Color::hex( 0x55, 0x55, 0x55 ), blend: BlendMode::PlusLighter },
InsetShadow { offset: [ 0.45, 0.45 ], blur: 0.9, spread: 0.0, color: Color::BLACK, blend: BlendMode::Overlay },
InsetShadow { offset: [ 1.8, 1.8 ], blur: 7.2, spread: 0.0, color: Color::rgba( 0.0, 0.0, 0.0, 0.15 ), blend: BlendMode::Normal },
],
};
assert_eq!( s.inset_shadows.len(), 4 );
assert_eq!( s.inset_shadows[0].blend, BlendMode::PlusLighter );
assert_eq!( s.inset_shadows[2].blend, BlendMode::Overlay );
let _ = Shadow
{
offset: [ 0.0, 0.0 ],
blur: 9.0,
spread: 0.0,
color: Color::rgba( 33.0 / 255.0, 33.0 / 255.0, 33.0 / 255.0, 0.25 ),
blend: BlendMode::Normal,
};
}
}

225
src/theme/text_style.rs Normal file
View File

@@ -0,0 +1,225 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! Typography tokens: a resolved text style (family + weight + size + …) as
//! the theme knows it.
//!
//! The struct is deliberately named [`TextStyle`] rather than `Typography`
//! to avoid colliding with the [`super::typography`] constants module that
//! ships alongside it.
//!
//! # Resolution
//!
//! A [`TextStyle`] carries a [`FontRef`] (by name) and optionally a slot
//! reference for its default colour. Both are resolved lazily by the theme's
//! slot table: the string IDs live here, the actual font bytes and
//! [`crate::types::Color`] value come from the font registry and the slot
//! store respectively. This keeps the struct copy-cheap and JSON-friendly.
// ─── Font reference ──────────────────────────────────────────────────────────
/// Reference to a font family registered in the theme's `fonts` block.
///
/// A family name like `"sora"` maps to a [`super::FontFamilyDef`] that
/// lists the actual `.ttf` paths for each weight/style. Keeping the
/// reference by name lets multiple text styles share a family without
/// duplicating the source list.
#[ derive( Debug, Clone, PartialEq, Eq, Hash ) ]
pub enum FontRef
{
/// Reference by family id, looked up in the theme's `fonts` block.
Named( String ),
}
// ─── Style axes ──────────────────────────────────────────────────────────────
/// Italic vs upright.
#[ derive( Debug, Clone, Copy, PartialEq, Eq, Hash ) ]
pub enum FontStyle
{
Normal,
Italic,
}
impl Default for FontStyle
{
fn default() -> Self { FontStyle::Normal }
}
/// Case transform applied at render time.
#[ derive( Debug, Clone, Copy, PartialEq, Eq ) ]
pub enum TextTransform
{
/// Render the string unchanged.
None,
/// Uppercase every character.
Uppercase,
/// Lowercase every character.
Lowercase,
/// Uppercase the first letter of each word, leave the rest untouched.
Capitalize,
}
impl Default for TextTransform
{
fn default() -> Self { TextTransform::None }
}
/// Underline / strikethrough decoration.
#[ derive( Debug, Clone, Copy, PartialEq, Eq ) ]
pub enum TextDecoration
{
None,
Underline,
Strikethrough,
}
impl Default for TextDecoration
{
fn default() -> Self { TextDecoration::None }
}
// ─── Line height ─────────────────────────────────────────────────────────────
/// How the vertical advance between lines is specified.
///
/// Both forms are supported because design-tool exports usually emit absolute
/// pixel heights, while hand-authored themes more often prefer relative
/// line-heights that scale with the font size.
#[ derive( Debug, Clone, Copy, PartialEq ) ]
pub enum LineHeight
{
/// Absolute pixel box height for the line.
Px( f32 ),
/// Multiplier of the font size. `1.5` means line-height is 1.5× the size.
Multiplier( f32 ),
}
impl LineHeight
{
/// Resolve to an absolute pixel height given the font size.
pub fn resolve( self, font_size: f32 ) -> f32
{
match self
{
LineHeight::Px( h ) => h,
LineHeight::Multiplier( m ) => font_size * m,
}
}
}
// ─── TextStyle ───────────────────────────────────────────────────────────────
/// A resolved text style: family, weight, size, line-height and the visual
/// modifiers that go with them.
///
/// `color` is a slot id (string) rather than a resolved [`crate::types::Color`]
/// because the slot store is what knows how to resolve names to values — and
/// widgets can override the colour with [`.color()`] at the call-site, which
/// always wins over the style's default.
#[ derive( Debug, Clone, PartialEq ) ]
pub struct TextStyle
{
/// Font family, looked up in the theme's `fonts` block.
pub family: FontRef,
/// Weight as a CSS numeric value (100..=900).
pub weight: u16,
/// Italic vs upright.
pub style: FontStyle,
/// Font size in CSS pixels.
pub size: f32,
/// Line height.
pub line_height: LineHeight,
/// Letter spacing in `em` (fraction of the font size). `0.0` by default.
pub letter_spacing: f32,
/// Case transform applied before shaping.
pub transform: TextTransform,
/// Underline / strikethrough.
pub decoration: TextDecoration,
/// Default colour slot id. `None` means "inherit from the widget's own
/// colour setting". When `Some`, widgets that don't override with
/// [`.color()`] get this.
pub color: Option<String>,
}
impl TextStyle
{
/// Convenience constructor that fills the non-essential fields with
/// sensible defaults (upright, no transform, no decoration, no default
/// colour, no letter spacing).
pub fn new( family: FontRef, weight: u16, size: f32, line_height: LineHeight ) -> Self
{
Self
{
family,
weight,
style: FontStyle::Normal,
size,
line_height,
letter_spacing: 0.0,
transform: TextTransform::None,
decoration: TextDecoration::None,
color: None,
}
}
}
// ─── Tests ───────────────────────────────────────────────────────────────────
#[ cfg( test ) ]
mod tests
{
use super::*;
#[ test ]
fn new_constructor_sets_sensible_defaults()
{
let s = TextStyle::new
(
FontRef::Named( "sora".to_string() ),
400,
16.0,
LineHeight::Px( 24.0 ),
);
assert_eq!( s.weight, 400 );
assert_eq!( s.size, 16.0 );
assert_eq!( s.line_height.resolve( 16.0 ), 24.0 );
assert_eq!( s.transform, TextTransform::None );
assert_eq!( s.letter_spacing, 0.0 );
assert!( s.color.is_none() );
}
#[ test ]
fn caption_l_upcases_at_render_time()
{
let s = TextStyle
{
family: FontRef::Named( "sora".to_string() ),
weight: 400,
style: FontStyle::Normal,
size: 16.0,
line_height: LineHeight::Px( 20.0 ),
letter_spacing: 0.0,
transform: TextTransform::Uppercase,
decoration: TextDecoration::None,
color: None,
};
assert_eq!( s.transform, TextTransform::Uppercase );
}
#[ test ]
fn line_height_multiplier_scales_with_size()
{
let lh = LineHeight::Multiplier( 1.5 );
assert_eq!( lh.resolve( 16.0 ), 24.0 );
assert_eq!( lh.resolve( 12.0 ), 18.0 );
}
#[ test ]
fn defaults_are_normal_and_none()
{
assert_eq!( FontStyle::default(), FontStyle::Normal );
assert_eq!( TextTransform::default(), TextTransform::None );
assert_eq!( TextDecoration::default(), TextDecoration::None );
}
}