Raxol.Harness.Surface.ViewText (Raxol v2.6.1)

View Source

The assembled harness's small view-map -> plain-text-lines bridge.

Every harness Component in Raxol.UI.Components.Harness.* (Block, BlockBody, Composer, and everything BodyProvider mounts) renders a plain view map (Raxol.View.Components-shaped: %{type: :column, children: [...]} / %{type: :text, content:, style:}) meant for the normal Preparer -> LayoutEngine -> UIRenderer pipeline. The append-path / footer-viewport substrate (InlineAuthority/FlatAuthority) bypasses that pipeline entirely -- it is a byte-level, pinned-region writer whose seal/2, repaint/2, and keyframe/2 all take already-flattened iodata(), one binary per row (InlineAuthority's own moduledoc: "callers hand the paint authority already width-truncated text").

This module is the bridge the assembled harness owns: flatten a view map into an ordered list of one-line-per-leaf-text-node strings, truncated to a display-width budget (Raxol.UI.TextMeasure, never String.length -- CJK undercounts), optionally wrapped in minimal SGR (24-bit truecolor :fg, :dim) for the styled/inline path.

Why truncate BEFORE styling

lines/3's pipeline is: flatten -> truncate to width (plain content only) -> THEN wrap in SGR. Reversing that order would count escape bytes as display columns (TextMeasure has no ANSI awareness), silently truncating either mid-escape-sequence or too early. Truncating the plain string first and adding SGR as a pure string-wrap afterward keeps the width budget exact regardless of styling.

Modes

  • :plain -- FlatAuthority's destination (pipe/CI/screen-reader): zero escape bytes, ever (mirrors FlatAuthority's own "zero escape bytes, full stop" contract) -- content only.
  • :styled -- InlineAuthority's destination: content wrapped in a minimal SGR run (24-bit :fg, :dim) when the source view node carries a :style map with those keys, \e[0m reset at the end of any styled line. A style-free node (no :fg/:dim) round-trips byte-identical to plain -- neutral by default, matching every harness Component's own "absent prominence = zero change" contract.

This module is the trust boundary: sanitize content here, not downstream

