Raxol. UI. Rendering. PaintAuthority. ModeSelect
(Raxol v2.6.1)
View Source
The degradation ladder's startup mode-pick decision: caps + env ->
which PaintAuthority profile a session renders through.
This is a PURE function. It never calls System.get_env/1 or does any
I/O itself -- callers (the assembled harness's assembler, or a mix run
entry point) are responsible for gathering the env map and passing it
in, exactly the same discipline
Raxol.Terminal.Capabilities.Classifier.classify/3 already uses for its
own env seed (env sniffing is only ever a free first-pass seed). Keeping
the decision pure is what makes the full mode-pick matrix table-testable
without a pty or a real tmux session.
The three tiers
:inline_log— the default:InlineAuthority(the append path + the footer viewport), full DECSTBM-pinned footer + scrolling history.:tmux_conservative— NOT a separate authority module. The tmux tier assumes no OSC marks are consumed, clamps caps, and possibly uses a transient-region algorithm; the capability ladder already clamps%Capabilities{}for a detected multiplexer before it ever reachesInlineAuthority.new/5(reflow_capable?/1is conservativelyfalsefor anything that isn't measured, and tmux is never on that allowlist);ModeSelectonly picks the TIER NAME so a caller knows to route through that sameInlineAuthoritywith the already-clamped capability record it fetched fromCapabilities. There is noTmuxConservativeAuthoritymodule and this unit does not add one.:flat—FlatAuthority(this unit, sibling module): append-only, zero regions, zero cursor jumps. The screen-reader answer, the CI/pipe answer, the block-hater answer.
Rule order
Mode-pick happens in two passes: first a CANDIDATE mode is resolved (from an explicit override or from auto-detection), then a degenerate-geometry FLOOR is applied to whatever that candidate is. The floor runs LAST, after the candidate is chosen, not as one more item in the override/auto-detect priority list -- see "Why the degenerate floor applies after override resolution" below for why that distinction is load-bearing.
Pass 1: resolve a candidate mode
- Explicit env override (
RAXOL_HARNESS_MODE=flat|tmux|inline, case- and whitespace-insensitive) is the candidate, when recognized. An unrecognized non-empty value does NOT win -- it falls through to auto-detection below, and is surfaced separately viaselect_with_reason/3's:override_unrecognizedreason so a caller can warn instead of silently guessing what the operator meant. - Otherwise, auto-detect:
a. Headless (
TERM=dumb, not a tty, orCItruthy AND not a tty) ->:flat. This has to run before the tmux check: a CI runner piping output throughTERM=dumb(or no tty at all) is not a place any cursor-positioning tier belongs, tmux-flavored or not. b. tmux/screen multiplexer detected ->:tmux_conservative. c. Otherwise ->:inline_log.
Pass 2: the degenerate-geometry floor
Degenerate geometry (ScrollRegionManager.degenerate?/2: the
terminal is too short to hold a footer plus a 1-row history region)
clamps ANY non-:flat candidate down to :flat, regardless of whether
that candidate came from an override or from auto-detection. A
candidate that is already :flat (override or auto-detected) is left
alone -- the floor is a one-way clamp, never a way to escape :flat.
Why the degenerate floor applies after override resolution
This ordering is load-bearing, not cosmetic: a degenerate geometry
(too few rows to hold a footer plus a 1-row history region, e.g.
rows: 2, footer_rows: 2) means ScrollRegionManager cannot carve out
a real history region, so region_top pins at row 1. Every subsequent
InlineAuthority seal then issues its append CUP to that same pinned
row 1 BEFORE the terminal has scrolled -- each new block overwrites the
previous one instead of accumulating (traced on real bytes: [1;1HL1...
then [1;1HM1..., with L1 clobbered, never reaching scrollback).
That clobber has nothing to do with WHY InlineAuthority was picked --
it fires identically whether the route there was auto-detected tmux
(this unit's original regression: a tmux-detected session at degenerate
geometry must not fall through to :tmux_conservative, since that tier
still reaches the same clobbering InlineAuthority append path) or an
operator-supplied RAXOL_HARNESS_MODE=inline/=tmux override picked
the same authority explicitly. An override that special-cased itself to
skip the floor would reproduce the EXACT byte-traced clobber above,
just reached via an exported env var instead of auto-detection --
there is no additional safety purchased by letting an override outrank
the floor, only a broken transcript once the geometry turns out to be
degenerate at runtime (which the operator setting the override at
shell-startup time cannot always know in advance). :flat sidesteps
the whole failure mode: it never positions a cursor, so there is no
pinned row to clobber, and every sealed line survives in order. That is
strictly safer than any cursor-positioning tier at degenerate geometry,
for a candidate reached by ANY path, which is why the floor applies
uniformly AFTER candidate resolution rather than being folded into the
override-vs-auto-detect priority order. A :flat override is always
honored -- it is already the floor's own target, so clamping is a
no-op -- and a session with ADEQUATE geometry has no pinning problem and
keeps whatever candidate it resolved to (override or auto-detected) as
before.
Summary
Types
Pre-gathered environment facts. String keys mirror OS env var names
(System.get_env/0's shape) verbatim so callers can pass that map
through with no translation. :tty? is the one non-OS-env key: real
tty detection is an I/O call, not a pure fact, so it is the caller's
job to determine it (e.g. via :io.columns/0/:file.isatty or a
driver-level flag) and thread the answer in here.
Which PaintAuthority tier a session should render through.
Why select_with_reason/3 picked the mode it returned
Types
Pre-gathered environment facts. String keys mirror OS env var names
(System.get_env/0's shape) verbatim so callers can pass that map
through with no translation. :tty? is the one non-OS-env key: real
tty detection is an I/O call, not a pure fact, so it is the caller's
job to determine it (e.g. via :io.columns/0/:file.isatty or a
driver-level flag) and thread the answer in here.
@type mode() :: :inline_log | :tmux_conservative | :flat
Which PaintAuthority tier a session should render through.
@type reason() ::
:override
| :override_unrecognized
| :headless
| :tmux
| :default
| :degenerate_clamp
Why select_with_reason/3 picked the mode it returned:
:override— a recognizedRAXOL_HARNESS_MODEvalue was honored as-is (or was already:flat, so the degenerate floor was a no-op).:override_unrecognized—RAXOL_HARNESS_MODEwas set to a non-empty value that isn'tflat/tmux/inline(after trim+downcase); the mode fell through to auto-detection.:headless— auto-detected viaTERM=dumb/ non-tty / CI-without-tty.:tmux— auto-detected via aTMUX/screen-prefixedTERMenv var or aCapabilities.multiplexerof:tmux/:screen.:default— auto-detection fell through to the:inline_logdefault.:degenerate_clamp— the degenerate-geometry floor overrode whatever candidate the above resolved to (see moduledoc).
Functions
@spec select(Raxol.Terminal.Capabilities.t() | nil, env(), keyword()) :: mode()
Picks the render mode. opts carries geometry for the degenerate-
terminal check:
:rows— total terminal rows (integer). Omit when geometry is unknown (e.g. before the first resize event) — the degenerate check is then skipped (treated as non-degenerate), matching every other rule's fail-open-to-:inline_logdefault.:footer_rows— the footer row count the caller intends to pin (Nin theH - Nsplit). Defaults to0. A negative or non-integer value is also treated as fail-open-to-non-degenerate rather than raised —ScrollRegionManager.degenerate?/2itself guardsfooter_rows >= 0, so this module has to guard the same thing before delegating, or a caller-suppliedfooter_rows: -1would crash mode-pick instead of degrading gracefully.
Never consults System.get_env/1, :persistent_term, or any device —
purely a function of its three arguments. Delegates to
select_with_reason/3 and discards the reason; use that function
directly when the reason is needed (e.g. a startup notice explaining
WHY a session ended up in :flat).
@spec select_with_reason(Raxol.Terminal.Capabilities.t() | nil, env(), keyword()) :: {mode(), reason()}
Same mode-pick as select/3, plus the reason() the pick came from
(see the reason/0 typedoc). This is the seam the assembled harness's
assembler uses to print a startup notice ("routing through :flat
because geometry is too small for a footer" and similar).