Files
ltk/docs/theming.md
T
admin 492553829e
CI / build + test (push) Has been cancelled
CI / cargo audit (push) Has been cancelled
widgets, input: PatternGrid for pattern unlock, framed icon buttons, and text keys that bubble past a focused non-text widget; Debian 0.3.0-2
PatternGrid is a 3×3 drag widget for the unlock pattern. It is stateless: the app owns the visited nodes and passes them back in for drawing; a press or a drag that reaches a node within the snap radius fires on_node with the index, 0 to 8 row-major, and release fires on_release, so the crossing and minimum-length rules stay in the app. Geometry lives in widget/pattern_grid/theme.rs (size, dot and node radii, stroke width, snap radius, ring alpha), and the snap radius is resolved through the widget-scaling mode at layout time like the slider thumb (set_pattern_snap_px). The gesture machine reuses the slider's drag ownership: WidgetHandlers::is_drag_target covers both, so hover painting, pointer capture and release behave the same, and hover_affects_paint no longer special-cases sliders. The a11y tree maps it to GenericContainer, since a drawn gesture has no assistive equivalent and the app must offer another method. Exported as pattern_grid / PatternGrid, documented in widgets.md, theming.md and architecture.md, with tests for node centres, a wide rect, snapping, the builders, the handler snapshot and map_msg, and a Security tab in examples/widgets.rs that shows the drawn path.
Icon buttons given a width or height are framed keys: preferred_size honours the explicit size instead of the icon size, and drawing paints the variant's background, border and focus ring, shared with text buttons through paint_frame, then centres the icon. An icon button without a size keeps the circular hover overlay it had. This is what lets the PIN keypad's backspace and OK keys sit in the same circles as the digits.
Keyboard routing now matches what App::on_key documents. handle_key_default, Backspace, Delete and Space checked focused_idx.is_some() and sent the key into text insertion whenever any widget had focus, which is a no-op on a button, so a focused button or pressable swallowed typed characters. focused_text_input replaces the five copies of that check; characters, Backspace and Delete stay in a focused text input and reach the app otherwise, Return and Space still go to the focused widget's submit or press message when it has one, and the arrow, Home and End handlers use the same helper. The lockscreen relies on this to take PIN digits with its dots row focused.
debian/changelog: 0.3.0-2.
2026-09-09 17:27:42 +02:00

36 KiB
Raw Blame History

ltk theming

ltk loads a JSON theme document lazily — on the first theme accessor call, not at startup — and exposes a process-wide active state for widgets and applications to query. This document describes the on-disk format, the runtime APIs, and the slot conventions that built-in widgets expect.

For background on why theming is process-global and how it interacts with the runtime, see docs/architecture.md. This file is the schema reference.

File layout

A theme is a directory under one of:

  1. LTK_THEMES_DIR/<id>/ — only when the env var is set; intended for development.
  2. $XDG_DATA_HOME/ltk/themes/<id>/ (defaults to ~/.local/share/ltk/themes/<id>/).
  3. /usr/share/ltk/themes/<id>/ — system-wide install path (ltk-theme-default Debian package).

The ordered list is queryable at runtime via ltk::theme::search_paths().

The directory tree:

<id>/
├── theme.json                  required — the schema below
├── branding/                   mode-aware branded assets
│   ├── light/
│   │   ├── launcher.svg        launcher logo
│   │   ├── wallpaper.svg       homescreen wallpaper
│   │   ├── lockscreen.svg      greeter / lockscreen image
│   │   └── logo/               brand wordmark / icon variants
│   │       ├── logo.svg        primary logo (about / splash)
│   │       ├── square.svg      1:1 variant (avatars, app icons)
│   │       └── horizontal.svg  wordmark (header / sign-in bars)
│   └── dark/
│       └── (same set, dark variants)
├── icons/                      symbolic + app icons
│   ├── apps/                   per-application icons (firefox.svg, …)
│   │   └── default.svg         generic app icon shipped with the set
│   └── catalogue/              symbolic glyph catalogue
│       ├── filled/             solid silhouettes — preferred by default
│       │   └── <category>/     general, system, window, …
│       └── line/               outlined variants (same names)
├── cursors/                    X cursor files (consumed by the
└── cursor.theme                compositor, not parsed by ltk itself)

