Raxol.Core Architecture
View SourceDesign decisions, patterns, and implementation details for Raxol.Core.
Design Philosophy
1. Pure Functional Design
Decision: All modules use pure functions with immutable data structures.
Rationale:
- Predictable behavior - same input always produces same output
- Easy testing - no mocking or setup required
- Thread-safe - no shared state or race conditions
- Composable - functions can be chained naturally
- No hidden dependencies - all inputs explicit
Implementation:
# Every function returns new buffer, never mutates
def write_at(buffer, x, y, content, style) do
# Create new cells, lines, and buffer
# Original buffer unchanged
endTrade-offs:
- Pro: Safety, testability, concurrency
- Con: Memory allocations (mitigated by BEAM optimization)
2. Zero Dependencies
Decision: Raxol.Core has no runtime dependencies beyond Elixir stdlib.
Rationale:
- Minimal footprint (< 100KB)
- No version conflicts
- Fast compilation
- Easy adoption
- Clear boundaries
Implementation:
- Only uses Elixir stdlib modules (Enum, List, Map, String)
- No external packages in mix.exs
- Self-contained
Trade-offs:
- Pro: Simplicity, reliability, small size
- Con: Must implement everything ourselves
3. Performance First
Decision: Target < 1ms for all operations on standard buffers.
Rationale:
- 60fps requires < 16ms frame budget
- Leaves headroom for user code
- Responsive UIs
- Suitable for real-time applications
Implementation:
- Benchmarks in CI
- Algorithmic optimization (Enum.zip for diffs)
- Lazy evaluation where possible
- Efficient data structures
Measured Results:
- Buffer operations: 0.001-0.5ms
- Render diff: ~2ms (80x24 buffer)
- Box drawing: 0.04-0.6ms
- Style generation: < 0.1ms
4. Fail-Safe Boundaries
Decision: Out-of-bounds operations are silently ignored, never crash.
Rationale:
- Graceful degradation in production
- Less defensive code required by users
- Predictable behavior
- No exception handling noise
Implementation:
def set_cell(buffer, x, y, char, style) do
cond do
y >= height or y < 0 -> buffer # No-op
x >= width or x < 0 -> buffer # No-op
true -> do_update(buffer, x, y, char, style)
end
endTrade-offs:
- Pro: Never crashes, easy to use
- Con: Silent failures can hide bugs (use debug mode if needed)
Module Architecture
Buffer Module
Responsibility: Core data structure and basic operations.
Data Structure:
%{
lines: [%{cells: [%{char: "A", style: %{...}}]}],
width: 80,
height: 24
}Design Decisions:
List of Lists vs 2D Array
Choice: List of lines, each containing list of cells.
Rationale:
- Elixir lists are optimized for sequential access
- Most rendering is line-by-line
- Easy to implement line operations (scroll, insert)
- Natural mapping to terminal output
Alternative Considered: Flat array with index math
- Rejected: More complex, less idiomatic Elixir
Cell Representation
Choice: Map with :char and :style keys.
Rationale:
- Flexible - can add more properties later
- Clear semantics
- Pattern matching friendly
Renderer Module
Responsibility: Convert buffers to output strings.
Key Algorithm: Diff Rendering
def render_diff(old_buffer, new_buffer) do
old_buffer.lines
|> Enum.zip(new_buffer.lines)
|> Enum.with_index()
|> Enum.filter(fn {{old_line, new_line}, _} -> old_line != new_line end)
|> Enum.map(fn {{_, new_line}, y} ->
"\e[#{y + 1};1H" <> line_to_string(new_line)
end)
endDesign Decisions:
Enum.zip for Line Comparison
Choice: Use Enum.zip to pair corresponding lines.
Rationale:
- Elegant functional approach
- Efficient - single pass through both buffers
- Built-in Elixir optimization
- Clear intent
Performance: < 2ms for 80x24 buffer
ANSI Escape Sequences
Choice: Generate minimal ANSI codes.
Rationale:
- Universal terminal support
- Small output size
- Direct control over positioning
Format: \e[row;colH for cursor positioning
Style Module
Responsibility: Style management and ANSI generation.
Data Structure:
%{
bold: boolean(),
italic: boolean(),
underline: boolean(),
fg_color: atom() | integer() | {r, g, b},
bg_color: atom() | integer() | {r, g, b}
}Design Decisions:
Flexible Color Representation
Choice: Support named colors, 256-color, and RGB.
Rationale:
- Named colors: Easy to use, portable
- 256-color: Good palette, wide support
- RGB: Full color for modern terminals
Implementation:
defp color_to_ansi(:red, :fg), do: "31"
defp color_to_ansi(n, :fg) when is_integer(n), do: "38;5;#{n}"
defp color_to_ansi({r, g, b}, :fg), do: "38;2;#{r};#{g};#{b}"Style Merging
Choice: Simple map merge with last-wins semantics.
Rationale:
- Predictable behavior
- Easy to understand
- Standard Elixir pattern
Box Module
Responsibility: Higher-level drawing utilities.
Design Decisions:
Unicode Box Characters
Choice: Use Unicode box-drawing characters.
Rationale:
- Native terminal support
- Clean rendering
- No custom font required
- Standard across platforms
Character Sets:
- Single: ─│┌┐└┘
- Double: ═║╔╗╚╝
- Rounded: ─│╭╮╰╯
- Heavy: ━┃┏┓┗┛
- Dashed: ╌╎┌┐└┘
Composition Over Primitives
Choice: Build complex shapes from simple operations.
Implementation:
def draw_box(buffer, x, y, width, height, style) do
chars = box_chars(style)
buffer
|> draw_corners(x, y, width, height, chars)
|> draw_edges(x, y, width, height, chars)
endRationale:
- Reusable components
- Easy to test individually
- Clear separation of concerns
Performance Optimizations
1. Lazy Line Updates
Only modify lines that actually change:
# Skip unchanged lines in diff
|> Enum.filter(fn {{old, new}, _} -> old != new end)Impact: ~50% reduction in diff size for typical updates.
2. Efficient Reduce Operations
Use reduce for stateful transformations:
def fill_area(buffer, x, y, width, height, char, style) do
Enum.reduce(0..(height - 1), buffer, fn row_offset, row_buffer ->
Enum.reduce(0..(width - 1), row_buffer, fn col_offset, col_buffer ->
Buffer.set_cell(col_buffer, x + col_offset, y + row_offset, char, style)
end)
end)
endRationale: Single-pass, tail-recursive, BEAM-optimized.
3. Pattern Matching Guards
Use guards for fast validation:
def set_cell(buffer, x, y, char, style)
when x >= 0 and y >= 0 and x < buffer.width and y < buffer.height do
# Hot path - no branching
end
def set_cell(buffer, _, _, _, _), do: buffer # Boundary caseImpact: Branch prediction, compile-time optimization.
4. Structural Sharing
Elixir's persistent data structures share structure:
new_buffer = %{buffer | lines: updated_lines}
# Shares all unchanged lines with old bufferImpact: Minimal memory overhead for partial updates.
Memory Management
Buffer Size Calculation
For an 80x24 buffer:
24 lines ×
80 cells ×
(1 grapheme + 1 style map) ×
~100 bytes/cell (estimate)
= ~192KB per bufferActual: ~50-100KB due to structural sharing and BEAM optimization.
Garbage Collection
- Immutable buffers become garbage when replaced
- BEAM GC per-process, generational
- Old buffers collected quickly if not referenced
- No manual memory management needed
Best Practice: Don't hold references to old buffers.
# Good - old buffer can be GC'd
buffer = update_buffer(buffer)
# Avoid - old buffer kept in history
history = [buffer | history] # Memory growsTesting Strategy
Unit Tests
Each module has comprehensive tests:
- Buffer: 13 tests
- Renderer: 10 tests
- Style: 26 tests
- Box: 24 tests
Coverage: 100% of public API.
Property-Based Testing
Potential additions (not yet implemented):
property "buffer dimensions are preserved" do
check all width <- positive_integer(),
height <- positive_integer(),
max_runs: 100 do
buffer = Buffer.create_blank_buffer(width, height)
assert buffer.width == width
assert buffer.height == height
end
endPerformance Tests
Benchmarks verify < 1ms targets:
bench/core/buffer_benchmark.exsbench/core/box_benchmark.exs- Automated in CI (future)
Future Optimizations
1. Sparse Buffers
Idea: Only store non-blank cells.
Benefit: 90%+ memory savings for sparse UIs.
Implementation:
%{
width: 80,
height: 24,
cells: %{ # Map instead of list
{5, 3} => %{char: "A", style: %{...}},
{10, 5} => %{char: "B", style: %{...}}
},
default_cell: %{char: " ", style: %{}}
}Trade-off: Slower random access, more complex code.
2. Dirty Regions
Idea: Track which buffer regions changed.
Benefit: Skip diff computation for unchanged areas.
Implementation:
%{
buffer: buffer,
dirty_regions: [{x, y, width, height}]
}Trade-off: More complex state management.
3. Binary Packing
Idea: Pack cells into binary for cache efficiency.
Benefit: Better memory locality, faster iteration.
Trade-off: Loses pattern matching, more complex access.
Design Patterns
1. Pipeline Pattern
Chain buffer operations:
Buffer.create_blank_buffer(80, 24)
|> Box.draw_box(0, 0, 80, 24, :double)
|> Buffer.write_at(5, 3, "Title", %{bold: true})
|> Box.fill_area(5, 5, 70, 15, ".", %{})2. Builder Pattern
Construct complex UIs incrementally:
defmodule Dashboard do
def create do
Buffer.create_blank_buffer(80, 24)
|> add_header()
|> add_sidebar()
|> add_main_panel()
|> add_footer()
end
defp add_header(buffer), do: ...
defp add_sidebar(buffer), do: ...
end3. Renderer Pattern
Separate data from presentation:
defmodule MyComponent do
def render(buffer, state) do
buffer
|> draw_background()
|> draw_content(state.data)
|> draw_cursor(state.cursor_pos)
end
endIntegration Patterns
With Phoenix LiveView
def mount(_params, _session, socket) do
buffer = create_initial_buffer()
{:ok, assign(socket, buffer: buffer)}
end
def handle_event("update", params, socket) do
new_buffer = update_buffer(socket.assigns.buffer, params)
{:noreply, assign(socket, buffer: new_buffer)}
endWith GenServer
defmodule TerminalServer do
use GenServer
def handle_call({:write, x, y, text}, _from, state) do
new_buffer = Buffer.write_at(state.buffer, x, y, text)
{:reply, :ok, %{state | buffer: new_buffer}}
end
endStandalone CLI
defmodule CLI do
def main do
buffer = create_ui()
IO.puts(Buffer.to_string(buffer))
end
endError Handling Philosophy
No Exceptions for Normal Use
Principle: Valid operations never throw.
Implementation:
- Bounds checking returns buffer unchanged
- Invalid styles use defaults
- Empty strings handled gracefully
Let It Crash for Invalid Input
Principle: Pattern match on types.
def write_at(%{} = buffer, x, y, text, %{} = style)
when is_integer(x) and is_integer(y) and is_binary(text)Result: Compile-time guarantees + runtime type checking.
Comparison with Alternatives
vs Raw ANSI Codes
Raxol.Core Advantages:
- Buffer abstraction (easier to reason about)
- Diff rendering (performance)
- Type safety (compile-time checks)
- Testability (pure functions)
Raw ANSI Advantages:
- Lower level control
- Smaller code size
- No abstractions to learn
vs ncurses Bindings
Raxol.Core Advantages:
- Pure Elixir (no C dependencies)
- Functional (immutable state)
- Lightweight (< 100KB)
- Thread-safe (no global state)
ncurses Advantages:
- More features (input handling, etc.)
- Decades of optimization
- Wide platform support
vs Terminal-kit (Node.js)
Raxol.Core Advantages:
- BEAM concurrency model
- Elixir ecosystem integration
- Smaller footprint
Terminal-kit Advantages:
- More mature
- Richer widget library
Versioning and Stability
Semantic Versioning
- v2.0.0: Initial Raxol.Core release
- API stability guaranteed in 2.x
- Breaking changes only in major versions
Deprecation Policy
- Deprecated features: 1 minor version warning
- Removed: Next major version
- Migration guides provided
Contributing Guidelines
Code Style
- Pure functional patterns only
- Comprehensive typespecs
- No dependencies
- < 1ms performance targets
Testing Requirements
- 100% coverage for public API
- Property tests for invariants
- Performance benchmarks for new features
Documentation
- @moduledoc for all public modules
- @doc for all public functions
- Examples in documentation
- CHANGELOG updates
References
- BUFFER_API.md - Complete API reference
- GETTING_STARTED.md - Quick start guide
- ANSI Escape Codes
- Box Drawing Characters
Conclusion
Raxol.Core prioritizes:
- Simplicity - Pure functions, no dependencies
- Performance - < 1ms operations
- Safety - Immutable, fail-safe
- Usability - Clean API, good defaults
This foundation enables building complex terminal UIs while maintaining code quality and performance.