A field built with `select_on_focus( true )` kept its cursor at the end of the value after every insertion and deletion. That was meant for the time picker, whose `on_change` normalises what was typed, "2" becoming "02", so that a cursor placed at the end of the typed text would land in the middle of the normalised one. But the flag was also the only way to have a field select its whole value on focus, so every field that wanted that got the pinning too, and in an address bar it is a defect: tapping inside the address to correct a word, the cursor was placed where the finger was, and the first character typed sent it to the end, so the rest of the edit went to the wrong place. The two behaviours are now separate. `select_on_focus` only selects the value when the field takes the focus. The new `TextEdit::pin_cursor_to_end( bool )`, `false` by default, keeps the cursor at the end of the displayed value through the `usize::MAX` sentinel, as before, for fields whose `on_change` rewrites the value. It reaches the runtime in a new `pin_cursor_to_end` field of `WidgetHandlers::TextEdit`, set where the snapshot is built in `Element` and copied in its `Clone`, and the four places in `insert_delete.rs` that anchored the cursor, for an insertion, a delete forward, the deletion of a selection and the move after a replacement, read it instead of `select_on_focus`. The time picker's value fields ask for it, so they behave as they did. A field that uses `select_on_focus` only to select on focus now edits with an ordinary cursor. The one other user known is a dialog in `crate`, which gets the ordinary behaviour; if its `on_change` normalises the text it has to ask for `pin_cursor_to_end( true )` as the time picker does. Documentation of the previous commit and of what building crate showed. `docs/widgets.md` says that a `pressable` loses the tap and the right click to an interactive widget inside it even when that widget has no handler, as a `list_item` without `on_press`, so its content should be built from pieces that take no input; and that the hint of `Button::tooltip` is an xdg-popup beside the button in a window, placed by the compositor and taking no grab, and an overlay spanning the output on a layer surface. `docs/theming.md` gains "Application icons": a desktop entry can point at an installed theme icon, the Figma glass effects resvg does not reproduce, a `backdrop-filter` in a `<foreignObject>` ignored and `plus-lighter` rewritten to `screen` by `decode_svg_bytes`, and the advice to flatten them; the table row of `decode_svg_bytes` mentions the rewrite and that its pixels come back premultiplied. `docs/cookbook.md` says that requesting the focus for the text field that already has it puts the cursor at the end of its value without a second `on_focus`, which is how an application moves the cursor after replacing the value, and that Tab and the vertical arrows reach `App::on_key_with_modifiers` before the field, while Escape reaches it after ltk has closed any transient UI and taken the focus off the field. Losing the keyboard. An application that repeats keys on its own timer, as prompt does for its terminal, never heard that the window lost the keyboard, so a key held while the focus went elsewhere kept repeating until the next key, since its release went to the other window. The new `App::on_keyboard_leave()`, a no-op by default, is called from the keyboard's `leave`, right after ltk stops its own repeat, for the application to stop its repeat and forget the modifiers it kept. Documentation of the previous commit's hooks and widgets. `docs/architecture.md` lists `on_mouse_button` with `MouseButtonEvent` and `MouseButton`, `on_modifiers` and `on_keyboard_leave` among the input hooks. `docs/widgets.md` describes `ListItem::ink( label, secondary )`, which draws a row's label, subtitle, trailing text and hover and press tints in colours of its own for a surface the theme does not know about, and says that `icon_button` keeps a bitmap's proportions, fitting one that is not square into its size and centring it rather than stretching it. Not done. There is no test for the new flag, as `select_on_focus` has one in `text_edit/tests.rs` for its builder and its propagation to the handler, and the documentation of the widget does not mention it.
874 lines
37 KiB
Markdown
874 lines
37 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, pattern-grid idle dots | color |
|
||
| `accent` | toggle on, slider fill, focus ring, pattern-grid stroke and visited dots | color |
|
||
| `divider` | separator, toggle off, list item border | color |
|
||
| `icon` | icon-button glyph colour | color |
|
||
| `danger` | destructive / error foreground, pattern-grid path in error | color |
|
||
| `danger-bg` | soft fill behind error states | color |
|
||
|
||
The default theme defines `danger` but not `danger-bg`, which
|
||
therefore resolves to the built-in pink-wash default from the palette
|
||
projection.
|
||
|
||
**Effects (optional):**
|
||
|
||
| id | consumer | type |
|
||
| --- | --- | --- |
|
||
| `surface-slider-track` | `Slider` / `VSlider` track background | surface |
|
||
| `surface-slider-fill` | `Slider` / `VSlider` filled portion | surface |
|
||
| `surface-card` | no built-in widget reads it automatically — apps opt in via `Container::surface("surface-card")` | surface |
|
||
| `shadows-glass` | referenced from the theme's own surface slots; no direct widget consumer | shadows |
|
||
| `surface-panel`, `surface-toggle-active`, `surface-card-flat`, `surface-slider-track-flat`, `surface-slider-fill-flat` | reserved — currently unconsumed by any built-in widget | surface |
|
||
|
||
Only the slider track / fill pair is looked up by widgets on their
|
||
own. The `-flat` variants are *not* wired to the software backend
|
||
automatically; a caller can pass one explicitly (e.g.
|
||
`Slider::track_surface("surface-slider-track-flat")`), but nothing in
|
||
`ltk` selects them per backend.
|
||
|
||
## Using the theme from app code
|
||
|
||
The active theme is process-global mutable state: a `(ThemeDocument,
|
||
ThemeMode)` pair guarded by a `RwLock` behind the `ltk::theme` API.
|
||
Widgets and apps read it through cheap accessors that clone an `Arc`
|
||
or project a small struct out of the slot table — designed so it's
|
||
fine to call them dozens of times per frame from inside `view()`.
|
||
|
||
### The canonical pattern: read in `view()`, never cache
|
||
|
||
Read the theme at the top of every `view()` and let the next frame
|
||
re-resolve automatically. Storing a `Color` in your app state freezes
|
||
it at the moment you captured it: a later `set_active_mode( Dark )`
|
||
will repaint everyone who reads palette per-frame and skip the widgets
|
||
that read a stale field.
|
||
|
||
```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`. Rewrites the CSS-only `plus-lighter` blend to `screen` first (see [Application icons](#application-icons)); the pixels come back premultiplied. |
|
||
| `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`.
|
||
|
||
### Application icons
|
||
|
||
An application's desktop entry can point at a theme icon by its installed path, `Icon=/usr/share/ltk/themes/default/icons/apps/files.svg`, which `ltk-theme-default` provides, instead of shipping an icon of its own.
|
||
|
||
The shipped app icons are exported from Figma with glass effects, and resvg, which rasterises every SVG in ltk, reproduces only part of them:
|
||
|
||
- A `<foreignObject>` carrying `backdrop-filter: blur(…)` is HTML, not SVG, and is ignored: the frosted panel comes out as a flat translucent one.
|
||
- `feBlend mode="plus-lighter"` is a CSS blend mode resvg does not know, and it would fall back to `normal`, painting the highlights as grey edges that cover whole pixels at dock size. `decode_svg_bytes` rewrites it to `screen`, the closest mode resvg supports, before parsing, so every path that rasterises an SVG through ltk gets it.
|
||
|
||
An icon meant to look the same everywhere is best drawn without these effects, or with them flattened into its geometry.
|
||
|
||
### 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.
|