738 lines
29 KiB
Markdown
738 lines
29 KiB
Markdown
# 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.
|