Web backend (ftui-web)
ftui-web is the FrankenTUI backend that runs inside a browser. It implements
the same ftui-backend traits the TTY backend does, but inverts the
scheduling model: JavaScript is in charge of time and events, Rust just
answers when asked.
FrankenTUI does not use xterm.js, wezterm’s web layer, or any other
third-party terminal emulator in the browser. ftui-web renders directly
through a patch format that frankenterm-web consumes — typically against
a canvas or a DOM grid. If you see a tutorial online that pipes ftui output
into xterm.js, it is wrong.
Why host-driven
A wasm32-unknown-unknown target has:
- no threads,
- no blocking syscalls,
- no wall-clock
Instant::now()worth trusting across runs, - no way to poll for input without yielding back to the browser event loop.
So ftui-web inverts the relationship: the JS host owns the event loop, and
the Rust side is a state machine that advances when the host pushes events
and asks for a frame. This is what makes WASM showcase runs
byte-reproducible — the clock and the event order are explicit inputs.
The three pieces
The crate (crates/ftui-web/src/lib.rs) exports three small types that
together satisfy the backend contract:
DeterministicClock
pub struct DeterministicClock { /* now: Duration */ }
impl DeterministicClock {
pub const fn new() -> Self;
pub fn set(&mut self, now: Duration); // host sets time
pub fn advance(&mut self, dt: Duration); // host ticks time
}
impl BackendClock for DeterministicClock {
fn now_mono(&self) -> Duration;
}Every animation tick, the JS host calls advance(Duration::from_millis(16))
before asking for a frame. Replays that feed the same dt sequence are
bit-identical.
WebEventSource
pub struct WebEventSource {
size: (u16, u16),
features: BackendFeatures,
queue: VecDeque<Event>,
}A pure queue. The host pushes ftui_core::event::Event values in; the
runtime pops them out. poll_event never blocks — it returns true when
the queue is non-empty and false otherwise. Size and features are pushed
in from the host as well.
WebOutputs
pub struct WebOutputs {
pub logs: Vec<String>,
pub last_buffer: Option<Buffer>,
pub last_patches: Vec<WebPatchRun>,
pub last_patch_stats: Option<WebPatchStats>,
pub last_patch_hash: Option<String>,
pub last_full_repaint_hint: bool,
}The presenter side. After each frame the runtime fills this struct, and the
JS host reads last_patches to update the DOM or canvas.
The JSON input parser
The input-parser feature (gated by dep:serde, dep:serde_json) activates
ftui_web::input_parser::parse_encoded_input_to_event, which converts the
JSON envelope emitted by frankenterm-web into the runtime’s
Event enum.
JSON kind | Translates to | Notes |
|---|---|---|
"key" | Event::Key(KeyEvent) | DOM KeyboardEvent.code → KeyCode; modifiers + repeat preserved |
"mouse" | Event::Mouse | Down / move / up with SGR-equivalent button + modifier flags |
"wheel" | Event::Mouse(::Wheel) | Optional; axis + magnitude |
"paste" | Event::Paste | Bracketed-paste text arrives intact |
"focus" | Focus gain/loss | Matches terminal focus-in / focus-out events |
"composition" | IME composition | Optional; raw composition strings |
"touch" / "accessibility" | Ok(None) | No direct event mapping; dropped silently |
The parser lives on the Rust side (not in frankenterm-web) specifically so
the WASM showcase can depend on it without pulling in web-sys or js-sys.
Pointer and touch parity
pane_pointer_capture.rs is where browser pointer lifecycle meets the pane
system. The adapter translates DOM pointer events into the same
PaneSemanticInputEvent stream the terminal
backend generates.
pub enum PanePointerLifecyclePhase {
PointerDown, PointerMove, PointerUp, PointerCancel,
PointerLeave, Blur, VisibilityHidden,
LostPointerCapture, CaptureAcquired,
}
pub enum PanePointerCaptureCommand {
Acquire { pointer_id: u32 }, // JS: element.setPointerCapture(id)
Release { pointer_id: u32 }, // JS: element.releasePointerCapture(id)
}The adapter enforces four invariants:
- One active pointer at a time. If a second
PointerDownarrives while a capture is live, it is rejected. - Explicit capture handshake. The adapter emits
Acquirecommands that the JS host executes and acknowledges viaCaptureAcquired. No “lost-capture-mid-drag” silent-drop paths. - Cancellation on interruption.
Blur,VisibilityHidden, andLostPointerCaptureall cancel the drag cleanly, with aPaneCancelReason. - Direction-change accounting. Cumulative delta, sample count, and the
sign of each step are tracked so inertial throw
can compute a
PaneMotionVectorwhen the pointer lifts.
The practical effect is that dragging a pane divider on a touchscreen, trackpad, or mouse produces the same event stream the terminal backend produces — the pane state machine does not know which one it is looking at.
DPR and zoom
ftui-web itself operates in cell coordinates, not pixels: the host decides
the grid size and pushes it in via WebEventSource::set_size(cols, rows). DPR
(device pixel ratio) and browser zoom are the host’s concern — the Rust side
just sees a resize event. This mirrors the terminal case where the OS reports
a new window size and FrankenTUI does not care whether the font got bigger or
the window got smaller.
The patch format
Each frame, the presenter emits a list of WebPatchRun records. Cells are
packed as a contiguous u32 payload:
[bg, fg, glyph, attrs, bg, fg, glyph, attrs, ...]and runs of dirty spans ship as u32 pairs:
[offset, len, offset, len, ...]Each frame also carries an optional FNV-1a-64 hash of the patch payload for
the JS host to verify integrity or detect duplicates. The hash uses the
standard constants (offset_basis = 0xcbf29ce484222325, prime = 0x100000001b3) and is computed lazily on demand. A
last_full_repaint_hint: bool tells the host when the next frame is better
served by a full clear-and-redraw than by incremental patches.
The format is deliberately dumb. The host does not need to know anything
about cell layout, grapheme clusters, or escape sequences — only how to
turn four u32s into a styled glyph.
Example: a round-trip frame
Host pushes input
runner.push_input(JSON.stringify({
kind: "key", code: "KeyA", key: "a", modifiers: { shift: false, ... }
}));Host advances the clock and ticks
runner.advance_time(16); // DeterministicClock::advance(16ms)
runner.tick(); // runtime reads events, renders, produces patchesHost reads patches and repaints
const patches = runner.take_patches();
for (const run of patches) draw_run(canvas_ctx, run);No xterm.js. No hidden DOM textarea. No virtual scrollback. The frame you see in the browser is the frame the renderer produced.
Pitfalls
- Do not bypass the event queue. If you want to synthesize input in a
test, push it through
WebEventSource::push_event. Direct widget callbacks skip focus management and a11y tree updates. - Do not treat DPR as a runtime concern. If you find yourself needing pixel coordinates on the Rust side, you are probably working in the wrong layer — the host should translate before pushing events.
- Keep the JSON schema in lockstep. The parser tolerates new optional
fields but will reject unknown
kindvalues by returningOk(None). Add new event kinds on both sides in one commit. input-parseris feature-gated. Native tests that exerciseWebEventSourcedirectly do not need it; the WASM showcase does.
See also
- Platforms overview — the backend seam
- WASM showcase — how the crate ships in a bundle
- FrankenTerm integration — the adjacent JS wrapper
- Pane semantic input — where pointer events land
- Runtime overview · Demo showcase screens