Themes
Minecraft 1.21.1Fabric + NeoForgeArmature 0.2.0
Every colour the toolkit draws with comes from one place. A button does not know what a themeThemeThe colours, corner radius and motion a drawing reads, in one record. Sixteen are built in, 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.
Sixteen built-ins#
default, modern, tome, vanilla_plus, 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.
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 sixteen 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"
}basedOn may name a built-in or another file-loaded theme, so a pack can have a variant of its own.
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. Flat kinds only: an image is always screen-fixed and ignores both.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 — 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.coverdraws it once, scaled to fill the rectangle and centre-cropped to the rectangle's aspect.- 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 and canvas background 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 — and 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.
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.