Skip to main content

The backend contract

A backend turns moth's semantic node tree into pixels and input events. One implementation today: moth_render — the native renderer the ESP32-S3 firmware ships. An LVGL backend was the original plan and was never built (ADR-008); the contract stays backend-neutral so a second one remains possible. Dart code — the widget framework and apps — sees only this contract, never a backend's internals.

The C header moth_render/include/moth_render.h is the normative API; this document is the normative semantics. A backend is correct iff it passes the conformance suite (§7).

1. Design rules

  • Semantic nodes, not primitives. The contract speaks in label, slider, box — not rects and glyphs. This lets the LVGL backend use lv_slider (mature, accessible, themed) while moth_render composes primitives internally.
  • moth owns the semantics. Layout (§4) and style (§5) behavior is defined here, in backend-neutral terms. Backends map moth semantics onto their engine — never the reverse. If LVGL flex and this spec disagree, the LVGL backend must compensate; the spec does not bend to an engine.
  • Batched frames. Mutations (create/attach/set_*) are cheap and deferred; commit() applies them: layout → damage → paint, and returns whether a repaint occurred — platforms skip the display flush (expensive over SPI) when it didn't. One commit per event-loop tick, at most.
  • The VM is never in the frame loop. Continuous animation runs inside the backend (§6); the contract only starts/stops it.

2. Node kinds

kindpurposenotes
boxcontainer; background, layoutthe only node with children
labelsingle run of textwraps within its bounds
imageraster asset by keyasset resolution is backend-supplied
sliderhorizontal value controlemits value_changed
switchboolean toggleemits value_changed (0/1)
arcstroked ring segmentgauges, progress; no children (contract v2)

Deliberately small; textinput and canvas are still post-v1 additions and require a contract version bump (§8). arc landed in contract v2.

2.5 Display shape

A panel declares itself rectangular or round. Layout stays rectangular either way — a round panel still has a rectangular framebuffer — but the corners of a round one sit behind the bezel.

mr_safe_area() reports the largest rectangle guaranteed to be visible: the whole display when rectangular, the inscribed square when round. For a 466px circular panel that is 329x329 at (68, 68), since the inscribed square of a circle has side diameter / sqrt(2).

Apps should keep content inside it rather than hardcoding a padding. Nothing clips to the circle: a background may still cover the full framebuffer, which is usually what you want.

3. Tree operations

create(kind) → id, destroy(id) (recursive, detaches first), attach(parent, child, index), detach(child). Only box may have children. Node ids are opaque uint32, never reused within a session. The root box is created by init and spans the display.

4. Layout — the moth flex subset

Every box lays out children on one axis. Properties and exact meaning:

  • flex_direction: row | column | stack (default column). stack places every child at the container's origin rather than in sequence, so they overlap.
  • width, height: px, or auto (default). Auto = content size: sum of children (+ gaps) on the main axis, max child on the cross axis, plus padding. Leaf auto sizes: label = measured text; image = intrinsic; slider = 160×24; switch = 40×24 (px, before styling).
  • flex_grow: float ≥ 0 (default 0). After fixed/auto sizing, remaining main- axis space is distributed proportionally to grow factors. No shrink in v1 — overflow simply clips.
  • main_align: start | center | end | space_between (default start). Ignored when any child grows (leftover space is zero).
  • cross_align: start | center | end | stretch (default stretch, matching CSS flexbox). Stretch makes auto-sized children fill the cross axis; children with a fixed cross size keep it and fall back to start placement. Without stretch, a row inside a column could never fill the width.
  • gap: px between adjacent children (default 0)
  • padding: uniform px inset (default 0; per-side is post-v1)
  • position: flow (default) | absolute. Absolute nodes leave the flex flow and place at (left, top) relative to the parent's padding box.

No wrap, no percent sizes, no margins in v1 (margins = wrap in a padded box). Coordinates are float px; backends may snap to physical pixels when painting but must report unsnapped frames in frame_of (§7).

5. Style properties

propertytypeapplies todefault
bg_colorARGB8888box, slider, switchtransparent
radiuspxbox, image0
border_width / border_colorpx / ARGBbox0
opacity0..1all (multiplies subtree)1
text, font_size, text_colorutf8 / px / ARGBlabel"", 14, opaque black
image_srcasset keyimage
value, min, maxfloatslider, switch0, 0, 100
arc_startdegrees, 0 at twelve o'clock, clockwisearc0
arc_sweepdegrees; >= 360 draws a closed ringarc0 (nothing drawn)
thicknessstroke width, pxarc0, painted as 4
stroke_align-1 inside / 0 centred / 1 outside (Flutter's strokeAlign)arc0
stroke_capbutt | roundarcbutt
arc_track_colorARGB8888, the unswept remainder; zero alpha leaves it undrawnarctransparent

Setters are typed (set_f32, set_u32, set_str); setting a property a node kind doesn't support is a no-op (logged in debug builds, never a crash).

6. Events and animation

Events flow backend → one registered sink: {node, kind, value, x, y} with kinds pressed, released, clicked, value_changed. The backend performs hit-testing and control gestures (slider drag) natively; the sink (the VM event loop) only sees semantic results. The sink must never re-enter the contract synchronously — it queues. Hit-testing for slider and switch extends to at least a 48px-tall band around the painted control, plus 8px on each side (finger-sized targets); a touch that misses the pixels but lands in the band still hits. When expanded bands overlap, the node attached later wins — the band is a tolerance, so keep adjacent controls at least 24px apart if the tie-break matters. Controls claim their gesture: a slider drag or switch tap reports only value_changed (plus pressed/released), never clicked, so a control inside a tappable ancestor does not also fire the ancestor.

Animation: anim_start(node, prop, from, to, duration_ms, easing) → anim_id, anim_stop(anim_id). Easing: linear, ease_out, ease_in_out. Animatable: any f32 property plus bg_color/opacity. The backend interpolates every frame natively; a completed event fires at the end. This is the only sanctioned path for continuous motion.

Time: the platform drives tick(dt_ms) + commit(); the contract has no internal clock (keeps the core deterministic and testable).

7. Conformance suite

What exists today (host-run, no hardware): contract tests in moth_render/test/ — control gestures, events and painted pixels (controls_test.cpp), per-frame paint-cost budgets (perf_test.cpp), and asset blitting plus lifetime-across-swap (image_test.cpp) — plus reconciler, wrapping and tap checks driven through the simulator in CI. These gate every commit via make test.

The full suite this section originally specified is still planned, and a second backend cannot be accepted without it:

  1. Layout goldens — scene scripts (JSON: build tree, set props, commit) with expected frame_of(node) rects for every node. Numeric, exact to 0.5px. This is the primary gate; it encodes §4 completely.
  2. Op-trace goldens — for the reconciler: widget tree in → expected contract-call sequence out (backend-independent; uses the recording "null backend").
  3. Pixel goldens — screenshot comparisons with perceptual tolerance; advisory, not gating (backends legitimately render differently).

A backend passes ⇔ 1 is green. The suite is the spec's executable form — behavior changes land as a golden change first.

8. Versioning

The contract carries a single integer MR_CONTRACT_VERSION. Additive changes (new node kind, new prop) bump it; backends report the version they implement and the framework refuses mismatches at init. Pre-1.0 there is no compatibility promise, only honesty.