Skip to content

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.

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, focused
ui_press {"button": "down", "times": 2} -> {"focused": "settings", "events": []}
ui_press {"button": "confirm"} -> events: click on `settings` -> the screen changes
ui_assert {"expect": {"screen": "settings", "focused": "quality"}}
ui_set_value {"id": "fov", "value": 100} -> change event, `settings.fov` in the data store is 100
ui_theme {"theme": "daylight", "language": "ru"}
ui_capture {"path": "ui-settings-ru.png"} -> PNG written below the output folder, no GPU needed

Rust: 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.

{
"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.

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

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.

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.

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).

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 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.

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.

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.

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.

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 targets. A node is a hover target (it gets the hover state style) when it is focusable or clickable, a scroll or list, binds on.hover or has a tooltip; a disabled node is none, and it hides the nodes above it like it does for clicks. A node that only has a tooltip or on.hover is no press target: a click on it still reaches the button around it.
  • hover and leave events go only to a node that binds on.hover ({"on": {"hover": {"action": "help.hint", "args": {...}}}}): hover when the pointer enters it (or a child that is no on.hover node itself), leave when it goes. Both carry the action and args of the on.hover binding; value is true for hover and false for leave. A built-in ui.* action runs on hover only. Nodes without on.hover raise nothing. leave also goes out when the hovered node is removed (its screen changes, a repeat row 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 token tooltip_delay of 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 class tooltip of 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() and tooltip_rect() read it, UiStats.tooltips (and ui.stats stats.tooltips) counts it (0 or 1), and ui.tree / ui.query list a node’s rendered tooltip (with tooltip_visible while it shows).

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), then overlay.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. KitRenderer therefore keeps the canvas triangles of Fill, Polyline and Polygon ops of at least emit::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, and KitRenderer::cache_stats() counts hits, misses, evictions and size. Entries not drawn for GEOMETRY_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 backs ui.capture and 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.

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.

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.

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.

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.