TermUI.Backend.Raw (TermUI v1.0.0)

View Source

Raw terminal backend providing full terminal control.

The Raw backend is the primary high-fidelity rendering path in TermUI. It provides direct terminal control with immediate keystroke detection, true color support, mouse tracking, and all advanced terminal features.

Requirements

  • OTP 28+: Raw mode is activated via :shell.start_interactive({:noshell, :raw})
  • Terminal access: Requires a real terminal (not pipes or redirected I/O)

How It Works

The Raw backend assumes raw mode has already been activated by TermUI.Backend.Selector before init/1 is called. The selector uses :shell.start_interactive({:noshell, :raw}) to enter raw mode, and on success, routes to this backend.

Important: The init/1 callback does NOT activate raw mode itself. It only performs terminal setup (alternate screen, cursor hiding, etc.) assuming raw mode is already active.

Features

When raw mode is active, this backend provides:

  • Alternate screen buffer: Preserves original terminal content, restored on exit
  • Cursor control: Hide/show cursor, precise positioning
  • True color rendering: Full 24-bit RGB color support ({r, g, b} tuples)
  • 256-color palette: Extended color support (0-255 indices)
  • Mouse tracking: Click, drag, and movement detection
  • Immediate input: Character-by-character keystroke detection
  • Escape sequence handling: Function keys, arrow keys, modifiers

Initialization Flow

1. Selector calls :shell.start_interactive({:noshell, :raw})
    Returns :ok (raw mode active)

2. Runtime creates Raw backend state
    Calls Raw.init(opts)

3. Raw.init/1 performs terminal setup:
    Enter alternate screen buffer (optional)
    Hide cursor
    Enable mouse tracking (optional)
    Clear screen

Configuration Options

The init/1 callback accepts these options:

  • :alternate_screen - Use alternate screen buffer (default: true)
  • :hide_cursor - Hide cursor during rendering (default: true)
  • :mouse_tracking - Mouse tracking mode (default: :none)
    • :none - No mouse tracking
    • :click - Track button clicks only
    • :drag - Track clicks and drag events
    • :all - Track all mouse movement
  • :size - Explicit terminal dimensions {rows, cols} (default: auto-detect)

Shutdown Behavior

The shutdown/1 callback restores the terminal to its pre-init state:

  1. Disable mouse tracking (if enabled)
  2. Show cursor
  3. Reset all text attributes
  4. Leave alternate screen (if entered)
  5. Return to cooked mode via :shell.start_interactive({:noshell, :cooked})

Shutdown is designed to be error-safe - individual failures don't prevent subsequent cleanup steps from running.

Usage 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:
# 1. Backend selection via Selector
# 2. Backend initialization
# 3. Rendering via draw_cells/2
# 4. Input polling via poll_event/2
# 5. Clean shutdown

Mouse Tracking Modes

The Raw backend uses intuitive mode names that map to underlying ANSI protocol modes:

