Theming
A theme decides how every widget looks: colors per state, borders, corner radius, padding, fonts, and images. You write it as JSON, load it once, and attach it to a widget. Every widget under that one picks it up.
Where a theme applies
| Call | Effect |
|---|---|
component.setTheme(theme) | That widget and everything under it use theme. setTheme(null) goes back to inheriting. |
setDefaultTheme(theme) | The theme for any widget with no theme on itself or an ancestor. |
getDefaultTheme() | The current default. willowui starts with its built-in default theme. |
A widget looks for a theme on itself, then its parent, then up the tree, then the default. So the usual pattern is one screen.root.setTheme(theme) per screen, plus an override on the odd panel that needs a different look.
The theme file
json
{
"variants": ["card"],
"colors": {
"ink": "#EEEEEE",
"surface": "#262629",
"accent": "#59A6FF"
},
"components": {
"_": { "borderWidth": 1, "cornerRadius": 4, "focusColor": { "focus": "$accent" } },
"button": {
"_": { "padding": { "top": 6, "right": 14, "bottom": 6, "left": 14 } },
"primary": {
"backgroundColor": { "default": "$surface", "hover": "#34343A", "disabled": "rgba(115, 115, 122, 0.6)" },
"textColor": { "default": "$ink" }
}
},
"panel": {
"card": { "backgroundColor": { "default": "$surface" }, "cornerRadius": 8 }
}
}
}The top-level keys:
| Key | What it holds |
|---|---|
colors | Named colors. Use them anywhere as "$name". |
variants | Your own variant names (card, muted, ...). Each takes one of the custom slots. See Variants and inline themes. |
components | One entry per widget kind (button, label, panel, slider, list, window, ...), then one entry per variant (primary, secondary, accent, neutral, danger, success, warning, info, or your own). |
nine-grids | Images drawn as stretchable nine-slice backgrounds. See below. |
sprites | Named rectangles cut from image files, for icons. |
fonts | Font family per role. |
_ is the default layer. components._ applies to every widget that has a matching property. components.button._ applies to every variant of the button. A variant's own value wins over both.
Values per state. Color and background properties take an object keyed by state: default, hover, active, disabled, focus, focusHover, focusActive and focusDisabled. Plain numbers such as borderWidth and cornerRadius take one value.
Colors are #RRGGBB, #RRGGBBAA, rgb(...), rgba(...), white, black, transparent, or a $name from colors. A background color can also be a gradient string starting with gradient(, gradientH( or gradientV(.
To see which widgets your theme leaves out, and which $names it uses but never defines, run the coverage report (see themec).
Load a theme
loadThemeAny(path) is the loader to use. It takes any of three things:
path | What happens |
|---|---|
| a folder | Reads theme.json in it, plus the images it names. |
a .json file | Reads that file, plus the images it names, relative to the file. |
a compiled .theme file | Decodes it directly. This is what themec writes. |
From source, the first load compiles the theme and writes the result to a cache in the app's cache folder. Later loads, in this run or the next, read the cache. The cache key covers the JSON and every image, so editing either recompiles by itself. The cache is always safe to delete.
loadThemeAny is async and reads files, so the project lists the fs system and grants the fs permission.
ts
import { Button, DEFAULT_STAGE, Panel, Screen, Theme, VARIANT_DANGER, loadThemeAny } from "willowui";
const theme: Theme = await loadThemeAny(moduleDir + "/theme.json");
const screen: Screen = new Screen();
screen.root.setTheme(theme);
const box: Panel = new Panel("box");
box.setPosition(20.0, 20.0);
box.setSize(240.0, 120.0);
screen.add(box);
const save: Button = new Button("save", "Save", "default", 16.0);
save.setPosition(16.0, 16.0);
box.addChild(save);
const discard: Button = new Button("discard", "Discard", "default", 16.0);
discard.setVariant(VARIANT_DANGER);
discard.setPosition(16.0, 64.0);
box.addChild(discard);
DEFAULT_STAGE.setScene(engine.root2d);
DEFAULT_STAGE.add(screen);
App.onFrame((dt: float): void => { DEFAULT_STAGE.update(dt); });The matching thicket.json:
json5
{
entry: "app.tks",
systems: ["willow", "bitmap", "fs"],
permissions: { fs: true },
packages: { willowui: "../willowui-tks" },
window: { title: "Themed", width: 800, height: 600 },
}Themes with no images
loadTheme(jsonText) compiles a theme from a string, with no file access. Use it for themes that only set colors and sizes. Nine-slice images and sprites need loadThemeAny. validateTheme(theme, names) throws if the theme leaves any of the named widgets unconfigured, which is a cheap check to run at startup.
This runs in a console program, so you can try theme values without a window:
ts
import { Theme, loadTheme, validateTheme, VARIANT_PRIMARY, VARIANT_DANGER, STATE_DEFAULT } from "willowui";
const JSON_TEXT: string = `{
"colors": { "ink": "#EEEEEE", "red": "#CC3333", "redHover": "#EE4444" },
"components": {
"_": { "borderWidth": 1, "cornerRadius": 4 },
"button": {
"primary": { "textColor": { "default": "$ink" }, "cornerRadius": 6 },
"danger": { "backgroundColor": { "default": "$red", "hover": "$redHover" } }
}
}
}`;
const theme: Theme = loadTheme(JSON_TEXT);
console.log("primary corner " + theme.button.group(VARIANT_PRIMARY).cornerRadius);
console.log("danger corner " + theme.button.group(VARIANT_DANGER).cornerRadius);
console.log("danger border " + theme.button.group(VARIANT_DANGER).borderWidth);
console.log("primary text " + theme.button.group(VARIANT_PRIMARY).textColor.resolve(STATE_DEFAULT).toHex());
try {
validateTheme(theme, ["button", "slider"]);
} catch (e) {
console.log((e as Error).message);
}Output:
text
primary corner 6
danger corner 4
danger border 1
primary text #EEEEEEFF
theme: missing configs: sliderThe danger button has no cornerRadius of its own, so it gets 4 from _. The primary button sets 6, which wins.
Nine-slice backgrounds
A nine-slice image keeps its corners at their real size and stretches only the edges and the middle. That is how one small rounded-rectangle PNG becomes a button of any width.
Declare the image under nine-grids, then use its name in a backgroundGrid property:
json
{
"components": {
"button": {
"primary": {
"backgroundGrid": { "default": "button_up", "hover": "button_up", "active": "button_down" },
"textColor": { "default": "#EEEEEE" }
}
}
},
"nine-grids": {
"button_up": {
"source": "buttons.png",
"region": { "x": 0, "y": 0, "width": 48, "height": 48 },
"innerRegion": { "x": 8, "y": 8, "width": 32, "height": 32 },
"auto-slice": { "top": 8, "right": 8, "bottom": 8, "left": 8 }
},
"button_down": {
"source": "buttons.png",
"region": { "x": 48, "y": 0, "width": 48, "height": 48 },
"innerRegion": "auto",
"auto-slice": { "top": 8, "right": 8, "bottom": 8, "left": 8 }
}
}
}| Field | Required | Meaning |
|---|---|---|
source | yes | The image file, relative to the theme file. |
region | no | The part of source to use, so several grids can share one sheet. Without it, the whole image is used. |
innerRegion | yes | The content rectangle, or the string "auto" to derive it from auto-slice. |
auto-slice | one of these two | The border widths: top, right, bottom, left. |
slices | one of these two | The same, given as topLeftCorner and bottomRightCorner sizes (width, height). |
A grid must have exactly one of auto-slice and slices; the compiler rejects both or neither.
Sprites cut icons out of a sheet: "sprites": { "close": { "src": "icons.png", "x": 0, "y": 0, "w": 16, "h": 16 } }. w and h must not be zero.
Compile ahead with themec
When you ship, compile each theme into one .theme file. It packs every image into one atlas, so the game loads one file and compiles nothing at startup. loadThemeAny reads it directly.
willowui provides the compiler as a tool. Run it from a project that lists the willowui package:
sh
thicket tool themec themes/forest -o assets/forest.theme
thicket tool themec coverage themes/forest/theme.json| Command | Does |
|---|---|
themec <theme-dir> | Reads <theme-dir>/theme.json and the images it names, and writes <theme-dir>.theme. |
-o <file> | Writes the compiled theme to <file> instead. |
--glyphs <png> | Uses another default glyph sheet. |
themec coverage <theme.json or dir> | Lists $color names that are used but not defined, and widgets the theme does not configure. |
Then load the compiled file the same way: await loadThemeAny(moduleDir + "/assets/forest.theme").
See also
- Variants and inline themes:
variant=in XML, your own variant names, and the<Theme>element. - Concepts for how widgets, screens and the stage fit together.