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 emitting leave/0 last; see the live demo's teardown. teardown/1 writes 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.

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

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).

Types

t()

@type t() :: %Raxol.UI.Rendering.PaintAuthority.ViewportAuthority{
  device: IO.device(),
  height: pos_integer(),
  last_rows: [String.t()] | nil,
  width: pos_integer()
}

Functions

enter()

@spec enter() :: binary()

The alt-screen ENTER byte sequence (pure). See the moduledoc.

leave()

@spec leave() :: binary()

The alt-screen LEAVE byte sequence (pure). See the moduledoc.

new(device, width, height)

@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.

repaint(t, rows, opts \\ [])

@spec repaint(t(), [String.t()], keyword()) :: t()

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), or nil to leave the cursor HIDDEN (no composer on screen). Defaults to nil.

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).

resize(t, width, height)

@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.

teardown(t)

@spec teardown(t()) :: t()

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).