Note a known mismatch: the theme_app_default_icon() fallback looks for icons/app-default.svg, a path the shipped default theme does not provide (its generic icon lives at icons/apps/default.svg), so that fallback never resolves against the default theme.

Branded assets in branding/ and icons in icons/ are picked up by convention — see Branding assets and Icons below. Asset paths declared inside theme.json (e.g. a custom wallpaper override) resolve relative to the theme directory; a bare "path": "custom.png" is portable across install prefixes, absolute paths work for system fonts but break relocatable installs.

Top-level structure

{
  "theme":        { "id": "default", "name": "Default" },
  "fonts":        { ... },
  "colors":       { ... },
  "gradients":    { ... },
  "inset_stacks": { ... },
  "modes": {
    "light": { ... },
    "dark":  { ... }
  }
}

theme.id should match the directory name by convention — lookup goes by directory name and the id inside the JSON is not checked against it. theme.name is shown in any theme-picker UI a shell builds on top of ltk.

The other five sections are described below in order. The parser is strict (deny_unknown_fields) at most levels — the document root, fonts, modes, gradient stops, shadow entries — so typos there are an error. It is not strict everywhere: unknown keys directly inside a slot entry or inside a meta block are silently ignored, and the surface / typography slot bodies lose the check to serde's #[serde(flatten)].

fonts

"fonts": {
  "sora": {
    "name":      "Sora",
    "fallbacks": ["system-ui", "sans-serif"],
    "sources":   [
      { "weight": 300, "path": "/usr/share/fonts/opentype/sora/Sora-Light.otf"    },
      { "weight": 400, "path": "/usr/share/fonts/opentype/sora/Sora-Regular.otf"  },
      { "weight": 600, "path": "/usr/share/fonts/opentype/sora/Sora-SemiBold.otf" },
      { "weight": 700, "path": "/usr/share/fonts/opentype/sora/Sora-Bold.otf"     }
    ]
  }
}

Each entry defines a font family with one source per OpenType / TrueType weight. The map key ("sora") is the family alias used inside the theme ("font_family": "sora"); name is the human-readable label.

fallbacks is consulted when a glyph is missing from the primary font files at render time. The chain is walked in order; the first family that can rasterise the codepoint wins.

Sources that fail to read are skipped with a stderr warning; when the whole block yields nothing, text falls back to the embedded Sora Regular (~50 KB, OFL 1.1). Missing fonts never trigger the red warning banner — that banner is reserved for the case where the default theme document itself cannot be found (see Common errors). The registry that backs this is built by ltk::theme::build_font_registry(), which the draw loop already calls at canvas creation time.

colors

A flat dictionary of named hex literals, used as building blocks elsewhere in the document via the @name reference syntax:

"colors": {
  "navy":      "#0A032E",
  "white":     "#FFFFFF",
  "cyan":      "#04D9FE",
  "danger":    "#E5484D",
  "glass-hi":  "#555555",
  "ink":       "#000000"
}

