29 KiB
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. 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 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
├── 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 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 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
"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:
"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).
"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:
"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:
- 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" (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": {
"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 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.
Three slot variants:
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.
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",
"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 ashadowsslot or an inline list. Optional.inset_shadows— id of aninset_stacksentry (@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 upcolors.cyan,gradients.cyanorinset_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/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 |
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.
# 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:
// 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:
# 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 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. - 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 );
# 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
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
- 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-only theme: drop every
backdropblock from the slots and route everysurface-*-flatslot to a solidcolorsreference. 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.