Expand description
§The low-level contract
This page is the reference for the contract between a plugin component and its host: the exports each world expects, the memory rules, the surface buffer protocol and the error types. The Rust SDK implements all of it, so a plugin written with the SDK needs none of this page. Read it to debug what the SDK sends, or to read the protocol itself.
Sources, in order of authority:
- The WIT package
standard:plugin@2.0.0,wit/plugin.witin this crate, is the contract. - Where the WIT is silent, this page describes what the SDK does and cites the SDK file and item. A component that behaves the same way works with the same hosts.
- Where neither states a rule, this page says it is unspecified. Do not depend on unspecified behaviour.
§Worlds and exports
A plugin is a component that targets one of two worlds.
| World | Manifest kind | Exports |
|---|---|---|
ui-plugin | ui | activate, frame, event, deactivate |
daemon-plugin | daemon, companion | activate, event, drive, deactivate, the interface daemon-events (handle-event), the interface companion (handle-call) |
standard-plugin check refuses a component that lacks an export its world
requires, and warns about an export no host calls
(src/check.rs in standard-plugin-cli,
check_component).
§When the UI exports are called
| Export | When (from plugin.wit) |
|---|---|
activate(config) | Once after instantiation, with the configuration document (JSON text). |
frame(now-ms, interval-ms, power) -> option<u64> | When a paced frame is due and at least one of the plugin’s surfaces is visible. now-ms is the viewer’s monotonic clock; interval-ms is the paced frame interval. |
event(e) | For everything else: resizes, visibility, input, account changes, call results (the event variant in event-types). |
deactivate() | Before the instance is dropped. |
frame returns the now-ms at which the plugin next wants a frame:
now-ms itself for the next paced frame, a later instant for a clock, and
none to wait for an event. Nothing else wakes an idle viewer. From any
export, view.request-frame asks for the next paced frame and
view.wake-at(at-ms) asks for a frame at an instant; the earliest request
wins. The SDK’s Frame::request_frame is wake_at(now_ms), and the
earliest of the plugin’s requests is frame’s return value
(src/ui_runtime.rs,
Frame).
A commit made in event is not a frame request. The viewer samples it on
the next frame that something else causes. A plugin that repaints in
event also calls view.request-frame or view.wake-at.
§When the daemon exports are called
| Export | When (from plugin.wit) |
|---|---|
activate(config) | Once after instantiation, with the configuration document. |
event(e) | Account events: value changes, live messages, plugin events, account changes, call results. |
daemon-events.handle-event(e) | Machine events, in order: file changes, failed watches, process output and exits, pane changes. |
companion.handle-call(method, payload, from) | A call from another runtime of the plugin (from.kind is ui or daemon). Returns the answer, or invalid for an unknown method. |
drive(now-ms) -> option<u64> | After activate, after event, handle-event and handle-call, and at the instant the previous call asked for. Returns the next instant, or none to wait for an event. |
deactivate() | The WIT does not say when a host calls it. |
The export is named drive because a daemon plugin may link wasi-libc,
which defines a poll symbol (plugin.wit, daemon-plugin).
Rules a daemon plugin can rely on:
driveruns after the calls listed above. The WIT does not promise onedriveper call, so do all pending work in eachdrive.- A host may stop an instance without calling
deactivate. Do not put work that must happen indeactivate. - The host delivers the plugin event
system.plugin.interestonce when the plugin starts and again when the set of shown surfaces changes (plugin.wit,events). The SDK hears it only when it differs from the last one it received (src/daemon/runtime.rs,DaemonRuntime::event). - WebSocket activity arrives as the plugin event
system.net.websocket(plugin.wit,daemon-net).
The SDK’s daemon runtime runs the plugin’s async tasks on a small executor
inside drive and returns the earliest timer the tasks wait for
(src/daemon/runtime.rs, DaemonRuntime::drive).
§Threading
A component instance runs on one thread, and the host calls one export at
a time. The SDK’s runtimes and instance-local state depend on this
(src/local.rs,
instance_local!; src/ui_runtime.rs, the Sync impl of UiRuntime).
Host calls go out from inside an export.
§Gestures
Some calls work only while the plugin handles a user gesture: a key, a
paste or a pointer down on one of its surfaces, or one of its
commands. surface.open and view.focus-pane need a gesture.
url.open needs a gesture or the second after one, once per input. Each
answers invalid outside that window (plugin.wit, surface, view,
url).
§Memory
- The component exports its linear memory and the canonical ABI realloc
function. The host uses the realloc function to place strings and lists
in the plugin’s memory: the
activateconfiguration, event payloads and the results of host calls. The plugin owns those allocations and frees them. Bindings thatwit-bindgengenerates do this. - The SDK’s components use UTF-8 for strings (the
wit-bindgendefault). Other string encodings are unspecified. - The shared buffer is little-endian. The SDK refuses to compile for a
big-endian target (
src/lib.rs, thecompile_error!fortarget_endian = "big"). - A UI plugin has no libc. The SDK’s
runtimefeature supplies what ano_stdcomponent needs: a global allocator that reuses freed blocks, a panic handler that traps,cabi_realloc, and the C symbolsmemcmp,bcmp,fmodfandfmod(src/runtime.rs). The allocator reuses memory because every resize allocates a new region and frees the old one; an allocator that never reuses memory reaches the memory limit after a few resizes. memoryMibin the manifest limits linear memory; the host caps it (manifest). The contract states no other allocation rule.
§The surface buffer
A UI plugin paints each surface into a region of its own linear memory.
The host reads that region directly. The layout comes from plugin.wit
(surface, region-layout); the SDK constants are in
src/surface/mod.rs
(HEADER_LEN, CELL_LEN, PIXEL_LEN).
§Layout
offset 0 header: 8 little-endian u32 words (32 bytes)
offset 32 slot 0, slot-len bytes
offset 32 + slot-len slot 1, slot-len bytes
total len = 32 + 2 * slot-len| Header word | Value |
|---|---|
| 0 | seq: the sequence number (below) |
| 1 | model: 0 cells, 1 pixels |
| 2, 3 | cols, rows |
| 4, 5 | px-w, px-h |
| 6, 7 | cell-px-w, cell-px-h |
slot-len is cols * rows * 16 in the cells model and px-w * px-h * 4
in the pixels model. Always take len, header-len, slot-len, the
model and the geometry from surface.layout(id). Do not compute them
yourself.
px-w and px-h are zero while the viewer has no pixel geometry. A
pixels surface then has an empty slot and nothing to paint.
The WIT states no alignment for the region. The SDK allocates it as
u32 words, so it is 4-byte aligned (src/surface/mod.rs, Binding).
Align it the same way.
§The cells model
A cell is 16 bytes, four little-endian u32 words:
| Bytes | Field | Encoding |
|---|---|---|
| 0..4 | grapheme | 0 is empty. Otherwise a Unicode scalar value. Values from 0x8000_0000 up are reserved for a grapheme pool that is not implemented. |
| 4..8 | fg | A colour word (below). |
| 8..12 | bg | A colour word. The default colour is a transparent background. |
| 12..14 | attrs | u16 bits: bold 1, dim 2, italic 4, underline 8, reverse 16, strikethrough 32. |
| 14..16 | padding | Zero. |
A colour word’s top byte is its model
(src/colour.rs,
Colour::encode and Colour::decode):
| Word | Meaning |
|---|---|
0x00______ | The default: the theme’s text colour, or a transparent background. |
0x01RRGGBB | An RGB colour. |
0x020000II | Terminal palette index II. |
0x030000TT | Theme slot TT: 0 fg, 1 bg, 2 recede-fg, 3 recede-bg, 4 accent. |
Colour::decode reads an unknown model byte as the default colour and
ignores bits a model does not use.
The SDK writes cells by these rules
(src/surface/cells.rs,
char_width, Grid::put). The module documentation says they mirror the
viewer:
- A cell holds one scalar: the first scalar of a grapheme cluster. Combining marks, variation selectors and joined sequences are dropped.
- A character’s width is its East Asian width, 1 or 2. Control characters (C0, DEL, C1), the bidi controls U+061C, U+200E, U+200F, U+202A to U+202E and U+2066 to U+2069, and zero-width characters are never written.
- A wide character takes two cells: the character, then a cell whose grapheme is 0 with the same colours and attributes.
- A wide character that does not fit before the right edge is replaced by one empty cell.
- A write over the right half of a wide character empties its left half. A write over the left half leaves the right half empty. Either way the neighbour cell joins the dirty rectangle, so the viewer reads it again.
§The pixels model
A pixel is 4 bytes, r, g, b, a, in rows from top to bottom. px-w is
cols * cell-px-w. Alpha is straight (unpremultiplied)
(rendering).
view.capabilities().pixel-scale is 1, or 2 while the host has halved
the plugin’s pixel resolution. The geometry the plugin receives is then
the smaller one, and the viewer scales the image up (plugin.wit, view).
§Binding a region
The SDK binds a surface in Inner::bind (src/surface/mod.rs):
- Call
surface.layout(id). Anidthe manifest does not declare answersinvalid. - If the layout’s model is not the model you paint in, stop. The SDK
returns
Error::WrongModel. - If
colsorrowsis 0, the surface has no size yet. Wait forevent::resize. - Allocate
lenbytes, zeroed. - Write header words 1 to 7 from the layout. The SDK writes word 0 with its current sequence number.
- Call
surface.attach(id, region), whereregionis the allocated bytes. The canonical ABI passes alist<u8>argument as an address and a length in the plugin’s memory, so the host sees the region itself.
attach refuses a region that is shorter than len or whose header does
not describe the current layout (plugin.wit, attach).
After attach returns, the host reads the region at any time until it is
detached or replaced. Keep it allocated at the same address. Do not reuse
its memory for anything else.
A surface can have a size before the plugin creates it: for example, when
an instance is restarted. The SDK’s Surface::new calls layout at once
and binds when the surface has a size, else on its first resize
(src/surface/mod.rs, Surface::new).
§Painting and committing
The region holds two slots. The plugin paints one slot (the back slot) while the host may read the other one.
The SDK’s rules (src/surface/mod.rs, Inner::commit, Inner::apply_sync,
Inner::back):
- After a bind, the back slot is slot 0.
- Paint only the back slot. Record every rectangle you change, in cells (cells model) or pixels (pixels model).
- To commit, increment the sequence number (wrapping at 2^32), write it
to header word 0, and call
surface.commit(id, slot, dirty)with the back slot and the changed rectangles. - When
commitsucceeds, the other slot becomes the back slot. - Before the first write to the new back slot, copy into it the rectangles of the commit that just went out, from the slot that was just committed. Each slot then holds a whole frame, and drawing stays incremental.
- When
commitfails, the SDK keeps the back slot and adds the rectangles back to its dirty set for the next commit.
The contract says that the viewer reads only committed slots, only the
dirty rectangles, and only when a frame is due and the surface is visible
(plugin.wit, surface). It does not say that the viewer reads every
commit. Rule 5 keeps the image correct when the viewer skips one.
Other commit rules:
- An empty
dirtylist changes nothing and requests no frame. The SDK makes no host call when nothing changed:Surface::commitreturnsOk(false). - The first commit after an attach is fully dirty. The SDK sends one rectangle that covers the surface.
- The SDK clamps rectangles to the surface. It merges rectangles that
touch, and past 16 it sends their bounding box
(
src/surface/dirty.rs,MAX_RECTS). The contract states no limit on the list. slotis 0 or 1.- To repaint all of a slot, skip rule 5 and mark the whole surface dirty.
The SDK’s
clear()andSurface<Pixels>::repaint_all()do this.
The WIT says the header carries “a sequence number so a half-written slot is never sampled”. It does not say how a host uses word 0. Write it as the SDK does.
§Resize and model changes
event::resize(id, geometry) says a surface changed size or model. The
plugin then binds a new region: layout, allocate, write the header,
attach (plugin.wit, event).
- The host keeps reading the old region until the first commit at the new
size, and never reads it after that commit returns. Free the old region
only after that commit (
plugin.wit,attach). - The SDK keeps one retired region. When several resizes arrive before a
commit, it keeps the first old region and frees the later ones at once
(
src/surface/mod.rs,Inner::bind, theretiredfield). - The new region starts zeroed and the next commit is fully dirty. Repaint everything.
- The SDK rebinds every surface of the resized id before the plugin’s
eventsees the resize (src/ui_runtime.rs,UiRuntime::event;src/surface/mod.rs,rebind_surfaces). - A surface whose manifest lists both models may switch model.
layoutthen reports the other model. The SDK’sAnySurfacedetaches the old surface and binds a new one in the new model under the same id (src/surface/mod.rs,AnySurface::sync). - An instance surface (
<surface>@<owner>) that resizes to zero is gone. The SDK’sInstancesdrops its surface then (Instances::event).
§Detaching
surface.detach(id) unbinds the region. The host must stop reading a
region before its memory is freed, so the SDK detaches when a Surface is
dropped (src/surface/mod.rs, impl Drop for Surface).
§Sizing requests
surface.request-size(id, cols, rows) asks for a size in cells instead of
the manifest’s height and width. 0 in a dimension is the viewer’s
choice, except for the rows of a machine.after, project.after or
project.before instance, where 0 rows hides the instance. The viewer
clamps the request to what the anchor allows and answers with a resize
when the size changes. The request stays in force until the plugin asks
again. An instance id may be asked before the viewer has sized it
(plugin.wit, request-size; manifest).
The plugin paints at the size the latest resize reports.
§Instances
pane.footer, pane.header, pane.overlay, machine.after,
project.after and project.before surfaces have one instance per owner,
named <surface-id>@<owner-id>. Each instance arrives as its own
resize and visibility, and the plugin binds a region per instance by
that id. view.surface-pane, view.surface-machine and
view.surface-project name an instance’s owner (plugin.wit, view;
manifest).
§Errors
Every fallible host call returns call-error (plugin.wit, types):
| Variant | Meaning |
|---|---|
grant-denied(grant) | The manifest does not carry the grant the call needs. The payload names it. |
rate-limited | The per-plugin rate limit for this call kind was reached. |
unavailable(reason) | The backing service is not reachable now. A calls.call timeout is unavailable("call_timeout"). |
invalid(reason) | The arguments were malformed: an unknown surface id, an oversized value, a key outside the plugin’s namespace, a gesture-only call outside a gesture. |
An import outside the plugin’s world fails at instantiation, before any
call (plugin.wit, the package comment).
The SDK’s Error
(src/error.rs) carries
those four variants and adds three of its own. The host never sends them:
| SDK variant | Meaning |
|---|---|
Json | A payload did not serialize or deserialize. |
NotAttached | The surface has no buffer yet: the viewer has not laid it out. |
WrongModel | The layout reports the other model. |
A panic in an SDK plugin traps (src/runtime.rs, the panic handler). What
a host does after a trap is outside the contract; rendering
and daemon plugins describe what the plugin sees.
§Unspecified
Neither the WIT nor the SDK states these. Do not depend on them:
- How a host uses header word 0 (
seq). - The alignment of a region. The SDK uses 4 bytes.
- A limit on the number of dirty rectangles in one commit.
- Whether the viewer reads every commit.
- When a daemon host calls
deactivate, and whether it calls it at all. - Whether
driveruns once per event or once per group of events. - String encodings other than UTF-8.
- The grapheme pool for scalars from
0x8000_0000up. It is reserved.