Themes
Minecraft 1.21.1Fabric + NeoForgeArmature 1.0.0
Every colour the toolkit draws with comes from one place. A button does not know what a themeThemeThe colours, corner radius, motion, canvas background and chrome style a drawing reads, in one record. Seventeen are built in: sixteen flat, and vanilla, which draws vanilla's own sprites, and more can be written as files in the themes folder the mod hands the library.
is. It asks for the body ink and gets whatever the current theme says, which is why ArmatureTheme is
a static class rather than something passed through every call.
A Theme is a record of colour tokens, plus the corner radius, the motion duration and the easing
curve. Tokens are read as fields by code (a record has fields the compiler knows, so a theme is
checked) and addressed by name by anything outside code: a theme file, a pack naming a palette.
ThemeToken groups the names: surfaces, text, progression, graph, rows and labels, scrollbar, tooltips
and controls. A token's name is what the code reads it for: a node's border is one of the four
nodeEdge* tokens, so a theme can give its borders a colour apart from its progress inks.
Seventeen built-ins#
default, modern, tome, vanilla_plus, vanilla, high_contrast, monochrome, paper, obsidian,
amethyst, copper, redstone, nether, end, deep_dark, terminal and neon: Themes.ALL
for the list, Themes.byName(name) for one. default is modern with a square corner radius, which
is a theme defined by what it changes rather than a second palette to keep in step. vanilla is
vanilla Minecraft itself (inventory grey, stone buttons, dirt under the graph) and the only
built-in whose chrome is vanilla rather than flat (see below).
Every built-in states every token, and the guard in Themes.set enforces it at class
initialisation: an omitted token silently becomes modern's value, and a token added to the registry
later would be inherited by all seventeen themes with nobody deciding it. A file theme is the
opposite by design: it is a diff, and everything it leaves out comes from the theme it names as its
basedOn (which defaults to default).
Theme files#
A theme can be written by hand, one file per theme, in the themes directory the mod hands Look.
The library never chooses one. A file is a diff: it says what it changes, and the rest comes
from the theme it names as its base.
{
"name": "my_pack",
"basedOn": "modern",
"colours": { "panel": "#2A2233", "canvas": "#1A1622", "available": "#C79BF0" },
"cornerRadius": 6,
"motion": 120,
"easing": "QUAD_OUT",
"chrome": "flat"
}basedOn may name a built-in or another file-loaded theme, so a pack can have a variant of its own.
chrome is "flat" (fills) or "vanilla" (vanilla's own sprites), so absent means the base's, and a
file based on vanilla keeps its stone buttons unless it says otherwise.
The directory is read by Look.load(path, themesDirectory) (the call a mod makes once at startup)
and again after Look.saveAsTheme writes a file, so a theme shipped as a file is selectable from the first
frame and a theme saved in game is selectable without a restart. ThemeFiles.reload(directory) is the
explicit re-read, and ThemeFiles.all() is everything loaded.
Canvas backgrounds#
A theme's canvas is a colour (canvas) and, optionally, a pattern drawn over it. The pattern is
canvasBackground: a top-level object beside colours, in the same file and read with the same
tolerance:
{
"name": "blueprint",
"basedOn": "modern",
"canvasBackground": { "pattern": "grid_lines", "space": "graph", "spacing": 24 },
"colours": { "canvasPattern": "#336FB4E8" }
}pattern:none(the default),dot_grid,grid_lines,speckle,hatchorimage. The five flat kinds are drawn from arithmetic: no texture ships with them and none has to resolve.space:graphanchors the pattern to the content, so it pans and zooms with the graph;screenfixes it to the surface.spacingis content units in the first case and screen pixels in the second. A tiled image honours this like any other repeating pattern; a covered image is screen-fixed by construction and ignores it.size: the edge of a dot or speck and the thickness of a line or hatch step, 1..4 pixels.density: speckle only: one cell in this many carries a mark, 2..16.direction: hatch only:slashorbackslash.canvasPattern: the ink, a colour token like any other, so it can be set undercoloursand listed by anything that enumerates the tokens. Its alpha is the pattern's strength:#33is a hint,#80is a blueprint. For an image the ink's RGB is ignored, because the texture draws in its own colours and only the alpha comes through, so a darkcanvasPatterncannot crush a coloured texture.
spacing is clamped to 6..256, and a pattern fades out rather than shimmering when its repeats fall
closer than a few screen pixels, or when drawing it would overrun the frame's fill budget. A pattern
is decoration; it never costs the frame.
An image is a texture the pack ships:
"canvasBackground": { "pattern": "image", "texture": "mymod:textures/gui/parchment.png",
"fit": "tile", "tile": 32 }texture: canonicalised tons:textures/path.png: lowercased, the namespace defaulted tominecraft, and a leadingtextures/and trailing.pngaccepted and folded away. The real pixel size is read from the file's own PNG header (24 bytes, no decode, nothing to leak), so a 32x16 texture keeps its 2:1 aspect when drawn.fit:tilerepeats the texture attilepixels wide (4..256, aspect-preserving), and the lattice steps by the drawn size, so rows and columns abut with no spacing knob and no seams. A tile honoursspace:graphtiles pan and zoom with the content,screentiles stay put.coverdraws it once, scaled to fill the rectangle and centre-cropped to the rectangle's aspect, always screen-fixed.- A texture that cannot be resolved draws nothing (the painter will not guess), and an id that is not a
texture id at all is
canonicalTexture's empty string, so a caller's own editor can refuse it with the resolved path in the message.
A patch that carries only a surface is a valid patch, so one region can be resurfaced without its colours being touched: the same object a theme file parses, and the same merge:
{ "canvasBackground": { "pattern": "speckle", "spacing": 18 } }Three built-ins carry a surface as part of what they are (tome a parchment speckle, paper a
one-pixel rule grid, terminal two-pixel phosphor dots), and a theme based on one of them inherits
it unless it names its own.
Look: the instance a mod owns#
The global palette is what is drawn; a Look is what a mod's player has chosen. It holds a theme
name, the motion toggle, the text scale, a radius, a canvas background, and any per-token overrides,
and it is what a settings screen edits. It is read from appearance.json in the mod's own config
directory (each mod names its own directory when it calls Look.load, so two mods never share one
player's file), with themes/ beside it for the themes saved from the editor:
Look look = new Look(); // this mod's instance
look.setRadius(8);
look.apply(); // push the settings into the palette everything draws fromsettings(), main(), radius() (0–12), motion(), chosen(), serverDefault(), custom(),
clearRadius(), background() and setBackground(...). The Settings record it exposes is what a
consumer stores, on the server for a default or wherever its own config lives. Settings.patch() is
the same settings as a ThemePatch, which is what makes "a pack suggests a palette" and "a player
picked one" the same merge.
Choosing a theme clears the per-token overrides, the radius and the canvas background with it: a
theme choice means "use this look", so a picked palette is not tinted by edits made under the previous
one. The motion toggle and the text scale are untouched, because they are accessibility settings rather
than parts of a look. Edits worth keeping across a choice are saved first: saveAsTheme writes them
into a theme file, and the file is a theme like any other.
ThemePatch, and the boundary it keeps#
applyTo(base) makes a whole theme (a file, a derived built-in) and takes the name, radius, motion,
easing, canvas background and chrome as well as the colours, because a theme is those things. tint(base)
reskins a region and takes colours only.
That split is a decision rather than an oversight. A region that could change the corner radius of
every panel in a screen, or the duration of every transition, would be a data file reaching into the
screen's construction. Motion would silently do nothing anyway, because animation timing is
pushed to Motion once per client rather than per region. A value that cannot take effect is worse
than one that is refused.
Chrome: flat fills or vanilla sprites#
chrome is the one non-colour setting that changes how things are painted rather than what
colour they are: "flat" draws bordered fills, "vanilla" draws vanilla's own nine-sliced
widget sprites (stone buttons, bevel panels, the vanilla tooltip box) with shadowed inks.
Only vanilla sets it among the built-ins. Like the radius it travels with the theme and with
scopes, so a vanilla-chromed chapter keeps its bevels inside a flat book; like motion it is
never set by a region reskin. A chapter themePatch may still name it, because a chapter patch
is a whole-theme applyTo over the chapter's base rather than a region tint.
Scoping a palette to a region#
ArmatureTheme.scope(theme) and scopeOf(name) return a Scope that is AutoCloseable, so a region
can be drawn in another palette and the old one restored on the way out, including on an early
return:
try (var ignored = ArmatureTheme.scopeOf("nether")) {
drawHeader(r);
}current(), chrome(), themeName(), setCurrent(...), selectByName(name) and resetCurrent()
are the rest of the palette's surface, with dim(), canvas(), recessed() and panel() as the
shorthands a drawing call actually wants.