Files
ltk/docs/theming.md
Pedro M. de Echanove Pasquin 1fd697aa6d
Some checks failed
CI / build + test (push) Has been cancelled
CI / cargo audit (push) Has been cancelled
docs overhaul, orientation API, fluid-sizing fixes, examples made honest
Documentation pass: every claim in docs/ and the meta files was audited against the source and the drift fixed — around ninety corrections. CONTRIBUTING and the CI workflow now run cargo test with --features test-support (the gated test_support module made both the documented commands and the CI build fail to compile), make example becomes make examples, make doctest-md and the debhelper requirement of make clean are documented, and patch shape asks for a CHANGELOG entry. theming.md loses the nonexistent surface.backdrop, gains the real gradient defaults (linear-rgb, oklab), the six slot variants including typography, the ten-field palette, a truthful effects-consumer table, the ThemePreference/from_hour API and a responsive-sizing note; the stale docstrings in src/theme that fed the drift are fixed too. architecture.md's "Known gaps" section is rewritten against reality (multi-touch slots, xdg-activation, a11y live regions and SetValue/Increment/Decrement are implemented), gains a module map, subsurfaces and window-lifecycle coverage, and correct crustace/loginmanager paths. widgets.md fixes the ten factual errors (stateless spinner, toast/combo via overlays(), tooltip hover contract, row has no max_width, scroll axes, multiline text_edit, dialog panic wording) and now states the column() 16 px default padding — the recurring ambush — plus row's differing 0 default and dialog's max_width. onboarding, README and cookbook get the remaining sweep: build/test instructions, complete example lists, img_widget, clipping-parity honesty, ~30 Hz software cap, read_rgba_pixels signature, tab indentation in snippets, and rustdoc-style links that rendered literally are gone everywhere. CHANGELOG is restructured per Keep a Changelog with the missing entries (window_resizable, claims_raw_touch, Row::align_top/fill_height, caret fixes, dependency pins) and the pad_v Added/Changed contradiction resolved.
New adaptive-layout API: ltk::orientation() with the Orientation enum, backed by viewport_size()/set_viewport_size — the runtime records the main surface's physical dimensions on every configure, before App::on_resize, so view() can branch a layout on portrait vs landscape without hand-tracking resizes. The portrait rule matches Length::orient (square counts as portrait); embedders driving core::UiSurface call set_viewport_size themselves. Documented in the crate root's responsive-design section and architecture.md.
Fluid-vs-fixed sizing fixes in widgets, all the same disease — fluid content inside a fixed-pixel box. TextEdit::fixed_width takes impl Into<Length> (f32 call sites keep compiling as px) and the time picker's digit fields move to Length::fluid( 72.0 ), matching their fluid font so digits can no longer outgrow the box. Dialog::max_width takes impl Into<Length> with a Length::fluid( 480.0 ) default so the card scales with the stock buttons inside it, and the card's interior no longer stacks the column() default 16 px padding on top of CARD_PADDING — that double inset squeezed the action row until its buttons clipped on narrow windows. App::on_pointer_axis now triggers a view rebuild and repaint; previously state mutated in the hook did not paint until the next unrelated event.
Examples reworked to be honest demos: responsive's mode/density controls become stock buttons in a grid/column so they follow the modes they demonstrate instead of overflowing; dialog's openers stack vertically, and the example gains the app-level ESC handler so the ESC chain closes an open dialog first and quits second; widgets' tab strip now switches real per-tab pages; carousel gains pointer/touch drag through the horizontal-swipe hooks (crustace's pager pattern), one-tile-per-detent mouse wheel, and snap math driven by the real surface width from on_resize instead of a hardcoded 800; clip_path arranges its cells by ltk::orientation() and sizes them from the counter-axis of the flow.
2026-07-30 19:28:26 +02:00

35 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 H0BODY_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 color
accent toggle on, slider fill, focus ring color
divider separator, toggle off, list item border color
icon icon-button glyph colour color
danger destructive / error foreground 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.