Protocols
8. Protocols
Section titled “8. Protocols”8.1 Ops (Worker → core)
Section titled “8.1 Ops (Worker → core)”One ArrayBuffer per commit. It holds a header, a section of newly interned strings, and the op stream as 32-bit words. f32 values are written through a Float32Array view over the same buffer.
header: magic u32 · version u16 · flags u16 · opCount u32 · stringsBytes u32strings: repeated { id u32 · byteLen u32 · utf8 bytes (padded to 4) } // new interned strings onlyops: repeated { opcode u16 · wordCount u16 · words… }Status: the op stream is implemented as above, with each op’s first word code | operandWords << 16. The header and the string section aren’t yet: strings travel inline, as a byte length and then the UTF-8 bytes padded to words. The source of truth is schema/protocol.ts; bun run gen generates the TypeScript and Rust bindings from it, and CI fails when they’re stale. The ops implemented so far:
- Tree:
CREATE(kindsView,TextandInput),INSERT,REMOVE,RELEASE,STYLE,STYLE_RESET,STYLE_DEF,STYLE_USE,LISTEN. - Content:
TEXT(a Text node’s content), and for inputsVALUE(id, rev, text),PLACEHOLDER,INPUT_OPTIONS(flags: multiline, read-only; the maximum length) andFOCUS(0 blurs), in place of thePROPandCOMMANDops below. - Window:
SET_ROOT,CLEAR_COLOR,TITLE.
| Op | Operands | Meaning |
|---|---|---|
CREATE |
id, kind | New node (View, Text, Span, Scroll, Image, Input, List, Window…) |
TEXT |
id, strId | Text content of a text leaf or span |
INSERT |
parent, child, before (0 = append) | Insert, or move if the child is already attached |
REMOVE |
parent, child | Detach only |
RELEASE |
id | Free the node and its resources |
STYLE |
id, prop, value… | Set one style property (typed value; see below) |
STYLE_RESET |
id, prop | Back to the default |
PROP |
id, prop, value… | Non-style props: src, placeholder, role, label, value+rev, disabled… |
LISTEN |
id, eventMask | Which events this node has handlers for |
STYLE_DEF |
styleId, (prop, value…)* | Define a shared style (§8.4) |
STYLE_USE |
id, styleId (0 = none) | Apply a shared style; per-node STYLE ops override it |
COMMAND |
id, cmd, reqId, args… | focus, blur, scrollTo, measure, animate, select… |
Value encodings:
f32(number)u32(enum)color(RGBA8 packed into a u32; wide gamut later)length(f32 + a unit: px, %, auto)strId- short lists: a count followed by the items
Style and prop ids, and their types, are generated from one schema shared by the Rust decoder and the TS encoder and typings, so the two sides can’t drift apart.
8.2 Events (core → Worker)
Section titled “8.2 Events (core → Worker)”- The main thread appends event records to a buffer guarded by a mutex. It sets an atomic
wake_pendingflag, and only if that flag was clear does it call the threadsafewakecallback. That coalesces bursts into one wake. - On wake, the Worker calls
mt_take_events(), which swaps the buffers and clears the flag. It then dispatches every record. Any ops the handlers produce flush in the same turn’s microtask. - Record format:
type u16 · size u16 · target u32 · time f64 · payload… - Status: records are
[head, a, b, c]as u32 words (floats as bits) and thenhead >> 16more words;head & 0xffffis the type. OnlyTEXT_CHANGEDhas more words so far: its text.mt_take_eventscopies whole records and returns 0 when none are left, or0x80000000 | sizewhen the next record doesn’t fit, and the runtime grows its buffer. Implemented:WINDOW_READY,RESIZED,SCALE_CHANGED,CLOSE_REQUESTED,POINTER_DOWN/MOVE/UP,KEY_DOWN/UP(with modifiers),PRESS,WHEEL,FOCUS,BLUR,TEXT_CHANGEDandSUBMIT.
| Event | Payload |
|---|---|
PRESS, LONG_PRESS |
position; the core synthesises these from the pointer, keys and accessibility actions |
POINTER_DOWN/MOVE/UP/ENTER/LEAVE |
position, buttons, modifiers. Sent only to nodes listening for them |
KEY_DOWN/UP |
key, code, modifiers, repeat |
TEXT_CHANGE |
value strId, selection, rev |
FOCUS, BLUR |
— |
SCROLL |
offset (throttled; only if listened for) |
LAYOUT |
rect. For onLayout listeners and measure replies (reqId) |
WINDOW |
resized, moved, close-requested, focus changed, scale changed |
MENU, A11Y_ACTION, FILE_DROP, COMMAND_RESULT |
… |
Propagation: the core hit-tests and sends the target, plus the path of ancestors that have listeners. JS runs the capture and bubble phases. stopPropagation happens in JS.
No synchronous preventDefault: input is asynchronous, so the core’s default actions (focus on press, scroll on wheel, editing keys) are controlled by props (focusable, scrollEnabled, onKeyDown with declared keys it consumes). This is a deliberate API constraint.
8.3 Text input
Section titled “8.3 Text input”- The core owns editing: caret, selection, IME composition, undo, clipboard and word movement, so typing never waits for JS.
- Decided (Q2): two modes, like the DOM. Typing never waits for JS in either.
defaultValuemakes the input uncontrolled: the core holds the text, andonChangereports it.value+onChangemakes it controlled: the core applies keystrokes at once and reportsTEXT_CHANGE(value, rev). Thevalueprop JS sends back carries therevit answers, and the core ignores values whoserevis stale. Avaluethat differs from what the user typed (uppercasing, say) replaces the text, and the caret is kept by its offset from the end.
- Constraints are declarative, not synchronous filters:
maxLength,allow(a regex over characters, checked natively),inputMode,autoCapitalize,secure. These cover whatbeforeinputfiltering is usually for, without a synchronous round trip through JS. - Status: implemented as
<Input>(Text input), withmaxLengthandreadOnlyof the constraints. The editor is parley’sPlainEditorwith Mantou’s undo, limits and scrolling (mantou-text/src/editor.rs); focus, keys, the pointer and the IME are inmantou-tree, and the clipboard is the FFI crate’s. A controlled value that replaces the user’s text keeps the caret at its byte offset, clamped, rather than its offset from the end. The keys the focused input uses aren’t sent to JS at all, which settles theonKeyDownquestion of §8.2 for inputs.
8.4 Styles
Section titled “8.4 Styles”- Decided (Q4): v1 uses typed style objects only (
style={{…}}). They cover flexbox and grid (Taffy), box properties (background, border with per-side widths, radii, shadow, opacity, transform, overflow/clip) and text (font family, size, weight, line height, color, alignment, decoration). TypeScript checks every property. - Shared styles on the wire from day one:
- The JSX plugin hoists static style objects. The runtime sends each one once as
STYLE_DEF(styleId, props…), and nodes reference it withSTYLE_USE(id, styleId). - Dynamic values still go through per-property
STYLEops, and they override the shared style. - A list of 1,000 rows sharing one style therefore costs one definition plus 1,000 four-byte references.
- The JSX plugin hoists static style objects. The runtime sends each one once as
- Later (once the base components settle): an optional build-time Tailwind subset that compiles
class="…"into the sameSTYLE_DEFs, so the core never parses classes. It’s worth adding for AI-generated UI, but not before theming and the base components settle. - Units are logical pixels. The core rounds layout to device pixels at the window’s scale factor.
8.5 Text
Section titled “8.5 Text”- parley lays text out on the Worker thread, inside
mt_commitduring layout. Measurements are cached by a hash of the layout-relevant content plus the width constraint; colour is excluded, so a colour change never triggers layout. - swash rasterizes glyphs into a coverage atlas and a colour atlas. A glyph seen for the first time goes into a transient zone and moves to a lasting one once drawn again. If an atlas fills mid-frame, the core makes room and repaints rather than showing missing glyphs.
- Status: implemented in
mantou-text. The bundled font is Inter (Regular, Medium, SemiBold, Bold; OFL), with the system’s fonts as the fallback. Layouts are cached by text, style and width, and the parley font context is built once per process. Glyphs are rasterized at device size with four horizontal subpixel positions into one coverage atlas, so colour emoji draw as monochrome shapes for now.maxLinescuts off with an ellipsis. Text styles aren’t inherited yet, and a<Text>has one style (no spans). - Decided (Q5): parley on every platform in v1, behind a
TextEnginetrait.- Why: one implementation to build and debug. Layout is also identical on every OS when the fonts are the same: tests bundle a test font, so headless text and image snapshots taken on Linux CI hold everywhere.
- Cost: text won’t match each OS’s own toolkit pixel for pixel, as a system text stack would.
- Checkpoint once text renders: compare screenshots of parley text against native labels on each OS. If the difference is noticeable, add a Core Text backend first, since Mac users notice text rendering most. The trait keeps that change local to
mantou-text.