Phosphor::Terminal
Owns the terminal connection: raw mode, alternate screen, dimensions, and the
root Frame that every higher layer renders relative to.
A thin, RenderMode-aware layer over a Backend chosen at construction
(AnsiBackend by default — a real terminal) — Phase 27's pluggable-backend
refactor. Terminal holds a @backend : Backend rather than inheriting
from AnsiBackend, so App#run can inject a TestBackend (or any other
Backend) in its place for fast, deterministic specs, while Terminal
itself keeps every bit of RenderMode-aware coordination logic that isn't
part of a Backend's own job: mode branching, @origin_row bookkeeping,
auto-height resolution, scroll-drift detection, and so on.
#size, #write(diff), and #read_event are pure one-line delegations to
@backend — those three never differ between Fullscreen and Inline.
#enter/#exit are more involved: for Fullscreen, entering/exiting is
exactly what @backend.enter/@backend.exit do (raw mode, alternate
screen, mouse/paste toggling), so those two delegate wholesale. For
Inline, the alternate-screen sequence bundled into a real Backend#enter
would be wrong — an inline session must never touch it — so Inline mode
never calls @backend.enter/@backend.exit at all. It instead uses its
own private helpers (setup_raw_mode, restore_termios,
write_mouse_and_paste_enable/disable) — a small, deliberate duplication
of AnsiBackend's own private methods of the same name, unavoidable now
that composition (not inheritance) means Terminal can no longer reach
them directly. See AnsiBackend's own doc comment for the same point from
its side.
Constants
The one AutoHeight instance — pass this as height: to request
auto-height, e.g. App.new.run(state, Screen, mode: RenderMode::Inline, height: Terminal::AUTO_HEIGHT).
Row count reserved for an AUTO_HEIGHT session before its first
#auto_resize call (#enter_inline needs some height to reserve
immediately, before any screen has mounted widgets to measure), and
the fallback #auto_resize itself uses whenever View#preferred_height
returns nil — no mounted widget declares a preference.
Constructors
Creates a new Terminal in the given mode.
height is required for RenderMode::Inline — #enter, #exit, and
#frame all raise ArgumentError if it's missing. Unused in
RenderMode::Fullscreen. Pass AUTO_HEIGHT instead of an Int32 to
have #enter/#auto_resize size the block from its mounted widgets'
declared content instead of a fixed row count — see #auto_resize.
tty overrides the terminal device Inline-mode coordination writes
go to (see @tty's own doc comment) — for tests only; production
code should never pass this. Defaults to a real /dev/tty handle
only when backend is also omitted (see below) — never opened at
all when a backend is given without an explicit tty, since a
session driven entirely by an injected Backend (App#run's usual
backend: path) has no real terminal to open in the first place,
and would otherwise fail (or hang, mid-Inline-mode CPR query) on
a machine with no /dev/tty at all — exactly the machines specs
need to run on.
backend is the Backend #enter (in Fullscreen), #exit (in
Fullscreen), #size, #write(diff), and #read_event delegate to.
Defaults to a fresh AnsiBackend sharing this session's own tty —
the real-terminal behavior every existing caller already gets with no
changes needed. Pass a TestBackend here (typically via
App#run(backend: ...)) to drive a session without a real terminal.
Class methods
Detects the terminal's color rendering capability from environment
variables and whether STDOUT is a real terminal — no round-trip
query needed, unlike .detect_dark_background.
Checked in order: $NO_COLOR (any value at all forces NoColor,
per https://no-color.org) — non-TTY tty (piped/redirected output
never gets color codes) — $COLORTERM of truecolor or 24bit
(case-insensitive) for TrueColor — $TERM containing 256color
for Indexed256 — otherwise Ansi16.
A class method, not an instance method — it only inspects ENV and
tty, no Terminal instance state involved. tty defaults to
STDOUT.tty? (the real check, for production use) but is a plain
Bool parameter rather than reading STDOUT directly, so a spec can
pass tty: true to exercise the $COLORTERM/$TERM branches below
without a real terminal — a non-interactive crystal spec process
never has one, and there's no other way to reach that code
otherwise.
Detects whether the terminal has a dark or light background via an
OSC 11 query (\e]11;?\a), returning true for dark.
A class method, not an instance method — it opens its own /dev/tty
handle and must run before any Terminal#enter call puts the real
terminal into raw mode. OSC 11 queries only get a reply in normal
mode on some terminals.
Parses the reply's rgb:RRRR/GGGG/BBBB color (terminated by \a or
\e\\) and computes perceptual luminance
(0.2126*R + 0.7152*G + 0.0722*B); below half of 65535 counts as
dark. Returns true on timeout (100ms), a parse failure, no reply at
all, or any other error opening/reading /dev/tty — dark is the safe
default, since most terminals are dark-themed.
Instance methods
Resizes an Inline session's block to fit view's mounted widgets'
declared content, via #resize_inline — a no-op unless this session
was constructed with AUTO_HEIGHT (including Fullscreen mode, or
Inline with a fixed height).
App#run calls this once, right after the initial screen's
on_mount (before any widget has had a chance to render, so there's
a real height to reserve for the first frame), and again whenever a
ResizeEvent arrives, since View#preferred_height depends on the
terminal's current width (ParagraphMock rewraps at the new width,
for one).
Queries View#preferred_height at the terminal's current width,
falling back to DEFAULT_AUTO_HEIGHT when it returns nil — no
mounted widget declares a preference.
Whether the Inline block's assumed position (@origin_row through
@origin_row + height - 1) is still plausible, given the terminal's
current dimensions and how much output this session has printed.
Always true in Fullscreen mode — there's no fixed block position
to lose track of there.
Two independent checks, either one enough to report false:
- Structural:
@origin_rowno longer fits within the terminal's current row count at all (reachable after aSIGWINCHshrink big enough that even the block's own top row is off-screen —#inline_minimum_height's guard catches most shrinks before this, butApp#runalso calls this after every render as a backstop). - Heuristic:
@lines_printedhas exceeded@origin_row + height. Every newlineTerminalitself ever prints (#print_newlines) either advances@origin_rowin lockstep (#scroll_down) or claims already-available blank space below the block (#enter_inline,#resize,#resize_inline's expand) — unless the terminal happened to already be at its bottom edge when one of those printed, in which case a real scroll occurred that@origin_rowwas never told about. There's no ioctl for "did a write I already made scroll the terminal" — this cumulative count is the closest approximation available, and it's deliberately biased toward eventually reportingfalserather than silently drifting forever undetected. See TASKS.md § "Phase 24: Inline mode completion" — precisely tracking external scrollback (the user's own terminal emulator, not this session's own output) remains a known, documented limitation; this only catches drift this session's own writes could plausibly have caused.
Removes an Inline session's block entirely, for Cmd::InlineDismiss.
Unlike normal exit (#exit / exit_inline), which preserves the
block's last rendered frame in scrollback, this leaves no trace of it
at all — not even blank rows.
Uses Delete Line (\e[{n}M), not per-row Erase Line (\e[2K) — Erase
only blanks a row's content, leaving it in place occupying vertical
space, so anything printed afterward (e.g. a later Inline session
started in the same process) would end up with a gap of blank rows
where the dismissed block used to be. Delete Line actually removes
the rows, scrolling whatever's below them up to close the gap — same
fix, same reason, as #resize_inline's contract path.
Delete Line doesn't move the cursor, so after this the cursor sits at
@origin_row, column 1 — precisely where the block's first row used
to start, now the first free row since everything below it just
shifted up to fill the gap. Doesn't leave raw mode — #exit still
runs afterward through the normal quit path and handles that.
Enters the terminal session: switches STDIN to raw mode and, for
Fullscreen, switches to the alternate screen buffer, clears it, and
moves the cursor to the home position.
Registers SIGINT, SIGTERM, and at_exit handlers that call #exit before
the process terminates, ensuring the terminal is always restored. The
handlers capture self as a closure — no global state is used.
Pass resize_channel to receive a ResizeEvent whenever the terminal is
resized (SIGWINCH). Omit it to ignore resize signals. The event carries
the new dimensions from #size; the main loop re-renders automatically
on the next iteration because #frame reads dimensions fresh each call.
Set mouse to true to enable mouse and touch tracking: VT200 button
press/release (\e[?1000h), button motion/drag (\e[?1002h), and SGR
extended coordinates (\e[?1006h, required for touch and for any
column/row past 223). Events arrive as TouchEvent or MouseEvent
values on the main event channel via StdinPort — see
TouchEvent.from_sgr? for how the two are told apart on the wire.
Disabled automatically on #exit.
Bracketed paste (\e[?2004h) is enabled unconditionally, regardless of
mouse — pasting into a text input is a common need independent of
mouse support, so it isn't gated behind that flag. Once enabled, the
terminal wraps pasted text in \e[200~...\e[201~, which StdinPort
parses into a single PasteEvent instead of one KeyEvent per
character. Disabled automatically on #exit.
Call #exit to undo every change made here.
For Fullscreen, this is @backend.enter(mouse) — raw mode, the
alternate screen, and mouse/bracketed-paste toggling, all in one
Backend call. For Inline, @backend.enter is never called at all
(see this class's own doc comment): raw-mode setup and the
mouse/bracketed-paste writes go through this class's own private
helpers instead, and #enter_inline reserves the block in place of
ever touching the alternate screen.
Exits the terminal session: deregisters signal handlers, leaves the alternate screen buffer, and restores the original termios settings.
Idempotent — safe to call multiple times. In Inline mode, after the
first call the saved termios is cleared, so subsequent calls (e.g.
from at_exit after a signal handler already ran) are no-ops for the
termios restore. In Fullscreen mode, idempotency is @backend.exit's
own responsibility — AnsiBackend#exit has the identical property.
For Fullscreen, this is @backend.exit — the exact inverse of
#enter's @backend.enter(mouse) call. For Inline, same as
#enter, @backend.exit is never called: mouse/paste-disable and
termios-restore go through this class's own private helpers instead.
Returns the render region as a Frame.
In Fullscreen mode this covers the entire terminal: origin (0, 0),
width and height from #size. In Inline mode it's a fixed-height
block starting at @origin_row, spanning the terminal's current width.
Every higher layer renders relative to this frame and never uses
absolute terminal coordinates.
Re-reads #size on every call rather than caching it, so a SIGWINCH
resize is picked up on the next render without any special handling.
Which mode this Terminal was constructed with — App#print checks
this to raise outside Inline sessions.
Writes a one-line explanation directly to @tty, bypassing the
normal render path — for App#run to call after #exit has
already restored normal terminal state, when #block_visible?
reports false and #reanchor can't recover. See #block_visible?.
Non-blocking read of the next input event from @backend — a pure
delegation, unconditionally regardless of @mode. Not currently
called anywhere in the framework — App#run still polls STDIN
through its own StdinPort rather than through this — but part of
Backend's contract this class delegates in full regardless.
Attempts to recover from #block_visible? reporting false by
asking the terminal where the cursor actually is right now (the same
CPR round-trip #enter_inline uses at startup) and adopting that as
the new @origin_row — but only if a block of the current height
would actually fit on-screen starting there. Declines (returns
false, leaving @origin_row untouched) rather than relocating to a
position that would just fail #block_visible? again on the very
next render. Always false in Fullscreen mode.
Resets @lines_printed to 0 on success — the old count is
meaningless once @origin_row has just been corrected to a fresh,
confirmed-accurate reference point.
Changes an Inline session's block height, clearing the old block and
re-reserving the terminal rows for the new size at the same
@origin_row. Content is not preserved — the caller (App#run, via
Cmd::InlineResize) is expected to force a full redraw afterward.
Not to be confused with the no-arg #resize below, which reacts to
the terminal window being resized (SIGWINCH) — an unrelated event
that happens to share this method's name.
Erases each of the old block's rows individually via absolute CUP
(\e[{row};1H\e[K) rather than clearing then stepping down, so it
doesn't depend on the cursor's position between writes. Re-reserving
the new rows reuses enter_inline's technique: print new_height
newlines, then move back up — except the cursor must first return to
@origin_row, since the clearing loop left it at the old block's last
row.
Acknowledges that the terminal window itself was resized (SIGWINCH,
delivered to App#run as a ResizeEvent). Not to be confused with
#resize(new_height) above, which changes an Inline block's height
on purpose — this one reacts to the user resizing their terminal.
#size/#frame already re-read dimensions from the kernel on every
call — nothing on Terminal caches them — so there's no dimension
state here to update. This method exists so "the terminal just
resized" has one explicit place to react, in case a future need
arises; for now it only confirms the current size is queryable.
Adjusts an Inline session's block from its current height to
new_height in place, without disturbing rows the block keeps —
unlike #resize, which always erases and re-reserves the whole
block for a deliberate phase transition (Cmd::InlineResize) where
discarding old content is the point. #auto_resize uses this instead,
since a terminal-width-driven height change should feel like the
block growing or shrinking in place, not flashing to blank and back.
No-op if new_height equals the current height.
Expanding (new_height greater): moves the cursor to the
block's current last row, writes new_height - @height newlines to
claim that many additional rows below it, then moves back up to
@origin_row — the origin itself never changes. If the block is
already flush against the terminal's bottom edge, those newlines
scroll the whole terminal (shell history included) upward to make
room, exactly like a normal shell prompt would; that's expected, not
a bug.
Contracting (new_height smaller): moves the cursor to the
first row the block no longer needs (@origin_row + new_height) and
deletes the remaining @height - new_height rows with ANSI Delete
Line (\e[{n}M) — not \e[2K (Erase Line). Erasing only blanks a
row's content; the row itself still occupies vertical space, so
the block would still visually span its old height with empty rows
at the bottom instead of actually shrinking. Delete Line removes the
rows outright, scrolling whatever sits below them up to fill the
gap — the sole mechanism that actually shrinks a block's on-screen
footprint. The block shrinks upward from the bottom; @origin_row
stays fixed either way.
Scrolls an Inline session's block down one row to make room for a
persistent line above it, and returns the absolute row that scroll
vacated — the row App#print writes that line at.
Writes a bare \n, then advances @origin_row so every later
@origin_row-based write (#frame, #exit) tracks the block's new,
one-row-lower position. Returns the pre-scroll row, not the new one —
that's the row the scroll just freed up, not where the block moved to.
Returns the output target's current {width, height} — a pure
delegation to @backend, unconditionally regardless of @mode. Never
cached; see Backend#size's own doc comment for why.
Confines event to #frame's row range, or returns nil if the click
falls outside the render region entirely (only reachable in Inline
mode — see below).
MouseEvent#x/#y come off the wire already 0-based (see
MouseEvent.from_sgr?), measured from the terminal's own top-left
corner — the same coordinate space #frame.origin_row itself is
expressed in (see #frame's own doc comment: it's @origin_row - 1,
not 0, in Inline mode). That's also the space every widget already
writes cells in (buf.set_string(frame.x, frame.y + local_row, ...))
and reads back in MockWidget#hit_test — so a click's coordinates
need no further adjustment once confirmed to land inside the block;
subtracting #frame.origin_row here would only shift them out of
the space widgets actually compare against.
In Fullscreen mode #frame.origin_row is always 0 and covers the
whole terminal, so this always passes the event through unchanged —
#App calls this unconditionally rather than special-casing the mode.
In Inline mode, a click outside `#frame.origin_row...#frame.origin_row
- #frame.height
is a click above or below the block (on shell history, or on nothing at all) —niltellsApp#dispatch_event` to drop it rather than dispatching a position no mounted widget's own frame could ever contain.
Known limitation: this assumes @origin_row still reflects the
block's true on-screen row. #scroll_down (App#print) keeps it
correct for scrolls Phosphor itself causes, but if the user's
terminal scrolls shell history off-screen independently (e.g.
scrolling up in the terminal emulator to read earlier output), there's
no signal that tells Phosphor its saved origin is now stale — clicks
can mis-map in that case. Tracked as a known gap in TASKS.md § "Phase
24: Inline mode completion".
Writes a set of changed cells to the output target — a pure
delegation to @backend, unconditionally regardless of @mode. This
is Renderer#draw's output path; distinct from the raw-bytes
#write(bytes : String) overload below, which App#print uses
instead.