How Raxol works, from application model to terminal output.
Your App (TEA) Raxol (Framework) Rendering Targets
┌─────────────┐ ┌───────────────────────┐ ┌─────────────┐
│ init/1 │ │ Lifecycle (GenServer) │ │ termbox2 NIF│
│ update/2 │───>│ Rendering Engine │───>│ IOTerminal │
│ view/1 │ │ Layout Engine │ │ LiveView │
│ subscribe/1 │ │ Event Dispatcher │ │ SSH │
│ │ │ MCP Tool Deriver │───>│ MCP (tools) │
└─────────────┘ └───────────────────────┘ └─────────────┘Your app provides pure functions. Raxol manages the runtime loop, layout, rendering, and I/O. You never write ANSI escape codes.
Every Raxol app implements The Elm Architecture:
use Raxol.Core.Runtime.Application
def init(context) -> model # Initial state
def update(message, model) -> {model, cmds} # State transitions
def view(model) -> view_tree # Declarative UI
def subscribe(model) -> [subscription] # External eventsThe runtime calls view(model) after every update, diffs the resulting Element tree against the previous one, and renders only what changed. Same diffing idea as React's virtual DOM, but the Element tree describes terminal cells, not HTML nodes.
The view/1 callback uses macros to build a tree of plain maps:
column style: %{padding: 1} do
[
text("Hello", fg: :cyan),
row do
[button("+", on_click: :inc), button("-", on_click: :dec)]
end
]
endProduces: %{type: :column, children: [%{type: :text, ...}, %{type: :row, ...}], ...}
Raxol.UI.Layout.Preparer walks the element tree and pre-measures all text nodes via Raxol.UI.TextMeasure, producing a PreparedElement tree with cached display widths. This is the "prepare" phase of a two-phase prepare/layout architecture (inspired by Pretext):
- Text measurement handles CJK double-width characters, fullwidth symbols, and combining characters correctly via
Raxol.Terminal.CharacterHandling - On terminal resize, only the layout phase re-runs -- text measurements are cached and reused when content hasn't changed
prepare_incremental/2compares content hashes to skip re-measurement of unchanged nodesPreparedElementalso carriesanimation_hints-- declarative metadata attached viaRaxol.Animation.Helpers.animate/2inview/1. These hints flow through to backends untouched; the Preparer just preserves them alongside measurements
Raxol.UI.Layout.Engine takes the element tree and computes {x, y, width, height} for every node. Uses cached measurements from the Preparer when available. Supports:
- Flexbox:
row/columnwithflex,gap,align_items,justify_content - CSS Grid:
gridwithtemplate_columns,template_rows - Box model:
padding,border,margin,width,height
Raxol.UI.Rendering.Composer walks the positioned tree and produces cell tuples:
{x, y, char, fg_color, bg_color, attrs}Each cell is one character at one position with its styling. Cell x-positions account for character display width -- CJK characters advance x by 2, not 1.
Raxol.Terminal.ScreenBuffer holds the current and previous frame. Only changed cells produce output.
Platform-detected backend writes ANSI escape sequences:
- Unix/macOS: Native C NIF via termbox2 (
lib/termbox2_nif/c_src/) - Windows: Pure Elixir
IOTerminalusingIO.write/1 - Browser: LiveView bridge via PubSub (
Raxol.LiveView.TEALiveinraxol_liveviewpackage). When positioned elements carry animation hints,TerminalBridge.animation_css/1emits CSStransitionrules targetingdata-raxol-idselectors, plus aprefers-reduced-motionmedia query. The browser handles interpolation client-side instead of re-rendering every frame from the server. - SSH: Erlang
:sshmodule (Raxol.SSH.Server) - Telegram: Buffer-to-plaintext via an
io_writercallback (Raxol.Core.Runtime.Rendering.Backends.render_to_telegram/2) - MCP: Tool/resource derivation from Component tree (
Raxol.MCP.Server, see ADR-0012).StructuredScreenshotincludes animation hints in JSON Component summaries so agents can reason about animated state.
MCP is a first-class rendering target alongside terminal, LiveView, and SSH. Instead of rendering pixels, it renders capabilities: tools and resources derived from the Component tree.
view(model) -> Component tree -> ToolProvider per Component -> MCP tool set
-> app projections -> MCP resources
Each Component type implements Raxol.MCP.ToolProvider, mapping its state to MCP tools (e.g., TextInput -> type_into/clear/get_value, Table -> sort/filter/select_row). A focus lens filters to ~10 relevant tools per interaction. The context tree assembles model, Components, agents, swarm topology, and notifications into browsable MCP resources.
This means every Raxol app is AI-controllable with zero glue code. Package: raxol_mcp (depends on raxol_core). See docs/adr/0012-mcp-as-rendering-target.md for full details.
Terminal Input
-> Driver (raw bytes -> Event struct)
-> Dispatcher (GenServer)
-> Capture phase (root -> target, W3C-style)
-> Target handlers (on_click, on_change)
-> Bubble phase (target -> root)
-> Component handle_event/3
-> App update/2
Events bubble through the view tree. Any handler can return :stop to halt propagation or :passthrough to continue. Unhandled events reach update/2.
Every Raxol app runs as a supervision tree:
Application Supervisor
├── Lifecycle (GenServer) -- owns the TEA loop
├── Dispatcher (GenServer) -- event routing
├── FocusManager (GenServer) -- tab order, focus state
├── Rendering.Engine -- view -> layout -> render -> output
├── ThemeManager -- ETS-backed theme registry
├── I18nServer -- ETS-backed translations
└── [ProcessComponent supervisors] -- optional per-Component processes
Any Component can run in its own process via process_component/2:
process_component(ExpensiveChart, data: sensor_feed)The component gets its own GenServer under a DynamicSupervisor. If it crashes, it restarts without affecting the rest of the UI. State is preserved in ETS across restarts.
Raxol.Dev.CodeReloader watches .ex files via FileSystem, debounces changes, recompiles, and sends :render_needed to the Lifecycle. Your app updates in-place without restart.
- Two-phase rendering: Text measurement (expensive, Unicode-aware) is cached separately from layout (cheap arithmetic). On resize, only layout re-runs.
- Buffer diff: Only changed cells are written. ~2ms for 80x24.
- ETS for reads: Theme, i18n, config, and metrics use ETS tables. Reads bypass GenServer serialization entirely.
- Synchronized output: Uses DEC mode 2026 (
\e[?2026h) to batch terminal writes, preventing flicker. - Damage tracking:
DamageTrackercomputes rectangular dirty regions.RenderBatchercoalesces rapid updates into single frames at 60fps. - Color downsampling:
Raxol.Style.Colors.Adaptivedetects terminal capabilities and maps 24-bit colors to 256 or 16 colors automatically. - Lazy scroll content:
ScrollContentbehaviour enables cursor-based streaming for large datasets inViewport-- only the visible slice is materialized.
- Unicode width:
TextMeasuredelegates toCharacterHandlingfor correct CJK double-width, combining characters, fullwidth symbols, and emoji width calculation across layout, rendering, and text wrapping - Border fallback: Box drawing uses ASCII (
+-|) when Unicode isn't supported - Color detection:
COLORTERM,TERM, capability queries for truecolor/256/16/mono
| Module | Role |
|---|---|
Raxol.Core.Runtime.Lifecycle |
TEA loop GenServer |
Raxol.Core.Runtime.Events.Dispatcher |
Event routing + bubbling |
Raxol.Core.Runtime.Rendering.Engine |
view -> prepare -> layout -> render |
Raxol.UI.TextMeasure |
Unicode display width (facade) |
Raxol.UI.Layout.Preparer |
Pre-measure text, cache widths |
Raxol.UI.Layout.Engine |
Flexbox/Grid layout computation |
Raxol.UI.Layout.ScrollContent |
Cursor-based lazy scroll behaviour |
Raxol.UI.Rendering.Composer |
Element tree -> cell grid |
Raxol.Terminal.ScreenBuffer |
Double-buffered cell storage |
Raxol.Terminal.CharacterHandling |
CJK/Unicode width (wcwidth) |
Raxol.Terminal.Renderer |
Cell grid -> ANSI string |
Raxol.Terminal.Driver |
Platform backend selection |
Raxol.Core.Renderer.View |
View DSL macros |
Raxol.Animation.Helpers |
animate/2, stagger/2, sequence/2 for view hints |
Raxol.Animation.Hint |
Hint struct, CSS property/timing mapping |