Values are 6- or 8-digit hex (#RRGGBB or #RRGGBBAA). Names use kebab-case. There is no distinction between "palette" colours (used in many places) and "raw" colours (single-use) — any hex can live here, and single-use literals are equally valid inline.

Inline colour values elsewhere in the document (slot values, gradient stops, shadow colours, window-controls tokens) additionally accept the functional forms rgb(R, G, B) and rgba(R, G, B, A) — R/G/B as integers or floats in 0..=255, A as a float in 0.0..=1.0. The top-level colors map is the asymmetric exception: its entries must be hex literals; a functional form there is rejected.

gradients

Named paints. Two variants: linear and radial. Used both as fills for surfaces and as values referenced from a slot. Stops carry a pos (alias: position; in [0, 1], but stop positions outside that range are accepted and used for extrapolation) and a color (literal hex or @reference).

"gradients": {
  "fill-cyan": {
    "type":      "linear",
    "angle_deg": 270,
    "space":     "linear-rgb",
    "stops": [
      { "pos": 0, "color": "@cyan"      },
      { "pos": 1, "color": "@cyan-soft" }
    ]
  }
}

space is "linear-rgb" (interpolate in linear-light space — the default, physically correct and fast), "srgb" (interpolate in gamma-encoded sRGB; reproduces designs authored against it, but midpoints of saturated gradients look muddy) or "oklab" (perceptual; best for high-chroma brand gradients, slightly more expensive). angle_deg is the conventional CSS gradient angle (0 = up, 90 = right, 180 = down, 270 = left).

Radial gradients use center: [x, y] (relative to the painted rect, both in [0, 1]) and radius: r (also relative).

Soft cap: a single gradient may carry 64 stops. Beyond that the parser truncates the tail with a stderr warning, so a hostile or mistakenly-large gradient cannot blow up CPU + memory at parse time. Realistic designs use 2 – 6 stops.

inset_stacks

Named lists of inset shadows reused across surfaces. Convenient for the "glass" stack used by every translucent slot in the default theme:

"inset_stacks": {
  "glass-insets": [
    { "offset": [0, 1], "blur": 4, "color": "@ink/0F", "blend": "normal" }
  ]
}

Each entry has offset: [x, y] (logical pixels), blur (Gaussian sigma × 2, matching the CSS convention), optional spread (default 0), color (literal or @ref), and blend (normal, plus-lighter, overlay, multiply, or screen).

Reference an entire stack from a slot with "inset_shadows": "@glass-insets", or inline the array if you only use it once.

Responsive sizing

Pixel values in a theme are logical design pixels: they resolve through the Length / WidgetScaling system (Length::dp for constant-physical sizes, Length::fluid for surface-proportional ones, set_density, and Canvas::geom_px / Canvas::font_px at draw time) — see docs/architecture.md#responsive-sizing. For text, the theme::typography module ships the H0…BODY_XS px constants plus the fluid h0()…body_xs() Length builders that scale with the surface's smaller dimension.

modes

Two required entries: light and dark. Each carries the look the theme applies in that lighting mode plus its own slots table.

wallpaper / lockscreen

Both are optional. The runtime resolves them in two steps:

  1. Convention — looks for branding/{mode}/wallpaper.svg and branding/{mode}/lockscreen.svg, with the standard mode → opposite-mode → no-mode fallback (see Branding assets).
  2. Override — if theme.json declares an explicit block, that path wins over the convention. Useful for raster wallpapers or non-conventional locations:
"wallpaper":  { "path": "custom/path.png", "fit": "cover" }

path resolves against the theme directory. fit is one of "cover", "contain", "stretch", "center", "tile" (default "cover"). The wallpaper bundle helper ([ltk::WallpaperBundle::for_size]) returns the right crop for landscape or portrait surfaces, so a single landscape SVG / PNG covers both.

launcher

"launcher": { "background": "@white/E6", "border_radius": 24.0 }

background is any color reference (@name[/AA]). border_radius is the corner radius for the launcher panel, in logical pixels.

window_controls

Per-mode tokens for the title-bar control buttons:

"window_controls": {
  "bar_bg":         "@off-white",
  "icon":           "#5F5F68",
  "hover_bg":       "@navy/14",
  "pressed_bg":     "@navy/24",
  "close_hover_bg": "@danger",
  "close_icon":     "@white",
  "focus_ring":     "@teal"
}

bar_bg is the background fill of the server-side-decoration title bar strip the buttons sit on; the six remaining keys style the buttons themselves.

Consumed by the window_button widget through [theme_window_controls()].

The actual SVG glyphs (close, maximize, minimize, restore) live in icons/catalogue/filled/window/ and are tinted at runtime with the icon colour from this block (and close_icon on close-hover). Themes don't need to ship per-mode variants of these glyphs — the symbolic tinting handles light vs dark colouring. They also don't need a line/window/ variant; chrome controls should look the same regardless of the theme's overall icon style preference, and the existing filled fallback in icon_path already covers the case.

slots

The mode's slot table. Each entry is keyed by a stable id; widgets look their slot up by id and the slot's meta.semantic field supplies a human-readable hint that's useful in theme inspectors.

Six slot variants: color, linear, radial, shadows, surface and typography.

color

"text-primary": {
  "type":  "color",
  "value": "@navy",
  "meta":  { "semantic": "palette/text_primary" }
}

Plain colour values. value is a literal hex or @reference. meta is optional but conventional: palette/<role> for the palette layer and effect/<group>/<name> for everything else. Besides semantic, meta also carries three optional free-form fields the runtime ignores: fluent (equivalent token name in another design system), usage (guidance on where to use the slot) and note.

linear / radial

"chip-active": {
  "type":      "linear",
  "angle_deg": 90,
  "stops": [
    { "pos": 0, "color": "@cyan" },
    { "pos": 1, "color": "@teal" }
  ]
}

Inline gradient slots — the same fields as a gradients entry (angle_deg + stops + optional space for linear; center + radius + stops + optional space for radial), declared directly in the slot table instead of being named and referenced. Resolved by widgets via [theme_paint( id )], which also promotes plain color slots to a solid paint.

typography

"body-m": {
  "type":        "typography",
  "family":      { "ref": "sora" },
  "weight":      400,
  "size":        16,
  "line_height": { "px": 24 }
}

A resolved text style, looked up via [theme_text_style( id )]. family.ref names an entry of the top-level fonts block; weight and size are required. line_height is either { "px": n } (absolute) or { "mul": n } (multiplier of the size). Optional fields with defaults: style (normal / italic), letter_spacing (0), transform (none / uppercase / lowercase / capitalize), decoration (none / underline / strikethrough) and color (a slot-id string).

shadows

"shadows-glass": {
  "type": "shadows",
  "shadows": [
    { "offset": [0, 0], "blur": 9, "color": "@glass-elev/1F" }
  ]
}

Outer drop shadows applied via [theme_shadows(id)]. Same field shape as inset_stacks entries.

surface

"surface-card": {
  "type":          "surface",
  "fill":          "@surface-glass-dark",
  "shadows":       "shadows-glass",
  "inset_shadows": "@glass-insets",
  "meta":          { "semantic": "effect/glass/card" }
}

The most expressive slot kind. Composes:

  • fill — a paint reference (@gradient-name) or inline gradient / solid colour. Required.
  • shadows — id of a shadows slot or an inline list. Optional.
  • inset_shadows — id of an inset_stacks entry (@glass-insets) or inline list. Optional.

References

The @name syntax substitutes a palette / gradient / inset-stack value in place of an inline literal:

  • @cyan — looks up gradients.cyan / inset_stacks.cyan first, then colors.cyan. A name defined in both gradients and inset_stacks is an error; a name defined in both colors and one of the token sections is not — the token silently wins, so avoid reusing names across sections. When the resolved value is a colour, the alpha channel comes from the original.
  • @cyan/80 — the /AA suffix is a colour-only alpha override (two hex digits). Lets a single base @navy serve @navy/14, @navy/24, @navy/99, @navy/D9 etc. without a separate entry per alpha.

References inside gradient stops or inset shadows are resolved at parse time, so each downstream substitution at a slot call site is a flat clone with no recursion at runtime.

Unknown references fail loud: parsing aborts with ThemeError:: UnknownColorRef("foo").

Canonical slot ids

The default widgets look up these slot ids; a custom theme that omits any of them falls back to embedded defaults.

Palette (every mode must define):

id role type
bg-page window background color
surface card / panel surface color
surface-alt text-input field background color
text-primary regular text colour color
text-secondary muted / placeholder text, pattern-grid idle dots color
accent toggle on, slider fill, focus ring, pattern-grid stroke and visited dots color
divider separator, toggle off, list item border color
icon icon-button glyph colour color
danger destructive / error foreground, pattern-grid path in error color
danger-bg soft fill behind error states color

The default theme defines danger but not danger-bg, which therefore resolves to the built-in pink-wash default from the palette projection.

Effects (optional):

id consumer type
surface-slider-track Slider / VSlider track background surface
surface-slider-fill Slider / VSlider filled portion surface
surface-card no built-in widget reads it automatically — apps opt in via Container::surface("surface-card") surface
shadows-glass referenced from the theme's own surface slots; no direct widget consumer shadows
surface-panel, surface-toggle-active, surface-card-flat, surface-slider-track-flat, surface-slider-fill-flat reserved — currently unconsumed by any built-in widget surface

Only the slider track / fill pair is looked up by widgets on their own. The -flat variants are not wired to the software backend automatically; a caller can pass one explicitly (e.g. Slider::track_surface("surface-slider-track-flat")), but nothing in ltk selects them per backend.

Using the theme from app code

The active theme is process-global mutable state: a (ThemeDocument, ThemeMode) pair guarded by a RwLock behind the ltk::theme API. Widgets and apps read it through cheap accessors that clone an Arc or project a small struct out of the slot table — designed so it's fine to call them dozens of times per frame from inside view().

The canonical pattern: read in view(), never cache

Read the theme at the top of every view() and let the next frame re-resolve automatically. Storing a Color in your app state freezes it at the moment you captured it: a later set_active_mode( Dark ) will repaint everyone who reads palette per-frame and skip the widgets that read a stale field.

# use ltk::{ column, container, text, Element };
# #[ derive( Clone ) ] enum Msg {}
# struct MyApp;
# impl MyApp {
fn view( &self ) -> Element<Msg>
{
    let palette = ltk::theme_palette();

    column::<Msg>()
        .push( text( "Hola" ).color( palette.text_primary ) )
        .push( text( "subtítulo" ).color( palette.text_secondary ) )
        .push(
            container( text( "tarjeta" ).color( palette.text_primary ) )
                .background( palette.surface )
                .radius( 12.0 ),
        )
        .into()
}
# }

Helpers reference

Helper Returns When to use
theme_palette() Palette Common case — named colour fields (bg, surface, surface_alt, text_primary, text_secondary, accent, divider, icon, danger, danger_bg). Cheap projection, ideal at the top of view().
theme_color( id ) Option<Color> Pull a single colour slot by id when it's not in the palette ("surface-card-border", custom theme tokens).
theme_color_or( id, fallback ) Color Same, with a baked-in default — ergonomic in widget defaults so missing slots don't return None.
theme_paint( id ) Option<Paint> Slot may be a colour or a gradient — promotes a colour to Paint::Solid automatically.
theme_surface( id ) Option<Surface> Surface slot (fill + shadows + insets).
theme_resolve_surface( id ) Option<( Surface, Vec<Shadow> )> Same, but pre-resolves a ShadowsRef::Named reference to a flat Vec. Use this when you call canvas.fill_surface directly.
theme_shadows( id ) Option<Vec<Shadow>> Outer shadow stack.
theme_text_style( id ) Option<TextStyle> Typography slot (size, weight, line-height).
theme_window_controls() WindowControlsSpec Per-mode chrome tokens for the title-bar buttons.
theme_wallpaper() / theme_lockscreen() Option<WallpaperSpec> Full-screen branded images (SVG), with the convention fallback chain.
theme_branding_image( name, sw, sh ) Option<PathBuf> Sized branded image: smallest covering raster (WebP / PNG / JPEG) under branding/{mode}/{name}/, or the largest available raster if none cover, falling back to the SVG only when no rasters exist. Pass (0, 0) for the smallest available (startup before surface-configure).
theme_branding_raster( name, sw, sh ) Option<PathBuf> Raster-only variant of the above; returns None only when no rasters exist at all.
theme_branding_asset( name, ext ) Option<PathBuf> Generic branded asset lookup (any extension). Powers theme_launcher_icon, theme_wallpaper, theme_lockscreen.
theme_launcher_icon() Option<PathBuf> Launcher logo SVG path, with the convention fallback.
theme_logo() Option<PathBuf> Primary brand logo SVG (branding/{mode}/logo/logo.svg) — about dialogs, splash screens.
theme_logo_square() Option<PathBuf> Square 1:1 logo variant (logo/square.svg) — app icons, login avatars, lockscreen badges.
theme_logo_horizontal() Option<PathBuf> Wordmark logo variant (logo/horizontal.svg) — header bars, sign-in screens.
theme_app_icon( name ) / theme_app_default_icon() Option<PathBuf> Per-app icons under icons/apps/.
theme_icon_path( "category/name" ) Option<PathBuf> Catalogue icon path (filled-then-line lookup).
theme_icon_rgba( "category/name", size ) Option<( Arc<Vec<u8>>, u32, u32 )> Rasterised + cached RGBA. Pair with theme::tint_symbolic for chrome glyphs.
theme_icon_tinted( name, size, tint ) Option<ImageData> One-call rasterise + tint (theme_icon_rgba + tint_symbolic).
decode_svg_bytes( bytes, size ) Option<( Arc<Vec<u8>>, u32, u32 )> Rasterise arbitrary SVG bytes (uncached, no path resolution); the primitive under theme_icon_rgba.
active_document() Arc<ThemeDocument> The whole loaded document, for slot-typed lookups the per-slot helpers do not cover (e.g. iterating slots).
active_theme_id() String Id of the active theme.
is_fallback_active() bool true when the embedded B/W fallback theme is in force (no theme on disk). Useful to disable a theme-picker UI or warn the user.

Switching mode or document at runtime

Mutators live next to the readers. They take effect on the next render:

// Light → dark.
ltk::set_active_mode( ltk::ThemeMode::Dark );

// Replace the whole document (user picked a different theme id in a
// settings panel, etc.).
let doc = ltk::ThemeDocument::find( "midnight" )
    .expect( "midnight theme not installed" );
ltk::set_active_document( doc );

ThemeDocument::find( id ) walks the standard search paths; ThemeDocument::load_from_dir( dir ) loads a specific directory (useful for previews or tests). Once loaded, doc.mode( ThemeMode::Dark ) projects the per-mode content without installing the document.

For the phone-style "auto" setting, ThemePreference (Light / Dark / Auto) is the shell-side persisted preference: pref.resolve( hour ) maps it to a concrete ThemeMode, with Auto delegating to ThemeMode::from_hour( hour ) — dark between 19:00 and 06:59 local time, light otherwise. The toolkit itself only ever sees the resolved ThemeMode.

The conventional wiring is to dispatch a message from the UI:

# struct MyApp;
enum Msg { ToggleTheme }

# impl MyApp {
fn update( &mut self, msg: Msg )
{
    match msg
    {
        Msg::ToggleTheme =>
        {
            let next = match ltk::active_mode()
            {
                ltk::ThemeMode::Light => ltk::ThemeMode::Dark,
                ltk::ThemeMode::Dark  => ltk::ThemeMode::Light,
            };
            ltk::set_active_mode( next );
        }
    }
}
# }

Every widget that read theme_palette() / theme_surface(...) / ... in view() automatically gets the new colours on the next frame — there is no manual invalidation step, no observer registration, no list of subscribed widgets. The entire reactivity story is "ltk re-runs view() every frame and you read fresh values".

Do / don't

  • Do read palette / surfaces / icons inside view(), every frame.
  • Do use theme_color_or( id, fallback ) for non-palette slots so a custom theme that omits a slot still paints something sane.
  • Do use theme_palette().<field> for the ten common roles — the palette is precomputed and cheap; named slot lookups only beat it when you genuinely need a non-canonical token.
  • Don't store Color, Surface, Paint, or icon PathBufs in your app state. They snapshot the moment of capture and stop responding to mode changes.
  • Don't hard-code Color::hex( ... ) for chrome that should adapt to mode — route it through a palette token or a custom slot.
  • Don't call set_active_mode from view() (it's a side effect; do it in update() from a Msg).

