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.
863 lines
35 KiB
Markdown
863 lines
35 KiB
Markdown
# 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`](./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:
|
||
|
||
```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
|
||
│ ├── 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](#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` 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`
|
||
|
||
```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.
|
||
|
||
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](#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](#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.
|
||
|
||
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`).
|
||
|
||
```json
|
||
"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:
|
||
|
||
```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`,
|
||
`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`](./architecture.md#responsive-sizing).
|
||
For text, the `theme::typography` module ships the `H0`…`BODY_XS` px
|
||
constants plus the fluid `h0()`…`body_xs()` `Length` builders that
|
||
scale with the surface's smaller dimension.
|
||
|
||
## `modes`
|
||
|
||
Two required entries: `light` and `dark`. Each carries the look the
|
||
theme applies in that lighting mode plus its own `slots` table.
|
||
|
||
### `wallpaper` / `lockscreen`
|
||
|
||
Both are optional. The runtime resolves them in two steps:
|
||
|
||
1. **Convention** — looks for `branding/{mode}/wallpaper.svg` and
|
||
`branding/{mode}/lockscreen.svg`, with the standard mode →
|
||
opposite-mode → no-mode fallback (see
|
||
[Branding assets](#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"`, `"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`
|
||
|
||
```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": {
|
||
"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`](../src/widget/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`](#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.
|
||
|
||
Six slot variants: `color`, `linear`, `radial`, `shadows`, `surface`
|
||
and `typography`.
|
||
|
||
#### `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. 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`
|
||
|
||
```json
|
||
"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`](#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`
|
||
|
||
```json
|
||
"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`](#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`
|
||
|
||
```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",
|
||
"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.
|
||
|
||
```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). |
|
||
| `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:
|
||
|
||
```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 );
|
||
```
|
||
|
||
`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:
|
||
|
||
```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 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 `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. 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
|
||
|
||
```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 );
|
||
|
||
// 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
|
||
|
||
```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 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.
|