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 (mirrorsFlatAuthority'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:stylemap with those keys,\e[0mreset 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:
- Embedded
\nsplits into multiple collected lines. A:textnode whosecontentcontains 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 whateverInlineAuthoritybelieves it painted. Splitting on\nhere (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. - ESC and C0 control bytes (except
\t) are stripped from content. An embedded ESC in a:textnode'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 whatInlineAuthority/FlatAuthorityboth assume is inert text, capable of anything from a stray color change to a full screen clear. The same byte-wise stripFlatAuthority.scrub/1uses 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 modeFlatAuthoritydocuments: 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
Functions
@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.
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.
@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.