Every string this module flattens can originate from an untrusted source -- a fixture's tool-call output, an LLM's streamed response, a bracketed paste landing in the Composer buffer (Composer's own moduledoc: pasted content is inserted verbatim, "no matter how many lines the paste contains"). Neither InlineAuthority nor FlatAuthority re-validates the iodata() rows a caller hands them -- both take already-flattened content ON TRUST, one binary per row. So this module, the ONE seam every harness Component's content passes through before reaching either authority, is where two hostile-content properties get enforced, once, for both paint substrates:

  1. Embedded \n splits into multiple collected lines. A :text node whose content contains a literal newline (a multi-line tool result, a pasted multi-line composer buffer, Composer's own queued-steer banner built from raw composer text) would otherwise collect as ONE list entry containing an embedded newline -- InlineAuthority.repaint/2's "one binary per row" row-accounting assumes every list entry maps to exactly one physical terminal row, so a single entry that actually PRINTS as two rows (the terminal itself breaks on the embedded \n) desyncs that count silently: every row after the split one is now off-by-one against whatever InlineAuthority believes it painted. Splitting on \n here (before truncation, so each resulting line gets its own width budget) is CORRECT, not lossy -- it turns one row-accounting bug into the same content rendered as the multiple rows it actually is.
  2. ESC and C0 control bytes (except \t) are stripped from content. An embedded ESC in a :text node's content -- unlike the styling this module adds itself around a truncated line -- is an INJECTION: it would ride through untouched and reach the terminal inside what InlineAuthority/FlatAuthority both assume is inert text, capable of anything from a stray color change to a full screen clear. The same byte-wise strip FlatAuthority.scrub/1 uses is used here (see that module's own comment: multi-byte UTF-8 lead (0xC2-0xF4) and continuation (0x80-0xBF) bytes are both >= 0x20, so byte-wise stripping never splits a valid codepoint) -- the C0 byte is removed and the rest of a hostile sequence's bytes are left as a visible, garbled fragment, same honest failure mode FlatAuthority documents: a reader sees something was stripped rather than an invisible, silently-swallowed injection.

This is complementary to, not a substitute for, FlatAuthority's own scrub (a module-enforced flat scrub). Two independent layers, two independent jobs: FlatAuthority.append_sealed/2 holds the FLAT-TIER guarantee ("this authority never emits a cursor-moving byte, regardless of what any caller passes in" -- a property that has to live in the authority itself, since a caller bypassing this bridge entirely must still get it). This module's sanitize fixes the INLINE-TIER row-accounting bug (1) that FlatAuthority's scrub has no reason to know or care about (flat mode has no row-accounting to desync -- it is append-only), and belt-and- braces re-applies the same ESC/C0 strip (2) at the one seam BOTH tiers' content flows through, before either authority ever sees it. Neither layer alone is the full guarantee; removing either one reopens exactly the hole the other was never responsible for closing.

Summary

Functions

Flattens view (a Raxol.View.Components-shaped map, or a list of them) into an ordered list of plain/styled lines, each truncated to width display columns (ellipsis-truncated when it would overflow, mirroring Raxol.Harness.StatusStrip's own truncation convention).

Strips control code points before untrusted content reaches the wire: C0 (0x00-0x1F, incl. ESC/0x1B) except \t, DEL (0x7F), AND the C1 range (U+0080..U+009F) -- the 8-bit-encoded siblings of the ESC-led CSI/OSC/DCS sequences (0x9B is CSI, 0x9D is OSC), which a byte-wise >= 0x20 allowlist would wrongly pass. Decodes by code point so a C1 encoded as UTF-8 (0xC2 0x9B) is caught while legitimate multi-byte text is preserved; a lone raw high byte (a bare C1 like 0x9B, or a stray continuation byte) is dropped rather than passed through. \n needs no exception -- add_lines/3 already consumed every \n as the line-split delimiter before this runs.

Truncates text to width display columns (Raxol.UI.TextMeasure, never String.length -- CJK undercounts), ellipsis-truncating () when it would overflow, mirroring Raxol.Harness.StatusStrip's own truncation convention. Plain content only -- apply BEFORE any styling, never after (see the moduledoc's "Why truncate BEFORE styling"). Public for the same reason sanitize_line/1 is: Raxol.Harness.DiffExpansion's per-row renderer reuses this exact truncation instead of duplicating it.

Types

mode()

@type mode() :: :plain | :styled

Functions

lines(view, width, mode \\ :plain)

@spec lines(map() | [map()], non_neg_integer(), mode()) :: [String.t()]

Flattens view (a Raxol.View.Components-shaped map, or a list of them) into an ordered list of plain/styled lines, each truncated to width display columns (ellipsis-truncated when it would overflow, mirroring Raxol.Harness.StatusStrip's own truncation convention).

One line per :text leaf node, in document order -- every harness Component in this package already builds its multi-line bodies as one Components.text/1 child per line (see Block.plain_content_lines/2, Composer.render/2's children list), so this never needs to re-wrap text itself.

The one exception: MultiLineInput's per-run tuple leaves

Composer.render/2 mounts Raxol.UI.Components.Input.MultiLineInput directly (a general-purpose input Component, not one of this package's own harness Components) for the actual typed buffer. That component renders each VISIBLE LINE as cursor/selection-aware RUN SEGMENTS -- bare {:text, content, style} tuples, one per styling run (e.g. [{:text, "hel", %{}}, {:text, " ", %{background: :red}}] for a 3-character buffer with the cursor at the end) -- never the %{type: :text, content:} map shape every harness Component uses. A naive collect/2 walk either drops these silently (no clause matches a bare tuple) or, worse, would treat each RUN as its own line if a clause matched tuples individually -- splitting one visual row into N. This module's collect/2 special-cases a children: list that is ENTIRELY bare text-tuples: it joins their content into ONE line (a single style, same as every other line here -- this module has never supported per-segment styling within one line, and style_line/2 has no :background handling regardless, so the cursor-highlight run's style is dropped the same way it always would be). Any other children: shape (including a MIX of tuples and maps) falls through to the normal recursive walk unchanged.

sanitize_line(text)

@spec sanitize_line(String.t()) :: String.t()

Strips control code points before untrusted content reaches the wire: C0 (0x00-0x1F, incl. ESC/0x1B) except \t, DEL (0x7F), AND the C1 range (U+0080..U+009F) -- the 8-bit-encoded siblings of the ESC-led CSI/OSC/DCS sequences (0x9B is CSI, 0x9D is OSC), which a byte-wise >= 0x20 allowlist would wrongly pass. Decodes by code point so a C1 encoded as UTF-8 (0xC2 0x9B) is caught while legitimate multi-byte text is preserved; a lone raw high byte (a bare C1 like 0x9B, or a stray continuation byte) is dropped rather than passed through. \n needs no exception -- add_lines/3 already consumed every \n as the line-split delimiter before this runs.

Public: this is the ONE sanitize implementation every caller of untrusted single-line content shares -- add_lines/3 above, and Raxol.Harness.DiffExpansion's own per-row renderer, which needs this exact trust-boundary sanitize WITHOUT lines/3's view-tree flatten. See the moduledoc's "trust boundary" section.

truncate(text, width)

@spec truncate(String.t(), non_neg_integer()) :: String.t()

Truncates text to width display columns (Raxol.UI.TextMeasure, never String.length -- CJK undercounts), ellipsis-truncating () when it would overflow, mirroring Raxol.Harness.StatusStrip's own truncation convention. Plain content only -- apply BEFORE any styling, never after (see the moduledoc's "Why truncate BEFORE styling"). Public for the same reason sanitize_line/1 is: Raxol.Harness.DiffExpansion's per-row renderer reuses this exact truncation instead of duplicating it.