Files
ltk/src/theme/mod.rs
Pedro M. de Echanove Pasquin bfe27b6fef event_loop, widget, input: pointer-dwell tooltips, global drag coords, foreign-toplevel name cascade
`Button::tooltip( text )` registers a hint string that fires after a 600 ms pointer dwell. `LaidOutWidget` gains a `tooltip: Option<String>` field, `Element::tooltip()` exposes it to the input layer, and the existing pointer-hover path now calls `arm_tooltip` on hover-enter and `cancel_tooltip` on hover-leave / touch. The deadline is polled alongside `next_long_press_wakeup` in `try_run` so an idle pointer still gets a wake-up at the firing instant; on fire, `tooltip_overlay()` synthesises an `OverlaySpec` — a rounded `text_primary @ 95%` pill drawn with `bg` text — anchored above the hovered widget, flipping below or clamping inside the screen if it would clip, and pushed alongside the app's own overlays both in the redraw path and in `reconcile_overlays` so the layer surface is created the same frame the tooltip becomes visible. Pointer-only by design: touch events explicitly cancel because a tap-and-release should never linger into a hint. The `showcase` example wires `.tooltip(..)` on the three button variants as a smoke test.
The drag pipeline now reports positions in main-surface (global) coordinates instead of per-surface. `surface_offset_for( focus )` derives the top-left of an overlay surface from `SurfaceState::layer_anchor` — newly stored at `reconcile_overlays` time from `OverlaySpec::anchor` — combined with the main surface's dimensions; `on_drag_move`, `on_drop`, the synthetic move emitted on drag-promotion in `pointer.rs`, and the `pending_drag_inits` push site in `gesture::start_drag` all translate before handing coordinates to the app. The motion and release paths additionally `request_redraw()` every overlay so a dock-style drop target painted on an `Anchor::Bottom` layer surface gets repainted as the drag moves — without that, the visible drop indicator only updates when the cursor re-enters the main surface. Drops still target whichever surface fired the release; only the coordinates are unified.
`ForeignToplevelListHandler` previously read `app_id` directly via `ForeignToplevelList::info()`. Clients that never set `app_id` (some winit-windowed compositors, simple test clients) were silently invisible to crustace-style docks because the empty string fell through `unwrap_or_default()` and the dock then keyed entries off `""`. `toplevel_display_id()` cascades: prefer `app_id` for desktop-entry matching, fall back to `title` for human-readable identification, and finally to the protocol-issued `identifier` which is always present and unique per handle. Applied to both `new_toplevel` and `update_toplevel`.
`theme::system_fontdb()` lazily loads the system font database once via `OnceLock` and reuses the `Arc` for every `decode_svg_bytes` call. resvg's default `Options::fontdb` is empty, so any SVG containing `<text>` rendered with the built-in fallback font or no font at all; with the system DB attached, icons and decorative SVGs with embedded labels now resolve glyphs correctly. Cached because `load_system_fonts()` walks every font path on the system and is comfortably tens of milliseconds on a cold cache — not something to repeat per icon decode.
`themes/default/theme.json` tweaks one variant's slot palette: `surface-alt` from `@indigo/D9` to `@white/D9` and `text-primary` from `@white` to `@navy`, plus a cosmetic re-alignment of the `"value"` columns in the same slots block.
2026-05-14 22:36:17 +02:00

1118 lines
42 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! 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/<id>/ (system overlay, lower priority)
//! ~/.local/share/ltk/themes/<id>/ (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<resvg::usvg::fontdb::Database>
{
static DB: OnceLock<Arc<resvg::usvg::fontdb::Database>> = 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<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,
}
// ─── 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<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
}
// ─── 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<ThemeDocument>,
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<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.
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<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
}
// ─── 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<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 )
}
/// 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 } )
}
/// 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" )
}
/// 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" )
}
/// 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 )
}
/// 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 ) )
}
// ─── 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<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 );
/// 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 )
}
// ─── 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##"<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
}
#[ 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 );
}
}