Skip to content

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 ​

CallEffect
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:

KeyWhat it holds
colorsNamed colors. Use them anywhere as "$name".
variantsYour own variant names (card, muted, ...). Each takes one of the custom slots. See Variants and inline themes.
componentsOne 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-gridsImages drawn as stretchable nine-slice backgrounds. See below.
spritesNamed rectangles cut from image files, for icons.
fontsFont 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:

pathWhat happens
a folderReads theme.json in it, plus the images it names.
a .json fileReads that file, plus the images it names, relative to the file.
a compiled .theme fileDecodes 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: slider

The 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 }
        }
    }
}
FieldRequiredMeaning
sourceyesThe image file, relative to the theme file.
regionnoThe part of source to use, so several grids can share one sheet. Without it, the whole image is used.
innerRegionyesThe content rectangle, or the string "auto" to derive it from auto-slice.
auto-sliceone of these twoThe border widths: top, right, bottom, left.
slicesone of these twoThe 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
CommandDoes
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 ​