// SPDX-License-Identifier: LGPL-2.1-only // Copyright (C) 2026 Liberux Labs, S. L. //! Theming for ltk-based applications. //! //! A *theme* is a directory on disk holding a `theme.json` and any //! supporting assets (wallpapers, fonts, …). The JSON declares `light` //! and `dark` modes — each with its own slot table plus wallpaper, //! lockscreen, launcher and window-controls blocks — plus a shared //! `fonts` registry. The shell picks which mode is active via //! [`ThemeMode`]. //! //! # On-disk layout //! //! ```text //! /usr/share/ltk/themes// (system overlay, lower priority) //! ~/.local/share/ltk/themes// (user overlay, higher priority) //! theme.json //! background-light.png //! background-dark.png //! fonts/ //! Sora-Regular.ttf //! ``` //! //! Paths inside `theme.json` are interpreted relative to the theme's //! directory and are eagerly resolved to absolute paths at load time. //! //! # Process-wide active state //! //! The active theme is published process-wide so widgets can consult their //! colours without every caller threading the palette through their own //! state. Use [`set_active_document`] / [`set_active_mode`] to change it, //! and [`active_document`] / [`active_mode`] / [`active_theme_id`] to read //! it back. Per-slot shorthand accessors ([`color`], [`paint()`], [`surface()`], //! [`palette`], …) cover the common patterns without going through the //! full document. //! //! There is **no in-code fallback**: if `ensure_active` cannot locate the //! `default` theme in any search path, the process aborts with a message //! pointing at the `ltk-theme-default` Debian package (it `Provides: //! ltk-theme`) or at the `LTK_THEMES_DIR` environment variable for //! development installations. use std::collections::HashMap; use std::path::{ Path, PathBuf }; use std::sync::{ Arc, Mutex, OnceLock, RwLock }; fn system_fontdb() -> Arc { static DB: OnceLock> = OnceLock::new(); DB.get_or_init( || { let mut db = resvg::usvg::fontdb::Database::new(); db.load_system_fonts(); Arc::new( db ) } ).clone() } use serde::{ Deserialize, Serialize }; use crate::types::Color; // ─── Submodules ────────────────────────────────────────────────────────────── // // Paint / Shadow / Surface / TextStyle are the building blocks of the // slot-typed theme schema; the SlotStore (`slots`) indexes them by id; // the JSON loader (`schema`) parses the on-disk shape. `document` holds // the top-level `ThemeDocument` and per-mode `Mode` types. `fonts` + // `font_registry` cover the typed font references and their // runtime-loaded counterparts. `gradient_lut` is the small CPU helper // the GPU gradient shaders consume. pub mod paint; pub mod shadow; pub mod surface; pub mod text_style; pub mod fonts; pub mod font_registry; pub mod gradient_lut; pub mod slots; pub mod document; pub ( crate ) mod schema; pub ( crate ) mod fallback; pub use paint::{ ColorStop, GradientSpace, LinearGradient, Paint, RadialGradient }; pub use shadow::{ BlendMode, InsetShadow, Shadow, ShadowsRef }; pub use surface::Surface; pub use text_style::{ FontRef, FontStyle, LineHeight, TextDecoration, TextStyle, TextTransform }; pub use fonts::{ FontFamilyDef, FontSource }; pub use font_registry::{ FontKey, FontLoadError, FontRegistry }; pub use slots::{ Metadata, Slot, SlotStore }; pub use document::{ Mode, ThemeDocument }; // ─── 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, } // ─── 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 [`Palette::bg`]. pub path: Option, 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, } // ─── 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 [`palette`] and /// [`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 ) ), } } } // ─── 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 { 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 } // ─── 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 ), } } } // ─── Active state ──────────────────────────────────────────────────────────── #[ derive( Clone ) ] 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`]. document: Arc, 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. is_fallback: bool, } // `RwLock::new` and `Option::None` are both const, so this needs no lazy init. static ACTIVE: RwLock> = 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. 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. if let Ok( mut cache ) = SVG_CACHE.lock() { if let Some( ref mut map ) = *cache { map.clear(); } } } /// 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 ([`color`], [`surface()`], …) are not /// expressive enough — e.g. iterating `mode.slots.entries`. pub fn active_document() -> Arc { 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 } // ─── Slot-typed shorthands ─────────────────────────────────────────────────── // // 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. /// 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 { 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 { 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> { 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 { 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` 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 )> { 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 { 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 ) } /// 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 { 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 { 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 } ) } /// 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 { 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 { 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 { branding_asset( "launcher", "svg" ) } /// 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 { 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 { 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 { branding_asset( "logo/horizontal", "svg" ) } /// 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 { 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.` where /// `` 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 { 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 { branding_raster( name, sw, sh ) .or_else( || branding_asset( name, "svg" ) ) } /// Walk `dir` for files named `WIDTHxHEIGHT.` (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 { 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::(), h_str.parse::() ) 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 ) } /// 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 { let doc = ensure_active().document; if doc.fonts.is_empty() { return None; } Some( FontRegistry::from_families_lenient( &doc.fonts ) ) } // ─── Typography ────────────────────────────────────────────────────────────── /// 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 mod typography { 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; } // ─── 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` 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 { 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>, u32, u32 )>>> = Mutex::new( None ); /// 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>, 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/.svg` variant first and falls back to /// `catalogue/line/.svg`; returns `None` when neither file /// exists or the active document has no on-disk root. pub fn icon_path( name: &str ) -> Option { 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>, 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 ) } // ─── 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 ( super ) 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, } } // ─── Errors ────────────────────────────────────────────────────────────────── #[ derive( Debug ) ] pub enum ThemeError { Io( PathBuf, std::io::Error ), ParseJson( PathBuf, serde_json::Error ), NotFound( String ), InvalidColor( String ), UnknownColorRef( String ), } impl std::fmt::Display for ThemeError { fn fmt( &self, f: &mut std::fmt::Formatter<'_> ) -> std::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 {} // ─── Tests ─────────────────────────────────────────────────────────────────── #[ 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##""##; 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##""##; 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"", 0 ).is_none() ); // size 0 } #[ 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 ); } #[ 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 ); } }