UI toolkit: retained, data-driven game UI
crates/sparklight-ui-kit builds menus and HUDs from JSON documents. A document describes a widget tree; the toolkit lays it out
(flexbox, grid, anchors), styles it with a theme, keeps it bound to game data and localisation keys, moves focus with a gamepad or the
keyboard, animates it, and produces a display list that the 3D overlay, the 2D draw path or the CPU rasteriser draws. Everything an agent
needs to build, change and test a UI is on the control surface as ui.* commands (and so as MCP tools ui_load, ui_tree, …).
The immediate-mode crates/sparklight-ui stays: it provides fonts (glyph atlases), geometry and input types, and games may keep using it for
one-off drawing. New UI should use the kit.
Quick start
Section titled “Quick start”ui_load {"path": "samples/ui/menu.ui.json", "language": "en", "width": 1280, "height": 720}ui_tree {"depth": 3, "visible_only": true} -> nested widgets with id, type, text, rect, focusedui_press {"button": "down", "times": 2} -> {"focused": "settings", "events": []}ui_press {"button": "confirm"} -> events: click on `settings` -> the screen changesui_assert {"expect": {"screen": "settings", "focused": "quality"}}ui_set_value {"id": "fov", "value": 100} -> change event, `settings.fov` in the data store is 100ui_theme {"theme": "daylight", "language": "ru"}ui_capture {"path": "ui-settings-ru.png"} -> PNG written below the output folder, no GPU neededRust: Ui::new(metrics, fonts), ui.load_file(path), then each frame ui.input(..), ui.update(dt), ui.paint(); draw the paint through
sparklight_scene3d::overlay::kit::KitRenderer (3D overlay), sparklight_ui_kit::draw2d::to_draw_commands (2D quads and glyphs) or
sparklight_ui_kit::raster::Rasterizer (CPU pixels). A complete example is crates/sparklight-ui-kit/tests/menu.rs.
Documents
Section titled “Documents”{ "ui": 1, "theme": "racing", "scaling": {"reference": [1280, 720], "match": 1}, "tokens": {"colors": {"brand": "#ff4a1c"}}, "classes": {"cta": {"background": "$brand", "uppercase": true}}, "components": {"MenuButton": {"params": {"label": {"required": true}}, "root": {"type": "button", "text": "{{label}}"}}}, "data": {"settings": {"volume": 0.8}}, "screens": {"main": {"type": "panel", "children": [{"use": "MenuButton", "id": "play", "args": {"label": "@menu.play"}}]}}, "start": "main", "overlay": [{"type": "toasts", "id": "toasts", "anchor": "top"}]}root is a single-screen shorthand for screens: {"main": ...}. overlay widgets stay on top of every screen. Every error has a code, a JSON
pointer and a message (see Errors); unknown properties come with a “did you mean” suggestion. Loading a document that has errors changes nothing.
Widget properties
Section titled “Widget properties”Every widget has type, id (unique; auto ids are parent.kind<index>), class (string or array), visible, enabled, bind, on, nav,
children, repeat, plus layout and style keys (flat, below) and widget properties:
| type | Draws | Properties |
|---|---|---|
panel |
a box with children | |
label |
text | text, rich (markup [b] [c=#f00] [s=1.5] [i] [u] [font=name]) |
image |
a registered image | src, tint |
button |
text or children | text, toggle |
toggle |
label left, switch right | text, checked |
slider |
label, track, value | text, min, max, step, value, format |
dropdown |
label, box, popup | text, options, value |
list |
virtualised rows | item (template), item_height, selectable, bind.items |
scroll |
clipped scrolling box | horizontal |
tabs |
strip + one visible page | pages are the children; each page has title; selected |
input |
text field with caret, selection, IME preedit | placeholder, max_len, secure, multiline |
progress |
bar | min, max, value, text, bar {zones, span, segments} (see Gauge and progress) |
gauge |
radial dial, ring or pips | min, max, value, gauge {start,end,ticks,major_every,redline,thickness,label_divisor, zones,span,needle,labels,segments,cap} |
map |
a view onto a 2D world: paths, lines, circles, markers, an image | map {view, clip, layers} (see Map) |
shapes |
a vector picture: rects, circles, paths, arcs, images, groups, loops | shapes {view_box, fit, items} (see Shapes) |
minimap |
framed outline + markers (the first, simple map; map replaces it, it stays as it was) |
minimap {outline, markers, line_width} (data paths) |
toasts |
notification stack | filled by ui.toast |
modal |
dialog over a dimmed screen | title, open, dismissable |
spacer, divider |
||
keybind |
action label + binding chip | action |
Layout (flat keys)
Section titled “Layout (flat keys)”display (flex, grid, none), direction, wrap_items, gap (number or [row, column]), grow, shrink, basis, width, height,
min_width … max_height, aspect, padding, margin (8, [h, v], [l, t, r, b]), align_items, align_self, justify,
columns / rows (["1fr", "200px", "auto"]), col, row, col_span, row_span, overflow, safe. Lengths are numbers (layout units),
"12px", "50%", "auto" or "1fr". Anchors pin a widget to its parent: anchor (top_left … bottom_right, center, fill) with
offset: [left, top, right, bottom] (distances from the anchored edges). safe: true pads a container by the screen’s safe area (notch, home
indicator, TV overscan).
Layout units are 1/reference of the screen: scaling.reference is the design size (default 1280x720) and scaling.match is 1 to follow the height, 0
to follow the width (use 0 with a portrait reference for phones). The factor is clamped to min..max. The same document gives the same layout at
720p, 1080p, 1440p, 4K and on tall phone screens.
Style (flat keys) and states
Section titled “Style (flat keys) and states”background (a fill, see Fills and strokes; or {"image", "slice": [l,t,r,b], "tint", "mode"} for a 9-slice or stretched
image), border_width, border_color, radius, skew (the racing parallelogram), shadow, opacity, scale, translate, color, font, size,
text_align, uppercase, line_height, letter_spacing, wrap, ellipsis, text_shadow, accent. Values may be tokens "$accent". radius is one number or four,
[top-left, top-right, bottom-right, bottom-left] (corners that do not fit a side are scaled down together, as CSS does; borders and shadows follow), and a background can be
{"corners": ["#ff3b3b", "#ffc233", "#3ddc84", "#22d3ee"]}: a colour per corner of the box, mixed bilinearly (the 3D overlay cuts it into at most 8 x 8 cells, so a rounded box keeps its shape). Blocks can carry states
(hover, focus, pressed, disabled, active, and the same states of the parent or of any ancestor: parent:focus, parent:hover, ancestor:pressed …: a label in a focused row takes the row’s
colour without a data flag; the own states come first, then parent:, then ancestor:), parts (named pieces of composite widgets: track, fill, knob, chip, tab_active,
popup, scrollbar, backdrop, …), transition {duration, ease} and enter {opacity, scale, translate, duration, delay, stagger, ease}.
letter_spacing is tracking in layout units added after every glyph (inherited by children, negative tightens, scaled with the UI scale, 0 = the font’s own spacing; kerning is not applied). A line of n glyphs is (n - 1) x letter_spacing wider than untracked: no spacing follows the last glyph, so a centred or right-aligned line is not shifted by a trailing gap. A tracked width never goes below 0, however negative the spacing (a line tightened past its own width is 0 wide). Wrapping, ellipsis, the intrinsic width of labels and buttons, and the caret, selection and click position of input use the tracked width (a caret after k glyphs sits one spacing after the k-th glyph, where the next one starts). Op::Text.letter_spacing carries it (in the units of size) to the CPU rasteriser, the 2D path and the overlay. Pen rounding: the pen advances by the glyph advance plus the spacing, both unrounded, in physical pixels; the bitmap font snaps each glyph to the nearest whole physical pixel (so the error never accumulates along a line), distance-field glyphs are not snapped. sparklight-ui has the tracked variants Font::{measure_tracked, measure_scaled_tracked, draw_scaled_tracked} and SdfFont::{measure_tracked, draw_scaled_tracked}; the untracked calls are unchanged. Sample: samples/ui/tracking.ui.json.
The cascade: theme base, then the theme block of the widget type, then each class (theme, then document), then the widget’s own keys.
Text properties are inherited from the parent; boxes are not.
Fills and strokes
Section titled “Fills and strokes”Everywhere a widget paints with a colour it can paint with a fill, and every line is a stroke. Both are plain data and both take theme tokens.
A fill is one of
"#ff4a1c" or "$accent" (a colour token, or a name from the theme’s fills),
{"gradient": {"angle": 90, "stops": [[0, "#000"], [1, "#fff"]]}} (linear; 0 runs left to right, 90 top to bottom),
{"gradient": {"kind": "angular", "center": [0.5, 0.5], "from": 0, "sweep": 360, "stops": [...]}} (angular: angles are degrees clockwise from twelve o’clock,
center is a fraction of the painted box, stop positions run over sweep degrees from from; a negative sweep runs the other way) or
{"image": ...} (backgrounds only). Hard stops are stops with equal positions: [[0, "#3c6"], [0.5, "#3c6"], [0.5, "#fc3"], [1, "#fc3"]] is two flat bands, which is how
per-segment colours are written (a red zone, ammo pips that turn amber and red). Gradients on a widget box, a shape, a path, a gauge part or a zone all use this one form.
A fill can also be {"corners": ["#ff3b3b", "#ffc233", "#3ddc84", "#22d3ee"]} (backgrounds): a colour per corner of the box (top-left, top-right, bottom-right, bottom-left), mixed bilinearly.
A stroke is "$token" (a name from the theme’s strokes) or
{"width": 3, "color": <any fill>, "cap": "butt|round|square", "join": "miter|round|bevel", "miter_limit": 4, "dash": [6, 4], "dash_offset": 0, "outline": {"width": 1.5, "color": "#000a"}}.
outline is the under-stroke: a wider line of its own colour drawn first, width more on each side (a road casing, a glow). Dashes follow the path round corners.
Widths are layout units (shape units inside a shapes view box, scaled with the picture). Tokens: tokens.strokes next to colors, numbers, fonts, shadows, fills.
"tokens": {"strokes": {"road": {"width": 3, "color": "#c9d3e6", "cap": "round", "join": "round", "outline": {"width": 1.4, "color": "#000a"}}}, "fills": {"rev": {"gradient": {"kind": "angular", "stops": [[0, "#22d3ee"], [0.55, "#ff4a1c"], [1, "#ffe14a"]]}}}}Not in this version: radial gradients (use an angular one or a shadow for glows), gradient strokes that follow the path (a stroke gradient is laid over the shape’s box).
Values from data
Section titled “Values from data”Any widget can also bind properties of its own box (bind, a data path as a string or {"path", "scale", "offset", "default"} reading value * scale + offset): opacity (0..1),
scale (about the widget’s centre), translate {"x": ..., "y": ...} (layout units), and min / max (the range of a slider, progress or gauge; so a dial’s end follows the car in the data). They are read whenever the
data changes, sit on top of the style and its transitions and under a script tween (ui.tween), and a value that has no number at its path (and no default) leaves the style’s value alone.
bind.color and bind.background take a path to a colour string as before, or {"path": "hp", "ramp": [[0, "$bad"], [1, "$good"]], "range": [0, 1]}: a number through a colour ramp (equal positions make bands).
Ops that paint nothing (an alpha of 0, a box without fill or border) are dropped from the display list.
Shapes and maps read data when a frame is painted, so a game drives a picture by writing data. Four small forms are used everywhere:
| form | written as | reads |
|---|---|---|
| number | 4.5 or {"bind": "hud.speed", "scale": 0.5, "offset": 10, "default": 0} |
value * scale + offset; default when the path has no number |
| point | [x, y] or {"bind": "player.pos"} |
an [x, y] array or an {x, y} object |
| list of points | [[x, y], ...] or {"bind": "track.points"} |
an array of points (at most 20 000, longer lists are cut and reported UI-W005) |
| colour | {"path": "hp.frac", "ramp": [[0, "$good"], [1, "$bad"]], "range": [0, 1]} or "unit.color" |
a #rrggbb[aa] or $token string as it is; a number through the ramp (range maps it onto 0..1) |
Loop variables of repeat are visible in these paths (b.value, and the index variable as a number). There is no game logic in the toolkit: a rule such as “paint a zone by how
damaged it is” is a number in the data plus a ramp in the document. Where a widget would need a rule the data cannot express, the data is the hook: the game computes the number, the
colour string, the heading or the visibility and writes it (ui.data.set / Ui::data), and the widget stays generic.
Shapes
Section titled “Shapes”shapes draws a vector picture. The widget has shapes {"view_box": [x, y, w, h], "fit": "contain|cover|stretch", "items": [...]}: the view box is the picture’s own
coordinate space, mapped into the widget’s content box (default: the widget’s size, 1 unit per layout unit). Shapes are painted in order.
Shape types and their keys (x, y, cx, … are numbers or bound numbers; a missing required key is UI-E015, a key of another type UI-E003):
| type | keys |
|---|---|
rect |
x, y, width, height, radius |
circle |
cx, cy, r |
ellipse |
cx, cy, rx, ry |
line |
x1, y1, x2, y2 |
polyline, polygon |
points |
path |
d: SVG path data (M L H V C S Q T A Z, absolute and relative; arcs with the compact flag form) |
arc |
cx, cy, inner, outer, from, to (degrees clockwise from twelve o’clock), fraction (0..1 of the sweep: cooldown pies, health arcs) |
image |
src, x, y, width, height; the fill colour is its tint |
group |
children |
repeat |
each (data path of an array), as, index, template (one shape): one copy per element, ids get [i] |
Every shape also takes id (unique among the static shapes), fill, stroke, fill_rule (nonzero, the default, or evenodd for holes), translate: [x, y],
rotate (degrees clockwise), scale (number or [sx, sy]), origin (the point rotate and scale act about), opacity, visible, and bind {fill, stroke, visible}:
bind.fill and bind.stroke are colour binds (above), bind.visible a condition. A group’s transform and opacity apply to its children; opacity multiplies per shape (overlapping
translucent children show their overlap, there is no group compositing). A shape with neither fill nor stroke draws nothing.
{"type": "shapes", "id": "car", "width": 128, "height": 148, "shapes": {"view_box": [0, 0, 128, 148], "items": [ {"type": "rect", "id": "body", "x": 40, "y": 28, "width": 48, "height": 100, "radius": 14, "fill": "#ffffff1a"}, {"type": "rect", "id": "front", "x": 44, "y": 30, "width": 40, "height": 20, "radius": 8, "fill": "#fff3", "bind": {"fill": {"path": "hud.damage.front", "ramp": [[0, "$good"], [0.5, "$warn"], [1, "$bad"]]}}}, {"type": "repeat", "each": "hud.lost", "as": "part", "index": "i", "template": {"type": "rect", "x": {"bind": "i", "scale": 18, "offset": 14}, "y": 132, "width": 14, "height": 8, "fill": "$bad"}}]}}Diagrams made this way: damage and status silhouettes, inventory and equipment, skill trees (repeat over nodes and edges), charts (repeat with bound height), cooldown pies (arc with fraction).
ui.get on a shapes widget lists every shape that has an id: visibility, fill, stroke and transform as the data left them. Limits: 20 000 shapes after loops are expanded, groups nest
12 deep (UI-E018).
map is a view onto a 2D world with any number of layers. It knows nothing about what the world is: a racing mini-map, an RTS map and a dungeon overview are the same widget.
map {"view": {...}, "clip": "shape|rect|none", "layers": [...]}. The widget’s own style paints the frame (background, border, radius); the layers paint inside its content box.
View: center (a world point, default the origin), zoom (layout units per world unit, default 1), rotate (degrees: the world direction that points up; 0 is north-up, bind it to
the player’s heading for a heading-up map), y_up (world +y points up; default is screen-like, y grows downwards), anchor (where center sits in the box as fractions, default
[0.5, 0.5]; [0.5, 0.75] keeps the player in the lower half), or fit {"bounds": [min_x, min_y, max_x, max_y] | {"bind": path}, "margin": 4} instead of center and zoom (the whole
bounds fill the box). center, zoom and rotate can be bound to data (pan, zoom and follow-the-player are data, there is no built-in mouse pan yet).
Clip: shape (default) cuts every layer to the widget’s content box with its rounded corners: with radius set to half the size the map is a circle. Rectangle clips use the display
list’s scissor; rounded ones cut the geometry (and the picture of an image layer) against the outline, so all three back ends agree. rect uses only the scissor; none lets layers
draw outside.
Layers, painted in order, each with id, visible, bind.visible (a condition) and width_space (screen, the default, or world: stroke widths scale with the zoom):
| type | keys |
|---|---|
path |
points (bound or literal), closed, fill (closed paths), stroke (with outline for the under-stroke), segment_colors: {"bind": path} (an array with one colour per segment: a track coloured by speed) |
lines |
items, stroke. An item is {"a": [x, y], "b": [x, y]} or {"at": [x, y], "angle": deg, "length": units} (a mark of fixed screen length through a world point, the angle measured against the world: a start line), optional color |
circles |
items ({x, y, radius?, color?}), radius and radius_space (world default, or screen), fill, stroke |
markers |
items ({x, y, heading?, color?, size?, shape?, image?, label?, visible?}), shape (disc ring square diamond triangle arrow cross star image), size (layout units), fill, stroke, image, heading_space (world, the default: headings turn with the view; or screen), label {size, offset, color}, fields (rename the item keys: {"x": "px", "heading": "yaw"}) |
image |
src, bounds (the world rectangle the picture covers), tint; needs y_up false |
Items come from the document or, usually, from data ({"bind": "world.enemies"}), so the game writes positions, headings and colours each frame. Headings are degrees clockwise from the
world’s up (north). An arrow is the heading pointer, a ring is an outline in the fill colour, an image marker is a sprite turned by the heading.
{"type": "map", "id": "radar", "width": 170, "height": 170, "padding": 0, "radius": 85, "map": {"view": {"center": {"bind": "hud.player"}, "zoom": 520, "rotate": {"bind": "hud.player.heading"}, "anchor": [0.5, 0.62]}, "layers": [ {"type": "path", "points": {"bind": "hud.track"}, "closed": true, "stroke": {"width": 3.2, "color": "#c9d3e6", "cap": "round", "join": "round", "outline": {"width": 1.4, "color": "#000a"}}}, {"type": "lines", "items": {"bind": "hud.start"}, "stroke": {"width": 2, "color": "$text"}}, {"type": "markers", "items": {"bind": "hud.cars"}, "shape": "disc", "size": 7, "stroke": {"width": 1.2, "color": "#000c"}}, {"type": "markers", "items": {"bind": "hud.me"}, "shape": "arrow", "size": 14, "fill": "$accent_hot"}]}}ui.get on a map gives the resolved view (centre, zoom, rotation) and each layer with its id, visibility and entry count. Limits: 64 layers, 10 000 entries per layer, 20 000 points per path.
Marker labels are plain text (one line, centred under the marker) and are dropped when the marker is outside the box. minimap is the older, simple widget (a unit-square outline and discs) and is unchanged.
Moving a minimap to a map: the outline list becomes a path layer (points: {"bind": "hud.map.outline"}, closed: true, a stroke with an outline for the dark under-stroke) with view: {"fit": {"bounds": [0, 0, 1, 1]}}
(the old widget mapped the unit square); the marker list becomes a markers layer (items keep x, y, color), and the player, formerly a bigger disc, becomes its own markers layer with shape: "arrow"
and a heading per item (plus a translucent disc layer under it for a halo); a start or finish line is a lines layer.
Gauge and progress
Section titled “Gauge and progress”Both paint with fills, so any part can be a gradient or a token: parts track, fill, redline, tick, needle. In a gauge a gradient runs along the arc (linear and angular fills are
read the same way: stops from the start angle to the end angle), over the whole scale by default (span: "full": a colour belongs to a place on the dial and shows as the value grows) or squeezed into
the filled part (span: "value").
gauge {start, end, ticks, major_every, redline, thickness, label_divisor} as before (ticks: 0 draws no scale; redline and the zone ranges can be bound numbers {"bind": "hud.redline"}), plus
tick_step (a tick every that many value units: the number of ticks follows the range, so a bound max keeps the spacing; major_every counts ticks),
tick {"inset": 4, "major": 9, "minor": 5, "major_width": 2, "minor_width": 1} (the gap between the ring and the tick’s outer end, the lengths and widths; the defaults are 4, 0.9 and 0.5 of the thickness, 2 and 1),
label {"radius": 72, "offset": 0, "size": 12, "color": "#fff"} (the scale numbers: radius puts the middle of a number at that distance from the dial centre, otherwise it sits just inside its tick and offset moves it
that many units towards the centre; size in layout units and a colour of its own, by default the tick’s),
fill_ramp {"blend": "step|linear", "stops": [{"at": 0, "fill": "#22d3ee"}, {"at": {"bind": "hud.band"}, "fill": "#ff8a1c"}, {"at": 90, "fill": "$bad"}]} (the filled arc takes the fill of the last stop at or below the value: cyan, then orange,
then red; linear mixes neighbouring colours; positions in value units, bindable, at most 16 stops),
zones: [{"from": 7000, "to": 8000, "fill": "$bad", "inset": -3, "thickness": 5, "over": false, "tint_ticks": true}] (a range of the scale in its own fill, inset moves the band inwards from the ring’s outer
edge and a negative one puts it outside, over draws it above the filled arc, tint_ticks colours the ticks inside it),
needle {"style": "line|taper|none", "width", "length" (fraction of the radius), "tail", "hub" (fraction of the radius, 0 for no hub), "hub_fill" (the hub's own fill), "hub_rim" {"width", "fill"} (a bigger disc under it)},
labels: false (no numbers on the scale), segments {"count": 12, "gap": 3} (pips: the track and the fill are split into count pieces with gap degrees between them; a partly filled pip
is partly drawn) and cap: "round" (rounded ends). A full ring needs only start: 0, end: 360, ticks: 0, needle {style: "none"}: a health ring, a cooldown ring, a progress circle.
progress takes bar {"zones": [...], "span": "value|full", "segments": {"count", "gap"}, "fill_ramp": {...}} (fill_ramp as on a gauge: the whole filled part takes the fill the value selects): zones colour ranges of the bar (the fill inside a zone takes the zone’s fill: a red stretch at the start of
an ammo bar), span: "full" makes a gradient belong to the whole track instead of squeezing it into the filled part, segments makes pips (gap in layout units). A bar without bar draws exactly as before.
Zone ranges are in the widget’s min..max units; at most 32 zones and 200 pips.
Themes
Section titled “Themes”Two themes are built in as JSON: racing (dark glass, orange accent, skewed panels, uppercase Russo One titles; the look of games/racing) and
daylight (light, rounded, blue accent). A theme has tokens (colors, numbers, fonts, shadows, fills, strokes), base, widgets and
classes. Both ship the same token and class names (title, subtitle, heading, panel_glass, primary, danger, hud_panel, hud_value,
hud_label, dim, small, …), so a document works with either. ui_theme / Ui::set_theme switches at run time and everything transitions.
Custom themes by name. Ui::register_theme(name, Theme) (a theme from Theme::parse) stores a theme under a name; after that a document’s
"theme": "<name>", Ui::set_theme(name) and the ui.theme / ui.load / ui.validate commands find it, and every reload keeps it. A
registered name that is a built-in (racing, daylight) is refused with UI-E004; registering a name again replaces the theme (and restyles
at once when it is the active one). Ui::set_theme_value(theme) registers the theme under its own name (custom when it has none) and
switches to it, so the next load_document without a "theme" keeps it. An unknown theme name is UI-E004 and the message lists the built-in
and registered names with a “did you mean” hint. Agents register a theme with ui.register_theme {name?, path | text | definition, activate?};
a fresh ui.load keeps the registered themes.
Data, text and localisation
Section titled “Data, text and localisation”The data store is JSON addressed by dotted paths (hud.speed, players.2.name). The game writes it (ui.data.set, ui.data.merge), widgets
read it through bind:
| key | effect |
|---|---|
bind.visible, bind.enabled, bind.open |
a condition: path, !path, path == literal, !=, >, >=, <, <= |
bind.text, text |
a template: "Lap {lap}/{laps}", {t:time} (m:ss.mmm), {n:0} decimals, {x:pct}, {pos:place}, {v:signed}, {@key}, {n:@key} (plural form), {@key:gen} (case form) and a whole-string "@menu.play" localisation key |
bind.value |
two-way: slider, progress, gauge, toggle, input, dropdown, tabs index, list selection, keybind |
bind.items |
a list’s rows, a dropdown’s options |
bind.color, bind.background |
#rrggbb[aa] strings from data |
repeat: {"each": "players", "as": "p", "template": {...}} builds children per array item ({p.name}); a list builds only the visible rows.
Localisation uses sparklight_core::Localization (en.json plus <lang>.json); a missing key shows the key and raises UI-W001. Switching the
language re-resolves every text and re-measures it.
Counted texts and case forms (syntax and rules: localization.md): "{n:@inventory.items}" reads the number at n, takes its
CLDR plural class in the active language and shows inventory.items.one, .few, .many or .other with {n} in that text replaced by the
number (en: one, other; ru: one, few, many, other; a fraction such as 1.5 is other). "{@owner.name:gen}" shows owner.name.gen.
In a placeholder the first : splits it; write \: for a colon that belongs to a key. A group with no .other text, or a case with neither
key.<case>, key.other nor key, raises UI-W001; a missing number prints nothing and raises UI-W004, and a value that is not a number
shows the other form (with the value as {n}) and raises UI-W004. These warnings are raised for text and bind.text.
Input and focus
Section titled “Input and focus”UiInput: pointer down/move/up (mouse and touch), wheel, Nav(dir), Confirm, Cancel, TabPrev/TabNext (bumpers), Text, Key, Preedit
(IME), Binding (for keybind capture); feed_stick turns an analog stick into repeated steps (first at once, then after 0.4 s every 0.12 s).
Focus rules: arrows move to the nearest focusable widget in that direction (explicit nav.up/down/left/right ids win); Up/Down wrap at the ends of
a menu; sliders take Left/Right, toggles set off/on, dropdowns cycle, tab strips switch tabs, inputs move the caret; an open modal traps focus and
gives it back to the opener; nav.autofocus marks the first focus; a screen remembers its focus for back. Mouse hover focuses (switch off with
set_hover_focus(false)), touch taps click and drags scroll. Cancel closes dropdowns and modals, then goes back; with nothing left it raises
the event cancel.
Events (UiEvent {widget, event, action, args, value}) are collected for the game: click, change, submit, select, open, close,
focus, blur, hover, leave. Actions beginning with ui. run inside the toolkit: ui.goto {screen}, ui.push {screen}, ui.back, ui.open {modal},
ui.close, ui.set {path, value}, ui.theme {name}, ui.toast {text, level, ttl}, ui.focus {id}.
Hover and tooltips
Section titled “Hover and tooltips”- Hover targets. A node is a hover target (it gets the
hoverstate style) when it is focusable or clickable, a scroll or list, bindson.hoveror has atooltip; a disabled node is none, and it hides the nodes above it like it does for clicks. A node that only has atooltiporon.hoveris no press target: a click on it still reaches the button around it. hoverandleaveevents go only to a node that bindson.hover({"on": {"hover": {"action": "help.hint", "args": {...}}}}):hoverwhen the pointer enters it (or a child that is noon.hovernode itself),leavewhen it goes. Both carry the action and args of theon.hoverbinding;valueistrueforhoverandfalseforleave. A built-inui.*action runs onhoveronly. Nodes withouton.hoverraise nothing.leavealso goes out when the hovered node is removed (its screen changes, arepeatrow goes, a reload), hidden or disabled: in the update where that happens, before the node goes. Hover otherwise follows pointer moves: a touch has none, and a press does not end a hover.- Tooltips.
"tooltip": "text"(a template:@key,{path}) on any node. The tooltip of the hovered node shows after a delay (Ui::set_tooltip_delay(Some(secs)), else the number tokentooltip_delayof the theme or document, else 0.5 s) in the popup layer, above modals and dropdowns, below the node (above it when there is no room), inside the screen; the text is rendered when it shows, so data and language changes reach it. Another target restarts the delay and a press hides it until the target changes. With touch, gamepad or keyboard input (a touch press,Nav,Confirm,TabPrev/TabNext) the tooltip follows the focused node (or its nearest ancestor with a tooltip) instead, until the mouse moves again. An open dropdown replaces it. The box is styled by the classtooltipof the theme or document (background,border_*,radius,shadow,color,font,size,padding,max_width); both built-in themes define it, and a theme without it gets a plain dark box. A tooltip is never hit-tested.Ui::tooltip_text(),tooltip_widget()andtooltip_rect()read it,UiStats.tooltips(andui.statsstats.tooltips) counts it (0 or 1), andui.tree/ui.querylist a node’s renderedtooltip(withtooltip_visiblewhile it shows).
Rendering
Section titled “Rendering”Ui::paint() returns Paint: ops in physical pixels (Rect, Text, Image, Arc, Line, Polyline, Polygon, Disc, Shadow, clips, and the two generic ops below).
Two ops carry everything the general widgets draw, so a back end implements nothing per widget: Op::Fill {contours, rule, fill, bounds} fills closed contours under the non-zero or even-odd rule with any
fill (colour, linear or angular gradient; bounds is the box the gradient is relative to), and Op::Sprite {name, center, size, angle, tint, clip} is an image turned by an angle and optionally cut to a
convex polygon. Strokes (caps, joins, dashes, under-strokes), arcs, markers, map layers and shapes are turned into contours by sparklight_ui_kit::vector (affine transforms, SVG path parsing, flattening,
stroking, convex clipping, decomposition into convex trapezoids) and emit. The three back ends draw them like this: the CPU rasteriser fills the contours with its scanline coverage under either rule;
the 2D path cuts them into strips (strips are cut into pieces a few pixels wide where a fill changes along a row) and draws a sprite as a flat tinted polygon; the 3D overlay cuts them into
convex trapezoids and fans them with per-vertex colours (linear gradients are cut at their stops, angular ones into wedges of at most 5 degrees) and draws sprites as textured polygons.
- 3D overlay:
overlay::kit::KitRenderer::draw(&mut overlay, &mut canvas, &paint)batches everything into the overlay canvas (one draw call per texture and clip), thenoverlay.draw(...)as before. Geometry cache: cutting a stroked path into trapezoids is the costliest thing a HUD asks of the canvas (a mini-map track of 836 points took 0.6 ms a frame for the stroke and the same for its under-stroke), and a map, shape or gauge that did not change paints the very same ops frame after frame.KitRenderertherefore keeps the canvas triangles ofFill,PolylineandPolygonops of at leastemit::CACHE_MIN_POINTS(16) points, keyed by a 64-bit hash of every bit of the op and the canvas scale (emit::geometry_key), and pushes them again while the key repeats. An op is stored on its second sighting (an op that differs every draw, a moving needle, is built each time and costs no memory). Budget:GEOMETRY_CACHE_BUDGET_BYTES(16 MiB of canvas vertices; a 300-point stroke with under-stroke is about 1.8 MB, an 836-point stroke of about 4 200 triangles about 0.45 MB); over it the least recently drawn entries go, the first eviction logs a warning once, andKitRenderer::cache_stats()counts hits, misses, evictions and size. Entries not drawn forGEOMETRY_CACHE_IDLE_DRAWS(300) draws - 2D path:
draw2d::to_draw_commands(&paint, &mut fonts)gives axis-aligned colour quads and glyph quads for the sprite renderer. - CPU:
raster::Rasterizer::render(&paint)gives RGBA8 with anti-aliased shapes and real glyphs; it backsui.captureand the goldens.
Fonts: register a font name with a list of faces (FontBook::register("text", &[0, 1])); each character uses the first face that has its glyph, so a
Latin face with a Cyrillic fallback works.
Scenarios
Section titled “Scenarios”A UI scenario is a JSON file (samples/ui/menu.uitest.json) with a document and steps: press {button, times}, click {id}, pointer {x, y, seconds?} (move the mouse to layout units), hover {id, seconds?} (move it to the centre of a widget; fails for a hidden, disabled
or covered one; seconds lets time pass so a tooltip can show; a wait over 100 000 frames, like a step that long, fails with UI-E040), focus {id}, type {text},
set_value {id, value}, data {path, value | merge}, step {seconds | frames}, screen {action, name}, theme {name}, toast {text}, tween {...} and
expect {focused, screen, text {id, equals|contains}, value {id, equals}, selected, visible, open, checked, tooltip {equals|contains, id, visible}, tooltips (count), events [{widget, event, action, value}]}.
samples/ui/tooltips.uitest.json hovers a widget, sees its hover event, waits for its tooltip and reads it. The run stops at the
first failing step and reports it (ui_scenario, or sparklight_ui_kit::scenario::run in Rust). The sample plays the whole menu by gamepad: focus
moves, a screen opens, a slider changes by key presses, tabs switch with the bumpers, a modal traps and returns focus.
Errors and warnings
Section titled “Errors and warnings”| code | meaning |
|---|---|
UI-E001 |
invalid JSON |
UI-E002 |
unknown widget type |
UI-E003 |
unknown property (with a suggestion) |
UI-E004 |
wrong value type or range |
UI-E005 |
duplicate widget id |
UI-E006 |
unknown component in use |
UI-E007 |
component cycle |
UI-E008 |
missing component parameter |
UI-E009 |
unknown theme token |
UI-E010 |
bad colour |
UI-E011 |
bad length |
UI-E012 |
bad binding path, condition or template |
UI-E013 |
navigation target that does not exist |
UI-E014 |
unsupported ui version |
UI-E015 |
missing required property |
UI-E016 |
unknown class |
UI-E017 |
unknown screen |
UI-E018 |
nesting too deep |
UI-E040 |
a scenario step (step, or seconds of pointer / hover) asks for more than 100 000 frames: the step fails, nothing is cut |
UI-E019 |
a fill token has the name of a colour token ($name resolves to the colour, the fill is never used) |
UI-W001 |
localisation key without text |
UI-W004 |
binding path without a value |
UI-W005 |
a data-driven list (shapes after loops, points, markers) is over its limit and the rest is skipped (once per widget) |
Command errors besides these: E_UI_NOT_LOADED, E_UI_NO_WIDGET, E_UI_CLICK (hidden, disabled or covered), E_UI_BUTTON, E_UI_VALUE,
E_UI_DATA, E_UI_FONT, E_UI_LOCALE.
Commands
Section titled “Commands”ui.load, ui.validate, ui.tree, ui.query, ui.get, ui.click, ui.press, ui.type, ui.stick, ui.focus, ui.read_text, ui.set_value,
ui.data, ui.step, ui.events, ui.screen, ui.theme, ui.register_theme, ui.toast, ui.tween, ui.scenario, ui.assert, ui.stats, ui.capture. Schemas:
schemas/commands/ui.*.schema.json.
Text rendering
Section titled “Text rendering”Faces come in two kinds under the overlay: bitmap fonts (Font, glyphs rasterised per size) and distance-field fonts (SdfFont, overlay.add_sdf_font).
The SDF face rasterises each glyph once at 4x the base size, builds an exact Euclidean distance field and averages it down, so one atlas serves 720p to 4K
and animated scales; the shader reconstructs the edge from the field (kind 3 in ui.wgsl). KitRenderer picks the path from the overlay font id, so a
host switches between them by registering one or the other. Measured on this Mac’s GPU, the same menu drawn with SDF and with bitmap fonts differs by a
mean 0.33 / 0.14 channel levels (of 255) at 720p / 4K (tests/kit_overlay.rs).
Colours are blended in linear light on the GPU (an sRGB target); the CPU rasteriser does the same, which is why their frames agree (mean channel difference 0.5 to 0.8 of 255 on the three sample screens). Pick translucent colours with that in mind: a 6 % white overlay is much lighter than it looks in sRGB.
Goldens (CPU rasteriser, deterministic) are in goldens/ui/: menu, settings and HUD in both themes and both languages, a dropdown popup, a modal and a
phone-portrait menu, and for the general widgets hud-general-racing-en (gradient tachometer with a red zone, a north-up track map with a heading arrow, start line and under-stroke, a
heading-up round radar, a damage diagram bound to data), rpg-hud-racing-en (health ring with round caps, ammo pips, a segmented stamina bar with a red zone, an RPG map with a terrain image, territory,
river coloured per segment, road, vision circle, objective line, markers and labels, an equipment silhouette, a bar chart, a skill tree and cooldown pies) and vector-gallery-racing-en (every shape type,
fill rules, joins, caps, dashes, under-stroke, curves, hard-stop bands, angular and linear fills on boxes). The documents are samples/ui/hud-general.ui.json, rpg-hud.ui.json and vector-gallery.ui.json;
round2-gallery-racing-en (a tachometer whose fill band, ticks, numbers and hub follow data, bars with value ramps, per-corner radii and fills, a child that follows its focused parent, bound opacity, scale, slide and colour, and a stroked static path) comes from samples/ui/round2-gallery.ui.json;
every frame: 493 widgets, 1920x1080), release builds, measured:
| where | what | mean | p99 |
|---|---|---|---|
| this Mac (Apple GPU) | update + layout + paint + canvas (6758 triangles in 47 draw calls) | 2.76 ms | 7.23 ms |
| this Mac (Apple GPU) | the whole frame including the overlay pass, submit and wait | 4.53 ms | 10.15 ms |
The Mac numbers come from cargo test --release -p sparklight-scene3d --features overlay --test kit_overlay heavy_hud_frame_time -- --nocapture. A CPU raster of one 1080p frame
(ui.capture) takes about 100 ms; it is for tests and agents, not for play.