TermUI.Backend.TTY (TermUI v1.0.0)

View Source

TTY 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:

  1. Raw mode activation fails with :already_started (a shell is already running)
  2. The environment is detected as constrained (Nerves, remote IEx)
  3. 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:

ModeDescriptionEscape Format
:true_colorFull 24-bit RGBESC[38;2;r;g;bm
:color_256256-color paletteESC[38;5;nm
:color_16Basic 16 colorsESC[31m etc.
:monochromeNo colorsAttributes 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_redraw or :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 environment

See Also

Summary

Types

Character set for box-drawing and special characters.

Color rendering mode based on terminal capabilities.

Rendering strategy for frame updates.

t()

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

character_set()

@type character_set() :: :unicode | :ascii

Character set for box-drawing and special characters.

  • :unicode - Full Unicode box-drawing characters
  • :ascii - ASCII fallback characters

color_mode()

@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[31m etc.)
  • :monochrome - No color support, attributes only

line_mode()

@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)

t()

@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_redraw or :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} or nil
  • :input_buffer - Buffer for partial escape sequences between poll_event calls

Functions

clear(state)

@spec clear(t()) :: {:ok, t()}

Clears the entire screen and moves cursor to home position.

Outputs the following escape sequences:

  1. \e[2J - Clear entire screen
  2. \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.

compare_frames(last_frame, current_cells)

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 frame
  • current_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 frame
  • removed_positions - Positions that were in last frame but not in current

draw_cells(state, cells)

@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:

  • position is {row, col} (1-indexed)
  • cell_data is {char, fg_color, bg_color, attrs}

Rendering Process

  1. In full_redraw mode, clear screen and home cursor
  2. Group cells by row for efficient rendering
  3. For each row, position cursor and output styled characters
  4. Apply color degradation based on color_mode

Returns

{:ok, updated_state} with last_frame updated for incremental mode.

flush(state)

@spec flush(t()) :: {:ok, t()}

Flushes pending output to the terminal.

For TTY mode, output is synchronous so this is largely a no-op.

hide_cursor(state)

@spec hide_cursor(t()) :: {:ok, t()}

Hides the terminal cursor.

Outputs \e[?25l escape sequence.

This operation is idempotent - if the cursor is already hidden, no escape sequence is written.

init(opts \\ [])

@spec init(keyword()) :: {:ok, t()}

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

move_cursor(state, arg)

@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.

poll_event(state, timeout)

@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.

refresh_size(state)

@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)

set_size(state, new_size)

@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 state
  • new_size - New terminal dimensions as {rows, cols}

Returns

{:ok, updated_state} with new size and cleared last_frame.

show_cursor(state)

@spec show_cursor(t()) :: {:ok, t()}

Shows the terminal cursor.

Outputs \e[?25h escape sequence.

This operation is idempotent - if the cursor is already visible, no escape sequence is written.

shutdown(state)

@spec shutdown(t()) :: :ok

Shuts down the TTY backend and restores terminal state.

Performs the following cleanup sequence:

  1. Reset all text attributes (colors, bold, underline, etc.)
  2. Show the cursor (in case it was hidden)
  3. 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.

size(tty)

@spec size(t()) :: {:ok, {pos_integer(), pos_integer()}}

Returns the current terminal dimensions.

Returns

  • {:ok, {rows, cols}} - Terminal size