Core Concepts
View SourceUnderstand the fundamentals of Raxol's architecture and design philosophy.
Table of Contents
What is a Buffer?
A buffer is a 2D grid of cells representing terminal content. Think of it like a canvas for text.
Buffer Structure
%{
width: 80,
height: 24,
lines: [
%{cells: [
%{char: "H", style: %{fg_color: :cyan, bold: true}},
%{char: "e", style: %{}},
%{char: "l", style: %{}},
# ... more cells
]},
# ... more lines
]
}Key Points:
- Width x Height - Dimensions in characters (columns x rows)
- Lines - List of rows, top to bottom
- Cells - Each cell contains:
char- Single character (grapheme)style- Visual styling (colors, bold, etc.)
Why This Structure?
Immutable & Functional:
# Each operation returns a NEW buffer
new_buffer = Buffer.write_at(old_buffer, 5, 3, "Text")
# old_buffer is unchanged (functional programming)Simple & Inspectable:
# You can always see what's in the buffer
IO.inspect(buffer.lines |> Enum.at(3) |> Map.get(:cells) |> Enum.at(5))
# => %{char: "T", style: %{}}Fast & Efficient:
- No server processes required
- Pure data structure operations
- Optimal for diffing and caching
Cell Coordinates
Buffers use (x, y) coordinates:
(0,0) ─────────────────> x (width)
│
│ (5,3) = Column 5, Row 3
│
v
y (height)Remember:
x= column (horizontal position)y= row (vertical position)- Both are 0-indexed
# Write "Hello" starting at column 10, row 5
buffer = Buffer.write_at(buffer, 10, 5, "Hello")The Rendering Pipeline
Raxol uses a multi-stage rendering pipeline optimized for terminal output.
Stage 1: Buffer Construction
Build the buffer by combining operations:
buffer = Buffer.create_blank_buffer(80, 24)
|> Box.draw_box(0, 0, 80, 24, :double)
|> Buffer.write_at(10, 5, "Title", %{bold: true})
|> Buffer.write_at(10, 7, "Content goes here")This is pure data transformation. No I/O, no side effects.
Stage 2: Diffing (Optional but Recommended)
Calculate minimal changes between frames:
old_buffer = # ... previous frame
new_buffer = # ... current frame
# Calculate what changed
diff = Renderer.render_diff(old_buffer, new_buffer)
# => [
# {:move, 10, 7},
# {:write, "Updated text"},
# {:move, 15, 10},
# {:write, "More changes"}
# ]Why Diff?
Without diffing:
# Redraw everything (slow, flickery)
IO.write("\e[2J\e[H") # Clear screen
IO.puts(Buffer.to_string(new_buffer))With diffing:
# Only update changed cells (fast, smooth)
Enum.each(diff, &IO.write/1)Performance Impact:
- Full render: ~100ms for 80x24 buffer
- Diff render: ~2ms for typical updates (50x faster!)
Stage 3: Output Generation
Convert buffer data to terminal sequences:
# Option 1: Full output (for debugging)
output = Buffer.to_string(buffer)
IO.puts(output)
# Option 2: Diff output (for efficiency)
diff = Renderer.render_diff(old, new)
Enum.each(diff, &IO.write/1)
# Option 3: HTML output (for web)
html = TerminalBridge.buffer_to_html(buffer)The Complete Pipeline
[User Code]
│
v
[Create Buffer] ─────> Immutable data structure
│
v
[Apply Operations] ──> write_at, draw_box, fill_area
│
v
[Calculate Diff] ────> Compare with previous frame
│
v
[Generate Output] ───> ANSI codes / HTML / String
│
v
[Display] ───────────> Terminal / Browser / FileState Management
Raxol supports multiple state management patterns.
Pattern 1: Pure Functional (Simplest)
No state, just transformations:
defmodule SimpleRender do
alias Raxol.Core.{Buffer, Box}
def render(data) do
Buffer.create_blank_buffer(80, 24)
|> Box.draw_box(0, 0, 80, 24, :single)
|> Buffer.write_at(10, 5, "Count: #{data.count}")
|> Buffer.to_string()
end
endWhen to use: Scripts, one-off renders, testing
Pattern 2: Stateful Loop (Classic)
Maintain state in a loop:
defmodule StatefulApp do
def run do
initial_state = %{count: 0, buffer: create_initial_buffer()}
loop(initial_state)
end
defp loop(state) do
# Update state
new_state = handle_input(state)
# Render new frame
new_buffer = render(new_state)
# Diff and output
diff = Renderer.render_diff(state.buffer, new_buffer)
Enum.each(diff, &IO.write/1)
# Continue loop
loop(%{new_state | buffer: new_buffer})
end
endWhen to use: Interactive CLIs, games, monitoring tools
Pattern 3: GenServer (Concurrent)
Use OTP for concurrent state management:
defmodule TerminalServer do
use GenServer
alias Raxol.Core.{Buffer, Renderer}
def init(_) do
state = %{
buffer: Buffer.create_blank_buffer(80, 24),
data: %{}
}
{:ok, state}
end
def handle_call({:update, data}, _from, state) do
new_buffer = render(data)
diff = Renderer.render_diff(state.buffer, new_buffer)
# Send diff to client
{:reply, diff, %{state | buffer: new_buffer, data: data}}
end
endWhen to use: Multi-user applications, web servers, distributed systems
Pattern 4: Phoenix LiveView (Web)
Leverage Phoenix for web-based terminals:
defmodule MyAppWeb.TerminalLive do
use MyAppWeb, :live_view
def mount(_params, _session, socket) do
socket = assign(socket,
buffer: create_initial_buffer(),
count: 0
)
{:ok, socket}
end
def handle_event("increment", _, socket) do
new_count = socket.assigns.count + 1
new_buffer = update_buffer(socket.assigns.buffer, new_count)
{:noreply, assign(socket, buffer: new_buffer, count: new_count)}
end
endWhen to use: Web applications, dashboards, remote terminals
Performance Model
Raxol is designed for high-performance terminal rendering.
Performance Targets
| Operation | Target | Typical |
|---|---|---|
| Buffer create | < 1ms | 0.3ms |
| write_at (single) | < 100μs | 50μs |
| draw_box | < 500μs | 240μs |
| render_diff | < 2ms | 1.2ms |
| Full render | < 16ms | 8ms |
60 FPS = 16ms frame budget
Optimization Strategies
1. Minimize Buffer Operations
# Bad: Multiple intermediate buffers
buffer = Buffer.create_blank_buffer(80, 24)
buffer = Box.draw_box(buffer, 0, 0, 80, 24, :single)
buffer = Buffer.write_at(buffer, 10, 5, "Line 1")
buffer = Buffer.write_at(buffer, 10, 6, "Line 2")
# Good: Pipeline operations
buffer = Buffer.create_blank_buffer(80, 24)
|> Box.draw_box(0, 0, 80, 24, :single)
|> Buffer.write_at(10, 5, "Line 1")
|> Buffer.write_at(10, 6, "Line 2")Why? Elixir optimizes pipelines better than intermediate variables.
2. Use Diff Rendering
# Bad: Full redraws every frame
def render_loop(state) do
new_buffer = create_frame(state)
IO.write("\e[2J\e[H") # Clear screen - SLOW!
IO.puts(Buffer.to_string(new_buffer))
render_loop(update_state(state))
end
# Good: Diff rendering
def render_loop(state) do
new_buffer = create_frame(state)
diff = Renderer.render_diff(state.buffer, new_buffer)
Enum.each(diff, &IO.write/1) # FAST!
render_loop(%{state | buffer: new_buffer})
endImpact: 50x faster for typical updates
3. Batch Style Applications
# Bad: Create style repeatedly
header_style = %{bold: true, fg_color: :blue}
buffer
|> Buffer.write_at(0, 0, "Title 1", header_style)
|> Buffer.write_at(0, 2, "Title 2", header_style)
# Good: Reuse style reference
header = Style.new(bold: true, fg_color: :blue)
buffer
|> Buffer.write_at(0, 0, "Title 1", header)
|> Buffer.write_at(0, 2, "Title 2", header)Why? Avoid allocating duplicate style maps.
4. Choose Appropriate Fill Operations
# Bad: Loop with set_cell (slow for large areas)
for y <- 0..23, x <- 0..79, reduce: buffer do
acc -> Buffer.set_cell(acc, x, y, " ", %{})
end
# Good: Use fill_area (optimized)
Box.fill_area(buffer, 0, 0, 80, 24, " ", %{})Impact: 10x faster for area fills
Memory Management
Buffer Size:
- Each cell: ~100 bytes (character + style)
- 80x24 buffer: ~192KB
- 200x50 buffer: ~1MB
Guidelines:
- Keep buffers reasonably sized (< 200x50 for most apps)
- Don't create unnecessary intermediate buffers
- Use diff rendering to avoid keeping too many historical buffers
Profiling Your Application
# Measure rendering time
{time, buffer} = :timer.tc(fn ->
Buffer.create_blank_buffer(80, 24)
|> Box.draw_box(0, 0, 80, 24, :double)
# ... more operations
end)
IO.puts("Render time: #{time}μs (#{time / 1000}ms)")
# Check if you're hitting 60fps
if time > 16_000 do
IO.warn("Rendering too slow for 60fps! (#{time / 1000}ms > 16ms)")
endDesign Philosophy
Raxol's architecture is guided by several key principles.
1. Functional First
Immutable Data: All buffer operations return new buffers, never mutate.
old_buffer = Buffer.create_blank_buffer(10, 10)
new_buffer = Buffer.write_at(old_buffer, 5, 5, "X")
# old_buffer is unchanged
Buffer.get_cell(old_buffer, 5, 5) # => %{char: " ", style: %{}}
Buffer.get_cell(new_buffer, 5, 5) # => %{char: "X", style: %{}}Why?
- Easier to reason about
- No hidden side effects
- Enables time-travel debugging
- Safe for concurrent access
2. Composable Operations
Building Blocks: Complex UIs are compositions of simple operations.
def create_dashboard(buffer, data) do
buffer
|> draw_header(data.title)
|> draw_sidebar(data.menu)
|> draw_content(data.body)
|> draw_footer(data.status)
end
defp draw_header(buffer, title) do
buffer
|> Box.draw_box(0, 0, buffer.width, 3, :double)
|> Buffer.write_at(5, 1, title, %{bold: true})
endWhy?
- Encourages code reuse
- Easy to test individual components
- Clear separation of concerns
3. Zero Dependencies (Core)
Raxol.Core has ZERO runtime dependencies.
# mix.exs for raxol_core
def deps, do: [] # Nothing!Why?
- Minimal install size (< 100KB)
- No dependency conflicts
- Works everywhere Elixir runs
- Fast compilation
4. Incremental Adoption
Use what you need, when you need it:
# Level 1: Just buffers
{:raxol_core, "~> 2.0"}
# Level 2: Add LiveView
{:raxol_core, "~> 2.0"},
{:raxol_liveview, "~> 2.0"}
# Level 3: Full framework
{:raxol, "~> 2.0"}Why?
- No forced complexity
- Learn incrementally
- Pay for what you use
5. Performance Budgets
Every operation has a performance target:
- Buffer operations: < 1ms
- Rendering: < 16ms (60fps)
- Memory: < 100KB per buffer
Why?
- Guarantees smooth UX
- Prevents performance regressions
- Enables real-time applications
Common Questions
Why buffers instead of direct rendering?
Buffers enable diffing. By maintaining the full state, we can calculate minimal updates.
# Direct rendering (can't optimize)
IO.puts("\e[10;5HHello") # Move and write
# Buffer-based (can optimize)
old = %{lines: [...]}
new = Buffer.write_at(old, 5, 10, "Hello")
diff = Renderer.render_diff(old, new) # Only changed cells!Why not use ANSI escape codes directly?
You can! Buffers are optional:
# Direct ANSI (totally fine for simple cases)
IO.write("\e[2J\e[H") # Clear screen
IO.write("\e[10;5HHello, World!")
# But buffers give you:
# - Automatic diffing
# - State inspection
# - HTML rendering
# - Testing utilitiesHow does this compare to other TUI frameworks?
| Feature | Raxol | ncurses | blessed |
|---|---|---|---|
| Language | Elixir | C | Node.js |
| Paradigm | Functional | Imperative | Imperative |
| Web Support | Yes (LiveView) | No | No |
| Dependencies | 0 (core) | System libs | Many |
| Type Safety | Yes (specs) | No | No (JS) |
Can I mix Raxol with other libraries?
Yes! Raxol.Core is just data structures:
# Generate buffer with Raxol
buffer = Buffer.create_blank_buffer(80, 24)
|> Buffer.write_at(10, 5, "Generated by Raxol")
# Render with your own code
output = Buffer.to_string(buffer)
MyCustomRenderer.render(output)
# Or convert to your format
my_format = convert_buffer_to_my_format(buffer)Next Steps
- Migration Guide - Integrate Raxol with existing code
- Cookbook - Practical patterns and recipes
- API Reference - Complete function documentation
- Architecture - Deep dive into implementation details
Questions or feedback? Open an issue on GitHub!