Design Decisions
FrankenTUI has five design principles and a handful of hard invariants that follow from them. This page documents each one with the reasoning behind it, so you can tell the difference between “choice” (debatable) and “invariant” (load-bearing, do not violate).
These aren’t tastes; they are the things that make the rest of the kernel work. Change one and something breaks — usually determinism, sometimes correctness, occasionally a 10× performance cliff.
Read this page after architecture and before opening a PR that touches the render pipeline, the buffer, or the presenter.
The five design principles
- Correctness over cleverness. Predictable terminal state is non-negotiable. If a clever optimization breaks RAII cleanup, panic safety, or deterministic output, it gets rejected.
- Deterministic output. Buffer diffs and explicit presentation over ad-hoc writes. Given the same model state and viewport, the byte stream is bit-for-bit identical.
- Inline first. Preserve scrollback while keeping chrome stable. Most TUIs assume they own the terminal; FrankenTUI assumes they share it with a shell.
- Layered architecture. Core, render, runtime, widgets; no cyclic dependencies. Surface area stays small at each layer boundary.
- Zero-surprise teardown. RAII cleanup, guaranteed even when apps crash.
The rest of this page is the specific invariants those principles produce.
Invariant: the 16-byte Cell
Every terminal cell is exactly 16 bytes:
┌──────────────┬──────────────┬──────────────┬──────────────┬─────────┐
│ │ │ │ │ │
│ CellContent │ fg │ bg │ attrs │ link_id │
│ (4 bytes) │ PackedRgba │ PackedRgba │ CellAttrs │ (2B) │
│ char / gid │ (4 bytes) │ (4 bytes) │ (2 bytes) │ │
│ │ │ │ │ │
└──────────────┴──────────────┴──────────────┴──────────────┴─────────┘
Cell (16 bytes)Why this matters:
- 4 cells per 64-byte cache line. Sequential row scans hit L1 optimally. Randomizing the layout doubles miss rates.
- 128-bit SIMD equality.
Cell::bits_eq()is a single wide compare. The diff engine’s row-skip fast path is built on this. - No heap for 99% of cells. ASCII stores inline. Only complex graphemes (emoji, ZWJ) use the grapheme pool.
Changing Cell size by even one byte breaks the cache alignment and the
SIMD comparison. If you need to add a field, you have to take one away
or repack the existing ones. There is no “just add 4 bytes.”
Invariant: Buffer dimensions are immutable
Once a Buffer is constructed, its width and height never change.
A resize creates a new buffer; the old one is dropped.
Why:
- It removes a whole class of bugs where a reference into a buffer becomes invalid mid-render.
- It makes the dirty-row bitmap and dirty-span intervals trivially safe.
- It lets
BufferDiff::compute(&prev, &next)assert dimensions match, rather than reasoning about partial overlaps.
On resize, the runtime:
- Drains input events (coalescing resize storms via BOCPD).
- Allocates a new buffer at the new dimensions.
- Calls
view()to repaint. - Emits the full frame (no diff — nothing to compare against).
- Swaps the buffer.
Invariant: Scissor stack is monotonic
The Buffer scissor stack only intersects with the current clip
region. You can push a scissor to narrow what you’re drawing into; you
cannot push to widen it.
// OK — narrowing
buffer.push_scissor(inner_rect); // intersects with outer_rect
// ... draw clipped to inner ...
buffer.pop_scissor();
// NOT OK — you cannot escape a parent clip by pushingWhy:
- Widgets compose by pushing a scissor and drawing inside it. If children could escape, focus management, overlay rendering, and modal stacks would all have to re-implement clip safety.
- The invariant is checkable at the push site: if
new_rect.intersect(current_rect) != new_rect, that’s a bug.
Invariant: One-writer rule
Exactly one owner writes to stdout, and that owner is TerminalWriter.
Nothing else in the process calls write!(stdout, ...), println!,
or print!.
Why:
- The presenter state-tracks the cursor and style. If a second writer emits bytes, the tracker’s model of the terminal desynchronizes and the next frame renders garbage.
- Synchronized-output brackets (DEC 2026) wrap whole frames. A second writer inside those brackets will be visually merged with the frame and blend into the paint; a second writer outside them defeats the atomicity guarantee.
Practical consequences:
- Use
tracingfor logs, notprintln!. Configure tracing to write to a file or pipe, not stdout. - If you need background output interleaved with the UI, model it as a
Subscriptionthat emitsMsg::Log(line). The widget tree then draws the line and the writer handles it.
See one-writer rule for details.
Invariant: RAII terminal lifecycle
TerminalSession::Drop restores terminal state. Every drop path, every
panic, every ?-bailout, every SIGINT — if the session drops, the
terminal is restored.
Why:
- The alternative is “terminal corrupted after crash,” and that’s the failure mode we most want to eliminate.
- It means you never need
catch_unwindor signal handlers for terminal hygiene; the ownership model does it for you.
The drop implementation:
- Disables bracketed paste, mouse, focus reporting.
- Leaves the alt screen if it was entered.
- Clears any pending DECSTBM scroll region.
- Disables raw mode.
- Flushes stdout.
Do not call std::process::exit() from inside a program with a live
TerminalSession. exit() bypasses drop. Use Cmd::quit() instead,
which unwinds cleanly.
Invariant: Deterministic output
Given the same model state, viewport, and capability set, the ANSI byte stream is bit-for-bit identical. No wall clock, no PRNGs with default seeds, no environment reads mid-frame.
Why:
- Snapshot testing needs this.
BLESS=1would be meaningless if frames varied run-to-run. - Shadow-run validation compares frame checksums across execution lanes. Non-determinism would produce false diffs.
- Replay debugging of UI bugs only works if “replay this event stream against this model” is a pure function.
Where we need randomness (stagger animations, visual FX jitter), the PRNG is seeded deterministically per frame index — see the animation system’s xorshift helpers.
Invariant: ftui-extras runs at opt-level=3
The workspace default profile is size-optimized (opt-level = "z"). The
exception is ftui-extras:
[profile.release.package.ftui-extras]
opt-level = 3Why: the VFX rasterizer inner loops (Gray-Scott reaction-diffusion,
metaballs iso-surface evaluation, Clifford attractors, fractal escape-time
coloring) benefit from SIMD autovectorization, aggressive inlining, and
loop unrolling. Dropping to opt-level = "z" costs roughly an order of
magnitude in per-frame cost.
Do not remove the ftui-extras override from the workspace profile.
Every visual-effects demo screen in the showcase depends on it.
Invariant: Zero unsafe in the core pipeline
The render, runtime, and layout crates all declare:
#![forbid(unsafe_code)]Why:
- The render pipeline already meets our latency targets without
unsafe. unsafein a rendering hot path would make shadow-run comparison meaningless — subtle undefined behavior could manifest as determinism bugs that take weeks to root-cause.- Zero
unsafeis a public property that makes the crate auditable.
Integer overflow is handled explicitly with saturating_* / checked_*
operations. Intentional wrapping (PRNGs) uses wrapping_* with a comment.
Pitfalls
If you are tempted to “just resize the buffer in place” or “bypass
TerminalWriter for this one case” or “relax the scissor stack for the
modal overlay,” stop. Each of these has been considered and rejected.
Document what you’re actually trying to do and the existing primitive
will probably cover it.
Benchmarks that show unsafe or MaybeUninit would save N
microseconds per frame are not a sufficient reason to add them. The
frame budget is 16 ms. A few microseconds of headroom is not worth the
audit surface. We accept clear determinism over maximum throughput.