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 │
└──────────┬───────────────┘
▼
PaneTreeEverything 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 aPaneResizeTargetonly if the position falls within the grip inset of a divider (seePANE_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
PointerDownwith subsequent moves until aPointerUp. - Sequence assignment. Each emitted event gets the next monotonic
sequencevalue. - 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 event | PaneSemanticInputEventKind |
|---|---|
pointerdown | PointerDown { button, position, pointer_id, target } |
pointermove | PointerMove { position, pointer_id, delta_x, delta_y } |
pointerup | PointerUp { button, position, pointer_id, target } |
wheel | WheelNudge { lines } |
blur | Blur { 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-harnessfeedsPaneSemanticInputEvents. 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
The event vocabulary both adapters speak.
Semantic inputReplay diagnostics that prove parity.
Operations and timelineThe shared state machine both backends feed.
Drag/resize machineThe layer that decodes raw host events before the adapter sees them.
Events and input (core)How this piece fits in panes.
Panes overview