Files
ltk/docs/theming.md

738 lines
29 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# ltk theming
`ltk` reads a JSON theme document 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`](./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 directory tree:
```text
<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
├── app-default.svg fallback icon for unknown app ids
├── apps/ per-application icons (firefox.svg, …)
└── catalogue/ symbolic glyph catalogue
├── filled/ solid silhouettes — preferred by default
│ └── <category>/ general, system, window, …
└── line/ outlined variants (same names)
```
Branded assets in `branding/` and icons in `icons/` are picked up by
convention — see [Branding assets](#branding-assets) and
[Icons](#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
```json
{
"theme": { "id": "default", "name": "Default" },
"fonts": { ... },
"colors": { ... },
"gradients": { ... },
"inset_stacks": { ... },
"modes": {
"light": { ... },
"dark": { ... }
}
}
```
`theme.id` must match the directory name. `theme.name` is shown in any
theme-picker UI a shell builds on top of `ltk`.
The other six sections are described below in order. The parser is strict
(`deny_unknown_fields`): unknown keys at any level are an error so typos
surface immediately.
## `fonts`
```json
"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.
If none of the listed `sources` exist on disk, `ltk` falls back to its
embedded Sora Regular (~50 KB, OFL 1.1) and stamps a red banner on every
frame pointing at the missing-theme problem.
## `colors`
A flat dictionary of named hex literals, used as building blocks
elsewhere in the document via the [`@name` reference syntax](#references):
```json
"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.
## `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` (in
`[0, 1]`, but stop positions outside that range are accepted and used for
extrapolation) and a `color` (literal hex or `@reference`).
```json
"gradients": {
"fill-cyan": {
"type": "linear",
"angle_deg": 270,
"space": "linear-rgb",
"stops": [
{ "pos": 0, "color": "@cyan" },
{ "pos": 1, "color": "@cyan-soft" }
]
}
}
```
`space` is either `"srgb"` (perceptually quick, the default) or
`"linear-rgb"` (interpolate in linear-light space, more uniform mid-tones
for high-saturation gradients). `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:
```json
"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`, or
`overlay`).
Reference an entire stack from a slot with `"inset_shadows":
"@glass-insets"`, or inline the array if you only use it once.
## `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](#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:
```json
"wallpaper": { "path": "custom/path.png", "fit": "cover" }
```
`path` resolves against the theme directory. `fit` is one of `"cover"`,
`"contain"`, `"stretch"`, `"center"` (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`
```json
"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:
```json
"window_controls": {
"icon": "#5F5F68",
"hover_bg": "@navy/14",
"pressed_bg": "@navy/24",
"close_hover_bg": "@danger",
"close_icon": "@white",
"focus_ring": "@teal"
}
```
Consumed by the [`window_button`](../src/widget/window_button.rs) 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`](#icons) 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.
Three slot variants:
#### `color`
```json
"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.
#### `shadows`
```json
"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`
```json
"surface-card": {
"type": "surface",
"fill": "@surface-glass-dark",
"shadows": "shadows-glass",
"inset_shadows": "@glass-insets",
"backdrop": { "blur_px": 22.5 },
"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.
- `backdrop``{ "blur_px": <σ × 2> }` for backdrop blur. Optional;
GLES backend renders it, software backend ignores it (documented
parity gap).
## References
The `@name` syntax substitutes a palette / gradient / inset-stack value
in place of an inline literal:
- `@cyan` — looks up `colors.cyan`, `gradients.cyan` or
`inset_stacks.cyan` (collisions across sections are an error). 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 |
**Effects (optional but used by built-in widgets):**
| id | consumer | type |
| --- | --- | --- |
| `shadows-glass` | every surface that opts into elevation | shadows |
| `surface-card` | `Container::surface("surface-card")` | surface |
| `surface-card-flat` | flat variant for software backend | surface |
| `surface-panel` | overlay panels | surface |
| `surface-slider-track` | `Slider` track background | surface |
| `surface-slider-fill` | `Slider` filled portion | surface |
| `surface-slider-track-flat` | software-backend slider track | surface |
| `surface-slider-fill-flat` | software-backend slider fill | surface |
| `surface-toggle-active` | `Toggle` on-state surface | surface |
The `-flat` variants are used by the software backend, which lacks
backdrop blur; the GLES backend uses the non-flat ones.
## 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.
```rust,no_run
# 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 + backdrop). |
| `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. |
| `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:
```rust,no_run
// 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 );
```
The conventional wiring is to dispatch a message from the UI:
```rust,no_run
# 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 eight 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 `PathBuf`s 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
```text
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
```rust
// 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.
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
```rust,no_run
# 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 );
# 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.
## 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
```yaml
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_dow` (Sunday-first
in `en`, Monday-first in `es` / `fr` / `it` / `de` / `pt` / `pt_BR`).
The `Locale` struct ships `first_dow: u8` 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-only theme**: drop every `backdrop` block from the
slots and route every `surface-*-flat` slot to a solid `colors`
reference. Visual parity with the software backend is automatic.
All three recipes keep the slot ids and reference shapes intact, so the
built-in widgets continue to work without code changes.