Skip to Content
ftui-layoutPanesTerminal + web parity

Terminal / Web Parity

“Same gesture, same layout, same history” across a TTY and a browser is not a nice-to-have; it is the acceptance test for the pane system. Parity is only achievable because a single host-agnostic event type (PaneSemanticInputEvent) sits between raw host input and every piece of pane logic.

This page is about the translators that sit on each side of that boundary.

The diagram

Terminal backend Web / WASM backend ──────────────── ────────────────── CSI SGR mouse bytes DOM pointer events │ │ ▼ ▼ ┌──────────────────────┐ ┌──────────────────────────┐ │ PaneTerminalAdapter │ │ PanePointerCaptureAdapter│ │ (in ftui-runtime) │ │ (in ftui-runtime) │ └──────────┬───────────┘ └──────────┬───────────────┘ │ │ │ PaneSemanticInputEvent │ └──────────────┬─────────────────────────┘ ┌──────────────────────────┐ │ PaneDragResizeMachine │ │ (in ftui-layout) │ └──────────┬───────────────┘ ┌──────────────────────────┐ │ PaneOperation + │ │ PaneInteractionTimeline │ └──────────┬───────────────┘ PaneTree

Everything below the two adapters is one codepath. Run it in native; run it in the browser; get identical state_hash() after identical input.

PaneTerminalAdapter — TTY side

Lives at ftui_runtime::PaneTerminalAdapter (exported from the runtime crate’s program.rs). It consumes decoded mouse / keyboard events from ftui-core and produces PaneSemanticInputEvents.

pub struct PaneTerminalAdapterConfig { /* tuning knobs */ } pub struct PaneTerminalAdapter { config: PaneTerminalAdapterConfig, /* ... */ } impl PaneTerminalAdapter { pub fn new(config: PaneTerminalAdapterConfig) -> Self; /* dispatch entry points */ }

Responsibilities:

  • Coordinate normalization. Terminal mouse coordinates are 1-indexed and cell-based; semantic coordinates are 0-indexed. The adapter owns that conversion.
  • Grip classification. A raw (row, col) click becomes a PaneResizeTarget only if the position falls within the grip inset of a divider (see PANE_EDGE_GRIP_INSET_CELLS = 1.5). Clicks elsewhere never become pane input.
  • Pointer ID synthesis. Terminals don’t have “pointer IDs”; the adapter synthesizes a stable ID per gesture by pairing a PointerDown with subsequent moves until a PointerUp.
  • Sequence assignment. Each emitted event gets the next monotonic sequence value.
  • Modifier snapshots. The adapter reads the Kitty keyboard protocol modifier state and attaches it to every event.

Pointer-capture adapter — browser side

The web backend (ftui-web, consumed via ftui-showcase-wasm) registers a pointer-capture handler on the canvas element. Each DOM event (pointerdown / pointermove / pointerup / wheel / blur) is translated:

DOM eventPaneSemanticInputEventKind
pointerdownPointerDown { button, position, pointer_id, target }
pointermovePointerMove { position, pointer_id, delta_x, delta_y }
pointerupPointerUp { button, position, pointer_id, target }
wheelWheelNudge { lines }
blurBlur { target: None }

The browser does have pointer IDs natively — they’re reused directly rather than synthesized. Sub-pixel pointer positions are rounded to cell coordinates using a PaneCoordinateNormalizer with a configurable rounding policy so both backends produce the same cell for the same logical pointer.

Why a single type is load-bearing

Without the semantic event boundary:

  • Replay is per-backend. A trace recorded in the terminal would replay differently in the browser. You’d need two trace formats.
  • Tests are per-backend. Every pane behavior test would have to be written twice — once in CSI bytes, once in DOM events.
  • Bugs hide. A drift between backends would surface as “nobody mentioned the browser does a thing the terminal doesn’t.”

With the semantic event boundary:

  • One test harness. ftui-harness feeds PaneSemanticInputEvents. Both backends’ adapters are thin wrappers over the same machinery.
  • One trace format. Traces recorded in either backend replay identically in the other.
  • One conformance artifact. See operations-and-timeline for PaneSemanticReplayOutcome.

Worked example — round-trip a trace across backends

Record in terminal

User drives the terminal demo; the adapter writes every emitted semantic event to a PaneSemanticInputTrace with metadata.platform = "tty".

Serialize to JSON

let json = serde_json::to_string(&trace)?; std::fs::write("trace.json", json)?;

Replay in browser

The browser app loads trace.json, deserializes the trace, validates its checksum, and replays each event into the same PaneDragResizeMachine and PaneTree the live UI uses. After each event, state_hash() is compared against the recorded value.

Assert parity

let outcome = PaneSemanticReplayFixture::run(trace, /* … */); assert!(outcome.diffs.is_empty(), "backends diverged: {:?}", outcome.diffs);

If outcome.diffs is empty, parity holds for the recorded gesture. If it’s not, the diff artifacts tell you exactly which event and which hash mismatched.

Coordinate precision — the one remaining source of divergence

Sub-cell coordinates are where parity is easiest to break. Terminals report integer cells; browsers report sub-pixel floats. The PaneCoordinateNormalizer exposes a rounding policy (PaneCoordinateRoundingPolicy) that both adapters use so the cell chosen for, e.g., a (13.6, 7.2) pointer is the same on both sides.

pub enum PaneCoordinateRoundingPolicy { Floor, Round, Ceil, BankersRound, }

The default is Round (half-to-even / banker’s rounding is an explicit option for reproducibility under sign flips).

Pitfalls

Don’t dispatch raw host events into PaneDragResizeMachine. The machine only understands PaneSemanticInputEvent. Feeding it raw DOM events or raw CSI sequences will fail to compile, but feeding it something that looks semantic but was built by hand with the wrong sequence numbers will compile and then break replay months later. Always go through an adapter (or a test harness that mimics one).

Pointer IDs are not comparable across platforms. A terminal’s synthesized pointer ID has no relationship to a browser’s native pointer ID. That is fine — the machine only compares IDs within a single trace.

Don’t hand-roll coordinate rounding. Use PaneCoordinateNormalizer. Every one-off as u16 cast is a parity bug waiting to happen.

Where to go next