`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.
1118 lines
42 KiB
Rust
1118 lines
42 KiB
Rust
// 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 );
|
||
}
|
||
}
|