Branding assets

The branding/ directory holds the theme's identity assets — launcher logo, wallpaper, lockscreen, brand wordmark — keyed by mode. The runtime loader looks them up by convention so themes don't need to declare them in theme.json.

Layout

branding/
├── light/
│   ├── launcher.svg
│   ├── wallpaper.svg
│   ├── wallpaper/                  optional pre-rendered raster variants
│   │   ├── 1280x720.webp
│   │   ├── 1920x1080.webp
│   │   └── 3840x2160.webp
│   ├── lockscreen.svg
│   ├── lockscreen/
│   │   └── (same WIDTHxHEIGHT.webp set)
│   └── logo/                       brand wordmark / icon variants
│       ├── logo.svg                primary (about, splash)
│       ├── square.svg              1:1 (app icon, avatar)
│       └── horizontal.svg          wordmark (header bar, sign-in)
└── dark/
    └── (same structure)

The SVG is the canonical asset. Sized raster variants in the same-named subdirectory are an optimisation: the loader prefers a pre-rendered WebP / PNG / JPEG that already covers the surface (no upscale, no runtime SVG rasterisation), falling back to the SVG when no raster fits. Filenames must be WIDTHxHEIGHT.<ext> (literal numeric form, lower-case x); the parser is strict so a bad name is silently ignored rather than misclassified.

