TermUI.Renderer.BufferManager (TermUI v0.2.0)

View Source

GenServer managing double-buffered screen rendering.

The BufferManager owns two ETS-based buffers:

  • Current buffer: Components write to this buffer
  • Previous buffer: Contains the last rendered frame for diffing

After rendering, swap_buffers/0 exchanges the buffer references atomically. This enables efficient differential updates without copying buffer contents.

Usage

# Start the manager
{:ok, pid} = BufferManager.start_link(rows: 24, cols: 80)

# Get buffer for writing
buffer = BufferManager.get_current_buffer()
Buffer.set_cell(buffer, 1, 1, Cell.new("X"))

# Mark dirty after modifications
BufferManager.mark_dirty()

# Check if render needed
if BufferManager.dirty?() do
  current = BufferManager.get_current_buffer()
  previous = BufferManager.get_previous_buffer()
  # ... perform diff and render ...
  BufferManager.swap_buffers()
  BufferManager.clear_dirty()
end

Concurrency

Multiple processes can write to the current buffer concurrently via ETS. Cell writes are atomic but unordered—last writer wins for overlapping cells. Components should write to non-overlapping regions for deterministic results.

Important: This module is designed for a single-writer pattern where one process (typically the render loop) coordinates buffer access. If you hold a buffer reference while another process calls swap_buffers/1, your writes will go to the wrong buffer. To avoid this race condition:

  1. Complete all writes before calling swap_buffers/1
  2. Use a single coordinator process for the write → swap cycle
  3. Don't cache buffer references across swap operations

Typical Render Loop

# Single process coordinates all buffer access
buffer = BufferManager.get_current_buffer()

# All writes happen here
Buffer.write_string(buffer, 1, 1, "Hello")
BufferManager.mark_dirty()

# Only swap after writes are complete
if BufferManager.dirty?() do
  current = BufferManager.get_current_buffer()
  previous = BufferManager.get_previous_buffer()
  operations = Diff.diff(current, previous)
  # ... render operations ...
  BufferManager.swap_buffers()
  BufferManager.clear_dirty()
end

Direct Access

Most operations bypass the GenServer for maximum throughput. Buffer references and the dirty flag are stored in :persistent_term for lock-free access from any process. Only swap_buffers/1 and resize/3 require GenServer coordination.

Dirty Flag

The dirty flag uses :atomics for lock-free concurrent access. Any process can mark the buffer dirty after modifications, and the renderer checks and clears the flag during the render cycle.

Summary

Functions

Returns a specification to start this module under a supervisor.

Clears the entire current buffer.

Clears the dirty flag after rendering.

Clears a rectangular region in the current buffer.

Clears a single row in the current buffer.

Returns the buffer dimensions as {rows, cols}.

Returns whether the buffer is dirty and needs rendering.

Gets a cell from the current buffer.

Returns the current buffer for writing.

Returns the previous buffer for diffing.

Marks the buffer as dirty, indicating it needs rendering.

Resizes both buffers to new dimensions.

Sets a cell in the current buffer.

Sets multiple cells in the current buffer.

Starts the BufferManager with the given dimensions.

Atomically swaps the current and previous buffers.

Types

t()

@type t() :: %TermUI.Renderer.BufferManager{
  current: TermUI.Renderer.Buffer.t(),
  dirty: :atomics.atomics_ref(),
  name: atom(),
  previous: TermUI.Renderer.Buffer.t()
}

Functions

child_spec(init_arg)

Returns a specification to start this module under a supervisor.

See Supervisor.

clear_current(server \\ __MODULE__)

@spec clear_current(GenServer.server()) :: :ok

Clears the entire current buffer.

This is a direct access operation (no GenServer call).

clear_dirty(server \\ __MODULE__)

@spec clear_dirty(GenServer.server()) :: :ok

Clears the dirty flag after rendering.

This is a direct access operation (no GenServer call).

clear_region(server \\ __MODULE__, start_row, start_col, width, height)

@spec clear_region(
  GenServer.server(),
  pos_integer(),
  pos_integer(),
  pos_integer(),
  pos_integer()
) :: :ok

Clears a rectangular region in the current buffer.

This is a direct access operation (no GenServer call).

clear_row(server \\ __MODULE__, row)

@spec clear_row(GenServer.server(), pos_integer()) :: :ok

Clears a single row in the current buffer.

This is a direct access operation (no GenServer call).

dimensions(server \\ __MODULE__)

@spec dimensions(GenServer.server()) :: {pos_integer(), pos_integer()}

Returns the buffer dimensions as {rows, cols}.

This is a direct access operation (no GenServer call).

dirty?(server \\ __MODULE__)

@spec dirty?(GenServer.server()) :: boolean()

Returns whether the buffer is dirty and needs rendering.

This is a direct access operation (no GenServer call).

get_cell(server \\ __MODULE__, row, col)

Gets a cell from the current buffer.

Convenience function that delegates to Buffer.get_cell/3.

get_current_buffer(server \\ __MODULE__)

@spec get_current_buffer(GenServer.server()) :: TermUI.Renderer.Buffer.t()

Returns the current buffer for writing.

Components use this buffer for all cell modifications. This is a direct access operation (no GenServer call).

get_previous_buffer(server \\ __MODULE__)

@spec get_previous_buffer(GenServer.server()) :: TermUI.Renderer.Buffer.t()

Returns the previous buffer for diffing.

The renderer compares current against previous to identify changes. This is a direct access operation (no GenServer call).

mark_dirty(server \\ __MODULE__)

@spec mark_dirty(GenServer.server()) :: :ok

Marks the buffer as dirty, indicating it needs rendering.

This uses an atomic operation and can be called from any process. This is a direct access operation (no GenServer call).

resize(server \\ __MODULE__, rows, cols)

@spec resize(GenServer.server(), pos_integer(), pos_integer()) :: :ok

Resizes both buffers to new dimensions.

Content is preserved where it fits within the new dimensions.

set_cell(server \\ __MODULE__, row, col, cell)

@spec set_cell(
  GenServer.server(),
  pos_integer(),
  pos_integer(),
  TermUI.Renderer.Cell.t()
) ::
  :ok | {:error, :out_of_bounds}

Sets a cell in the current buffer.

Convenience function that delegates to Buffer.set_cell/4.

set_cells(server \\ __MODULE__, cells)

@spec set_cells(GenServer.server(), [
  {pos_integer(), pos_integer(), TermUI.Renderer.Cell.t()}
]) :: :ok

Sets multiple cells in the current buffer.

Cells is a list of {row, col, cell} tuples.

start_link(opts)

@spec start_link(keyword()) :: GenServer.on_start()

Starts the BufferManager with the given dimensions.

Options

  • :rows - Number of rows (required)
  • :cols - Number of columns (required)
  • :name - GenServer name (default: __MODULE__)

Examples

{:ok, pid} = BufferManager.start_link(rows: 24, cols: 80)

swap_buffers(server \\ __MODULE__)

@spec swap_buffers(GenServer.server()) :: :ok

Atomically swaps the current and previous buffers.

After rendering, call this to make the current frame the new previous frame for the next render cycle. This is O(1)—only references swap.

write_string(server \\ __MODULE__, row, col, string, opts \\ [])

@spec write_string(
  GenServer.server(),
  pos_integer(),
  pos_integer(),
  String.t(),
  keyword()
) ::
  non_neg_integer()

Writes a string to the current buffer.

Convenience function that delegates to Buffer.write_string/4.