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:
2026-05-15 23:46:56 +02:00
parent 3d237039c6
commit 4aa3480b64
155 changed files with 13832 additions and 13035 deletions

116
src/theme/accessors.rs Normal file
View 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
View 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
View 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
View 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 {}

File diff suppressed because it is too large Load Diff

122
src/theme/palette.rs Normal file
View 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
View 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 );
}
}

File diff suppressed because it is too large Load Diff

394
src/theme/schema/mod.rs Normal file
View 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
View 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
View 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
View 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
View 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
View 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;