Fallback chain — SVG

When a branded SVG asset is requested for the active mode, the runtime tries three locations in order:

  1. branding/{active_mode}/{name}.svg — preferred variant.
  2. branding/{opposite_mode}/{name}.svg — graceful degradation when the theme only ships one mode of the asset.
  3. branding/{name}.svg — mode-agnostic asset, for themes that don't bother with light/dark variants.

Returns None when none of the candidates exist; consumers fall back to whatever default they prefer (solid bg-page for wallpapers, no launcher decoration, etc.).

Resolution — raster

When the surface size is known, [theme_branding_raster] looks for a pre-rendered raster under branding/{mode}/{name}/. The same three-step mode chain applies to the directory: tries the active mode first, then the opposite mode, then the mode-less directory. Within the first existing directory it parses every WIDTHxHEIGHT.<ext> filename (<ext> ∈ {webp, png, jpg, jpeg}) and picks:

  • the smallest entry whose two dimensions cover the surface, if any;
  • otherwise the largest entry available — a fast-decoding upscaled raster beats paying the SVG rasterisation cost.

The directory does not cross over to the opposite-mode tree once an existing directory is found: a colour-wrong raster (light-mode asset served in dark mode or vice versa) would be more jarring than an upscaled same-mode raster. Cross-mode degradation only happens at the SVG layer, where the vector form re-paints crisply at any size.

