Raxol.Core Architecture

View Source

Design 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
end

Trade-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
end

Trade-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)
end

Design 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)
end

Rationale:

  • 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)
end

Rationale: 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 case

Impact: 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 buffer

Impact: 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 buffer

Actual: ~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 grows

Testing 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
end

Performance Tests

Benchmarks verify < 1ms targets:

  • bench/core/buffer_benchmark.exs
  • bench/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: ...
end

3. 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
end

Integration 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)}
end

With 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
end

Standalone CLI

defmodule CLI do
  def main do
    buffer = create_ui()
    IO.puts(Buffer.to_string(buffer))
  end
end

Error 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


Conclusion

Raxol.Core prioritizes:

  1. Simplicity - Pure functions, no dependencies
  2. Performance - < 1ms operations
  3. Safety - Immutable, fail-safe
  4. Usability - Clean API, good defaults

This foundation enables building complex terminal UIs while maintaining code quality and performance.