// SPDX-License-Identifier: LGPL-2.1-only // Copyright (C) 2026 Liberux Labs, S. L. //! 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 { 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 } ) } // ─── 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 { 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" ) } // ─── 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 { 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" ) } // ─── 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 { 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 ) } // ─── 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 ); /// 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>, 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 ) } #[ 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 } }