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.
35 KiB
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. This
file is the schema reference.
File layout
A theme is a directory under one of:
LTK_THEMES_DIR/<id>/— only when the env var is set; intended for development.$XDG_DATA_HOME/ltk/themes/<id>/(defaults to~/.local/share/ltk/themes/<id>/)./usr/share/ltk/themes/<id>/— system-wide install path (ltk-theme-defaultDebian package).
The ordered list is queryable at runtime via ltk::theme::search_paths().
The directory tree:
<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 and
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
{
"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
"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). 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:
"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).
"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:
"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.
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:
- Convention — looks for
branding/{mode}/wallpaper.svgandbranding/{mode}/lockscreen.svg, with the standard mode → opposite-mode → no-mode fallback (see Branding assets). - Override — if
theme.jsondeclares an explicit block, that path wins over the convention. Useful for raster wallpapers or non-conventional locations:
"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
"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:
"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 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 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
"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
"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
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
"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 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
"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
"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 ashadowsslot or an inline list. Optional.inset_shadows— id of aninset_stacksentry (@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 upgradients.cyan/inset_stacks.cyanfirst, thencolors.cyan. A name defined in bothgradientsandinset_stacksis an error; a name defined in bothcolorsand 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/AAsuffix is a colour-only alpha override (two hex digits). Lets a single base@navyserve@navy/14,@navy/24,@navy/99,@navy/D9etc. 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.
# 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:
// 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:
# 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 iconPathBufs 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_modefromview()(it's a side effect; do it inupdate()from aMsg).
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
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:
branding/{active_mode}/{name}.svg— preferred variant.branding/{opposite_mode}/{name}.svg— graceful degradation when the theme only ships one mode of the asset.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
// 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 whenfilleddoesn't ship a given icon. A theme that prefers a line aesthetic puts its SVGs infilled/(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
- 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#000000for 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 thansizeon its other axis. - Optionally ship the
line/variant incatalogue/line/<category>/<name>.svg. - Reference it from widget code by its slash-separated stem, no
extension:
theme::icon_path( "general/down-simple" ).
Runtime API
# 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
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
- Pick a key like
my_widget.label. - Add it to every file under
ltk/locales/so each language has a translation (English at minimum is required — it's the fallback). - Read it from widget code with
rust_i18n::t!( "my_widget.label" ).
Adding a language
- Create
ltk/locales/<code>.yamlwith all existing keys translated. - The
i18n!("locales", fallback = "en")macro at the crate root picks it up automatically — no registration step needed. - 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
colorspalette intheme.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.
- the
- Override a single icon: drop a replacement SVG at the same
catalogue path under your theme's
icons/catalogue/filled/<category>/<name>.svg. Theicon_pathlookup 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 solidcolorsreferences and drop the shadow / inset decorations. Acolorslot 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.