TermUI.Backend.TTY (TermUI v1.0.0)
View SourceTTY terminal backend for constrained environments.
The TTY backend provides terminal rendering when raw mode is unavailable. This
includes Nerves devices, shell-based SSH sessions, remote IEx consoles, and other scenarios
where :shell.start_interactive({:noshell, :raw}) returns {:error, :already_started}.
When This Backend is Selected
The TermUI.Backend.Selector chooses this backend when:
- Raw mode activation fails with
:already_started(a shell is already running) - The environment is detected as constrained (Nerves, remote IEx)
- Explicit TTY mode is requested via configuration
Key Difference from Raw Backend
This backend remains event-capable, but delivery is terminal-dependent. Without raw mode, it can:
- Request characters and escape sequences using
IO.getn/2; the shell or terminal driver may still buffer them until Enter - Process arrow keys, Tab, function keys, and control sequences
- Position the cursor and render styled text
The main differences from raw mode are:
- No terminal mode control - Cannot switch terminal modes (shell already running)
- Potential interference - The existing shell's line editing may occasionally interfere
- Capability uncertainty - Must detect and adapt to available features
- Limited mouse support - Mouse events may not be available or reliable
Rendering Modes
This backend supports two rendering modes via the :line_mode option:
:full_redraw(default) - Clears the screen and redraws everything on each frame. This is reliable but may cause visible flicker on slow connections.:incremental- Only updates cells that changed since the last frame. This is faster and reduces flicker but may have artifacts if the terminal state becomes out of sync.
Color Degradation
The TTY backend automatically degrades colors based on detected capabilities:
| Mode | Description | Escape Format |
|---|---|---|
:true_color | Full 24-bit RGB | ESC[38;2;r;g;bm |
:color_256 | 256-color palette | ESC[38;5;nm |
:color_16 | Basic 16 colors | ESC[31m etc. |
:monochrome | No colors | Attributes only |
Character Set Handling
When Unicode is unavailable, box-drawing characters are automatically mapped
to ASCII equivalents. The :character_set field tracks the current mode:
:unicode- Full Unicode box-drawing characters:ascii- ASCII fallback (+,-,|for corners and lines)
Configuration Options
The init/1 callback accepts these options:
:capabilities- Map of detected terminal capabilities (from Selector):line_mode- Rendering strategy (:full_redrawor:incremental):alternate_screen- Whether to use alternate screen buffer (default:false)
Example
This backend is typically used via the runtime, not directly:
# Automatic backend selection (recommended)
{:ok, runtime} = TermUI.Runtime.start_link(root: MyApp.Root)
# The runtime handles backend selection based on environmentSee Also
TermUI.Backend- Behaviour definitionTermUI.Backend.Selector- Backend selection logicTermUI.Backend.Raw- Full-featured backend for raw modeTermUI.CharacterSet- Unicode/ASCII character mapping
Summary
Types
Character set for box-drawing and special characters.
Color rendering mode based on terminal capabilities.
Rendering strategy for frame updates.
Internal state for the TTY backend.
Functions
Clears the entire screen and moves cursor to home position.
Compares two frames to find changed and removed cells.
Draws cells to the terminal at specified positions.
Flushes pending output to the terminal.
Hides the terminal cursor.
Initializes the TTY backend with detected capabilities.
Moves the cursor to the specified position.
Polls for input events with the specified timeout.
Queries the terminal for its current size and updates state.
Updates the terminal size and clears the frame buffer.
Shows the terminal cursor.
Shuts down the TTY backend and restores terminal state.
Returns the current terminal dimensions.
Types
@type character_set() :: :unicode | :ascii
Character set for box-drawing and special characters.
:unicode- Full Unicode box-drawing characters:ascii- ASCII fallback characters
@type color_mode() :: :true_color | :color_256 | :color_16 | :monochrome
Color rendering mode based on terminal capabilities.
Determines how colors are encoded in escape sequences:
:true_color- Full 24-bit RGB colors (ESC[38;2;r;g;bm):color_256- 256-color palette (ESC[38;5;nm):color_16- Basic 16 ANSI colors (ESC[31metc.):monochrome- No color support, attributes only
@type line_mode() :: :full_redraw | :incremental
Rendering strategy for frame updates.
:full_redraw- Clear and redraw entire screen each frame (reliable):incremental- Only update changed cells (faster but may have artifacts)
@type t() :: %TermUI.Backend.TTY{ alternate_screen: boolean(), capabilities: map(), character_set: character_set(), color_mode: color_mode(), cursor_position: {pos_integer(), pos_integer()} | nil, cursor_visible: boolean(), input_buffer: binary(), last_frame: map() | nil, line_mode: line_mode(), size: {pos_integer(), pos_integer()} }
Internal state for the TTY backend.
Tracks terminal configuration and rendering state.
Fields
:size- Terminal dimensions as{rows, cols}:capabilities- Map of detected terminal capabilities from Selector:line_mode- Rendering strategy (:full_redrawor:incremental):last_frame- Previous frame for incremental rendering comparison:character_set- Unicode or ASCII character set:color_mode- Color capability level:alternate_screen- Whether alternate screen buffer is active:cursor_visible- Whether cursor is currently visible:cursor_position- Current cursor position as{row, col}ornil:input_buffer- Buffer for partial escape sequences between poll_event calls
Functions
Clears the entire screen and moves cursor to home position.
Outputs the following escape sequences:
\e[2J- Clear entire screen\e[H- Move cursor to home position (1,1)
Also clears last_frame in state, which forces a full redraw on the next
draw_cells/2 call when in incremental mode.
Returns
{:ok, updated_state} with cursor_position set to {1, 1} and last_frame cleared.
@spec compare_frames( map(), [{TermUI.Backend.position(), TermUI.Backend.cell()}] ) :: {[{TermUI.Backend.position(), TermUI.Backend.cell()}], [TermUI.Backend.position()]}
Compares two frames to find changed and removed cells.
This is a testing helper function exposed for unit testing the incremental rendering logic. It is not part of the Backend behaviour API.
Uses MapSet for efficient position lookup when finding removed cells, avoiding the need to build a full frame map just for membership testing.
Parameters
last_frame- Map of{row, col}=>{char, fg, bg, attrs}from previous framecurrent_cells- List of{{row, col}, {char, fg, bg, attrs}}tuples for current frame
Returns
Tuple of {changed_cells, removed_positions}:
changed_cells- Cells that are new or different from last frameremoved_positions- Positions that were in last frame but not in current
@spec draw_cells(t(), [{TermUI.Backend.position(), TermUI.Backend.cell()}]) :: {:ok, t()}
Draws cells to the terminal at specified positions.
In :full_redraw mode (default), clears the screen first then renders all cells.
In :incremental mode, only renders the provided cells without clearing.
Cell Format
Each cell is a tuple of {position, cell_data} where:
positionis{row, col}(1-indexed)cell_datais{char, fg_color, bg_color, attrs}
Rendering Process
- In full_redraw mode, clear screen and home cursor
- Group cells by row for efficient rendering
- For each row, position cursor and output styled characters
- Apply color degradation based on
color_mode
Returns
{:ok, updated_state} with last_frame updated for incremental mode.
Flushes pending output to the terminal.
For TTY mode, output is synchronous so this is largely a no-op.
Hides the terminal cursor.
Outputs \e[?25l escape sequence.
This operation is idempotent - if the cursor is already hidden, no escape sequence is written.
Initializes the TTY backend with detected capabilities.
Accepts options from the Selector including terminal capabilities.
Options
:capabilities- Map of detected terminal capabilities:line_mode- Rendering strategy (default::full_redraw):alternate_screen- Use alternate screen buffer (default:false):size- Explicit terminal dimensions (default: from capabilities or{24, 80})
Returns
{:ok, state}- Successfully initialized{:error, reason}- Initialization failed
@spec move_cursor( t(), {pos_integer(), pos_integer()} ) :: {:ok, t()}
Moves the cursor to the specified position.
Position is 1-indexed: {1, 1} is the top-left corner.
Outputs \e[row;colH escape sequence.
Position is clamped to terminal bounds.
@spec poll_event(t(), non_neg_integer()) :: {:ok, TermUI.Backend.event(), t()} | {:timeout, t()} | {:error, term(), t()}
Polls for input events with the specified timeout.
Requests one character at a time with IO.getn/2. Because the terminal stays
in cooked mode, the shell or terminal driver may buffer input until Enter.
The timeout parameter may not be honored since IO.getn/2 is blocking.
Input is parsed using TermUI.Terminal.EscapeParser to handle escape
sequences like arrow keys, function keys, and mouse events.
Partial escape sequences are buffered in the state's input_buffer field
and will be completed on subsequent calls.
Returns
{:ok, event, state}- An input event was received{:timeout, state}- No input available (rare with blocking IO){:error, reason, state}- An error occurred
Note
The timeout parameter is not honored due to the blocking nature of IO.getn/2.
For non-blocking input, consider using the Raw backend when available.
@spec refresh_size(t()) :: {:ok, TermUI.Backend.size(), t()}
Queries the terminal for its current size and updates state.
Uses :io.rows/0 and :io.columns/0 to get the current terminal dimensions.
If the query fails (e.g., not connected to a terminal), the current size is preserved.
This function also clears last_frame to force a full redraw, since the
terminal dimensions may have changed.
Note: This is a TTY-specific extension function, not part of the Backend behaviour.
The return signature matches TermUI.Backend.Raw.refresh_size/1 for consistency.
Returns
{:ok, {rows, cols}, updated_state} with refreshed size and cleared last_frame.
Example
{:ok, {rows, cols}, state} = TTY.refresh_size(state)
@spec set_size( t(), {pos_integer(), pos_integer()} ) :: {:ok, t()}
Updates the terminal size and clears the frame buffer.
When the terminal is resized, the previous frame is no longer valid since
positions may now be out of bounds or content may need to be reflowed.
This function updates the size and clears last_frame to force a full
redraw on the next draw_cells/2 call.
Parameters
state- Current backend statenew_size- New terminal dimensions as{rows, cols}
Returns
{:ok, updated_state} with new size and cleared last_frame.
Shows the terminal cursor.
Outputs \e[?25h escape sequence.
This operation is idempotent - if the cursor is already visible, no escape sequence is written.
@spec shutdown(t()) :: :ok
Shuts down the TTY backend and restores terminal state.
Performs the following cleanup sequence:
- Reset all text attributes (colors, bold, underline, etc.)
- Show the cursor (in case it was hidden)
- Leave alternate screen buffer (if it was entered)
Idempotent Behavior
This function is safe to call multiple times. Each call will emit the same cleanup sequences, which is harmless since terminal state converges to the same result regardless of prior state.
Error Handling
All terminal writes use safe_write/1 which catches and ignores errors.
This ensures cleanup completes even if the terminal is in an error state
or has been disconnected. We prioritize best-effort cleanup over failing
on individual write errors.
No Cooked Mode Restoration
Unlike the Raw backend, the TTY backend never takes the terminal out of cooked mode (the shell is already running). Therefore, no mode restoration is needed during shutdown.
Returns
Always returns :ok.
@spec size(t()) :: {:ok, {pos_integer(), pos_integer()}}
Returns the current terminal dimensions.
Returns
{:ok, {rows, cols}}- Terminal size