refactor: split every monolithic module into focused submodules
Each source file that had grown beyond a single concern is replaced by an identically-named directory containing focused submodules. `src/event_loop/mod.rs` (878 lines) becomes a directory with clipboard, context_menu, cursor_shape, drag, focus, handlers, invalidation, overlays_reconcile, repeat, run, surface, text_editing, and tooltip. Every widget, input handler, and theme component follows the same split. Public interfaces are unchanged — only the internal file layout moves. image bumped from 0.25.2 to 0.25.9.
This commit is contained in:
116
src/theme/accessors.rs
Normal file
116
src/theme/accessors.rs
Normal file
@@ -0,0 +1,116 @@
|
||||
// SPDX-License-Identifier: LGPL-2.1-only
|
||||
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
|
||||
|
||||
//! Slot-typed shorthand accessors against the active theme document.
|
||||
//!
|
||||
//! Widgets speak in terms of slot ids. The plumbing for "which document is
|
||||
//! active" and "which mode" is captured here so widget code stays
|
||||
//! free of that plumbing.
|
||||
|
||||
use crate::types::Color;
|
||||
|
||||
use super::active::ensure_active;
|
||||
use super::paint::Paint;
|
||||
use super::palette::{ default_window_controls, Palette };
|
||||
use super::prefs::WindowControlsSpec;
|
||||
use super::shadow::{ Shadow, ShadowsRef };
|
||||
use super::surface::Surface;
|
||||
use super::text_style::TextStyle;
|
||||
|
||||
/// Resolve a colour slot in the active mode. `None` when the slot is
|
||||
/// missing, empty, or not a `Slot::Color` (use [`paint()`] if a gradient
|
||||
/// should also be acceptable).
|
||||
pub fn color( id: &str ) -> Option<Color>
|
||||
{
|
||||
let state = ensure_active();
|
||||
state.document.mode( state.mode ).slots.color( id )
|
||||
}
|
||||
|
||||
/// Like [`color`] but returns `fallback` when the slot is missing. The
|
||||
/// typical pattern for widgets that must always paint something.
|
||||
pub fn color_or( id: &str, fallback: Color ) -> Color
|
||||
{
|
||||
color( id ).unwrap_or( fallback )
|
||||
}
|
||||
|
||||
/// Resolve a paint slot (solid or gradient) in the active mode. A plain
|
||||
/// `Slot::Color` is promoted to `Paint::Solid`. `None` for missing or
|
||||
/// non-paintable slots (e.g. a text-style).
|
||||
pub fn paint( id: &str ) -> Option<Paint>
|
||||
{
|
||||
let state = ensure_active();
|
||||
state.document.mode( state.mode ).slots.paint( id )
|
||||
}
|
||||
|
||||
/// Resolve an elevation (outer-shadow stack) slot in the active mode. The
|
||||
/// returned `Vec` is a freshly cloned copy so call sites can hold it
|
||||
/// across frames without borrowing the global state.
|
||||
pub fn shadows( id: &str ) -> Option<Vec<Shadow>>
|
||||
{
|
||||
let state = ensure_active();
|
||||
state.document.mode( state.mode ).slots.shadows( id ).map( |s| s.to_vec() )
|
||||
}
|
||||
|
||||
/// Resolve a surface slot in the active mode. A colour or paint slot
|
||||
/// promotes to a surface with that fill and no decorations; a full
|
||||
/// surface slot returns unchanged.
|
||||
pub fn surface( id: &str ) -> Option<Surface>
|
||||
{
|
||||
let state = ensure_active();
|
||||
state.document.mode( state.mode ).slots.surface( id )
|
||||
}
|
||||
|
||||
/// Resolve a surface slot together with its outer shadow stack.
|
||||
///
|
||||
/// [`Surface::shadows`] may be `ShadowsRef::Named(id)`, pointing at a
|
||||
/// separate `Slot::Shadows` entry in the same mode. `canvas.fill_surface`
|
||||
/// expects the stack resolved as a plain `&[Shadow]`, so this helper does
|
||||
/// the second lookup in one place: widgets call `resolve_surface(id)` and
|
||||
/// get back the surface plus a `Vec<Shadow>` ready to hand to the canvas.
|
||||
///
|
||||
/// Returns `None` only when the surface slot itself is absent. A missing
|
||||
/// named shadow ref degrades silently to an empty stack — the surface
|
||||
/// still paints its fill and inset decorations, just without the outer
|
||||
/// glow.
|
||||
pub fn resolve_surface( id: &str ) -> Option<( Surface, Vec<Shadow> )>
|
||||
{
|
||||
let state = ensure_active();
|
||||
let mode = state.document.mode( state.mode );
|
||||
let surface = mode.slots.surface( id )?;
|
||||
let shadows = match &surface.shadows
|
||||
{
|
||||
Some( ShadowsRef::Named( sid ) ) => mode.slots.shadows( sid ).map( |s| s.to_vec() ).unwrap_or_default(),
|
||||
Some( ShadowsRef::Inline( v ) ) => v.clone(),
|
||||
None => Vec::new(),
|
||||
};
|
||||
Some( ( surface, shadows ) )
|
||||
}
|
||||
|
||||
/// Resolve a typography slot in the active mode. Cloned so the result
|
||||
/// can outlive the global-state borrow.
|
||||
pub fn text_style( id: &str ) -> Option<TextStyle>
|
||||
{
|
||||
let state = ensure_active();
|
||||
state.document.mode( state.mode ).slots.text_style( id ).cloned()
|
||||
}
|
||||
|
||||
/// The active mode's window-controls payload, derived from slots if the
|
||||
/// document did not declare an explicit block.
|
||||
pub fn window_controls() -> WindowControlsSpec
|
||||
{
|
||||
let state = ensure_active();
|
||||
let mode = state.document.mode( state.mode );
|
||||
if let Some( wc ) = mode.window_controls { return wc; }
|
||||
default_window_controls( Palette::from_slots( &mode.slots ) )
|
||||
}
|
||||
|
||||
/// The eight canonical palette slots of the active mode projected as a
|
||||
/// [`Palette`] struct. This is a one-call shortcut equivalent to
|
||||
/// `Palette::from_slots(&active_document().mode(active_mode()).slots)`,
|
||||
/// covering the common case where a widget needs `text_primary` /
|
||||
/// `surface` / `accent` / etc. without picking specific slot ids.
|
||||
pub fn palette() -> Palette
|
||||
{
|
||||
let state = ensure_active();
|
||||
Palette::from_slots( &state.document.mode( state.mode ).slots )
|
||||
}
|
||||
150
src/theme/active.rs
Normal file
150
src/theme/active.rs
Normal file
@@ -0,0 +1,150 @@
|
||||
// SPDX-License-Identifier: LGPL-2.1-only
|
||||
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
|
||||
|
||||
//! Process-wide active theme state.
|
||||
//!
|
||||
//! The active document is published once and read by every per-slot
|
||||
//! accessor in [`crate::theme`]. First access triggers a disk load of the
|
||||
//! `default` theme; if that fails, an embedded B/W document takes over
|
||||
//! and the [`is_fallback_active`] flag flips on so the draw layer can paint
|
||||
//! a warning banner.
|
||||
|
||||
use std::sync::{ Arc, RwLock };
|
||||
|
||||
use super::assets;
|
||||
use super::document::ThemeDocument;
|
||||
use super::fallback;
|
||||
use super::prefs::ThemeMode;
|
||||
|
||||
#[ derive( Clone ) ]
|
||||
pub ( crate ) struct ActiveState
|
||||
{
|
||||
/// Currently loaded theme document. The single source of truth —
|
||||
/// every consumer-facing accessor in this module reads through it
|
||||
/// against the active [`ThemeMode`].
|
||||
pub ( crate ) document: Arc<ThemeDocument>,
|
||||
pub ( crate ) mode: ThemeMode,
|
||||
/// `true` when the active document was produced by
|
||||
/// [`fallback::document`] because `ThemeDocument::find("default")`
|
||||
/// failed. The draw path stamps every frame with a red banner in
|
||||
/// this state so the user sees the missing-theme signal without
|
||||
/// the process having to abort.
|
||||
pub ( crate ) is_fallback: bool,
|
||||
}
|
||||
|
||||
// `RwLock::new` and `Option::None` are both const, so this needs no lazy init.
|
||||
static ACTIVE: RwLock<Option<ActiveState>> = RwLock::new( None );
|
||||
|
||||
/// Read the active state, loading the `default` theme from disk on
|
||||
/// first access. If the `default` theme cannot be found in any search
|
||||
/// path, the crate falls back to an in-memory B/W
|
||||
/// [`fallback::document`], marks the state with `is_fallback = true`
|
||||
/// (so the draw path can paint the warning banner), and logs a line
|
||||
/// to stderr pointing at the install instructions. The process never
|
||||
/// panics on first-access — making ltk embeddable inside programs
|
||||
/// that want to handle missing theme gracefully.
|
||||
pub ( crate ) fn ensure_active() -> ActiveState
|
||||
{
|
||||
{
|
||||
let guard = ACTIVE.read().expect( "theme: ACTIVE poisoned" );
|
||||
if let Some( s ) = guard.as_ref()
|
||||
{
|
||||
return s.clone();
|
||||
}
|
||||
}
|
||||
let mut guard = ACTIVE.write().expect( "theme: ACTIVE poisoned" );
|
||||
if guard.is_none()
|
||||
{
|
||||
let ( doc, is_fallback ) = match ThemeDocument::find( "default" )
|
||||
{
|
||||
Ok( d ) => ( d, false ),
|
||||
Err( e ) =>
|
||||
{
|
||||
eprintln!
|
||||
(
|
||||
"[ltk] default theme not found ({e}); using embedded B/W \
|
||||
fallback. Install the `ltk-theme-default` Debian package \
|
||||
(Provides: ltk-theme) or set `LTK_THEMES_DIR` to a \
|
||||
directory containing `default/theme.json` to get the \
|
||||
real theme back."
|
||||
);
|
||||
( fallback::document(), true )
|
||||
}
|
||||
};
|
||||
*guard = Some( ActiveState
|
||||
{
|
||||
document: Arc::new( doc ),
|
||||
mode: ThemeMode::Light,
|
||||
is_fallback,
|
||||
});
|
||||
}
|
||||
guard.as_ref().expect( "just installed" ).clone()
|
||||
}
|
||||
|
||||
/// Install `doc` as the active theme. The current mode is preserved
|
||||
/// (defaulting to [`ThemeMode::Light`] if nothing was set yet). Also
|
||||
/// clears the fallback flag — an explicit install supersedes the
|
||||
/// embedded B/W document and the warning banner stops painting from
|
||||
/// the next frame on.
|
||||
pub fn set_active_document( doc: ThemeDocument )
|
||||
{
|
||||
let mut guard = ACTIVE.write().expect( "theme: ACTIVE poisoned" );
|
||||
let mode = guard.as_ref().map( |s| s.mode ).unwrap_or( ThemeMode::Light );
|
||||
*guard = Some( ActiveState
|
||||
{
|
||||
document: Arc::new( doc ),
|
||||
mode,
|
||||
is_fallback: false,
|
||||
});
|
||||
// Drop any rasterised icons cached against the previous theme's
|
||||
// paths. Cache keys embed absolute paths so old entries are never
|
||||
// *wrong*, just dead memory; clearing keeps the working set small.
|
||||
assets::clear_svg_cache();
|
||||
}
|
||||
|
||||
/// Switch the active variant. The document is left untouched — if nothing
|
||||
/// has been loaded yet, the default theme is loaded first.
|
||||
pub fn set_active_mode( mode: ThemeMode )
|
||||
{
|
||||
// Ensure the default theme is loaded before mutating the mode so we
|
||||
// never publish an `ActiveState` with a missing document.
|
||||
let _ = ensure_active();
|
||||
let mut guard = ACTIVE.write().expect( "theme: ACTIVE poisoned" );
|
||||
if let Some( s ) = guard.as_mut() { s.mode = mode; }
|
||||
}
|
||||
|
||||
/// The id of the active theme.
|
||||
pub fn active_theme_id() -> String
|
||||
{
|
||||
ensure_active().document.id.clone()
|
||||
}
|
||||
|
||||
/// The active variant (light or dark).
|
||||
pub fn active_mode() -> ThemeMode
|
||||
{
|
||||
ensure_active().mode
|
||||
}
|
||||
|
||||
/// The currently loaded theme document. Use this for slot-typed lookups
|
||||
/// when the per-slot helpers ([`crate::theme::color`], [`crate::theme::surface`], …) are not
|
||||
/// expressive enough — e.g. iterating `mode.slots.entries`.
|
||||
pub fn active_document() -> Arc<ThemeDocument>
|
||||
{
|
||||
ensure_active().document
|
||||
}
|
||||
|
||||
/// `true` when the active theme was produced by the embedded B/W
|
||||
/// fallback because `ThemeDocument::find("default")` failed at
|
||||
/// first-access. Flipped back to `false` the moment a consumer calls
|
||||
/// [`set_active_document`] with any document (even another call to
|
||||
/// the same fallback — the point is "this state came from an
|
||||
/// explicit install, not from the missing-theme code path").
|
||||
///
|
||||
/// The draw layer reads this to decide whether to stamp each surface
|
||||
/// with the red "install `ltk-theme-default`" banner; apps can read
|
||||
/// it too, e.g. to log a diagnostic or surface a first-run
|
||||
/// installation helper.
|
||||
pub fn is_fallback_active() -> bool
|
||||
{
|
||||
ensure_active().is_fallback
|
||||
}
|
||||
484
src/theme/assets.rs
Normal file
484
src/theme/assets.rs
Normal file
@@ -0,0 +1,484 @@
|
||||
// SPDX-License-Identifier: LGPL-2.1-only
|
||||
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
|
||||
|
||||
//! Theme asset resolution: wallpapers, lockscreens, branding logos, app
|
||||
//! icons, launcher icon, and the SVG rasterisation pipeline (with
|
||||
//! process-wide cache) that the canvas consumes.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::{ Path, PathBuf };
|
||||
use std::sync::{ Arc, Mutex };
|
||||
|
||||
use crate::types::Color;
|
||||
|
||||
use super::active::ensure_active;
|
||||
use super::prefs::{ ThemeMode, WallpaperFit, WallpaperSpec };
|
||||
use super::system_fontdb;
|
||||
|
||||
// ─── Wallpaper / lockscreen ──────────────────────────────────────────────────
|
||||
|
||||
/// The active mode's homescreen / shell wallpaper. Prefers an explicit
|
||||
/// declaration in `theme.json`; when absent, falls back to the
|
||||
/// convention path `branding/{mode}/wallpaper.svg` via [`branding_asset`]
|
||||
/// (with mode → opposite-mode → no-mode fallback). Returns `None` when
|
||||
/// neither source resolves to an existing file.
|
||||
///
|
||||
/// Always returns the SVG. Use [`branding_image( "wallpaper", sw, sh
|
||||
/// )`](branding_image) when the surface size is known to prefer a
|
||||
/// pre-rendered raster variant under `branding/{mode}/wallpaper/`.
|
||||
pub fn wallpaper() -> Option<WallpaperSpec>
|
||||
{
|
||||
let state = ensure_active();
|
||||
if let Some( spec ) = state.document.mode( state.mode ).wallpaper.clone()
|
||||
{
|
||||
return Some( spec );
|
||||
}
|
||||
branding_asset( "wallpaper", "svg" )
|
||||
.map( |path| WallpaperSpec { path: Some( path ), fit: WallpaperFit::Cover } )
|
||||
}
|
||||
|
||||
/// The active mode's lockscreen / greeter wallpaper. Same resolution
|
||||
/// strategy as [`wallpaper`]: explicit `theme.json` declaration first,
|
||||
/// otherwise `branding/{mode}/lockscreen.svg` via [`branding_asset`].
|
||||
/// Use [`branding_image( "lockscreen", sw, sh )`](branding_image) for
|
||||
/// the size-aware raster lookup.
|
||||
pub fn lockscreen() -> Option<WallpaperSpec>
|
||||
{
|
||||
let state = ensure_active();
|
||||
if let Some( spec ) = state.document.mode( state.mode ).lockscreen.clone()
|
||||
{
|
||||
return Some( spec );
|
||||
}
|
||||
branding_asset( "lockscreen", "svg" )
|
||||
.map( |path| WallpaperSpec { path: Some( path ), fit: WallpaperFit::Cover } )
|
||||
}
|
||||
|
||||
// ─── App icons ───────────────────────────────────────────────────────────────
|
||||
|
||||
/// Path to the named app icon SVG inside the active theme's `icons/apps/`
|
||||
/// directory. `name` is the bare stem (e.g. `"firefox"`, `"calculator"`)
|
||||
/// without the `.svg` extension. Returns `None` when the active document
|
||||
/// has no on-disk root or the file does not exist.
|
||||
pub fn app_icon( name: &str ) -> Option<PathBuf>
|
||||
{
|
||||
let state = ensure_active();
|
||||
let root = state.document.root.as_ref()?;
|
||||
let path = root.join( "icons/apps" ).join( format!( "{}.svg", name ) );
|
||||
if path.is_file() { Some( path ) } else { None }
|
||||
}
|
||||
|
||||
/// Path to `app-default.svg` inside the active theme's icon directory.
|
||||
/// Returns `None` when the document has no on-disk root or the file is
|
||||
/// absent.
|
||||
pub fn app_default_icon() -> Option<PathBuf>
|
||||
{
|
||||
let state = ensure_active();
|
||||
let root = state.document.root.as_ref()?;
|
||||
let path = root.join( "icons/app-default.svg" );
|
||||
if path.is_file() { Some( path ) } else { None }
|
||||
}
|
||||
|
||||
/// Path to the launcher-logo SVG for the currently active mode.
|
||||
/// Resolves through [`branding_asset`] using the convention
|
||||
/// `branding/{mode}/launcher.svg`, with the standard mode →
|
||||
/// opposite-mode → no-mode fallback chain.
|
||||
pub fn launcher_icon() -> Option<PathBuf>
|
||||
{
|
||||
branding_asset( "launcher", "svg" )
|
||||
}
|
||||
|
||||
// ─── Logos ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Path to the brand logo SVG for the currently active mode.
|
||||
/// Convention: `branding/{mode}/logo/logo.svg`. The "main" logo —
|
||||
/// usually the wordmark variant a shell would put in an "About"
|
||||
/// dialog or a splash. Pair with [`logo_square`] / [`logo_horizontal`]
|
||||
/// when the surface dictates a different aspect ratio.
|
||||
///
|
||||
/// Resolves through [`branding_asset`] with the same three-step
|
||||
/// fallback (active mode → opposite mode → no-mode), so a theme that
|
||||
/// only ships one mode still produces a usable path.
|
||||
pub fn logo() -> Option<PathBuf>
|
||||
{
|
||||
branding_asset( "logo/logo", "svg" )
|
||||
}
|
||||
|
||||
/// Path to the square (1:1) brand logo SVG for the currently active
|
||||
/// mode. Convention: `branding/{mode}/logo/square.svg`. Use when the
|
||||
/// surface is roughly square — app icons, login avatars, splash
|
||||
/// screens, lockscreen brand badges. Same fallback chain as [`logo`].
|
||||
pub fn logo_square() -> Option<PathBuf>
|
||||
{
|
||||
branding_asset( "logo/square", "svg" )
|
||||
}
|
||||
|
||||
/// Path to the horizontal (wordmark) brand logo SVG for the currently
|
||||
/// active mode. Convention: `branding/{mode}/logo/horizontal.svg`.
|
||||
/// Use when there is meaningful horizontal space — header bars, menu
|
||||
/// bars, "About" dialogs, sign-in screens. Same fallback chain as
|
||||
/// [`logo`].
|
||||
pub fn logo_horizontal() -> Option<PathBuf>
|
||||
{
|
||||
branding_asset( "logo/horizontal", "svg" )
|
||||
}
|
||||
|
||||
// ─── Branding asset resolution ───────────────────────────────────────────────
|
||||
|
||||
/// Resolve a branding asset (launcher logo, wallpaper, lockscreen, …)
|
||||
/// against the active theme's `branding/` tree. Tries three candidate
|
||||
/// paths in order and returns the first one that exists on disk:
|
||||
///
|
||||
/// 1. `branding/{active_mode}/{name}.{ext}` — preferred variant.
|
||||
/// 2. `branding/{opposite_mode}/{name}.{ext}` — graceful degradation
|
||||
/// when the theme only ships one mode of the asset.
|
||||
/// 3. `branding/{name}.{ext}` — mode-agnostic asset, for themes that
|
||||
/// do not bother with light/dark variants.
|
||||
///
|
||||
/// Returns `None` when none of the candidates exist or the active
|
||||
/// document has no on-disk root.
|
||||
pub fn branding_asset( name: &str, ext: &str ) -> Option<PathBuf>
|
||||
{
|
||||
let state = ensure_active();
|
||||
let root = state.document.root.as_ref()?;
|
||||
let branding = root.join( "branding" );
|
||||
let filename = format!( "{name}.{ext}" );
|
||||
let ( pref_dir, alt_dir ) = match state.mode
|
||||
{
|
||||
ThemeMode::Dark => ( "dark", "light" ),
|
||||
ThemeMode::Light => ( "light", "dark" ),
|
||||
};
|
||||
let preferred = branding.join( pref_dir ).join( &filename );
|
||||
if preferred.is_file() { return Some( preferred ); }
|
||||
let alternate = branding.join( alt_dir ).join( &filename );
|
||||
if alternate.is_file() { return Some( alternate ); }
|
||||
let modeless = branding.join( &filename );
|
||||
if modeless.is_file() { return Some( modeless ); }
|
||||
None
|
||||
}
|
||||
|
||||
/// Resolve a raster variant of a branded asset for the given surface
|
||||
/// dimensions. Looks under `branding/{mode}/{name}/` (with the
|
||||
/// standard mode → opposite-mode → no-mode fallback chain on the
|
||||
/// *directory*), parses each filename as `WIDTHxHEIGHT.<ext>` where
|
||||
/// `<ext>` is `webp`, `png`, `jpg`, or `jpeg`. Returns the best match
|
||||
/// in the *first existing* directory:
|
||||
///
|
||||
/// - If one or more entries cover the surface (`W ≥ sw && H ≥ sh`),
|
||||
/// returns the smallest such by area — the smallest raster that
|
||||
/// fits without upscaling.
|
||||
/// - Otherwise returns the largest entry available — better to
|
||||
/// upscale a fast-decoding raster than to fall back to the
|
||||
/// comparatively expensive SVG rasterisation.
|
||||
///
|
||||
/// Ties on area are broken by `(width, height)` lexicographic order
|
||||
/// for determinism.
|
||||
///
|
||||
/// `(sw, sh)` of `(0, 0)` means "give me the smallest available
|
||||
/// raster" — every entry trivially covers a zero-sized surface, so
|
||||
/// the smallest by area wins. Useful at startup before the
|
||||
/// surface-configure event has reported the real dimensions.
|
||||
///
|
||||
/// Returns `None` only when no directory in the fallback chain
|
||||
/// contains any parseable raster file.
|
||||
///
|
||||
/// Note: only the *first existing* mode-directory in the chain is
|
||||
/// considered. If `branding/{active_mode}/{name}/` has any raster,
|
||||
/// the loader uses it; it does not cross over to the opposite-mode
|
||||
/// directory. Cross-mode fallback happens at the SVG layer through
|
||||
/// [`branding_asset`], where a colour-wrong raster would be more
|
||||
/// jarring than a colour-correct vector.
|
||||
pub fn branding_raster( name: &str, sw: u32, sh: u32 ) -> Option<PathBuf>
|
||||
{
|
||||
let state = ensure_active();
|
||||
let root = state.document.root.as_ref()?;
|
||||
let branding = root.join( "branding" );
|
||||
let ( pref_dir, alt_dir ) = match state.mode
|
||||
{
|
||||
ThemeMode::Dark => ( "dark", "light" ),
|
||||
ThemeMode::Light => ( "light", "dark" ),
|
||||
};
|
||||
|
||||
let candidates = [
|
||||
branding.join( pref_dir ).join( name ),
|
||||
branding.join( alt_dir ).join( name ),
|
||||
branding.join( name ),
|
||||
];
|
||||
for dir in &candidates
|
||||
{
|
||||
if !dir.is_dir() { continue; }
|
||||
if let Some( best ) = pick_best_raster( dir, sw, sh )
|
||||
{
|
||||
return Some( best );
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Resolve a branded image asset, preferring a raster variant (WebP /
|
||||
/// PNG / JPEG) over the canonical SVG. Tries
|
||||
/// [`branding_raster(name, sw, sh)`] first; on `None` (no raster files
|
||||
/// at all under `branding/{mode}/{name}/`) falls back to
|
||||
/// [`branding_asset(name, "svg")`].
|
||||
///
|
||||
/// Pass `(0, 0)` to get the smallest available raster — useful at
|
||||
/// startup before the surface size is known.
|
||||
pub fn branding_image( name: &str, sw: u32, sh: u32 ) -> Option<PathBuf>
|
||||
{
|
||||
branding_raster( name, sw, sh )
|
||||
.or_else( || branding_asset( name, "svg" ) )
|
||||
}
|
||||
|
||||
/// Walk `dir` for files named `WIDTHxHEIGHT.<ext>` (case-insensitive
|
||||
/// `x`, recognised raster extensions: webp, png, jpg, jpeg). Returns
|
||||
/// the smallest entry whose dimensions cover `( sw, sh )`; if none
|
||||
/// cover, returns the largest entry available (better to upscale a
|
||||
/// fast raster than fall through to SVG rasterisation). Ties are
|
||||
/// broken by `( width, height )` lexicographic order. `None` only
|
||||
/// when the directory holds no parseable raster files.
|
||||
fn pick_best_raster( dir: &Path, sw: u32, sh: u32 ) -> Option<PathBuf>
|
||||
{
|
||||
let entries = std::fs::read_dir( dir ).ok()?;
|
||||
let mut covering: Option<( u32, u32, PathBuf )> = None;
|
||||
let mut fallback: Option<( u32, u32, PathBuf )> = None;
|
||||
for entry in entries.flatten()
|
||||
{
|
||||
let path = entry.path();
|
||||
let Some( stem ) = path.file_stem().and_then( |s| s.to_str() ) else { continue };
|
||||
let ext = path.extension().and_then( |e| e.to_str() ).unwrap_or( "" );
|
||||
if !matches!( ext.to_ascii_lowercase().as_str(), "webp" | "png" | "jpg" | "jpeg" )
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let Some( ( w_str, h_str ) ) = stem.split_once( |c: char| c == 'x' || c == 'X' ) else { continue };
|
||||
let ( Ok( w ), Ok( h ) ) = ( w_str.parse::<u32>(), h_str.parse::<u32>() ) else { continue };
|
||||
let new_area = ( w as u64 ) * ( h as u64 );
|
||||
if w >= sw && h >= sh
|
||||
{
|
||||
let take = match &covering
|
||||
{
|
||||
None => true,
|
||||
Some( ( bw, bh, _ ) ) =>
|
||||
{
|
||||
let cur_area = ( *bw as u64 ) * ( *bh as u64 );
|
||||
new_area < cur_area || ( new_area == cur_area && ( w, h ) < ( *bw, *bh ) )
|
||||
}
|
||||
};
|
||||
if take { covering = Some( ( w, h, path ) ); }
|
||||
}
|
||||
else
|
||||
{
|
||||
let take = match &fallback
|
||||
{
|
||||
None => true,
|
||||
Some( ( bw, bh, _ ) ) =>
|
||||
{
|
||||
let cur_area = ( *bw as u64 ) * ( *bh as u64 );
|
||||
new_area > cur_area || ( new_area == cur_area && ( w, h ) > ( *bw, *bh ) )
|
||||
}
|
||||
};
|
||||
if take { fallback = Some( ( w, h, path ) ); }
|
||||
}
|
||||
}
|
||||
covering.or( fallback ).map( |( _, _, p )| p )
|
||||
}
|
||||
|
||||
// ─── Symbolic icon tinting ───────────────────────────────────────────────────
|
||||
|
||||
/// Re-tint a symbolic RGBA icon: replace every pixel's RGB with `tint` while
|
||||
/// keeping the source alpha (weighted by `tint.a`).
|
||||
///
|
||||
/// Input `rgba` must be straight-alpha RGBA8 with 4 bytes per pixel.
|
||||
/// Returns a freshly allocated `Vec<u8>` of the same length.
|
||||
///
|
||||
/// Useful for flattening Papirus / freedesktop icons to a single theme colour
|
||||
/// so they stay legible against both light and dark backgrounds.
|
||||
pub fn tint_symbolic( rgba: &[u8], tint: Color ) -> Vec<u8>
|
||||
{
|
||||
let r = (tint.r.clamp( 0.0, 1.0 ) * 255.0) as u8;
|
||||
let g = (tint.g.clamp( 0.0, 1.0 ) * 255.0) as u8;
|
||||
let b = (tint.b.clamp( 0.0, 1.0 ) * 255.0) as u8;
|
||||
let ta = tint.a.clamp( 0.0, 1.0 );
|
||||
|
||||
let mut out = Vec::with_capacity( rgba.len() );
|
||||
for px in rgba.chunks_exact( 4 )
|
||||
{
|
||||
let a = (px[3] as f32 / 255.0) * ta;
|
||||
out.extend_from_slice( &[ r, g, b, (a * 255.0) as u8 ] );
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
// ─── SVG rasterisation ───────────────────────────────────────────────────────
|
||||
|
||||
/// Process-wide cache of rasterised theme icons, keyed by (absolute path on
|
||||
/// disk, target longest-edge size in physical pixels). Entries are produced
|
||||
/// by [`icon_rgba`] and never invalidated — the key embeds the absolute path
|
||||
/// and the icon files are read-only on disk, so a `set_active_document`
|
||||
/// switch produces fresh keys rather than serving stale data.
|
||||
static SVG_CACHE: Mutex<Option<HashMap<( PathBuf, u32 ), ( Arc<Vec<u8>>, u32, u32 )>>>
|
||||
= Mutex::new( None );
|
||||
|
||||
/// Drop every entry from the SVG cache. Called when the active document
|
||||
/// is replaced — keys embed absolute paths so stale entries are never
|
||||
/// wrong, just dead memory.
|
||||
pub ( super ) fn clear_svg_cache()
|
||||
{
|
||||
if let Ok( mut cache ) = SVG_CACHE.lock()
|
||||
{
|
||||
if let Some( ref mut map ) = *cache { map.clear(); }
|
||||
}
|
||||
}
|
||||
|
||||
/// Decode a UTF-8 SVG document into a premultiplied RGBA8 pixmap of
|
||||
/// the requested longest-edge `size` in physical pixels. The shorter
|
||||
/// edge is scaled proportionally so the icon's aspect ratio is
|
||||
/// preserved; the returned `(width, height)` reflect the final
|
||||
/// pixmap.
|
||||
///
|
||||
/// Returns `None` for malformed SVG input or when the size is too
|
||||
/// small (≤ 0 px on the longest edge).
|
||||
///
|
||||
/// The implementation uses [`resvg`] (which bundles `usvg` and
|
||||
/// `tiny-skia`) so the rasteriser is the same as the rest of ltk's
|
||||
/// software-canvas path. Use [`icon_rgba`] when you want
|
||||
/// path-resolution + caching against the active theme tree.
|
||||
pub fn decode_svg_bytes( svg_bytes: &[u8], size: u32 ) -> Option<( Arc<Vec<u8>>, u32, u32 )>
|
||||
{
|
||||
if size == 0 { return None; }
|
||||
// SVGs that declare `width="100%"` without an explicit pixel size
|
||||
// fall back to usvg's `default_size` (100×100). Match it to the
|
||||
// requested size so percentage-only documents rasterise at the
|
||||
// dimensions the caller asked for instead of a tiny default.
|
||||
let mut opts = resvg::usvg::Options::default();
|
||||
if let Some( ds ) = resvg::usvg::Size::from_wh( size as f32, size as f32 )
|
||||
{
|
||||
opts.default_size = ds;
|
||||
}
|
||||
opts.fontdb = system_fontdb();
|
||||
let tree = resvg::usvg::Tree::from_data( svg_bytes, &opts ).ok()?;
|
||||
let svg_size = tree.size();
|
||||
let longest = svg_size.width().max( svg_size.height() );
|
||||
if longest <= 0.0 { return None; }
|
||||
let scale = size as f32 / longest;
|
||||
let w = ( svg_size.width() * scale ).ceil() as u32;
|
||||
let h = ( svg_size.height() * scale ).ceil() as u32;
|
||||
let mut pixmap = resvg::tiny_skia::Pixmap::new( w.max( 1 ), h.max( 1 ) )?;
|
||||
let transform = resvg::tiny_skia::Transform::from_scale( scale, scale );
|
||||
resvg::render( &tree, transform, &mut pixmap.as_mut() );
|
||||
Some( ( Arc::new( pixmap.take() ), w, h ) )
|
||||
}
|
||||
|
||||
/// Resolve a theme-relative icon name to an absolute path inside the
|
||||
/// active theme's `icons/catalogue/` tree.
|
||||
///
|
||||
/// `name` is the slash-separated path **without** the `.svg`
|
||||
/// extension (e.g. `"general/right-simple"`,
|
||||
/// `"system/wifi-signal-full"`). The lookup tries the
|
||||
/// `catalogue/filled/<name>.svg` variant first and falls back to
|
||||
/// `catalogue/line/<name>.svg`; returns `None` when neither file
|
||||
/// exists or the active document has no on-disk root.
|
||||
pub fn icon_path( name: &str ) -> Option<PathBuf>
|
||||
{
|
||||
let state = ensure_active();
|
||||
let root = state.document.root.as_ref()?;
|
||||
let filled = root.join( "icons/catalogue/filled" ).join( format!( "{name}.svg" ) );
|
||||
if filled.is_file() { return Some( filled ); }
|
||||
let line = root.join( "icons/catalogue/line" ).join( format!( "{name}.svg" ) );
|
||||
if line.is_file() { return Some( line ); }
|
||||
None
|
||||
}
|
||||
|
||||
/// Rasterise a theme icon to a premultiplied RGBA8 pixmap.
|
||||
///
|
||||
/// Combines [`icon_path`] (path resolution against the active theme)
|
||||
/// with [`decode_svg_bytes`] (SVG → RGBA), and caches the result by
|
||||
/// `(absolute path, size)` so a widget redrawn every frame pays the
|
||||
/// rasterisation cost only on first access.
|
||||
///
|
||||
/// Returns `None` when the icon cannot be located, the file cannot be
|
||||
/// read, or the SVG is malformed.
|
||||
pub fn icon_rgba( name: &str, size: u32 ) -> Option<( Arc<Vec<u8>>, u32, u32 )>
|
||||
{
|
||||
let path = icon_path( name )?;
|
||||
let key = ( path.clone(), size );
|
||||
|
||||
// Fast path: cache hit.
|
||||
{
|
||||
let mut guard = SVG_CACHE.lock().ok()?;
|
||||
let cache = guard.get_or_insert_with( HashMap::new );
|
||||
if let Some( v ) = cache.get( &key )
|
||||
{
|
||||
return Some( ( Arc::clone( &v.0 ), v.1, v.2 ) );
|
||||
}
|
||||
}
|
||||
|
||||
// Slow path: read + decode + populate cache.
|
||||
let bytes = std::fs::read( &path ).ok()?;
|
||||
let result = decode_svg_bytes( &bytes, size )?;
|
||||
if let Ok( mut guard ) = SVG_CACHE.lock()
|
||||
{
|
||||
let cache = guard.get_or_insert_with( HashMap::new );
|
||||
cache.insert( key, ( Arc::clone( &result.0 ), result.1, result.2 ) );
|
||||
}
|
||||
Some( result )
|
||||
}
|
||||
|
||||
#[ cfg( test ) ]
|
||||
mod tests
|
||||
{
|
||||
use super::*;
|
||||
|
||||
#[ test ]
|
||||
fn tint_preserves_source_alpha()
|
||||
{
|
||||
let src = [ 255, 0, 0, 255, 0, 255, 0, 128 ];
|
||||
let out = tint_symbolic( &src, Color::hex( 0x0B, 0x1B, 0x38 ) );
|
||||
assert_eq!( out[0..3], [ 0x0B, 0x1B, 0x38 ] );
|
||||
assert_eq!( out[3], 255 );
|
||||
assert_eq!( out[4..7], [ 0x0B, 0x1B, 0x38 ] );
|
||||
assert_eq!( out[7], 128 );
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn tint_respects_tint_alpha()
|
||||
{
|
||||
let src = [ 255, 255, 255, 255 ];
|
||||
let out = tint_symbolic( &src, Color { r: 1.0, g: 1.0, b: 1.0, a: 0.5 } );
|
||||
assert_eq!( out[3], 127 );
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn decode_svg_bytes_returns_pixmap_for_minimal_svg()
|
||||
{
|
||||
// 16×16 red square. Double-pound raw byte string so the `#`
|
||||
// inside `fill="#ff0000"` does not close the literal.
|
||||
let svg = br##"<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><rect width="16" height="16" fill="#ff0000"/></svg>"##;
|
||||
let ( rgba, w, h ) = decode_svg_bytes( svg, 16 ).expect( "decode" );
|
||||
assert_eq!( w, 16 );
|
||||
assert_eq!( h, 16 );
|
||||
assert_eq!( rgba.len(), ( 16 * 16 * 4 ) as usize );
|
||||
// Centre pixel should be opaque red (premultiplied → R=255 A=255).
|
||||
let centre = ( ( 8 * 16 + 8 ) * 4 ) as usize;
|
||||
assert!( rgba[ centre + 0 ] > 200, "red channel" );
|
||||
assert!( rgba[ centre + 1 ] < 50, "green channel" );
|
||||
assert!( rgba[ centre + 2 ] < 50, "blue channel" );
|
||||
assert_eq!( rgba[ centre + 3 ], 255, "alpha" );
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn decode_svg_bytes_scales_to_requested_size()
|
||||
{
|
||||
// 16×16 source rasterised at 32 px → output is 32×32.
|
||||
let svg = br##"<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><rect width="16" height="16" fill="#000"/></svg>"##;
|
||||
let ( _, w, h ) = decode_svg_bytes( svg, 32 ).expect( "decode" );
|
||||
assert_eq!( w, 32 );
|
||||
assert_eq!( h, 32 );
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn decode_svg_bytes_rejects_garbage()
|
||||
{
|
||||
assert!( decode_svg_bytes( b"not valid svg", 32 ).is_none() );
|
||||
assert!( decode_svg_bytes( b"<svg></svg>", 0 ).is_none() ); // size 0
|
||||
}
|
||||
}
|
||||
35
src/theme/error.rs
Normal file
35
src/theme/error.rs
Normal file
@@ -0,0 +1,35 @@
|
||||
// SPDX-License-Identifier: LGPL-2.1-only
|
||||
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
|
||||
|
||||
//! Error type returned by theme loading and parsing.
|
||||
|
||||
use std::fmt;
|
||||
use std::io;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[ derive( Debug ) ]
|
||||
pub enum ThemeError
|
||||
{
|
||||
Io( PathBuf, io::Error ),
|
||||
ParseJson( PathBuf, serde_json::Error ),
|
||||
NotFound( String ),
|
||||
InvalidColor( String ),
|
||||
UnknownColorRef( String ),
|
||||
}
|
||||
|
||||
impl fmt::Display for ThemeError
|
||||
{
|
||||
fn fmt( &self, f: &mut fmt::Formatter<'_> ) -> fmt::Result
|
||||
{
|
||||
match self
|
||||
{
|
||||
ThemeError::Io( p, e ) => write!( f, "reading {}: {}", p.display(), e ),
|
||||
ThemeError::ParseJson( p, e ) => write!( f, "parsing {}: {}", p.display(), e ),
|
||||
ThemeError::NotFound( id ) => write!( f, "theme `{}` not found in any search path", id ),
|
||||
ThemeError::InvalidColor( s ) => write!( f, "invalid colour `{}` (expected #RRGGBB, #RRGGBBAA or rgb[a](…))", s ),
|
||||
ThemeError::UnknownColorRef( s ) => write!( f, "unknown reference `@{}` (not defined in top-level `colors`, `gradients` or `inset_stacks`)", s ),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for ThemeError {}
|
||||
1064
src/theme/mod.rs
1064
src/theme/mod.rs
File diff suppressed because it is too large
Load Diff
122
src/theme/palette.rs
Normal file
122
src/theme/palette.rs
Normal file
@@ -0,0 +1,122 @@
|
||||
// SPDX-License-Identifier: LGPL-2.1-only
|
||||
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
|
||||
|
||||
//! The eight-slot semantic [`Palette`] every widget speaks in terms of, plus
|
||||
//! the derived [`WindowControlsSpec`] fallback used when a theme document
|
||||
//! omits the explicit `window_controls` block.
|
||||
|
||||
use crate::types::Color;
|
||||
|
||||
use super::prefs::WindowControlsSpec;
|
||||
use super::slots::SlotStore;
|
||||
|
||||
// ─── Palette ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Semantic colour tokens shared by every widget.
|
||||
#[ derive( Debug, Clone, Copy ) ]
|
||||
pub struct Palette
|
||||
{
|
||||
/// Page / wallpaper background when no image is present.
|
||||
pub bg: Color,
|
||||
/// Floating surface over the wallpaper (cards, panels, dock) — usually translucent.
|
||||
pub surface: Color,
|
||||
/// Elevated surface (hovered card, pressed toggle, inner pill).
|
||||
pub surface_alt: Color,
|
||||
/// Primary foreground text.
|
||||
pub text_primary: Color,
|
||||
/// Subdued text (date strip, helper text, disabled labels).
|
||||
pub text_secondary: Color,
|
||||
/// Brand accent used for active toggles, sliders, focus rings.
|
||||
pub accent: Color,
|
||||
/// Thin separators and low-contrast borders.
|
||||
pub divider: Color,
|
||||
/// Tint for symbolic icons (wifi / battery / search / etc).
|
||||
pub icon: Color,
|
||||
/// Foreground colour for destructive / error states — text in
|
||||
/// "delete" buttons, error helper rows under invalid inputs,
|
||||
/// the border of an errored pill.
|
||||
pub danger: Color,
|
||||
/// Soft fill behind error states. Pairs with `danger` as
|
||||
/// foreground; light enough that body text remains legible on
|
||||
/// top.
|
||||
pub danger_bg: Color,
|
||||
}
|
||||
|
||||
// ─── Palette projection ──────────────────────────────────────────────────────
|
||||
|
||||
impl Palette
|
||||
{
|
||||
/// Project a [`SlotStore`] onto the eight canonical palette fields.
|
||||
/// Slot ids are the ones declared in the default theme JSON
|
||||
/// (`bg-page`, `surface`, `surface-alt`, `text-primary`,
|
||||
/// `text-secondary`, `accent`, `divider`, `icon`). Missing slots
|
||||
/// fall back to a documented sensible default so downstream widgets
|
||||
/// never see uninitialised colours. Used by [`crate::theme::palette`] and
|
||||
/// [`crate::theme::window_controls`].
|
||||
pub fn from_slots( slots: &SlotStore ) -> Self
|
||||
{
|
||||
Self
|
||||
{
|
||||
bg: slots.color( "bg-page" ).unwrap_or( Color::WHITE ),
|
||||
surface: slots.color( "surface" ).unwrap_or( Color::WHITE ),
|
||||
surface_alt: slots.color( "surface-alt" ).unwrap_or( Color::WHITE ),
|
||||
text_primary: slots.color( "text-primary" ).unwrap_or( Color::BLACK ),
|
||||
text_secondary: slots.color( "text-secondary" ).unwrap_or( Color::rgba( 0.0, 0.0, 0.0, 0.6 ) ),
|
||||
accent: slots.color( "accent" ).unwrap_or( Color::hex( 0x00, 0xCE, 0xB1 ) ),
|
||||
divider: slots.color( "divider" ).unwrap_or( Color::rgba( 0.0, 0.0, 0.0, 0.08 ) ),
|
||||
icon: slots.color( "icon" ).unwrap_or( Color::BLACK ),
|
||||
// Conservative defaults: a rich red for fg, a tinted
|
||||
// pink wash for bg. Themes can override via the
|
||||
// `danger` / `danger-bg` slot ids.
|
||||
danger: slots.color( "danger" ).unwrap_or( Color::hex( 0xA8, 0x00, 0x10 ) ),
|
||||
danger_bg: slots.color( "danger-bg" ).unwrap_or( Color::rgba( 1.0, 0.85, 0.88, 1.0 ) ),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Shared helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
/// Derive a sensible [`WindowControlsSpec`] from a [`Palette`] when the
|
||||
/// theme document omits the `window_controls` block. Also used by the
|
||||
/// JSON loader in `schema.rs` when individual overrides are absent.
|
||||
pub ( crate ) fn default_window_controls( palette: Palette ) -> WindowControlsSpec
|
||||
{
|
||||
WindowControlsSpec
|
||||
{
|
||||
bar_bg: Color { a: 1.0, ..palette.surface },
|
||||
icon: palette.icon,
|
||||
hover_bg: palette.surface_alt,
|
||||
pressed_bg: palette.divider,
|
||||
close_hover_bg: Color::rgba( 0.92, 0.18, 0.18, 0.90 ),
|
||||
close_icon: Color::WHITE,
|
||||
focus_ring: palette.accent,
|
||||
}
|
||||
}
|
||||
|
||||
#[ cfg( test ) ]
|
||||
mod tests
|
||||
{
|
||||
use super::*;
|
||||
use super::super::slots::{ self, Metadata, Slot };
|
||||
|
||||
#[ test ]
|
||||
fn palette_from_slots_uses_canonical_ids()
|
||||
{
|
||||
let mut store = slots::SlotStore::new();
|
||||
store.insert
|
||||
(
|
||||
"bg-page",
|
||||
Slot::Color { value: Color::hex( 0x01, 0x02, 0x03 ), meta: Metadata::default() },
|
||||
);
|
||||
store.insert
|
||||
(
|
||||
"text-primary",
|
||||
Slot::Color { value: Color::hex( 0xF0, 0xF1, 0xF2 ), meta: Metadata::default() },
|
||||
);
|
||||
let p = Palette::from_slots( &store );
|
||||
assert_eq!( p.bg, Color::hex( 0x01, 0x02, 0x03 ) );
|
||||
assert_eq!( p.text_primary, Color::hex( 0xF0, 0xF1, 0xF2 ) );
|
||||
// Missing slots fall back to documented sensible defaults.
|
||||
assert_eq!( p.icon, Color::BLACK );
|
||||
}
|
||||
}
|
||||
147
src/theme/prefs.rs
Normal file
147
src/theme/prefs.rs
Normal file
@@ -0,0 +1,147 @@
|
||||
// SPDX-License-Identifier: LGPL-2.1-only
|
||||
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
|
||||
|
||||
//! Theme preference / mode types and per-asset specification structs.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use serde::{ Deserialize, Serialize };
|
||||
|
||||
use crate::types::Color;
|
||||
|
||||
// ─── Wallpaper / launcher ────────────────────────────────────────────────────
|
||||
|
||||
/// How a wallpaper image is fitted to its surface.
|
||||
#[ derive( Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize ) ]
|
||||
#[ serde( rename_all = "kebab-case" ) ]
|
||||
pub enum WallpaperFit
|
||||
{
|
||||
/// Scale uniformly to fill the surface, cropping the overflow.
|
||||
Cover,
|
||||
/// Scale uniformly to fit inside the surface, letterboxing if needed.
|
||||
Contain,
|
||||
/// Scale non-uniformly to exactly match the surface.
|
||||
Stretch,
|
||||
/// Place at original size centered on the surface.
|
||||
Center,
|
||||
/// Repeat the image at original size to cover the surface.
|
||||
Tile,
|
||||
}
|
||||
|
||||
impl Default for WallpaperFit
|
||||
{
|
||||
fn default() -> Self { WallpaperFit::Cover }
|
||||
}
|
||||
|
||||
/// Wallpaper backing for a single theme variant.
|
||||
#[ derive( Debug, Clone ) ]
|
||||
pub struct WallpaperSpec
|
||||
{
|
||||
/// Absolute path to the image, or `None` for the built-in fallback that
|
||||
/// just paints [`crate::theme::Palette::bg`].
|
||||
pub path: Option<PathBuf>,
|
||||
pub fit: WallpaperFit,
|
||||
}
|
||||
|
||||
/// Styling for the application launcher surface.
|
||||
#[ derive( Debug, Clone, Copy ) ]
|
||||
pub struct LauncherSpec
|
||||
{
|
||||
/// Background fill of the launcher panel.
|
||||
pub background: Color,
|
||||
/// Outer corner radius of the launcher panel, in CSS pixels.
|
||||
pub border_radius: f32,
|
||||
}
|
||||
|
||||
/// Styling for compositor / window-decoration controls.
|
||||
#[ derive( Debug, Clone, Copy ) ]
|
||||
pub struct WindowControlsSpec
|
||||
{
|
||||
/// Background fill of the SSD title bar strip the controls sit on.
|
||||
pub bar_bg: Color,
|
||||
/// Symbol colour for minimize / maximize / restore / close glyphs.
|
||||
pub icon: Color,
|
||||
/// Button background while hovered.
|
||||
pub hover_bg: Color,
|
||||
/// Button background while pressed.
|
||||
pub pressed_bg: Color,
|
||||
/// Close-button background while hovered.
|
||||
pub close_hover_bg: Color,
|
||||
/// Close-button symbol colour while hovered.
|
||||
pub close_icon: Color,
|
||||
/// Keyboard focus ring colour.
|
||||
pub focus_ring: Color,
|
||||
}
|
||||
|
||||
// ─── Mode and preference ─────────────────────────────────────────────────────
|
||||
|
||||
/// Which of the two variants a theme family resolves to.
|
||||
#[ derive( Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize ) ]
|
||||
#[ serde( rename_all = "kebab-case" ) ]
|
||||
pub enum ThemeMode
|
||||
{
|
||||
Light,
|
||||
Dark,
|
||||
}
|
||||
|
||||
impl Default for ThemeMode
|
||||
{
|
||||
fn default() -> Self { ThemeMode::Light }
|
||||
}
|
||||
|
||||
impl ThemeMode
|
||||
{
|
||||
/// Auto-select light or dark from the local hour of day.
|
||||
///
|
||||
/// Dark is used between 19:00 and 06:59 local time, light otherwise.
|
||||
/// `hour` must be in `0..=23`.
|
||||
pub const fn from_hour( hour: u32 ) -> Self
|
||||
{
|
||||
if hour >= 19 || hour < 7 { ThemeMode::Dark } else { ThemeMode::Light }
|
||||
}
|
||||
}
|
||||
|
||||
/// Shell-side persisted preference. `Auto` is resolved to a [`ThemeMode`]
|
||||
/// before reaching ltk — the toolkit itself only knows about Light and Dark.
|
||||
#[ derive( Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize ) ]
|
||||
#[ serde( rename_all = "kebab-case" ) ]
|
||||
pub enum ThemePreference
|
||||
{
|
||||
Light,
|
||||
Dark,
|
||||
Auto,
|
||||
}
|
||||
|
||||
impl Default for ThemePreference
|
||||
{
|
||||
fn default() -> Self { ThemePreference::Auto }
|
||||
}
|
||||
|
||||
impl ThemePreference
|
||||
{
|
||||
/// Resolve to a concrete mode given the current local hour `[0, 23]`.
|
||||
pub const fn resolve( self, hour: u32 ) -> ThemeMode
|
||||
{
|
||||
match self
|
||||
{
|
||||
ThemePreference::Light => ThemeMode::Light,
|
||||
ThemePreference::Dark => ThemeMode::Dark,
|
||||
ThemePreference::Auto => ThemeMode::from_hour( hour ),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[ cfg( test ) ]
|
||||
mod tests
|
||||
{
|
||||
use super::*;
|
||||
|
||||
#[ test ]
|
||||
fn preference_resolves_auto_by_hour()
|
||||
{
|
||||
assert_eq!( ThemePreference::Auto.resolve( 3 ), ThemeMode::Dark );
|
||||
assert_eq!( ThemePreference::Auto.resolve( 12 ), ThemeMode::Light );
|
||||
assert_eq!( ThemePreference::Auto.resolve( 22 ), ThemeMode::Dark );
|
||||
assert_eq!( ThemePreference::Light.resolve( 22 ), ThemeMode::Light );
|
||||
}
|
||||
}
|
||||
1452
src/theme/schema.rs
1452
src/theme/schema.rs
File diff suppressed because it is too large
Load Diff
394
src/theme/schema/mod.rs
Normal file
394
src/theme/schema/mod.rs
Normal file
@@ -0,0 +1,394 @@
|
||||
// SPDX-License-Identifier: LGPL-2.1-only
|
||||
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
|
||||
|
||||
//! JSON schema for the theme document: raw deserialisation types, colour
|
||||
//! (de)serialiser, and conversion to the public runtime types.
|
||||
//!
|
||||
//! This module is the single place where impedance between "JSON as designers
|
||||
//! and tools write it" and "the Rust types widgets consume" lives. Every
|
||||
//! public type in `theme::paint`, `theme::shadow`, `theme::surface`,
|
||||
//! `theme::text_style` and `theme::slots` gets a private `Raw*` counterpart
|
||||
//! here with the minimum serde annotations to match the JSON shape, and a
|
||||
//! `From<Raw*> for *` conversion that builds the public value.
|
||||
//!
|
||||
//! Keeping the derives off the public types means the public API stays
|
||||
//! free of serde dependencies and the JSON format can evolve (add fields,
|
||||
//! accept aliases, reject unknown keys) without touching consumer code.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::{ Path, PathBuf };
|
||||
|
||||
use serde::{ Deserialize, Deserializer, Serializer };
|
||||
use serde::de::Error as DeError;
|
||||
|
||||
use crate::types::Color;
|
||||
|
||||
use super::document::{ Mode, ThemeDocument };
|
||||
use super::fonts::{ FontFamilyDef, FontSource };
|
||||
use super::palette::default_window_controls;
|
||||
use super::slots::{ Slot, SlotStore };
|
||||
use super::
|
||||
{
|
||||
LauncherSpec, ThemeError, ThemeMode, WallpaperSpec,
|
||||
WindowControlsSpec,
|
||||
};
|
||||
|
||||
mod raw;
|
||||
mod refs;
|
||||
#[ cfg( test ) ]
|
||||
mod tests;
|
||||
|
||||
use raw::*;
|
||||
use refs::*;
|
||||
|
||||
// ─── Color serialiser ────────────────────────────────────────────────────────
|
||||
|
||||
/// Encode / decode [`Color`] as a hex or `rgba(…)` string.
|
||||
///
|
||||
/// Accepted input forms:
|
||||
///
|
||||
/// * `#RRGGBB` or `RRGGBB` — opaque, 8 bits per channel.
|
||||
/// * `#RRGGBBAA` or `RRGGBBAA` — with straight alpha, 8 bits per channel.
|
||||
/// * `rgba(R, G, B, A)` — R/G/B as integers `0..=255` (or floats), A as a
|
||||
/// float in `0.0..=1.0`. Whitespace around commas is tolerated.
|
||||
/// * `rgb(R, G, B)` — same as above with implicit A = 1.0.
|
||||
///
|
||||
/// Output form (serialisation): `#RRGGBB` when alpha is 1.0, `#RRGGBBAA`
|
||||
/// otherwise.
|
||||
pub mod color_serde
|
||||
{
|
||||
use super::*;
|
||||
|
||||
/// Parse one of the accepted color syntaxes into a [`Color`].
|
||||
pub fn parse( s: &str ) -> Result<Color, String>
|
||||
{
|
||||
let t = s.trim();
|
||||
if t.starts_with( "rgba(" ) || t.starts_with( "rgb(" )
|
||||
{
|
||||
parse_functional( t )
|
||||
}
|
||||
else
|
||||
{
|
||||
parse_hex( t )
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_hex( s: &str ) -> Result<Color, String>
|
||||
{
|
||||
let h = s.trim_start_matches( '#' );
|
||||
let ( r, g, b, a ) = match h.len()
|
||||
{
|
||||
6 =>
|
||||
{
|
||||
let r = u8::from_str_radix( &h[0..2], 16 ).map_err( |_| bad( s ) )?;
|
||||
let g = u8::from_str_radix( &h[2..4], 16 ).map_err( |_| bad( s ) )?;
|
||||
let b = u8::from_str_radix( &h[4..6], 16 ).map_err( |_| bad( s ) )?;
|
||||
( r, g, b, 0xFF )
|
||||
}
|
||||
8 =>
|
||||
{
|
||||
let r = u8::from_str_radix( &h[0..2], 16 ).map_err( |_| bad( s ) )?;
|
||||
let g = u8::from_str_radix( &h[2..4], 16 ).map_err( |_| bad( s ) )?;
|
||||
let b = u8::from_str_radix( &h[4..6], 16 ).map_err( |_| bad( s ) )?;
|
||||
let a = u8::from_str_radix( &h[6..8], 16 ).map_err( |_| bad( s ) )?;
|
||||
( r, g, b, a )
|
||||
}
|
||||
_ => return Err( bad( s ) ),
|
||||
};
|
||||
Ok( Color
|
||||
{
|
||||
r: r as f32 / 255.0,
|
||||
g: g as f32 / 255.0,
|
||||
b: b as f32 / 255.0,
|
||||
a: a as f32 / 255.0,
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_functional( s: &str ) -> Result<Color, String>
|
||||
{
|
||||
let ( with_alpha, inner ) = if let Some( rest ) = s.strip_prefix( "rgba(" )
|
||||
{
|
||||
( true, rest.strip_suffix( ')' ).ok_or_else( || bad( s ) )? )
|
||||
}
|
||||
else if let Some( rest ) = s.strip_prefix( "rgb(" )
|
||||
{
|
||||
( false, rest.strip_suffix( ')' ).ok_or_else( || bad( s ) )? )
|
||||
}
|
||||
else
|
||||
{
|
||||
return Err( bad( s ) );
|
||||
};
|
||||
let parts: Vec<&str> = inner.split( ',' ).map( str::trim ).collect();
|
||||
let expected = if with_alpha { 4 } else { 3 };
|
||||
if parts.len() != expected { return Err( bad( s ) ); }
|
||||
|
||||
let r = parse_channel( parts[0] ).ok_or_else( || bad( s ) )?;
|
||||
let g = parse_channel( parts[1] ).ok_or_else( || bad( s ) )?;
|
||||
let b = parse_channel( parts[2] ).ok_or_else( || bad( s ) )?;
|
||||
let a = if with_alpha
|
||||
{
|
||||
parts[3].parse::<f32>().map_err( |_| bad( s ) )?.clamp( 0.0, 1.0 )
|
||||
}
|
||||
else
|
||||
{
|
||||
1.0
|
||||
};
|
||||
|
||||
Ok( Color { r: r / 255.0, g: g / 255.0, b: b / 255.0, a })
|
||||
}
|
||||
|
||||
fn parse_channel( s: &str ) -> Option<f32>
|
||||
{
|
||||
// Integer 0..=255 or float 0..=255.
|
||||
if let Ok( n ) = s.parse::<u16>()
|
||||
{
|
||||
if n <= 255 { return Some( n as f32 ); }
|
||||
return None;
|
||||
}
|
||||
if let Ok( f ) = s.parse::<f32>()
|
||||
{
|
||||
if ( 0.0..=255.0 ).contains( &f ) { return Some( f ); }
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn bad( s: &str ) -> String
|
||||
{
|
||||
format!
|
||||
(
|
||||
"invalid colour `{}` (expected `#RRGGBB`, `#RRGGBBAA` or `rgb[a](…)`)",
|
||||
s
|
||||
)
|
||||
}
|
||||
|
||||
/// Canonical string form: `#RRGGBB` when opaque, `#RRGGBBAA` otherwise.
|
||||
pub fn format( c: Color ) -> String
|
||||
{
|
||||
let r = (c.r.clamp( 0.0, 1.0 ) * 255.0).round() as u8;
|
||||
let g = (c.g.clamp( 0.0, 1.0 ) * 255.0).round() as u8;
|
||||
let b = (c.b.clamp( 0.0, 1.0 ) * 255.0).round() as u8;
|
||||
let a = (c.a.clamp( 0.0, 1.0 ) * 255.0).round() as u8;
|
||||
if a == 0xFF
|
||||
{
|
||||
format!( "#{:02X}{:02X}{:02X}", r, g, b )
|
||||
}
|
||||
else
|
||||
{
|
||||
format!( "#{:02X}{:02X}{:02X}{:02X}", r, g, b, a )
|
||||
}
|
||||
}
|
||||
|
||||
pub fn serialize<S>( c: &Color, ser: S ) -> Result<S::Ok, S::Error>
|
||||
where S: Serializer
|
||||
{
|
||||
ser.serialize_str( &format( *c ) )
|
||||
}
|
||||
|
||||
pub fn deserialize<'de, D>( de: D ) -> Result<Color, D::Error>
|
||||
where D: Deserializer<'de>
|
||||
{
|
||||
let s = String::deserialize( de )?;
|
||||
parse( &s ).map_err( D::Error::custom )
|
||||
}
|
||||
}
|
||||
|
||||
/// Expose the colour parser so error messages and other loaders can share
|
||||
/// the same syntax understanding.
|
||||
pub fn parse_color_str( s: &str ) -> Result<Color, String>
|
||||
{
|
||||
color_serde::parse( s )
|
||||
}
|
||||
|
||||
// ─── Conversion from raw to runtime types ────────────────────────────────────
|
||||
|
||||
fn family_from_raw( root: Option<&Path>, r: RawFontFamily ) -> FontFamilyDef
|
||||
{
|
||||
FontFamilyDef
|
||||
{
|
||||
name: r.name,
|
||||
fallbacks: r.fallbacks,
|
||||
sources: r.sources.into_iter().map( |s| FontSource
|
||||
{
|
||||
weight: s.weight,
|
||||
style: s.style.into(),
|
||||
path: resolve_relative( root, &s.path ),
|
||||
}).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn wallpaper_from_raw( root: Option<&Path>, r: RawWallpaper ) -> WallpaperSpec
|
||||
{
|
||||
WallpaperSpec { path: Some( resolve_relative( root, &r.path ) ), fit: r.fit }
|
||||
}
|
||||
|
||||
fn window_controls_from_raw
|
||||
(
|
||||
fallback_palette: Option<&super::Palette>,
|
||||
_mode: ThemeMode,
|
||||
raw: RawWindowControls,
|
||||
) -> Result<WindowControlsSpec, ThemeError>
|
||||
{
|
||||
// When the mode has no palette to derive sensible defaults from, we fall
|
||||
// back to neutral black-on-white defaults for the fields the author did
|
||||
// not override.
|
||||
let fallback = match fallback_palette
|
||||
{
|
||||
Some( p ) => default_window_controls( *p ),
|
||||
None => WindowControlsSpec
|
||||
{
|
||||
bar_bg: Color::WHITE,
|
||||
icon: Color::BLACK,
|
||||
hover_bg: Color::rgba( 0.0, 0.0, 0.0, 0.08 ),
|
||||
pressed_bg: Color::rgba( 0.0, 0.0, 0.0, 0.12 ),
|
||||
close_hover_bg: Color::rgba( 0.92, 0.18, 0.18, 0.90 ),
|
||||
close_icon: Color::WHITE,
|
||||
focus_ring: Color::hex( 0x04, 0xD9, 0xFE ),
|
||||
},
|
||||
};
|
||||
Ok( WindowControlsSpec
|
||||
{
|
||||
bar_bg: parse_opt( raw.bar_bg.as_deref(), fallback.bar_bg )?,
|
||||
icon: parse_opt( raw.icon.as_deref(), fallback.icon )?,
|
||||
hover_bg: parse_opt( raw.hover_bg.as_deref(), fallback.hover_bg )?,
|
||||
pressed_bg: parse_opt( raw.pressed_bg.as_deref(), fallback.pressed_bg )?,
|
||||
close_hover_bg: parse_opt( raw.close_hover_bg.as_deref(), fallback.close_hover_bg )?,
|
||||
close_icon: parse_opt( raw.close_icon.as_deref(), fallback.close_icon )?,
|
||||
focus_ring: parse_opt( raw.focus_ring.as_deref(), fallback.focus_ring )?,
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_opt( s: Option<&str>, fallback: Color ) -> Result<Color, ThemeError>
|
||||
{
|
||||
match s
|
||||
{
|
||||
Some( v ) => parse_color_str( v ).map_err( ThemeError::InvalidColor ),
|
||||
None => Ok( fallback ),
|
||||
}
|
||||
}
|
||||
|
||||
fn mode_from_raw( root: Option<&Path>, r: RawMode ) -> Result<Mode, ThemeError>
|
||||
{
|
||||
let mut store = SlotStore::new();
|
||||
for ( id, raw ) in r.slots
|
||||
{
|
||||
store.insert( id, Slot::from( raw ) );
|
||||
}
|
||||
let wallpaper = r.wallpaper.map( |w| wallpaper_from_raw( root, w ) );
|
||||
let lockscreen = r.lockscreen.map( |w| wallpaper_from_raw( root, w ) );
|
||||
let launcher = match r.launcher
|
||||
{
|
||||
Some( l ) => Some( LauncherSpec
|
||||
{
|
||||
background: parse_color_str( &l.background ).map_err( ThemeError::InvalidColor )?,
|
||||
border_radius: l.border_radius,
|
||||
}),
|
||||
None => None,
|
||||
};
|
||||
let window_controls = match r.window_controls
|
||||
{
|
||||
Some( raw ) => Some( window_controls_from_raw( None, ThemeMode::Light, raw )? ),
|
||||
None => None,
|
||||
};
|
||||
Ok( Mode { wallpaper, lockscreen, launcher, window_controls, slots: store } )
|
||||
}
|
||||
|
||||
// ─── Public entry points ─────────────────────────────────────────────────────
|
||||
|
||||
/// Parse a theme document from its JSON source text.
|
||||
///
|
||||
/// `root` is the directory the document was read from, used to resolve
|
||||
/// relative paths (wallpaper images, font files) to absolute ones. Pass
|
||||
/// `None` when parsing in-memory fixtures from tests.
|
||||
///
|
||||
/// Performs the colour-reference resolution pass before structural
|
||||
/// deserialisation: any string of the form `@name` or `@name/AA` (where
|
||||
/// `AA` is a two-digit hex alpha override) is rewritten to its literal
|
||||
/// hex form by looking `name` up in the top-level `colors` object. The
|
||||
/// rewritten JSON is then deserialised into [`RawThemeDocument`] and
|
||||
/// converted to the runtime types as before.
|
||||
pub fn parse_document_json( text: &str, root: Option<&Path> )
|
||||
-> Result<ThemeDocument, ThemeError>
|
||||
{
|
||||
let mut value: serde_json::Value = serde_json::from_str( text ).map_err( |e|
|
||||
ThemeError::ParseJson( root.map( Path::to_path_buf ).unwrap_or_default(), e )
|
||||
)?;
|
||||
let colors = extract_colors_map( &value )?;
|
||||
let gradients = extract_gradients_map( &value )?;
|
||||
let inset_stacks = extract_inset_stacks_map( &value )?;
|
||||
// Gradients live in objects (`{ "type": "linear", … }`) and inset stacks
|
||||
// live in arrays (`[ { offset, blur, … }, … ]`); the resolver does not
|
||||
// care about the shape, so the two sections share a single internal
|
||||
// lookup. Names must therefore be unique across both — a collision is
|
||||
// rejected up front rather than letting `@foo` resolve ambiguously.
|
||||
let mut tokens = gradients;
|
||||
for ( k, v ) in inset_stacks
|
||||
{
|
||||
if tokens.contains_key( &k )
|
||||
{
|
||||
return Err( ThemeError::InvalidColor( format!(
|
||||
"name `{}` is defined in both `gradients` and `inset_stacks`", k
|
||||
)));
|
||||
}
|
||||
tokens.insert( k, v );
|
||||
}
|
||||
// Pre-resolve `@color` references that live inside token bodies so
|
||||
// subsequent substitutions are flat clones with no recursion. Tokens
|
||||
// cannot reference each other, so we resolve against an empty token
|
||||
// map here.
|
||||
let empty_tokens = HashMap::new();
|
||||
for ( _, t ) in tokens.iter_mut()
|
||||
{
|
||||
resolve_refs( t, &colors, &empty_tokens )?;
|
||||
}
|
||||
// Drop the top-level palette sections before resolving the rest, so the
|
||||
// walk does not waste cycles on entries that are about to be discarded
|
||||
// and `RawThemeDocument`'s `deny_unknown_fields` does not reject them.
|
||||
if let serde_json::Value::Object( ref mut map ) = value
|
||||
{
|
||||
map.remove( "colors" );
|
||||
map.remove( "gradients" );
|
||||
map.remove( "inset_stacks" );
|
||||
}
|
||||
resolve_refs( &mut value, &colors, &tokens )?;
|
||||
|
||||
let raw: RawThemeDocument = serde_json::from_value( value ).map_err( |e|
|
||||
ThemeError::ParseJson( root.map( Path::to_path_buf ).unwrap_or_default(), e )
|
||||
)?;
|
||||
let fonts = raw.fonts
|
||||
.into_iter()
|
||||
.map( |( k, v )| ( k, family_from_raw( root, v ) ) )
|
||||
.collect();
|
||||
let light = mode_from_raw( root, raw.modes.light )?;
|
||||
let dark = mode_from_raw( root, raw.modes.dark )?;
|
||||
Ok( ThemeDocument
|
||||
{
|
||||
id: raw.theme.id,
|
||||
name: raw.theme.name,
|
||||
root: root.map( Path::to_path_buf ),
|
||||
fonts,
|
||||
light,
|
||||
dark,
|
||||
})
|
||||
}
|
||||
|
||||
/// Load a theme document from a directory containing a `theme.json`.
|
||||
pub fn load_document_from_dir( dir: &Path ) -> Result<ThemeDocument, ThemeError>
|
||||
{
|
||||
let json_path = dir.join( "theme.json" );
|
||||
let text = std::fs::read_to_string( &json_path )
|
||||
.map_err( |e| ThemeError::Io( json_path.clone(), e ) )?;
|
||||
parse_document_json( &text, Some( dir ) )
|
||||
}
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
fn resolve_relative( root: Option<&Path>, rel: &str ) -> PathBuf
|
||||
{
|
||||
let p = Path::new( rel );
|
||||
if p.is_absolute() { return p.to_path_buf(); }
|
||||
match root
|
||||
{
|
||||
Some( r ) => r.join( p ),
|
||||
None => p.to_path_buf(),
|
||||
}
|
||||
}
|
||||
601
src/theme/schema/raw.rs
Normal file
601
src/theme/schema/raw.rs
Normal file
@@ -0,0 +1,601 @@
|
||||
// SPDX-License-Identifier: LGPL-2.1-only
|
||||
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use serde::{ Deserialize, Serialize };
|
||||
|
||||
use crate::types::Color;
|
||||
|
||||
use super::super::paint::{ ColorStop, GradientSpace, LinearGradient, Paint, RadialGradient };
|
||||
use super::super::shadow::{ BlendMode, InsetShadow, Shadow, ShadowsRef };
|
||||
use super::super::slots::{ Metadata, Slot };
|
||||
use super::super::surface::Surface;
|
||||
use super::super::text_style::
|
||||
{
|
||||
FontRef, FontStyle, LineHeight, TextDecoration, TextStyle, TextTransform,
|
||||
};
|
||||
use super::super::WallpaperFit;
|
||||
|
||||
use super::color_serde;
|
||||
|
||||
// ─── Enum mirrors with defaults ──────────────────────────────────────────────
|
||||
|
||||
#[ derive( Debug, Clone, Copy, Serialize, Deserialize ) ]
|
||||
#[ serde( rename_all = "kebab-case" ) ]
|
||||
pub( super ) enum RawGradientSpace
|
||||
{
|
||||
Srgb,
|
||||
LinearRgb,
|
||||
Oklab,
|
||||
}
|
||||
|
||||
impl Default for RawGradientSpace { fn default() -> Self { RawGradientSpace::LinearRgb } }
|
||||
|
||||
impl From<RawGradientSpace> for GradientSpace
|
||||
{
|
||||
fn from( r: RawGradientSpace ) -> Self
|
||||
{
|
||||
match r
|
||||
{
|
||||
RawGradientSpace::Srgb => GradientSpace::Srgb,
|
||||
RawGradientSpace::LinearRgb => GradientSpace::LinearRgb,
|
||||
RawGradientSpace::Oklab => GradientSpace::Oklab,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[ derive( Debug, Clone, Copy, Serialize, Deserialize ) ]
|
||||
#[ serde( rename_all = "kebab-case" ) ]
|
||||
pub( super ) enum RawBlendMode
|
||||
{
|
||||
Normal,
|
||||
PlusLighter,
|
||||
Overlay,
|
||||
Multiply,
|
||||
Screen,
|
||||
}
|
||||
|
||||
impl Default for RawBlendMode { fn default() -> Self { RawBlendMode::Normal } }
|
||||
|
||||
impl From<RawBlendMode> for BlendMode
|
||||
{
|
||||
fn from( r: RawBlendMode ) -> Self
|
||||
{
|
||||
match r
|
||||
{
|
||||
RawBlendMode::Normal => BlendMode::Normal,
|
||||
RawBlendMode::PlusLighter => BlendMode::PlusLighter,
|
||||
RawBlendMode::Overlay => BlendMode::Overlay,
|
||||
RawBlendMode::Multiply => BlendMode::Multiply,
|
||||
RawBlendMode::Screen => BlendMode::Screen,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[ derive( Debug, Clone, Copy, Serialize, Deserialize ) ]
|
||||
#[ serde( rename_all = "kebab-case" ) ]
|
||||
pub( super ) enum RawFontStyle { Normal, Italic }
|
||||
|
||||
impl Default for RawFontStyle { fn default() -> Self { RawFontStyle::Normal } }
|
||||
|
||||
impl From<RawFontStyle> for FontStyle
|
||||
{
|
||||
fn from( r: RawFontStyle ) -> Self
|
||||
{
|
||||
match r
|
||||
{
|
||||
RawFontStyle::Normal => FontStyle::Normal,
|
||||
RawFontStyle::Italic => FontStyle::Italic,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[ derive( Debug, Clone, Copy, Serialize, Deserialize ) ]
|
||||
#[ serde( rename_all = "kebab-case" ) ]
|
||||
pub( super ) enum RawTextTransform { None, Uppercase, Lowercase, Capitalize }
|
||||
|
||||
impl Default for RawTextTransform { fn default() -> Self { RawTextTransform::None } }
|
||||
|
||||
impl From<RawTextTransform> for TextTransform
|
||||
{
|
||||
fn from( r: RawTextTransform ) -> Self
|
||||
{
|
||||
match r
|
||||
{
|
||||
RawTextTransform::None => TextTransform::None,
|
||||
RawTextTransform::Uppercase => TextTransform::Uppercase,
|
||||
RawTextTransform::Lowercase => TextTransform::Lowercase,
|
||||
RawTextTransform::Capitalize => TextTransform::Capitalize,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[ derive( Debug, Clone, Copy, Serialize, Deserialize ) ]
|
||||
#[ serde( rename_all = "kebab-case" ) ]
|
||||
pub( super ) enum RawTextDecoration { None, Underline, Strikethrough }
|
||||
|
||||
impl Default for RawTextDecoration { fn default() -> Self { RawTextDecoration::None } }
|
||||
|
||||
impl From<RawTextDecoration> for TextDecoration
|
||||
{
|
||||
fn from( r: RawTextDecoration ) -> Self
|
||||
{
|
||||
match r
|
||||
{
|
||||
RawTextDecoration::None => TextDecoration::None,
|
||||
RawTextDecoration::Underline => TextDecoration::Underline,
|
||||
RawTextDecoration::Strikethrough => TextDecoration::Strikethrough,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Metadata ────────────────────────────────────────────────────────────────
|
||||
|
||||
#[ derive( Debug, Clone, Default, Serialize, Deserialize ) ]
|
||||
pub( super ) struct RawMetadata
|
||||
{
|
||||
#[ serde( default, skip_serializing_if = "Option::is_none" ) ]
|
||||
pub( super ) semantic: Option<String>,
|
||||
#[ serde( default, skip_serializing_if = "Option::is_none" ) ]
|
||||
pub( super ) fluent: Option<String>,
|
||||
#[ serde( default, skip_serializing_if = "Option::is_none" ) ]
|
||||
pub( super ) usage: Option<String>,
|
||||
#[ serde( default, skip_serializing_if = "Option::is_none" ) ]
|
||||
pub( super ) note: Option<String>,
|
||||
}
|
||||
|
||||
impl From<RawMetadata> for Metadata
|
||||
{
|
||||
fn from( r: RawMetadata ) -> Self
|
||||
{
|
||||
Metadata { semantic: r.semantic, fluent: r.fluent, usage: r.usage, note: r.note }
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Gradient stops and gradients ────────────────────────────────────────────
|
||||
|
||||
#[ derive( Debug, Clone, Serialize, Deserialize ) ]
|
||||
#[ serde( deny_unknown_fields ) ]
|
||||
pub( super ) struct RawColorStop
|
||||
{
|
||||
#[ serde( alias = "position" ) ]
|
||||
pub( super ) pos: f32,
|
||||
#[ serde( with = "color_serde" ) ]
|
||||
pub( super ) color: Color,
|
||||
}
|
||||
|
||||
impl From<RawColorStop> for ColorStop
|
||||
{
|
||||
fn from( r: RawColorStop ) -> Self { ColorStop { position: r.pos, color: r.color } }
|
||||
}
|
||||
|
||||
// ─── Paint (for `surface.fill`) ──────────────────────────────────────────────
|
||||
|
||||
#[ derive( Debug, Clone, Serialize, Deserialize ) ]
|
||||
#[ serde( tag = "type", rename_all = "kebab-case", deny_unknown_fields ) ]
|
||||
pub( super ) enum RawPaint
|
||||
{
|
||||
Solid
|
||||
{
|
||||
#[ serde( with = "color_serde" ) ]
|
||||
color: Color,
|
||||
},
|
||||
Linear
|
||||
{
|
||||
angle_deg: f32,
|
||||
#[ serde( default ) ]
|
||||
space: RawGradientSpace,
|
||||
stops: Vec<RawColorStop>,
|
||||
},
|
||||
Radial
|
||||
{
|
||||
center: [f32; 2],
|
||||
radius: f32,
|
||||
#[ serde( default ) ]
|
||||
space: RawGradientSpace,
|
||||
stops: Vec<RawColorStop>,
|
||||
},
|
||||
}
|
||||
|
||||
impl From<RawPaint> for Paint
|
||||
{
|
||||
fn from( r: RawPaint ) -> Self
|
||||
{
|
||||
match r
|
||||
{
|
||||
RawPaint::Solid { color } => Paint::Solid( color ),
|
||||
RawPaint::Linear { angle_deg, space, stops } => Paint::Linear( LinearGradient
|
||||
{
|
||||
angle_deg,
|
||||
space: space.into(),
|
||||
stops: collect_capped_stops( stops ),
|
||||
}),
|
||||
RawPaint::Radial { center, radius, space, stops } => Paint::Radial( RadialGradient
|
||||
{
|
||||
center,
|
||||
radius,
|
||||
space: space.into(),
|
||||
stops: collect_capped_stops( stops ),
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Soft cap on the number of color stops a gradient may carry. Realistic
|
||||
/// designs use 2 – 6 stops, the shipped theme tops out at 4, and
|
||||
/// 64 leaves comfortable headroom for hand-tuned tonemap palettes. The
|
||||
/// cap exists to bound CPU + memory under a hostile theme document — a
|
||||
/// 200 000-stop linear gradient would otherwise build a 6 MB
|
||||
/// `Vec<ColorStop>` per slot at parse time. Exceeding the cap truncates
|
||||
/// the tail and logs once via stderr.
|
||||
pub( super ) const MAX_GRADIENT_STOPS: usize = 64;
|
||||
|
||||
pub( super ) fn collect_capped_stops( raw: Vec<RawColorStop> ) -> Vec<ColorStop>
|
||||
{
|
||||
if raw.len() > MAX_GRADIENT_STOPS
|
||||
{
|
||||
eprintln!(
|
||||
"[ltk theme] gradient declares {} stops, truncating to {}",
|
||||
raw.len(), MAX_GRADIENT_STOPS,
|
||||
);
|
||||
raw.into_iter().take( MAX_GRADIENT_STOPS ).map( Into::into ).collect()
|
||||
}
|
||||
else
|
||||
{
|
||||
raw.into_iter().map( Into::into ).collect()
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Shadows ─────────────────────────────────────────────────────────────────
|
||||
|
||||
#[ derive( Debug, Clone, Serialize, Deserialize ) ]
|
||||
#[ serde( deny_unknown_fields ) ]
|
||||
pub( super ) struct RawShadow
|
||||
{
|
||||
pub( super ) offset: [f32; 2],
|
||||
pub( super ) blur: f32,
|
||||
#[ serde( default ) ]
|
||||
pub( super ) spread: f32,
|
||||
#[ serde( with = "color_serde" ) ]
|
||||
pub( super ) color: Color,
|
||||
#[ serde( default ) ]
|
||||
pub( super ) blend: RawBlendMode,
|
||||
}
|
||||
|
||||
impl From<RawShadow> for Shadow
|
||||
{
|
||||
fn from( r: RawShadow ) -> Self
|
||||
{
|
||||
Shadow { offset: r.offset, blur: r.blur, spread: r.spread, color: r.color, blend: r.blend.into() }
|
||||
}
|
||||
}
|
||||
|
||||
impl From<RawShadow> for InsetShadow
|
||||
{
|
||||
fn from( r: RawShadow ) -> Self
|
||||
{
|
||||
InsetShadow { offset: r.offset, blur: r.blur, spread: r.spread, color: r.color, blend: r.blend.into() }
|
||||
}
|
||||
}
|
||||
|
||||
/// Reference to a shadow stack inside a surface: either a string id
|
||||
/// pointing at a `shadows` slot, or a literal list.
|
||||
#[ derive( Debug, Clone, Serialize, Deserialize ) ]
|
||||
#[ serde( untagged ) ]
|
||||
pub( super ) enum RawShadowsRef
|
||||
{
|
||||
Named( String ),
|
||||
Inline( Vec<RawShadow> ),
|
||||
}
|
||||
|
||||
impl From<RawShadowsRef> for ShadowsRef
|
||||
{
|
||||
fn from( r: RawShadowsRef ) -> Self
|
||||
{
|
||||
match r
|
||||
{
|
||||
RawShadowsRef::Named( id ) => ShadowsRef::Named( id ),
|
||||
RawShadowsRef::Inline( items ) => ShadowsRef::Inline( items.into_iter().map( Into::into ).collect() ),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Surface ─────────────────────────────────────────────────────────────────
|
||||
|
||||
#[ derive( Debug, Clone, Serialize, Deserialize ) ]
|
||||
#[ serde( deny_unknown_fields ) ]
|
||||
pub( super ) struct RawSurfaceBody
|
||||
{
|
||||
pub( super ) fill: Box<RawPaint>,
|
||||
#[ serde( default, skip_serializing_if = "Option::is_none" ) ]
|
||||
pub( super ) shadows: Option<RawShadowsRef>,
|
||||
#[ serde( default ) ]
|
||||
pub( super ) inset_shadows: Vec<RawShadow>,
|
||||
}
|
||||
|
||||
impl From<RawSurfaceBody> for Surface
|
||||
{
|
||||
fn from( r: RawSurfaceBody ) -> Self
|
||||
{
|
||||
Surface
|
||||
{
|
||||
fill: Paint::from( *r.fill ),
|
||||
shadows: r.shadows.map( Into::into ),
|
||||
inset_shadows: r.inset_shadows.into_iter().map( Into::into ).collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Text style ──────────────────────────────────────────────────────────────
|
||||
|
||||
#[ derive( Debug, Clone, Serialize, Deserialize ) ]
|
||||
#[ serde( untagged ) ]
|
||||
pub( super ) enum RawLineHeight
|
||||
{
|
||||
Px { px: f32 },
|
||||
Multiplier { mul: f32 },
|
||||
}
|
||||
|
||||
impl From<RawLineHeight> for LineHeight
|
||||
{
|
||||
fn from( r: RawLineHeight ) -> Self
|
||||
{
|
||||
match r
|
||||
{
|
||||
RawLineHeight::Px { px } => LineHeight::Px( px ),
|
||||
RawLineHeight::Multiplier { mul } => LineHeight::Multiplier( mul ),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[ derive( Debug, Clone, Serialize, Deserialize ) ]
|
||||
#[ serde( deny_unknown_fields ) ]
|
||||
pub( super ) struct RawFontRef
|
||||
{
|
||||
pub( super ) r#ref: String,
|
||||
}
|
||||
|
||||
impl From<RawFontRef> for FontRef
|
||||
{
|
||||
fn from( r: RawFontRef ) -> Self { FontRef::Named( r.r#ref ) }
|
||||
}
|
||||
|
||||
#[ derive( Debug, Clone, Serialize, Deserialize ) ]
|
||||
#[ serde( deny_unknown_fields ) ]
|
||||
pub( super ) struct RawTextStyleBody
|
||||
{
|
||||
pub( super ) family: RawFontRef,
|
||||
pub( super ) weight: u16,
|
||||
#[ serde( default ) ]
|
||||
pub( super ) style: RawFontStyle,
|
||||
pub( super ) size: f32,
|
||||
pub( super ) line_height: RawLineHeight,
|
||||
#[ serde( default ) ]
|
||||
pub( super ) letter_spacing: f32,
|
||||
#[ serde( default ) ]
|
||||
pub( super ) transform: RawTextTransform,
|
||||
#[ serde( default ) ]
|
||||
pub( super ) decoration: RawTextDecoration,
|
||||
#[ serde( default, skip_serializing_if = "Option::is_none" ) ]
|
||||
pub( super ) color: Option<String>,
|
||||
}
|
||||
|
||||
impl From<RawTextStyleBody> for TextStyle
|
||||
{
|
||||
fn from( r: RawTextStyleBody ) -> Self
|
||||
{
|
||||
TextStyle
|
||||
{
|
||||
family: r.family.into(),
|
||||
weight: r.weight,
|
||||
style: r.style.into(),
|
||||
size: r.size,
|
||||
line_height: r.line_height.into(),
|
||||
letter_spacing: r.letter_spacing,
|
||||
transform: r.transform.into(),
|
||||
decoration: r.decoration.into(),
|
||||
color: r.color,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Slot ────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[ derive( Debug, Clone, Serialize, Deserialize ) ]
|
||||
#[ serde( tag = "type", rename_all = "kebab-case" ) ]
|
||||
pub( super ) enum RawSlot
|
||||
{
|
||||
Color
|
||||
{
|
||||
#[ serde( with = "color_serde" ) ]
|
||||
value: Color,
|
||||
#[ serde( default ) ]
|
||||
meta: RawMetadata,
|
||||
},
|
||||
Linear
|
||||
{
|
||||
angle_deg: f32,
|
||||
#[ serde( default ) ]
|
||||
space: RawGradientSpace,
|
||||
stops: Vec<RawColorStop>,
|
||||
#[ serde( default ) ]
|
||||
meta: RawMetadata,
|
||||
},
|
||||
Radial
|
||||
{
|
||||
center: [f32; 2],
|
||||
radius: f32,
|
||||
#[ serde( default ) ]
|
||||
space: RawGradientSpace,
|
||||
stops: Vec<RawColorStop>,
|
||||
#[ serde( default ) ]
|
||||
meta: RawMetadata,
|
||||
},
|
||||
Shadows
|
||||
{
|
||||
shadows: Vec<RawShadow>,
|
||||
#[ serde( default ) ]
|
||||
meta: RawMetadata,
|
||||
},
|
||||
Surface
|
||||
{
|
||||
#[ serde( flatten ) ]
|
||||
body: RawSurfaceBody,
|
||||
#[ serde( default ) ]
|
||||
meta: RawMetadata,
|
||||
},
|
||||
Typography
|
||||
{
|
||||
#[ serde( flatten ) ]
|
||||
body: RawTextStyleBody,
|
||||
#[ serde( default ) ]
|
||||
meta: RawMetadata,
|
||||
},
|
||||
}
|
||||
|
||||
impl From<RawSlot> for Slot
|
||||
{
|
||||
fn from( r: RawSlot ) -> Self
|
||||
{
|
||||
match r
|
||||
{
|
||||
RawSlot::Color { value, meta } => Slot::Color { value, meta: meta.into() },
|
||||
RawSlot::Linear { angle_deg, space, stops, meta } => Slot::Paint
|
||||
{
|
||||
value: Paint::Linear( LinearGradient
|
||||
{
|
||||
angle_deg,
|
||||
space: space.into(),
|
||||
stops: collect_capped_stops( stops ),
|
||||
}),
|
||||
meta: meta.into(),
|
||||
},
|
||||
RawSlot::Radial { center, radius, space, stops, meta } => Slot::Paint
|
||||
{
|
||||
value: Paint::Radial( RadialGradient
|
||||
{
|
||||
center,
|
||||
radius,
|
||||
space: space.into(),
|
||||
stops: collect_capped_stops( stops ),
|
||||
}),
|
||||
meta: meta.into(),
|
||||
},
|
||||
RawSlot::Shadows { shadows, meta } => Slot::Shadows
|
||||
{
|
||||
value: shadows.into_iter().map( Into::into ).collect(),
|
||||
meta: meta.into(),
|
||||
},
|
||||
RawSlot::Surface { body, meta } => Slot::Surface
|
||||
{
|
||||
value: body.into(),
|
||||
meta: meta.into(),
|
||||
},
|
||||
RawSlot::Typography { body, meta } => Slot::TextStyle
|
||||
{
|
||||
value: body.into(),
|
||||
meta: meta.into(),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Fonts block ─────────────────────────────────────────────────────────────
|
||||
|
||||
#[ derive( Debug, Clone, Serialize, Deserialize ) ]
|
||||
#[ serde( deny_unknown_fields ) ]
|
||||
pub( super ) struct RawFontSource
|
||||
{
|
||||
pub( super ) weight: u16,
|
||||
#[ serde( default ) ]
|
||||
pub( super ) style: RawFontStyle,
|
||||
pub( super ) path: String,
|
||||
}
|
||||
|
||||
#[ derive( Debug, Clone, Serialize, Deserialize ) ]
|
||||
#[ serde( deny_unknown_fields ) ]
|
||||
pub( super ) struct RawFontFamily
|
||||
{
|
||||
pub( super ) name: String,
|
||||
#[ serde( default ) ]
|
||||
pub( super ) fallbacks: Vec<String>,
|
||||
#[ serde( default ) ]
|
||||
pub( super ) sources: Vec<RawFontSource>,
|
||||
}
|
||||
|
||||
// ─── Wallpaper / window_controls ─────────────────────────────────────────────
|
||||
|
||||
#[ derive( Debug, Clone, Serialize, Deserialize ) ]
|
||||
#[ serde( deny_unknown_fields ) ]
|
||||
pub( super ) struct RawWallpaper
|
||||
{
|
||||
pub( super ) path: String,
|
||||
#[ serde( default ) ]
|
||||
pub( super ) fit: WallpaperFit,
|
||||
}
|
||||
|
||||
#[ derive( Debug, Clone, Serialize, Deserialize ) ]
|
||||
#[ serde( deny_unknown_fields ) ]
|
||||
pub( super ) struct RawLauncher
|
||||
{
|
||||
pub( super ) background: String,
|
||||
pub( super ) border_radius: f32,
|
||||
}
|
||||
|
||||
#[ derive( Debug, Clone, Serialize, Deserialize, Default ) ]
|
||||
#[ serde( deny_unknown_fields ) ]
|
||||
pub( super ) struct RawWindowControls
|
||||
{
|
||||
#[ serde( default ) ] pub( super ) bar_bg: Option<String>,
|
||||
#[ serde( default ) ] pub( super ) icon: Option<String>,
|
||||
#[ serde( default ) ] pub( super ) hover_bg: Option<String>,
|
||||
#[ serde( default ) ] pub( super ) pressed_bg: Option<String>,
|
||||
#[ serde( default ) ] pub( super ) close_hover_bg: Option<String>,
|
||||
#[ serde( default ) ] pub( super ) close_icon: Option<String>,
|
||||
#[ serde( default ) ] pub( super ) focus_ring: Option<String>,
|
||||
}
|
||||
|
||||
// ─── Mode / Modes / Document ─────────────────────────────────────────────────
|
||||
|
||||
#[ derive( Debug, Clone, Serialize, Deserialize ) ]
|
||||
#[ serde( deny_unknown_fields ) ]
|
||||
pub( super ) struct RawMode
|
||||
{
|
||||
#[ serde( default, skip_serializing_if = "Option::is_none" ) ]
|
||||
pub( super ) wallpaper: Option<RawWallpaper>,
|
||||
#[ serde( default, skip_serializing_if = "Option::is_none" ) ]
|
||||
pub( super ) lockscreen: Option<RawWallpaper>,
|
||||
#[ serde( default, skip_serializing_if = "Option::is_none" ) ]
|
||||
pub( super ) launcher: Option<RawLauncher>,
|
||||
#[ serde( default, skip_serializing_if = "Option::is_none" ) ]
|
||||
pub( super ) window_controls: Option<RawWindowControls>,
|
||||
#[ serde( default ) ]
|
||||
pub( super ) slots: HashMap<String, RawSlot>,
|
||||
}
|
||||
|
||||
#[ derive( Debug, Clone, Serialize, Deserialize ) ]
|
||||
#[ serde( deny_unknown_fields ) ]
|
||||
pub( super ) struct RawModes
|
||||
{
|
||||
pub( super ) light: RawMode,
|
||||
pub( super ) dark: RawMode,
|
||||
}
|
||||
|
||||
#[ derive( Debug, Clone, Serialize, Deserialize ) ]
|
||||
#[ serde( deny_unknown_fields ) ]
|
||||
pub( super ) struct RawThemeMeta
|
||||
{
|
||||
pub( super ) id: String,
|
||||
pub( super ) name: String,
|
||||
}
|
||||
|
||||
#[ derive( Debug, Clone, Serialize, Deserialize ) ]
|
||||
#[ serde( deny_unknown_fields ) ]
|
||||
pub( super ) struct RawThemeDocument
|
||||
{
|
||||
pub( super ) theme: RawThemeMeta,
|
||||
#[ serde( default ) ]
|
||||
pub( super ) fonts: HashMap<String, RawFontFamily>,
|
||||
pub( super ) modes: RawModes,
|
||||
}
|
||||
194
src/theme/schema/refs.rs
Normal file
194
src/theme/schema/refs.rs
Normal file
@@ -0,0 +1,194 @@
|
||||
// SPDX-License-Identifier: LGPL-2.1-only
|
||||
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use super::super::ThemeError;
|
||||
|
||||
/// Read the top-level `colors` object from the raw JSON value and validate
|
||||
/// each entry is a literal hex string. Returns an empty map if no `colors`
|
||||
/// section is present.
|
||||
pub( super ) fn extract_colors_map( value: &serde_json::Value )
|
||||
-> Result<HashMap<String, String>, ThemeError>
|
||||
{
|
||||
let mut out = HashMap::new();
|
||||
let raw = match value.get( "colors" )
|
||||
{
|
||||
Some( c ) => c,
|
||||
None => return Ok( out ),
|
||||
};
|
||||
let map = raw.as_object().ok_or_else( ||
|
||||
ThemeError::InvalidColor( "`colors` must be an object".to_string() )
|
||||
)?;
|
||||
for ( k, v ) in map
|
||||
{
|
||||
let s = v.as_str().ok_or_else( ||
|
||||
ThemeError::InvalidColor( format!( "`colors.{}` must be a hex string", k ) )
|
||||
)?;
|
||||
// References inside `colors` itself are not supported — every entry
|
||||
// must be a literal hex value the rest of the document can point at.
|
||||
let h = s.trim_start_matches( '#' );
|
||||
let valid = ( h.len() == 6 || h.len() == 8 ) && h.chars().all( |c| c.is_ascii_hexdigit() );
|
||||
if !valid
|
||||
{
|
||||
return Err( ThemeError::InvalidColor(
|
||||
format!( "`colors.{}` = `{}` (expected #RRGGBB or #RRGGBBAA)", k, s )
|
||||
));
|
||||
}
|
||||
out.insert( k.clone(), s.to_string() );
|
||||
}
|
||||
Ok( out )
|
||||
}
|
||||
|
||||
/// Read the top-level `gradients` object from the raw JSON value. Each
|
||||
/// entry must be a paint object (`{ "type": "linear", "angle_deg": …,
|
||||
/// "stops": [ … ] }`); shape correctness beyond "is an object" is left
|
||||
/// for downstream serde deserialisation to enforce when the gradient is
|
||||
/// substituted into a paint position.
|
||||
pub( super ) fn extract_gradients_map( value: &serde_json::Value )
|
||||
-> Result<HashMap<String, serde_json::Value>, ThemeError>
|
||||
{
|
||||
extract_token_map( value, "gradients", "paint object", serde_json::Value::is_object )
|
||||
}
|
||||
|
||||
/// Read the top-level `inset_stacks` object from the raw JSON value. Each
|
||||
/// entry must be an array of inset-shadow definitions — substituted
|
||||
/// wholesale into an `inset_shadows` field on a surface.
|
||||
pub( super ) fn extract_inset_stacks_map( value: &serde_json::Value )
|
||||
-> Result<HashMap<String, serde_json::Value>, ThemeError>
|
||||
{
|
||||
extract_token_map( value, "inset_stacks", "array of inset-shadow definitions", serde_json::Value::is_array )
|
||||
}
|
||||
|
||||
/// Generic helper for the `gradients` / `inset_stacks` extractors above:
|
||||
/// reads a top-level object, validates each entry against `valid`, and
|
||||
/// returns the entries cloned into a `HashMap`. Returns an empty map if
|
||||
/// `section` is absent.
|
||||
fn extract_token_map(
|
||||
value: &serde_json::Value,
|
||||
section: &str,
|
||||
expected_kind: &str,
|
||||
valid: impl Fn( &serde_json::Value ) -> bool,
|
||||
) -> Result<HashMap<String, serde_json::Value>, ThemeError>
|
||||
{
|
||||
let mut out = HashMap::new();
|
||||
let raw = match value.get( section )
|
||||
{
|
||||
Some( c ) => c,
|
||||
None => return Ok( out ),
|
||||
};
|
||||
let map = raw.as_object().ok_or_else( ||
|
||||
ThemeError::InvalidColor( format!( "`{}` must be an object", section ) )
|
||||
)?;
|
||||
for ( k, v ) in map
|
||||
{
|
||||
if !valid( v )
|
||||
{
|
||||
return Err( ThemeError::InvalidColor(
|
||||
format!( "`{}.{}` must be {}", section, k, expected_kind )
|
||||
));
|
||||
}
|
||||
out.insert( k.clone(), v.clone() );
|
||||
}
|
||||
Ok( out )
|
||||
}
|
||||
|
||||
/// Walk `value` and replace `@name` / `@name/AA` references with their
|
||||
/// resolved form. A reference whose name appears in `tokens` (gradient
|
||||
/// objects or inset-shadow arrays) is substituted by the cloned token
|
||||
/// value; a reference whose name appears in `colors` is substituted by
|
||||
/// its hex literal. After a substitution the new node is recursed into
|
||||
/// so a gradient that uses `@cyan-soft` in its stops or an inset stack
|
||||
/// that uses `@glass-hi` is fully expanded.
|
||||
pub( super ) fn resolve_refs(
|
||||
value: &mut serde_json::Value,
|
||||
colors: &HashMap<String, String>,
|
||||
tokens: &HashMap<String, serde_json::Value>,
|
||||
) -> Result<(), ThemeError>
|
||||
{
|
||||
// Step 1: if this node is a `@ref` string, resolve and overwrite.
|
||||
let replacement = match value
|
||||
{
|
||||
serde_json::Value::String( s ) => match s.strip_prefix( '@' )
|
||||
{
|
||||
Some( rest ) => Some( resolve_one_ref( rest, colors, tokens )? ),
|
||||
None => None,
|
||||
},
|
||||
_ => None,
|
||||
};
|
||||
if let Some( new_value ) = replacement
|
||||
{
|
||||
*value = new_value;
|
||||
// The replacement may itself contain references (typically a
|
||||
// gradient with `@color` stops). Recurse into the new node.
|
||||
resolve_refs( value, colors, tokens )?;
|
||||
return Ok( () );
|
||||
}
|
||||
// Step 2: walk children of objects / arrays.
|
||||
match value
|
||||
{
|
||||
serde_json::Value::Object( map ) =>
|
||||
{
|
||||
for ( _, v ) in map.iter_mut()
|
||||
{
|
||||
resolve_refs( v, colors, tokens )?;
|
||||
}
|
||||
}
|
||||
serde_json::Value::Array( arr ) =>
|
||||
{
|
||||
for v in arr.iter_mut()
|
||||
{
|
||||
resolve_refs( v, colors, tokens )?;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok( () )
|
||||
}
|
||||
|
||||
/// Resolve a single `@name[/AA]` reference. Looks up `tokens` first
|
||||
/// (gradient or inset-stack), then `colors`. The `/AA` alpha override
|
||||
/// only applies to colour references; using it on a non-colour token is
|
||||
/// rejected.
|
||||
fn resolve_one_ref(
|
||||
rest: &str,
|
||||
colors: &HashMap<String, String>,
|
||||
tokens: &HashMap<String, serde_json::Value>,
|
||||
) -> Result<serde_json::Value, ThemeError>
|
||||
{
|
||||
let ( name, alpha_hex ) = match rest.split_once( '/' )
|
||||
{
|
||||
Some( ( n, a ) ) => ( n, Some( a ) ),
|
||||
None => ( rest, None ),
|
||||
};
|
||||
if let Some( tok ) = tokens.get( name )
|
||||
{
|
||||
if let Some( a ) = alpha_hex
|
||||
{
|
||||
return Err( ThemeError::InvalidColor( format!(
|
||||
"@{} is a paint/inset token — alpha override `/{}` is not applicable", name, a
|
||||
)));
|
||||
}
|
||||
return Ok( tok.clone() );
|
||||
}
|
||||
let base = colors.get( name )
|
||||
.ok_or_else( || ThemeError::UnknownColorRef( name.to_string() ) )?;
|
||||
let h = base.trim_start_matches( '#' );
|
||||
// `extract_colors_map` already validated the base is 6 or 8 hex digits.
|
||||
let rgb = &h[0..6];
|
||||
let s = match alpha_hex
|
||||
{
|
||||
Some( a ) =>
|
||||
{
|
||||
if a.len() != 2 || u8::from_str_radix( a, 16 ).is_err()
|
||||
{
|
||||
return Err( ThemeError::InvalidColor(
|
||||
format!( "@{}/{} (alpha must be two hex digits)", name, a )
|
||||
));
|
||||
}
|
||||
format!( "#{}{}", rgb.to_uppercase(), a.to_uppercase() )
|
||||
}
|
||||
None => format!( "#{}", h.to_uppercase() ),
|
||||
};
|
||||
Ok( serde_json::Value::String( s ) )
|
||||
}
|
||||
292
src/theme/schema/tests.rs
Normal file
292
src/theme/schema/tests.rs
Normal file
@@ -0,0 +1,292 @@
|
||||
// SPDX-License-Identifier: LGPL-2.1-only
|
||||
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
|
||||
|
||||
use super::*;
|
||||
use super::raw::*;
|
||||
use super::super::paint::{ GradientSpace, Paint };
|
||||
use super::super::shadow::{ BlendMode, ShadowsRef };
|
||||
use super::super::slots::Slot;
|
||||
use super::super::text_style::LineHeight;
|
||||
|
||||
#[ test ]
|
||||
fn color_parser_accepts_hex_and_functional()
|
||||
{
|
||||
assert_eq!( color_serde::parse( "#04D9FE" ).unwrap(), Color::hex( 0x04, 0xD9, 0xFE ) );
|
||||
assert_eq!( color_serde::parse( "04D9FE" ).unwrap(), Color::hex( 0x04, 0xD9, 0xFE ) );
|
||||
let with_a = color_serde::parse( "#FFFFFF80" ).unwrap();
|
||||
assert!( ( with_a.a - 128.0 / 255.0 ).abs() < 1e-6 );
|
||||
let f = color_serde::parse( "rgba(255, 147, 169, 0.8)" ).unwrap();
|
||||
assert_eq!( f.r, 1.0 );
|
||||
assert!( ( f.g - 147.0 / 255.0 ).abs() < 1e-6 );
|
||||
assert!( ( f.a - 0.8 ).abs() < 1e-6 );
|
||||
let three = color_serde::parse( "rgb(0, 0, 0)" ).unwrap();
|
||||
assert_eq!( three, Color::BLACK );
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn color_parser_rejects_bad_input()
|
||||
{
|
||||
assert!( color_serde::parse( "" ).is_err() );
|
||||
assert!( color_serde::parse( "#FFF" ).is_err() );
|
||||
assert!( color_serde::parse( "rgba(1,2,3)" ).is_err() );
|
||||
assert!( color_serde::parse( "rgba(1,2,3,4,5)" ).is_err() );
|
||||
assert!( color_serde::parse( "rgba(300, 0, 0, 1)" ).is_err() );
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn color_formatter_is_canonical()
|
||||
{
|
||||
assert_eq!( color_serde::format( Color::hex( 0x04, 0xD9, 0xFE ) ), "#04D9FE" );
|
||||
let semi = Color { r: 1.0, g: 1.0, b: 1.0, a: 0.5 };
|
||||
assert_eq!( color_serde::format( semi ), "#FFFFFF80" );
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn slot_color_parses_with_meta()
|
||||
{
|
||||
let json = r##"
|
||||
{
|
||||
"type": "color",
|
||||
"value": "#04D9FE",
|
||||
"meta": { "semantic": "primary/500" }
|
||||
}
|
||||
"##;
|
||||
let raw: RawSlot = serde_json::from_str( json ).unwrap();
|
||||
let slot = Slot::from( raw );
|
||||
match slot
|
||||
{
|
||||
Slot::Color { value, meta } =>
|
||||
{
|
||||
assert_eq!( value, Color::hex( 0x04, 0xD9, 0xFE ) );
|
||||
assert_eq!( meta.semantic.as_deref(), Some( "primary/500" ) );
|
||||
}
|
||||
other => panic!( "expected color slot, got {:?}", other ),
|
||||
}
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn gradient_under_cap_is_passed_through_verbatim()
|
||||
{
|
||||
let raw_stops: Vec<RawColorStop> = ( 0..16 )
|
||||
.map( |i| serde_json::from_str(
|
||||
&format!( r##"{{ "pos": {}, "color": "#FFFFFFFF" }}"##, i as f32 / 15.0 ),
|
||||
).unwrap() )
|
||||
.collect();
|
||||
let result = collect_capped_stops( raw_stops );
|
||||
assert_eq!( result.len(), 16, "stops below the soft cap pass through unchanged" );
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn gradient_at_exact_cap_is_passed_through()
|
||||
{
|
||||
let raw_stops: Vec<RawColorStop> = ( 0..MAX_GRADIENT_STOPS )
|
||||
.map( |i| serde_json::from_str(
|
||||
&format!( r##"{{ "pos": {}, "color": "#FFFFFFFF" }}"##, i as f32 / 63.0 ),
|
||||
).unwrap() )
|
||||
.collect();
|
||||
let result = collect_capped_stops( raw_stops );
|
||||
assert_eq!( result.len(), MAX_GRADIENT_STOPS );
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn gradient_over_cap_is_truncated_to_cap()
|
||||
{
|
||||
let raw_stops: Vec<RawColorStop> = ( 0..200 )
|
||||
.map( |i| serde_json::from_str(
|
||||
&format!( r##"{{ "pos": {}, "color": "#000000FF" }}"##, i as f32 ),
|
||||
).unwrap() )
|
||||
.collect();
|
||||
let result = collect_capped_stops( raw_stops );
|
||||
assert_eq!( result.len(), MAX_GRADIENT_STOPS, "tail past the cap must be dropped" );
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn slot_linear_gradient_accepts_extrapolated_stops()
|
||||
{
|
||||
let json = r##"
|
||||
{
|
||||
"type": "linear",
|
||||
"angle_deg": 152.77,
|
||||
"space": "linear-rgb",
|
||||
"stops":
|
||||
[
|
||||
{ "pos": -1.1654, "color": "rgba(255, 147, 169, 0.8)" },
|
||||
{ "pos": 1.2332, "color": "rgba(255, 255, 255, 0.8)" }
|
||||
]
|
||||
}
|
||||
"##;
|
||||
let raw: RawSlot = serde_json::from_str( json ).unwrap();
|
||||
match Slot::from( raw )
|
||||
{
|
||||
Slot::Paint { value: Paint::Linear( grad ), .. } =>
|
||||
{
|
||||
assert_eq!( grad.stops.len(), 2 );
|
||||
assert!( grad.stops[0].position < 0.0 );
|
||||
assert!( grad.stops[1].position > 1.0 );
|
||||
assert_eq!( grad.space, GradientSpace::LinearRgb );
|
||||
}
|
||||
_ => panic!( "expected linear paint" ),
|
||||
}
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn slot_shadows_parses_stack()
|
||||
{
|
||||
let json = r##"
|
||||
{
|
||||
"type": "shadows",
|
||||
"shadows":
|
||||
[
|
||||
{ "offset": [0, 4], "blur": 10, "color": "#00000014" },
|
||||
{ "offset": [0, 1], "blur": 4, "color": "#0000000A" }
|
||||
]
|
||||
}
|
||||
"##;
|
||||
let raw: RawSlot = serde_json::from_str( json ).unwrap();
|
||||
match Slot::from( raw )
|
||||
{
|
||||
Slot::Shadows { value, .. } =>
|
||||
{
|
||||
assert_eq!( value.len(), 2 );
|
||||
assert_eq!( value[0].offset, [ 0.0, 4.0 ] );
|
||||
assert_eq!( value[0].blur, 10.0 );
|
||||
assert_eq!( value[0].blend, BlendMode::Normal );
|
||||
}
|
||||
_ => panic!( "expected shadows slot" ),
|
||||
}
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn slot_surface_reproduces_glass_accent()
|
||||
{
|
||||
let json = r##"
|
||||
{
|
||||
"type": "surface",
|
||||
"fill": { "type": "solid", "color": "#04D9FE" },
|
||||
"shadows": "shadows-glass",
|
||||
"inset_shadows":
|
||||
[
|
||||
{ "offset": [-3.6, -3.6], "blur": 13.5, "color": "#555555", "blend": "plus-lighter" },
|
||||
{ "offset": [ 1.8, 1.8], "blur": 1.8, "color": "#555555", "blend": "plus-lighter" },
|
||||
{ "offset": [ 0.45, 0.45], "blur": 0.9, "color": "#000000", "blend": "overlay" },
|
||||
{ "offset": [ 1.8, 1.8], "blur": 7.2, "color": "#00000026", "blend": "normal" }
|
||||
],
|
||||
"backdrop": { "blur_px": 22.5 }
|
||||
}
|
||||
"##;
|
||||
let raw: RawSlot = serde_json::from_str( json ).unwrap();
|
||||
match Slot::from( raw )
|
||||
{
|
||||
Slot::Surface { value, .. } =>
|
||||
{
|
||||
assert!( matches!( value.fill, Paint::Solid( _ ) ) );
|
||||
assert_eq!( value.inset_shadows.len(), 4 );
|
||||
assert_eq!( value.inset_shadows[0].blend, BlendMode::PlusLighter );
|
||||
assert_eq!( value.inset_shadows[2].blend, BlendMode::Overlay );
|
||||
match value.shadows.as_ref().unwrap()
|
||||
{
|
||||
ShadowsRef::Named( id ) => assert_eq!( id, "shadows-glass" ),
|
||||
other => panic!( "expected named shadow ref, got {:?}", other ),
|
||||
}
|
||||
}
|
||||
_ => panic!( "expected surface slot" ),
|
||||
}
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn slot_typography_parses_body_m()
|
||||
{
|
||||
let json = r##"
|
||||
{
|
||||
"type": "typography",
|
||||
"family": { "ref": "sora" },
|
||||
"weight": 400,
|
||||
"size": 16,
|
||||
"line_height": { "px": 24 },
|
||||
"letter_spacing": 0,
|
||||
"meta": { "semantic": "body/m" }
|
||||
}
|
||||
"##;
|
||||
let raw: RawSlot = serde_json::from_str( json ).unwrap();
|
||||
match Slot::from( raw )
|
||||
{
|
||||
Slot::TextStyle { value, meta } =>
|
||||
{
|
||||
assert_eq!( value.weight, 400 );
|
||||
assert_eq!( value.size, 16.0 );
|
||||
assert_eq!( value.line_height, LineHeight::Px( 24.0 ) );
|
||||
assert_eq!( meta.semantic.as_deref(), Some( "body/m" ) );
|
||||
}
|
||||
_ => panic!( "expected typography slot" ),
|
||||
}
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn document_parses_minimal_two_modes()
|
||||
{
|
||||
let json = r##"
|
||||
{
|
||||
"theme": { "id": "demo", "name": "Demo" },
|
||||
"fonts":
|
||||
{
|
||||
"sora":
|
||||
{
|
||||
"name": "Sora",
|
||||
"fallbacks": ["system-ui", "sans-serif"],
|
||||
"sources":
|
||||
[
|
||||
{ "weight": 400, "path": "fonts/Sora-Regular.ttf" }
|
||||
]
|
||||
}
|
||||
},
|
||||
"modes":
|
||||
{
|
||||
"light":
|
||||
{
|
||||
"slots":
|
||||
{
|
||||
"primary-500": { "type": "color", "value": "#04D9FE" },
|
||||
"body-m": { "type": "typography", "family": { "ref": "sora" }, "weight": 400, "size": 16, "line_height": { "px": 24 } }
|
||||
}
|
||||
},
|
||||
"dark":
|
||||
{
|
||||
"slots":
|
||||
{
|
||||
"primary-500": { "type": "color", "value": "#04D9FE" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"##;
|
||||
let doc = parse_document_json( json, None ).unwrap();
|
||||
assert_eq!( doc.id, "demo" );
|
||||
assert_eq!( doc.name, "Demo" );
|
||||
assert_eq!( doc.fonts.len(), 1 );
|
||||
assert_eq!( doc.fonts["sora"].sources[0].weight, 400 );
|
||||
assert_eq!( doc.light.slots.len(), 2 );
|
||||
assert_eq!( doc.dark.slots.len(), 1 );
|
||||
assert!( doc.light.slots.color( "primary-500" ).is_some() );
|
||||
assert!( doc.light.slots.text_style( "body-m" ).is_some() );
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn unknown_fields_in_stop_are_rejected()
|
||||
{
|
||||
// `deny_unknown_fields` doesn't cooperate well with
|
||||
// `#[ serde( flatten ) ]`, so the slot-level check would be unreliable
|
||||
// on variants that flatten a body struct. The safety net lives on
|
||||
// concrete nested types like `RawColorStop`, `RawShadow`, and
|
||||
// `RawWallpaper`, which reject typos outright.
|
||||
let bad = r##"
|
||||
{
|
||||
"type": "linear",
|
||||
"angle_deg": 90,
|
||||
"stops":
|
||||
[
|
||||
{ "pos": 0, "color": "#FFFFFF", "extraneous": true }
|
||||
]
|
||||
}
|
||||
"##;
|
||||
assert!( serde_json::from_str::<RawSlot>( bad ).is_err() );
|
||||
}
|
||||
55
src/theme/search.rs
Normal file
55
src/theme/search.rs
Normal file
@@ -0,0 +1,55 @@
|
||||
// SPDX-License-Identifier: LGPL-2.1-only
|
||||
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
|
||||
|
||||
//! Theme directory search paths and the font-registry builder that walks the
|
||||
//! active document's font block.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use super::active::ensure_active;
|
||||
use super::font_registry::FontRegistry;
|
||||
|
||||
// ─── Search paths ────────────────────────────────────────────────────────────
|
||||
|
||||
/// Returns the ordered list of directories under which theme families are
|
||||
/// looked up, highest-priority first.
|
||||
///
|
||||
/// `LTK_THEMES_DIR` (when set) takes absolute precedence — useful during
|
||||
/// development to point the shells at an in-tree `themes/` directory without
|
||||
/// having to install or symlink anything.
|
||||
pub fn search_paths() -> Vec<PathBuf>
|
||||
{
|
||||
let mut out = Vec::new();
|
||||
if let Some( dev ) = std::env::var_os( "LTK_THEMES_DIR" )
|
||||
{
|
||||
out.push( PathBuf::from( dev ) );
|
||||
}
|
||||
if let Some( home ) = std::env::var_os( "XDG_DATA_HOME" )
|
||||
{
|
||||
out.push( PathBuf::from( home ).join( "ltk/themes" ) );
|
||||
}
|
||||
else if let Some( home ) = std::env::var_os( "HOME" )
|
||||
{
|
||||
out.push( PathBuf::from( home ).join( ".local/share/ltk/themes" ) );
|
||||
}
|
||||
out.push( PathBuf::from( "/usr/share/ltk/themes" ) );
|
||||
out
|
||||
}
|
||||
|
||||
/// Build a live [`FontRegistry`] from the active document's `fonts`
|
||||
/// block, loading each declared source from disk. Sources that fail to
|
||||
/// read or parse are skipped with a warning on stderr — the registry
|
||||
/// degrades gracefully so one missing TTF does not take down the rest
|
||||
/// of the family.
|
||||
///
|
||||
/// Returns `None` when the active document declares no families at
|
||||
/// all (in which case the caller keeps the canvas' system-font
|
||||
/// fallback). Callers should hand the returned registry to the
|
||||
/// canvas via `Canvas::set_font_registry`; the draw loop already
|
||||
/// does this at canvas creation time.
|
||||
pub fn build_font_registry() -> Option<FontRegistry>
|
||||
{
|
||||
let doc = ensure_active().document;
|
||||
if doc.fonts.is_empty() { return None; }
|
||||
Some( FontRegistry::from_families_lenient( &doc.fonts ) )
|
||||
}
|
||||
20
src/theme/typography.rs
Normal file
20
src/theme/typography.rs
Normal file
@@ -0,0 +1,20 @@
|
||||
// SPDX-License-Identifier: LGPL-2.1-only
|
||||
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
|
||||
|
||||
//! Typography scale used by the default theme.
|
||||
//!
|
||||
//! Designed around the **Sora** typeface (Google Fonts). If Sora is not
|
||||
//! installed on the system, ltk falls back to Liberation Sans / DejaVu Sans;
|
||||
//! glyph metrics will differ slightly but the scale still reads correctly.
|
||||
|
||||
pub const H0: f32 = 50.0;
|
||||
pub const H1: f32 = 34.0;
|
||||
pub const H2: f32 = 24.0;
|
||||
pub const H3: f32 = 20.0;
|
||||
pub const BODY: f32 = 16.0;
|
||||
pub const BODY_S: f32 = 14.0;
|
||||
pub const BODY_XS: f32 = 12.0;
|
||||
|
||||
/// Interlineado (line-height) multiplier recommended by the kit. Apply as
|
||||
/// `size * LINE_HEIGHT` when laying out multi-line text blocks.
|
||||
pub const LINE_HEIGHT: f32 = 1.5;
|
||||
Reference in New Issue
Block a user