First commit. Version 0.1.0
This commit is contained in:
111
src/theme/document.rs
Normal file
111
src/theme/document.rs
Normal 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() ) )
|
||||
}
|
||||
}
|
||||
134
src/theme/fallback/Sora-LICENSE.txt
Normal file
134
src/theme/fallback/Sora-LICENSE.txt
Normal 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".
|
||||
BIN
src/theme/fallback/Sora-Regular.otf
Normal file
BIN
src/theme/fallback/Sora-Regular.otf
Normal file
Binary file not shown.
112
src/theme/fallback/mod.rs
Normal file
112
src/theme/fallback/mod.rs
Normal 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
357
src/theme/font_registry.rs
Normal 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
77
src/theme/fonts.rs
Normal 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
304
src/theme/gradient_lut.rs
Normal 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
1103
src/theme/mod.rs
Normal file
File diff suppressed because it is too large
Load Diff
207
src/theme/paint.rs
Normal file
207
src/theme/paint.rs
Normal 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
1449
src/theme/schema.rs
Normal file
File diff suppressed because it is too large
Load Diff
193
src/theme/shadow.rs
Normal file
193
src/theme/shadow.rs
Normal 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
345
src/theme/slots.rs
Normal 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
107
src/theme/surface.rs
Normal 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
225
src/theme/text_style.rs
Normal 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 );
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user