Skip to Content
PlatformsWeb backend

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 kindTranslates toNotes
"key"Event::Key(KeyEvent)DOM KeyboardEvent.codeKeyCode; modifiers + repeat preserved
"mouse"Event::MouseDown / move / up with SGR-equivalent button + modifier flags
"wheel"Event::Mouse(::Wheel)Optional; axis + magnitude
"paste"Event::PasteBracketed-paste text arrives intact
"focus"Focus gain/lossMatches terminal focus-in / focus-out events
"composition"IME compositionOptional; 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:

  1. One active pointer at a time. If a second PointerDown arrives while a capture is live, it is rejected.
  2. Explicit capture handshake. The adapter emits Acquire commands that the JS host executes and acknowledges via CaptureAcquired. No “lost-capture-mid-drag” silent-drop paths.
  3. Cancellation on interruption. Blur, VisibilityHidden, and LostPointerCapture all cancel the drag cleanly, with a PaneCancelReason.
  4. Direction-change accounting. Cumulative delta, sample count, and the sign of each step are tracked so inertial throw can compute a PaneMotionVector when 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 patches

Host 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 kind values by returning Ok(None). Add new event kinds on both sides in one commit.
  • input-parser is feature-gated. Native tests that exercise WebEventSource directly do not need it; the WASM showcase does.

See also