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

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
}
}