theme_branding_image(name, sw, sh) composes raster-then-SVG: tries theme_branding_raster first, falls back to theme_branding_asset(name, "svg") only when no raster files exist anywhere in the chain. Pass (0, 0) to get the smallest available raster — every entry trivially covers a zero-sized surface. Useful at startup before the surface-configure event arrives, so the first frame paints from a fast-decoded lightweight raster (typically a few ms) instead of the SVG (typically 1-2 s for a gradient-heavy abstract wallpaper).

Runtime API

// SVG resolution — mode-aware fallback chain on the file path.
let launcher   = ltk::theme_launcher_icon();   // Option<PathBuf>
let wallpaper  = ltk::theme_wallpaper();       // Option<WallpaperSpec>
let lockscreen = ltk::theme_lockscreen();      // Option<WallpaperSpec>

// Sized raster (WebP / PNG / JPEG) — pick the smallest covering variant.
let raster = ltk::theme_branding_raster( "wallpaper", 1920, 1080 );
//                                                            -> Option<PathBuf>

// Combined: raster first, SVG fallback. Recommended for wallpaper /
// lockscreen consumers that have the surface size at decode time.
let path = ltk::theme_branding_image( "wallpaper", 1920, 1080 );

// Brand wordmark / icon — three named variants under branding/{mode}/logo/.
let about_logo = ltk::theme_logo();              // Option<PathBuf> — primary
let app_icon   = ltk::theme_logo_square();       // Option<PathBuf> — 1:1
let header_lg  = ltk::theme_logo_horizontal();   // Option<PathBuf> — wordmark

