Terminal Layer
View SourceThis guide covers TermUI's low-level terminal interface, including raw mode, escape sequences, and platform handling.
Components
graph TB
subgraph "Terminal Layer"
TG[Terminal GenServer]
IR[InputReader]
EP[EscapeParser]
ANSI[ANSI Module]
end
subgraph "System"
TTY[/dev/tty]
STDIN[stdin]
STDOUT[stdout]
end
TG --> TTY
TG --> STDOUT
IR --> STDIN
IR --> EP
ANSI --> TGTerminal GenServer
TermUI.Terminal manages terminal state:
defmodule TermUI.Terminal do
use GenServer
defstruct [
:original_mode, # Saved terminal state
:raw_mode_enabled, # Currently in raw mode?
:mouse_mode, # Mouse tracking mode
:resize_callbacks # Processes to notify on resize
]
endInitialization
def init(_opts) do
state = %__MODULE__{
original_mode: nil,
raw_mode_enabled: false,
mouse_mode: nil,
resize_callbacks: []
}
{:ok, state}
endRaw Mode
Enabling Raw Mode
OTP 28+ uses the native shell API:
def enable_raw_mode do
if terminal?() do
# OTP 28+ native raw mode
:shell.start_interactive({:noshell, :raw})
:ok
else
{:error, :not_a_terminal}
end
endTerminal Detection
Multiple methods for SSH compatibility:
defp terminal? do
cond do
io_has_terminal?() -> true
File.exists?("/dev/tty") -> true
check_tty() -> true
true -> false
end
end
defp io_has_terminal? do
case :io.getopts(:standard_io) do
{:ok, opts} -> Keyword.get(opts, :terminal, false) == true
_ -> false
end
end
defp check_tty do
case System.cmd("test", ["-t", "0"], stderr_to_stdout: true) do
{_, 0} -> true
_ -> false
end
rescue
_ -> false
endRestoring Terminal
def restore do
# Disable raw mode
disable_raw_mode()
# Leave alternate screen
leave_alternate_screen()
# Show cursor
show_cursor()
# Disable mouse tracking
disable_mouse_tracking()
# Reset all attributes
write_to_terminal("\e[0m")
:ok
endEscape Sequences
ANSI Module
TermUI.ANSI generates escape sequences:
defmodule TermUI.ANSI do
# Cursor movement
def cursor_position(row, col), do: "\e[#{row};#{col}H"
def cursor_up(n \\ 1), do: "\e[#{n}A"
def cursor_down(n \\ 1), do: "\e[#{n}B"
def cursor_forward(n \\ 1), do: "\e[#{n}C"
def cursor_back(n \\ 1), do: "\e[#{n}D"
# Cursor visibility
def hide_cursor, do: "\e[?25l"
def show_cursor, do: "\e[?25h"
# Screen control
def clear_screen, do: "\e[2J"
def clear_line, do: "\e[2K"
def enter_alternate_screen, do: "\e[?1049h"
def leave_alternate_screen, do: "\e[?1049l"
# Style reset
def reset, do: "\e[0m"
endSGR (Select Graphic Rendition)
Text styling sequences:
# Colors
defp color_to_sgr(:fg, :default), do: "39"
defp color_to_sgr(:fg, :black), do: "30"
defp color_to_sgr(:fg, :red), do: "31"
defp color_to_sgr(:fg, :green), do: "32"
# ... etc
defp color_to_sgr(:bg, :default), do: "49"
defp color_to_sgr(:bg, :black), do: "40"
# ... etc
# 256 colors
defp color_to_sgr(:fg, n) when is_integer(n), do: "38;5;#{n}"
defp color_to_sgr(:bg, n) when is_integer(n), do: "48;5;#{n}"
# True color
defp color_to_sgr(:fg, {r, g, b}), do: "38;2;#{r};#{g};#{b}"
defp color_to_sgr(:bg, {r, g, b}), do: "48;2;#{r};#{g};#{b}"
# Attributes
defp attr_to_sgr(:bold), do: "1"
defp attr_to_sgr(:dim), do: "2"
defp attr_to_sgr(:italic), do: "3"
defp attr_to_sgr(:underline), do: "4"
defp attr_to_sgr(:blink), do: "5"
defp attr_to_sgr(:reverse), do: "7"
defp attr_to_sgr(:hidden), do: "8"
defp attr_to_sgr(:strikethrough), do: "9"
# Attribute off
defp attr_off_sgr(:bold), do: "22"
defp attr_off_sgr(:underline), do: "24"
# ... etcSequence Buffer
Batches sequences for efficient output:
defmodule TermUI.Renderer.SequenceBuffer do
defstruct [
buffer: [], # Accumulated iodata
size: 0, # Current size
threshold: 4096, # Auto-flush threshold
last_style: nil # For delta encoding
]
def append(buffer, data) do
new_size = buffer.size + IO.iodata_length(data)
new_buffer = %{buffer | buffer: [data | buffer.buffer], size: new_size}
if new_size >= buffer.threshold do
{flushed, reset} = flush(new_buffer)
{:flush, flushed, reset}
else
{:ok, new_buffer}
end
end
def flush(buffer) do
data = buffer.buffer |> Enum.reverse()
{data, %{buffer | buffer: [], size: 0}}
end
endStyle Delta Encoding
Only emit changed attributes:
def append_style(buffer, style) do
params = style_to_sgr_params(style, buffer.last_style)
if params == [] do
buffer
else
sequence = build_sgr_sequence(params)
buffer = append!(buffer, sequence)
%{buffer | last_style: style}
end
end
defp style_to_sgr_params(style, nil) do
# No previous - emit all
build_full_sgr_params(style)
end
defp style_to_sgr_params(style, last) do
params = []
# Only emit if changed
params = if style.fg != last.fg do
fg = style.fg || :default
[color_to_sgr(:fg, fg) | params]
else
params
end
params = if style.bg != last.bg do
bg = style.bg || :default
[color_to_sgr(:bg, bg) | params]
else
params
end
# Handle attribute changes...
params
endMouse Tracking
Modes
def enable_mouse_tracking(mode) do
sequences = case mode do
:click ->
# X11 mouse button events
["\e[?1000h", "\e[?1006h"]
:drag ->
# Button events + motion while pressed
["\e[?1002h", "\e[?1006h"]
:all ->
# All mouse events including motion
["\e[?1003h", "\e[?1006h"]
end
Enum.each(sequences, &write_to_terminal/1)
:ok
end
def disable_mouse_tracking do
sequences = [
"\e[?1000l", # Disable X11
"\e[?1002l", # Disable drag
"\e[?1003l", # Disable all
"\e[?1006l" # Disable SGR
]
Enum.each(sequences, &write_to_terminal/1)
:ok
endSGR Mouse Format
More precise than X10 format:
ESC [ < Cb ; Cx ; Cy M (button press)
ESC [ < Cb ; Cx ; Cy m (button release)
Cb = button info (bits encode button, modifiers, motion)
Cx = column (1-indexed)
Cy = row (1-indexed)Focus Events
def enable_focus_events do
write_to_terminal("\e[?1004h")
end
def disable_focus_events do
write_to_terminal("\e[?1004l")
end
# Terminal sends:
# \e[I - Focus gained
# \e[O - Focus lostTerminal Size
Query Size
def get_terminal_size do
case :io.columns() do
{:ok, cols} ->
case :io.rows() do
{:ok, rows} -> {:ok, {rows, cols}}
_ -> {:error, :unknown}
end
_ ->
{:error, :unknown}
end
endResize Detection
# Register for SIGWINCH
def register_resize_callback(pid) do
GenServer.cast(__MODULE__, {:register_resize, pid})
end
# On resize signal
def handle_info(:sigwinch, state) do
case get_terminal_size() do
{:ok, {rows, cols}} ->
# Notify all registered processes
Enum.each(state.resize_callbacks, fn pid ->
send(pid, {:terminal_resize, {rows, cols}})
end)
_ ->
:ok
end
{:noreply, state}
endAlternate Screen
sequenceDiagram
participant App as Application
participant Term as Terminal
participant Scr as Screen
App->>Term: enter_alternate_screen()
Term->>Scr: ESC[?1049h
Note over Scr: Switch to alt buffer
Note over App: TUI runs...
App->>Term: leave_alternate_screen()
Term->>Scr: ESC[?1049l
Note over Scr: Restore main bufferdef enter_alternate_screen do
write_to_terminal("\e[?1049h")
end
def leave_alternate_screen do
write_to_terminal("\e[?1049l")
endBracketed Paste
def enable_bracketed_paste do
write_to_terminal("\e[?2004h")
end
def disable_bracketed_paste do
write_to_terminal("\e[?2004l")
end
# Pasted text arrives as:
# \e[200~ <paste content> \e[201~Platform Differences
Unix/Linux/macOS
/dev/ttyfor terminal accesssttyfor fallback mode control- SIGWINCH for resize detection
Windows
- ConPTY for modern terminals
- Different escape sequence support
- Windows Terminal provides full ANSI support
defp platform do
case :os.type() do
{:unix, _} -> :unix
{:win32, _} -> :windows
end
endError Recovery
Terminal Restoration
Always restore on exit:
def terminate(_reason, state) do
# Best-effort restoration
try do
restore()
rescue
_ -> :ok
end
:ok
endCrash Recovery
The runtime traps exits:
def init(opts) do
Process.flag(:trap_exit, true)
# ...
end
def terminate(_reason, state) do
# Terminal.restore() always called
if state.terminal_started do
Terminal.restore()
end
:ok
endDebugging
Raw Escape Sequences
# See actual bytes
IO.inspect(data, binaries: :as_binaries)
# Example output:
# <<27, 91, 49, 59, 51, 49, 109>>
# = ESC [ 1 ; 3 1 m
# = bold + red foregroundTerminal State
# Check if in raw mode
:io.getopts(:standard_io)
# => {:ok, [terminal: true, ...]}Next Steps
- Event System - Input parsing
- Rendering Pipeline - Output flow
- Buffer Management - Screen buffers