Raxol. UI. Rendering. PaintAuthority. ViewportAuthority
(Raxol v2.6.1)
View Source
The FULL-VIEWPORT paint substrate: the alternate-screen, full-frame
repaint sibling of InlineAuthority (append + pinned footer) and
FlatAuthority (append-only).
STATUS: staged, not wired (read this before trusting the paragraph above)
This module is NOT reachable from any real session today.
Raxol.UI.Rendering.PaintAuthority.ModeSelect.select/3 — the harness's
only mode-pick seam — returns exactly :inline_log | :tmux_conservative | :flat; there is no :full_viewport candidate anywhere in that
ladder. There is also no Raxol.Harness.SessionPump module in this
repo — the TEA-driven pump this moduledoc used to claim would call
enter/0/leave/0 does not exist yet. Outside test/harness/ viewport_authority_test.exs, this module has zero callers. Treat every
"driven by"/"picked when" sentence below as the INTENDED future
integration this substrate was built FOR, not a description of
anything wired on master today. Wiring it in is later campaign work
(a :full_viewport ModeSelect candidate + a real pump); until that
lands, this file is dead code on purpose, kept green by its own test
suite so the wiring wave has a correct substrate to attach to. Do not
go looking for the pump or the mode candidate — file the wiring work
instead of assuming this doc is out of date.
Where InlineAuthority writes the terminal's PRIMARY screen in place
(a DECSTBM scroll region + print-once seals into native scrollback),
this authority claims the ALTERNATE screen buffer (\e[?1049h) and
repaints the WHOLE frame on every event: the caller hands it one
already-styled, already-width-truncated string per physical row (the
same Raxol.Harness.Surface.ViewText row vocabulary the inline path
hands InlineAuthority.repaint/3), and this module positions each row
with an absolute CUP and paints it. The session owns its own virtual
scrollback; nothing lands in the terminal's native scrollback, and the
primary screen is restored byte-for-byte on teardown (\e[?1049l).
This is a deliberate tradeoff, not a permanent commitment: a full repaint
every event trades incremental efficiency for simplicity, and a later
pass could return to inline-style diffing if that balance shifts.
Why a full repaint, not a diff (the honest v1)
The harness's row vocabulary is already STYLED STRINGS — one SGR-wrapped
String.t() per row out of ViewText.lines/3 — not the styled CELL
grid Raxol.Core.Renderer.render_diff/2 / Raxol.Terminal.ScreenBuffer
consume. Routing these rows back through a cell buffer would mean parsing
the SGR runs into Style structs and re-emitting them, a lossy
round-trip that buys nothing for a v1. So this authority repaints every
row of the frame each paint, exactly the per-row vocabulary
Raxol.Core.Runtime.Rendering.Backends.emit_rows/2 uses for real
full-screen apps (CUP;1H → \e[0m\e[2K → the row's ANSI), wrapped in
a DEC 2026 synchronized-update bracket (\e[?2026h/l) so the
whole-frame rewrite presents atomically and never tears. A row-level
diff (skip rows byte-identical to the last frame) is an obvious later
optimization the retained last_rows already enables; it is NOT needed
for correctness and is deliberately out of this v1.
The wire vocabulary lives in Dialect; the alt-screen bytes live here
Cursor positioning, hide/show, and the sync bracket come from the shared
Raxol.UI.Rendering.PaintAuthority.Dialect (the harness's single wire
home). The alternate-screen enter/leave bytes are NOT in Dialect (it
is footer/inline-region oriented and deliberately carries no 1049),
so they live here as pinned module attributes — the same discipline
Raxol.Terminal.InlineDriver.Sequences uses for the inline profile's
bytes. They match the framework's own full-screen driver
(Raxol.Terminal.Driver init/teardown) and
Raxol.Terminal.ANSI.Utils.Emitter.alternate_buffer_on/off.
Enter / leave asymmetry (the teardown-ordering law)
- Enter (
new/3) writes\e[?1049h(alt screen) +\e[2J(clear it) +\e[?25l(hide cursor) +\e[?7l(autowrap OFF — a full-width row must never wrap into the next). Done at construction, inside the driver process, before the first frame. - Leave (
leave/0, a pure constant) writes\e[?7h(autowrap back on) +\e[?25h(cursor back) +\e[?1049l(leave alt, primary screen restored with its saved cursor). This must be emitted as the LAST paint byte of the session — AFTER the input driver (Raxol.Terminal.InlineDriver) has torn down — or that driver's own bottom-park (\e[rows;1H\r\n) would scroll the freshly-restored PRIMARY screen. The embedder owns emittingleave/0last; see the live demo's teardown.teardown/1writes it to the authority's own device for callers that own the ordering themselves.
Raw mode, -isig, bracketed paste, and input parsing all stay
InlineDriver's job exactly as in inline mode — this authority only
owns the SCREEN, never the tty's input side.
Content is not trusted (ContentGuard)
repaint/3's rows argument is the SAME Raxol.Harness.Surface. ViewText row vocabulary InlineAuthority.append_sealed/2 (via
seal/2) receives — agent/LLM-originated, styled text, not
renderer-generated bytes. It can carry ANYTHING a model chooses to
emit, including control sequences that would defeat this authority's
own invariants from the INSIDE: a \e[?1049l embedded in a row would
leave the alt-screen frame mid-repaint, a DECSTBM set would carve a
scroll region this authority never intended, and a stray CUP would
reposition a LATER row in the same burst to an arbitrary screen
location. repaint/3 runs every row through
Raxol.UI.Rendering.PaintAuthority.ContentGuard.sanitize_line/1 before
building the frame body — the same allowlist InlineAuthority.seal/2
runs its history rows through and ContentGuard's own moduledoc names
this repaint path as an expected reuser. SGR passes through verbatim
(styled rows are unaffected); everything else is neutralized per that
module's grammar.
The sync bracket and ?7l are unconditional here (not capability-gated)
InlineAuthority.sync_open/1 gates the DEC 2026 bracket on a
measured sync_output? capability (never emitting a presentation-only
control sequence on a guess -- see that module's moduledoc). enter/0
and repaint/3 deliberately do NOT follow that discipline: they always
wrap in \e[?2026h/l and always send \e[?7l. This is a
considered asymmetry, not an oversight, because this authority's OWN
entry condition already gates on a stronger fact than sync_output?
alone -- claiming the alternate screen buffer (\e[?1049h) is itself
reserved for full-screen-capable terminals, and every terminal that
advertises alt-screen support in the wild also tolerates an unknown
DEC private mode (2026 or otherwise) as a silent no-op per ECMA-48/
DEC's own forward-compatibility convention for unrecognized h/l
parameters -- unlike the inline path, which runs on terminals that may
not have any alt-screen capability signal to key off of at all. Revisit
this if a real terminal is ever measured to mishandle an unrecognized
private mode inside the alternate buffer; until then, gating here would
add an unmeasured capability dependency this v1 does not need.
Summary
Functions
The alt-screen ENTER byte sequence (pure). See the moduledoc.
The alt-screen LEAVE byte sequence (pure). See the moduledoc.
Repaints the WHOLE frame. rows is one already-styled, already-width-
truncated string per physical row (top-to-bottom); it is taken/padded to
exactly height rows so every screen row is addressed, then run through
ContentGuard.sanitize_line/1 (see the moduledoc's ContentGuard
section) before any byte is written. Options
Adopts a new geometry. Writes no bytes — a full repaint follows from the
caller (a resize is a full reflow in this mode, which is exactly the
pivot's win). Clears last_rows so the next repaint/3 can never be
mistaken for unchanged against a stale-geometry frame.
Types
@type t() :: %Raxol.UI.Rendering.PaintAuthority.ViewportAuthority{ device: IO.device(), height: pos_integer(), last_rows: [String.t()] | nil, width: pos_integer() }
Functions
@spec enter() :: binary()
The alt-screen ENTER byte sequence (pure). See the moduledoc.
@spec leave() :: binary()
The alt-screen LEAVE byte sequence (pure). See the moduledoc.
@spec new(IO.device(), pos_integer(), pos_integer()) :: t()
Builds a new full-viewport authority and ENTERS the alternate screen
(writes enter/0 to device). The first frame is painted by the
caller's subsequent repaint/3.
Repaints the WHOLE frame. rows is one already-styled, already-width-
truncated string per physical row (top-to-bottom); it is taken/padded to
exactly height rows so every screen row is addressed, then run through
ContentGuard.sanitize_line/1 (see the moduledoc's ContentGuard
section) before any byte is written. Options:
:cursor—{row, col}(1-based, absolute) to park a VISIBLE cursor at (the composer edit point), ornilto leave the cursor HIDDEN (no composer on screen). Defaults tonil.
The whole burst is wrapped in a DEC 2026 synchronized-update bracket so
it presents atomically. Returns the authority with last_rows updated
to the SANITIZED rows actually painted (the retained frame a future
row-diff optimization would compare against).
@spec resize(t(), pos_integer(), pos_integer()) :: t()
Adopts a new geometry. Writes no bytes — a full repaint follows from the
caller (a resize is a full reflow in this mode, which is exactly the
pivot's win). Clears last_rows so the next repaint/3 can never be
mistaken for unchanged against a stale-geometry frame.
Leaves the alternate screen (writes leave/0 to the authority's own
device). Only for callers that own the teardown ordering themselves;
the live demo instead emits leave/0 as its very last byte, AFTER the
input driver's teardown (see the moduledoc's teardown-ordering law).