// Generic helper for arbitrary branded SVG assets (splash, watermarks, …).
let splash = ltk::theme_branding_asset( "splash", "svg" );

branding_asset(name, ext) is the underlying SVG helper; branding_raster and branding_image add the size-aware layer on top. The three theme_logo* helpers are thin wrappers over branding_asset( "logo/<variant>", "svg" ) — the name argument freely accepts a subdir/file shape, so any other sub-grouped asset family follows the same pattern.

Icons

Two parallel trees under icons/:

  • icons/apps/ — per-application icons (firefox.svg, calculator.svg, …). Each app's brand identity, mode-agnostic. Looked up via [theme::app_icon( "firefox" )].
  • icons/catalogue/ — symbolic glyph catalogue intended for tinting at runtime. Two style variants:
    • filled/ — solid silhouettes. Preferred by default.
    • line/ — outlined variants. Used as fallback when filled doesn't ship a given icon. A theme that prefers a line aesthetic puts its SVGs in filled/ (where the lookup goes first); the directory name reflects the style of the asset, not the lookup precedence.

Categories under catalogue/{filled,line}/: accessibility, actions, archives, communication, controls, customisation, energy, features, feedback, general, hardware, keyboard, multimedia, navigation, safety, session, system, window.

Adding an icon

  1. Drop a monochrome SVG into catalogue/filled/<category>/<name>.svg. Convention: <svg fill="none"> at the root and an explicit fill on the path (e.g. <g fill="#000000">). The rasteriser keeps only the alpha channel for symbolic tinting, so the actual RGB of the source doesn't matter — pick #000000 for consistency with the rest of the catalogue. Note the catalogue SVGs' viewBoxes are cropped to the ink, not to a uniform grid: the rasteriser scales the longest edge to the requested size preserving aspect ratio, so a non-square glyph comes out smaller than size on its other axis.
  2. Optionally ship the line/ variant in catalogue/line/<category>/<name>.svg.
  3. Reference it from widget code by its slash-separated stem, no extension: theme::icon_path( "general/down-simple" ).

Runtime API

