First commit. Version 0.1.0
This commit is contained in:
737
docs/theming.md
Normal file
737
docs/theming.md
Normal file
@@ -0,0 +1,737 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user