Raw BackendANSI ProtocolEscape SequenceDescription
:none(disabled)-No mouse tracking
:clickNormal (1000)ESC[?1000hButton press/release only
:dragButton (1002)ESC[?1002hPress/release + motion while pressed
:allAny (1003)ESC[?1003hAll mouse motion events

When mouse tracking is enabled, SGR extended mode (ESC[?1006h) is also activated for accurate coordinate encoding beyond column 223.

Note: The TermUI.ANSI module uses protocol names (:normal, :button, :all), while this backend uses user-friendly names (:click, :drag, :all). The mapping is handled internally when emitting sequences.

Style Delta Optimization

The current_style field in the backend state tracks the last-emitted SGR (Select Graphic Rendition) attributes. This enables style delta optimization in draw_cells/2:

Instead of emitting full style sequences for every cell:

ESC[0;38;2;255;0;0;48;2;0;0;0mA  <- 25 bytes per cell
ESC[0;38;2;255;0;0;48;2;0;0;0mB

We only emit changes from the previous style:

ESC[38;2;255;0;0;48;2;0;0;0mA   <- Full style for first cell
B                                <- No escape needed, same style!
ESC[38;2;0;255;0mC              <- Only foreground changed

This optimization can reduce escape sequence output by 80-90% for typical UIs where adjacent cells share styles (text blocks, borders, backgrounds).

The current_style map tracks:

  • :fg - Current foreground color
  • :bg - Current background color
  • :attrs - Current text attributes (:bold, :underline, :reverse, etc.)

See Also

Summary

Types

Mouse tracking mode for the terminal.

Current SGR (Select Graphic Rendition) style state.

t()

Internal state for the Raw backend.

Functions

Clears the entire screen and moves cursor to home position.

Disables mouse tracking.

Draws cells to the terminal at specified positions.

Enables mouse tracking with the specified mode.

Flushes pending output to the terminal.

Hides the terminal cursor.

Initializes the Raw backend with terminal setup.

Maps a Raw backend mouse mode to the corresponding ANSI protocol mode.

Moves the cursor to the specified position.

Polls for input events with the specified timeout.

Re-queries terminal dimensions and updates state.

Shows the terminal cursor.

Shuts down the backend and restores terminal state.

Returns the current terminal dimensions.

Checks if a position is valid within the terminal bounds.

Types

mouse_mode()

@type mouse_mode() :: :none | :click | :drag | :all

Mouse tracking mode for the terminal.

These are user-friendly names that map to ANSI protocol modes internally:

  • :none - No mouse tracking (disabled)
  • :click - Track button press/release only (ANSI "normal" mode, 1000)
  • :drag - Track clicks and motion while button pressed (ANSI "button" mode, 1002)
  • :all - Track all mouse movement (ANSI "any" mode, 1003)

See the "Mouse Tracking Modes" section in the module documentation for details.

style_state()

@type style_state() :: %{
  fg: TermUI.Backend.color(),
  bg: TermUI.Backend.color(),
  attrs: [atom()]
}

Current SGR (Select Graphic Rendition) style state.

Tracks the current foreground color, background color, and text attributes to enable style delta optimization - only emitting escape sequences for changed attributes.

Fields

  • :fg - Current foreground color (see TermUI.Backend.color())
  • :bg - Current background color (see TermUI.Backend.color())
  • :attrs - List of active text attributes:
    • :bold - Bold/bright text
    • :dim - Dimmed text
    • :italic - Italic text
    • :underline - Underlined text
    • :blink - Blinking text
    • :reverse - Swapped foreground/background
    • :hidden - Hidden text
    • :strikethrough - Struck-through text

See the "Style Delta Optimization" section in the module documentation for how this enables efficient rendering.

t()

@type t() :: %TermUI.Backend.Raw{
  alternate_screen: boolean(),
  current_style: style_state() | nil,
  cursor_position: {pos_integer(), pos_integer()} | nil,
  cursor_visible: boolean(),
  event_queue: [TermUI.Backend.event()],
  events_dropped: non_neg_integer(),
  input_buffer: binary(),
  mouse_mode: mouse_mode(),
  optimize_cursor: boolean(),
  size: {pos_integer(), pos_integer()}
}

Internal state for the Raw backend.

Tracks all terminal state needed for rendering and input handling.

Fields

  • :size - Terminal dimensions as {rows, cols}
  • :cursor_visible - Whether cursor is currently visible (default: false)
  • :cursor_position - Current cursor position as {row, col} or nil
  • :alternate_screen - Whether alternate screen buffer is active
  • :mouse_mode - Current mouse tracking mode
  • :current_style - Current SGR state for style delta tracking
  • :optimize_cursor - Whether to use cursor movement optimization (default: true)
  • :input_buffer - Buffer for partial escape sequences during input parsing
  • :event_queue - Queue of parsed events waiting to be returned

Functions

clear(state)

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

Clears the entire screen and moves cursor to home position.

Uses ANSI sequences:

  • ESC[2J - ED (Erase Display) parameter 2: clear entire screen
  • ESC[1;1H - CUP (Cursor Position): move to row 1, column 1

State Changes

After clear:

  • cursor_position is set to {1, 1} (home position)
  • current_style is reset to nil (terminal style state is unknown after clear)

All other state fields are preserved.

Idempotency

This operation is idempotent - calling clear/1 multiple times in succession is safe and will result in the same state each time.

Examples

{:ok, state} = Raw.init(size: {24, 80})
{:ok, moved} = Raw.move_cursor(state, {10, 20})
{:ok, cleared} = Raw.clear(moved)

cleared.cursor_position  # => {1, 1}
cleared.current_style    # => nil

See Also

disable_mouse(state)

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

Disables mouse tracking.

Turns off mouse event reporting, returning the terminal to normal operation where mouse actions are not reported to the application.

Escape Sequences

This function emits:

  1. Disable SGR extended mode (ESC[?1006l)
  2. Disable the current tracking mode:
    • :clickESC[?1000l
    • :dragESC[?1002l
    • :allESC[?1003l

Idempotent Behavior

If mouse tracking is already disabled (:none), no escape sequences are written and the same state is returned.

Returns

  • {:ok, updated_state} with mouse_mode set to :none

Examples

# Disable after enabling
{:ok, state} = Raw.enable_mouse(state, :click)
{:ok, state} = Raw.disable_mouse(state)
state.mouse_mode  # => :none

# Idempotent - safe to call when already disabled
{:ok, same_state} = Raw.disable_mouse(state)

See Also

draw_cells(state, cells)

@spec draw_cells(t(), [{TermUI.Backend.position(), TermUI.Backend.cell()}]) ::
  {:ok, t()}

Draws cells to the terminal at specified positions.

Cells are rendered with optimized cursor movement and style delta tracking to minimize escape sequence output. See the "Style Delta Optimization" section in the module documentation for details on how this works.

Cell Format

Each cell is a tuple {position, cell_data} where:

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

Performance

This function uses several optimizations:

  • Style delta tracking (only emit changed attributes)
  • Relative cursor movement when cheaper than absolute
  • Batched I/O writes

Examples

# Draw a single red "A" at position {1, 1}
cells = [{{1, 1}, {"A", :red, :default, []}}]
{:ok, state} = Raw.draw_cells(state, cells)

# Draw multiple cells with different styles
cells = [
  {{1, 1}, {"H", :green, :default, [:bold]}},
  {{1, 2}, {"i", :green, :default, [:bold]}},
  {{2, 1}, {"!", :yellow, :blue, []}}
]
{:ok, state} = Raw.draw_cells(state, cells)

enable_mouse(state, mode)

@spec enable_mouse(t(), :click | :drag | :all) :: {:ok, t()}

Enables mouse tracking with the specified mode.

Changes the mouse tracking mode, enabling detection of mouse events. This function can be called after initialization to change the tracking mode.

Parameters

  • state - Current backend state
  • mode - Mouse tracking mode:
    • :click - Track button press/release only (ANSI "normal" mode, 1000)
    • :drag - Track clicks and motion while button pressed (ANSI "button" mode, 1002)
    • :all - Track all mouse movement (ANSI "any" mode, 1003)

Escape Sequences

This function emits:

  1. The appropriate mouse tracking mode sequence:
    • :clickESC[?1000h
    • :dragESC[?1002h
    • :allESC[?1003h
  2. SGR extended mode (ESC[?1006h) for accurate coordinate encoding

Idempotent Behavior

If the requested mode matches the current mode, no escape sequences are written and the same state is returned.

Returns

  • {:ok, updated_state} with mouse_mode set to the new mode

Examples

# Enable click tracking
{:ok, state} = Raw.enable_mouse(state, :click)

# Enable all movement tracking
{:ok, state} = Raw.enable_mouse(state, :all)

See Also

  • disable_mouse/1 - Disable mouse tracking
  • init/1 - Can set initial mouse tracking mode via :mouse_tracking option

flush(state)

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

Flushes pending output to the terminal.

For the Raw backend, this is a no-op because IO.write/1 is synchronous - output is written directly to the terminal without buffering. The callback exists for API completeness and compatibility with backends that may use buffered I/O.

This function is idempotent and safe to call multiple times.

Returns

  • {:ok, state} - Always succeeds, returning state unchanged

hide_cursor(state)

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

Hides the terminal cursor.

Uses ANSI sequence ESC[?25l (DECTCEM off).

Idempotent Behavior

This operation is idempotent. When the cursor is already hidden:

  • No escape sequence is written to the terminal
  • The exact same state object is returned unchanged
  • Callers cannot distinguish a no-op from an actual state change

This design prevents redundant ANSI writes and allows callers to call without tracking current visibility state.

See Also

init(opts \\ [])

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

Initializes the Raw backend with terminal setup.

Assumes raw mode is already active (started by Selector). Performs terminal configuration including alternate screen, cursor hiding, and mouse tracking.

Options

  • :alternate_screen - Use alternate screen buffer (default: true)
  • :hide_cursor - Hide cursor during rendering (default: true)
  • :mouse_tracking - Mouse tracking mode (default: :none)
  • :size - Explicit dimensions {rows, cols} (default: auto-detect)
  • :optimize_cursor - Use cursor movement optimization (default: true)

Returns

  • {:ok, state} on success
  • {:error, :invalid_size} if size option is malformed
  • {:error, :terminal_setup_failed} if terminal configuration fails
  • {:error, :size_detection_failed} if auto-detect fails and no size provided

Examples

# Default initialization
{:ok, state} = Raw.init([])

# With explicit options
{:ok, state} = Raw.init(
  alternate_screen: true,
  hide_cursor: true,
  mouse_tracking: :click,
  size: {24, 80}
)

mouse_mode_to_ansi(atom)

@spec mouse_mode_to_ansi(mouse_mode()) :: :normal | :button | :all | nil

Maps a Raw backend mouse mode to the corresponding ANSI protocol mode.

This is used internally when emitting mouse tracking escape sequences.

Examples

iex> Raw.mouse_mode_to_ansi(:click)
:normal
iex> Raw.mouse_mode_to_ansi(:drag)
:button
iex> Raw.mouse_mode_to_ansi(:all)
:all

move_cursor(state, position)

@spec move_cursor(t(), TermUI.Backend.position()) :: {:ok, t()}

Moves the cursor to the specified position.

Position is 1-indexed: {1, 1} is the top-left corner.

Cursor Optimization

When optimize_cursor: true (default), this function uses CursorOptimizer to select the cheapest movement sequence. This can reduce cursor movement overhead by 40%+ compared to always using absolute positioning.

Movement options considered:

  • Absolute positioning: ESC[{row};{col}H (6-10 bytes)
  • Relative moves: up/down/left/right (3-6 bytes)
  • Carriage return + vertical (1 + 3-6 bytes)
  • Home position: ESC[H (3 bytes)
  • Literal spaces for small rightward moves (1 byte each)

Position Validation

Positions must have positive integer coordinates. This function does NOT validate positions against terminal bounds - positions beyond the terminal dimensions are accepted and recorded in state. Most terminals silently clamp out-of-bounds positions, which may cause state-reality divergence.

Callers should validate positions before calling using valid_position?/2:

if Raw.valid_position?(state, position) do
  Raw.move_cursor(state, position)
else
  {:error, :out_of_bounds}
end

This design allows the renderer layer to handle bounds checking appropriately for its use case (e.g., scrolling, wrapping, or clamping).

See Also

Examples

{:ok, state} = Raw.move_cursor(state, {1, 1})   # Top-left
{:ok, state} = Raw.move_cursor(state, {24, 80}) # Bottom-right (80x24)

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.

In raw mode, input arrives character-by-character enabling real-time keyboard and mouse event handling. This function uses the EscapeParser module to parse escape sequences into TermUI.Event structs.

Parameters

  • state - Current backend state
  • timeout - Milliseconds to wait (0 for non-blocking)

Returns

  • {:ok, event, state} - Event received and parsed
  • {:timeout, state} - No input within timeout period
  • {:error, reason, state} - Terminal I/O error occurred

Escape Sequence Handling

Some sequences are ambiguous (ESC alone vs ESC followed by another key). The function buffers partial sequences and uses the timeout to disambiguate. If the buffer contains a partial escape sequence and the timeout expires, the escape key is emitted and remaining bytes are re-parsed.

Examples

# Non-blocking poll (timeout = 0)
{:timeout, state} = Raw.poll_event(state, 0)

# Block up to 100ms for input
case Raw.poll_event(state, 100) do
  {:ok, %Event.Key{key: :enter}, state} -> handle_enter(state)
  {:ok, %Event.Mouse{action: :click}, state} -> handle_click(state)
  {:timeout, state} -> handle_idle(state)
end

refresh_size(state)

@spec refresh_size(t()) ::
  {:ok, TermUI.Backend.size(), t()} | {:error, :size_detection_failed}

Re-queries terminal dimensions and updates state.

This function queries the terminal for its current size using :io.rows/0 and :io.columns/0, then updates the cached size in state. It should be called after receiving a SIGWINCH signal to handle terminal resize events.

Return Value

  • {:ok, {rows, cols}, updated_state} - New dimensions and updated state
  • {:error, :size_detection_failed} - Failed to query terminal dimensions

SIGWINCH Handling

Terminal resize events are delivered via SIGWINCH. Your application should:

  1. Register a signal handler for SIGWINCH
  2. Call refresh_size/1 when the signal is received
  3. Trigger a re-render with the new dimensions

Example integration:

def handle_info({:signal, :sigwinch}, state) do
  case Raw.refresh_size(state.backend_state) do
    {:ok, new_size, new_backend_state} ->
      # Update state and trigger re-render
      {:noreply, %{state | backend_state: new_backend_state, size: new_size}}
    {:error, _reason} ->
      # Keep existing size
      {:noreply, state}
  end
end

Size Detection

Uses the same detection logic as init/1:

  1. Query :io.rows/0 and :io.columns/0
  2. Fall back to LINES and COLUMNS environment variables
  3. Return error if all methods fail

See Also

  • size/1 - Return cached dimensions without re-querying
  • init/1 - Initial size detection during initialization

show_cursor(state)

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

Shows the terminal cursor.

Uses ANSI sequence ESC[?25h (DECTCEM on).

Idempotent Behavior

This operation is idempotent. When the cursor is already visible:

  • No escape sequence is written to the terminal
  • The exact same state object is returned unchanged
  • Callers cannot distinguish a no-op from an actual state change

This design prevents redundant ANSI writes and allows callers to call without tracking current visibility state.

See Also

shutdown(state)

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

Shuts down the backend and restores terminal state.

Performs cleanup in order: disable mouse, show cursor, reset attributes, leave alternate screen, return to cooked mode.

Error Safety

This function is designed to be error-safe:

  • Each cleanup step is wrapped in try/rescue
  • Individual failures are logged but don't prevent subsequent steps
  • Always returns :ok regardless of individual step failures
  • Idempotent: safe to call multiple times

Cleanup Sequence

  1. Disable mouse tracking (if enabled)
  2. Show cursor (ANSI: ESC[?25h)
  3. Reset all text attributes (ANSI: ESC[0m)
  4. Leave alternate screen (ANSI: ESC[?1049l)
  5. Return to cooked mode via :shell.start_interactive({:noshell, :cooked})

size(state)

@spec size(t()) :: {:ok, TermUI.Backend.size()}

Returns the current terminal dimensions.

Returns the cached size from state as {rows, cols}. This does not re-query the terminal - it returns the dimensions captured at init/1 or last updated by refresh_size/1.

Return Value

  • {:ok, {rows, cols}} - Terminal dimensions (rows first, then columns)

Examples

{:ok, {24, 80}} = Raw.size(state)  # Standard 80x24 terminal
{:ok, {50, 120}} = Raw.size(state) # Larger terminal

See Also

valid_position?(arg1, arg2)

@spec valid_position?(
  t(),
  {integer(), integer()}
) :: boolean()

Checks if a position is valid within the terminal bounds.

Returns true if the position has positive coordinates and is within the terminal dimensions stored in state.

Examples

iex> state = %Raw{size: {24, 80}}
iex> Raw.valid_position?(state, {1, 1})
true
iex> Raw.valid_position?(state, {24, 80})
true
iex> Raw.valid_position?(state, {25, 1})
false
iex> Raw.valid_position?(state, {0, 1})
false