# fn _ex() -> Option<()> {
// Path resolution. Tries filled/<name>.svg first, then line/<name>.svg.
let path = ltk::theme::icon_path( "window/close" );
//                                              -> Option<PathBuf>

// Rasterise to RGBA8 (cached per (path, size) for the lifetime of the
// active document — set_active_document flushes the cache).
let ( rgba, w, h ) = ltk::theme::icon_rgba( "window/close", 16 )?;

// Tint a symbolic icon: keep the alpha, replace the RGB with `tint`.
// `palette.icon` is the catalogue tint; for window-chrome glyphs use
// `ltk::theme_window_controls().icon` instead.
let palette = ltk::theme_palette();
let tinted  = ltk::theme::tint_symbolic( &rgba, palette.icon );

// Or the one-call form combining both steps:
let image = ltk::theme_icon_tinted( "window/close", 16, palette.icon );
# Some( () )
# }

The icon_rgba + tint_symbolic pair is the standard pipeline for catalogue and chrome icons: rasterise once, recolour per mode via palette tokens. Themes ship a single SVG per glyph and the per-mode look comes from code, not from duplicated assets. tint_symbolic multiplies the source alpha by tint.a, so a translucent tint attenuates the whole glyph rather than just recolouring it.

Localisation

ltk integrates rust-i18n for built-in widget strings (context-menu labels, calendar month / day-of-week names, …). Locale files live in ltk/locales/<lang>.yaml. English is the fallback. Currently shipped: en, es, fr, it, de, pt, pt_BR.

Existing keys

context_menu:
  copy:   "Copy"
  cut:    "Cut"
  paste:  "Paste"
  delete: "Delete"

date_picker:
  month_1:     "January"
  ...
  month_12:    "December"
  dow_short_0: "S"
  ...
  dow_short_6: "S"

dow_short_<n> is indexed from each locale's first day of week (Sunday-first in en, Monday-first in es / fr / it / de / pt / pt_BR). The Locale struct's first_dow field is crate-private; apps pick a start day through the public Locale::MONDAY_FIRST / Locale::SUNDAY_FIRST constants and the date picker indexes dow_short_* accordingly.

Built-in widgets read these via rust_i18n::t!( "context_menu.copy" ) at render time, so switching locale at runtime via rust_i18n::set_locale( "es" ) flips the UI on the next frame without reconstructing widgets.

Adding a string

  1. Pick a key like my_widget.label.
  2. Add it to every file under ltk/locales/ so each language has a translation (English at minimum is required — it's the fallback).
  3. Read it from widget code with rust_i18n::t!( "my_widget.label" ).

Adding a language

  1. Create ltk/locales/<code>.yaml with all existing keys translated.
  2. The i18n!("locales", fallback = "en") macro at the crate root picks it up automatically — no registration step needed.
  3. Apps select the locale at startup via rust_i18n::set_locale( "<code>" ).

Common errors

Symptom Cause Fix
Red banner on every frame No theme found at any of the three search paths Install ltk-theme-default Debian package or set LTK_THEMES_DIR
unknown reference @foo at startup Typo in @name reference Check the colors / gradients / inset_stacks section spelling
unknown field 'foo' Stale schema after a ltk upgrade Compare against this document and the default theme
Slot is rendering with the wrong colour after set_active_mode App caches a Color from a previous frame Read palette / surface inside view() each frame; the per-frame Arc clone is cheap

Custom themes

A custom theme directory can live anywhere under $XDG_DATA_HOME/ltk/themes/. Three practical recipes:

  • Repaint for a brand: copy themes/default/, then change:
    • the colors palette in theme.json (everything else cascades),
    • the up-to-six SVGs in branding/{light,dark}/ (launcher, wallpaper, lockscreen),
    • leave the gradients, inset stacks, slot wiring and the entire icons/ tree untouched.
  • Override a single icon: drop a replacement SVG at the same catalogue path under your theme's icons/catalogue/filled/<category>/<name>.svg. The icon_path lookup resolves against whichever theme is active, so a partial catalogue overlays the default cleanly without forking the rest.
  • Build a flat theme: route the surface-* slots to solid colors references and drop the shadow / inset decorations. A color slot promotes to a plain surface automatically, so the built-in widgets keep working with the same ids.

All three recipes keep the slot ids and reference shapes intact, so the built-in widgets continue to work without code changes.