Buffer Management
View SourceThis guide explains TermUI's screen buffer system using ETS for efficient cell storage and double buffering for flicker-free updates.
Architecture
graph TB
subgraph "BufferManager GenServer"
BM[BufferManager]
BM --> PT[(persistent_term)]
end
subgraph "Buffer References"
PT --> CB[Current Buffer]
PT --> PB[Previous Buffer]
PT --> DF[Dirty Flag]
end
subgraph "ETS Storage"
CB --> ETSC[(ETS Table<br/>Current)]
PB --> ETSP[(ETS Table<br/>Previous)]
end
subgraph "Atomic"
DF --> AT[atomics ref]
end
NR[NodeRenderer] --> CB
Diff[Diff] --> CB
Diff --> PBBuffer Structure
A Buffer wraps an ETS table:
defmodule TermUI.Renderer.Buffer do
defstruct [
:table, # ETS table reference
:rows, # Number of rows
:cols # Number of columns
]
@type t :: %__MODULE__{
table: :ets.tid(),
rows: pos_integer(),
cols: pos_integer()
}
endCell Storage
Cells are stored as {{row, col}, cell} tuples:
# Cell at row 5, column 10
:ets.insert(buffer.table, {{5, 10}, %Cell{char: "X", fg: :red}})
# Lookup
[{_, cell}] = :ets.lookup(buffer.table, {5, 10})ETS Configuration
def new(rows, cols) do
table = :ets.new(:screen_buffer, [
:set, # Key-value storage
:public, # Any process can read/write
read_concurrency: true, # Optimized for concurrent reads
write_concurrency: true # Optimized for concurrent writes
])
# Initialize with empty cells
buffer = %__MODULE__{table: table, rows: rows, cols: cols}
clear(buffer)
{:ok, buffer}
endDouble Buffering
Two buffers swap roles each frame:
sequenceDiagram
participant NR as NodeRenderer
participant C as Current
participant P as Previous
participant D as Diff
Note over C,P: Frame N
NR->>C: Write cells
D->>C: Read current
D->>P: Read previous
D->>D: Compute diff
Note over C,P: Swap
C->>P: Becomes previous
P->>C: Becomes current
Note over C,P: Frame N+1
NR->>C: Write cells (was P)BufferManager Implementation
defmodule TermUI.Renderer.BufferManager do
use GenServer
def init(opts) do
rows = Keyword.fetch!(opts, :rows)
cols = Keyword.fetch!(opts, :cols)
{:ok, current} = Buffer.new(rows, cols)
{:ok, previous} = Buffer.new(rows, cols)
# Dirty flag using atomics for lock-free access
dirty = :atomics.new(1, signed: false)
# Store in persistent_term for direct access
:persistent_term.put({__MODULE__, :current}, current)
:persistent_term.put({__MODULE__, :previous}, previous)
:persistent_term.put({__MODULE__, :dirty}, dirty)
{:ok, %{current: current, previous: previous, dirty: dirty}}
end
endBuffer Swap
def handle_call(:swap_buffers, _from, state) do
# O(1) pointer swap
new_state = %{state | current: state.previous, previous: state.current}
# Update persistent_term references
:persistent_term.put({__MODULE__, :current}, new_state.current)
:persistent_term.put({__MODULE__, :previous}, new_state.previous)
{:reply, :ok, new_state}
endDirect Access
Most buffer operations bypass the GenServer for performance:
# These read from persistent_term (no GenServer call)
def get_current_buffer do
:persistent_term.get({__MODULE__, :current})
end
def get_previous_buffer do
:persistent_term.get({__MODULE__, :previous})
end
def dirty? do
dirty = :persistent_term.get({__MODULE__, :dirty})
:atomics.get(dirty, 1) == 1
end
def mark_dirty do
dirty = :persistent_term.get({__MODULE__, :dirty})
:atomics.put(dirty, 1, 1)
:ok
endBuffer Operations
Writing Cells
def set_cell(buffer, row, col, cell) do
if in_bounds?(buffer, row, col) do
:ets.insert(buffer.table, {{row, col}, cell})
:ok
else
{:error, :out_of_bounds}
end
end
def set_cells(buffer, cells) do
entries = Enum.map(cells, fn {row, col, cell} ->
{{row, col}, cell}
end)
:ets.insert(buffer.table, entries)
:ok
endReading Cells
def get_cell(buffer, row, col) do
case :ets.lookup(buffer.table, {row, col}) do
[{_, cell}] -> cell
[] -> Cell.empty()
end
end
def get_row(buffer, row) do
# Match all cells in row
pattern = {{row, :_}, :_}
cells = :ets.match_object(buffer.table, pattern)
# Sort by column and extract cells
cells
|> Enum.sort_by(fn {{_, col}, _} -> col end)
|> Enum.map(fn {_, cell} -> cell end)
endClearing
def clear(buffer) do
clear_region(buffer, 1, 1, buffer.cols, buffer.rows)
end
def clear_region(buffer, start_row, start_col, width, height) do
empty = Cell.empty()
entries =
for row <- start_row..(start_row + height - 1),
col <- start_col..(start_col + width - 1),
in_bounds?(buffer, row, col) do
{{row, col}, empty}
end
:ets.insert(buffer.table, entries)
:ok
endCell Structure
defmodule TermUI.Renderer.Cell do
defstruct [
char: " ", # Single grapheme
fg: :default, # Foreground color
bg: :default, # Background color
attrs: MapSet.new(), # Text attributes
width: 1, # Display width (1 or 2)
wide_placeholder: false
]
endCell Comparison
Used by the diff algorithm:
def equal?(a, b) do
a.char == b.char and
a.fg == b.fg and
a.bg == b.bg and
MapSet.equal?(a.attrs, b.attrs) and
a.width == b.width and
a.wide_placeholder == b.wide_placeholder
endWide Characters
CJK and emoji characters take 2 cells:
# Primary cell
primary = %Cell{char: "中", width: 2}
# Placeholder for second column
placeholder = %Cell{char: "", width: 0, wide_placeholder: true}
# Both must be written
:ets.insert(buffer.table, [
{{row, col}, primary},
{{row, col + 1}, placeholder}
])Dirty Flag
Tracks whether re-render is needed:
graph LR
subgraph "Write Path"
W[Write Cell] --> MD[mark_dirty]
MD --> A[(atomics)]
end
subgraph "Render Path"
RT[Render Tick] --> CD{dirty?}
CD -->|true| R[Render]
CD -->|false| S[Skip]
R --> CL[clear_dirty]
endUsing atomics for lock-free access:
# Mark dirty (from any process)
def mark_dirty do
dirty = :persistent_term.get({__MODULE__, :dirty})
:atomics.put(dirty, 1, 1)
end
# Check dirty (from render loop)
def dirty? do
dirty = :persistent_term.get({__MODULE__, :dirty})
:atomics.get(dirty, 1) == 1
end
# Clear dirty (after render)
def clear_dirty do
dirty = :persistent_term.get({__MODULE__, :dirty})
:atomics.put(dirty, 1, 0)
endResize Handling
def handle_call({:resize, rows, cols}, _from, state) do
# Create new buffers with new dimensions
{:ok, new_current} = Buffer.resize(state.current, rows, cols)
{:ok, new_previous} = Buffer.resize(state.previous, rows, cols)
new_state = %{state | current: new_current, previous: new_previous}
# Update persistent_term
:persistent_term.put({__MODULE__, :current}, new_current)
:persistent_term.put({__MODULE__, :previous}, new_previous)
{:reply, :ok, new_state}
endContent Preservation
def resize(buffer, new_rows, new_cols) do
{:ok, new_buffer} = new(new_rows, new_cols)
# Copy cells that fit in new dimensions
old_entries = :ets.tab2list(buffer.table)
entries_to_copy =
old_entries
|> Enum.filter(fn {{row, col}, _} ->
row <= new_rows and col <= new_cols
end)
:ets.insert(new_buffer.table, entries_to_copy)
# Clean up old table
:ets.delete(buffer.table)
{:ok, new_buffer}
endPerformance Characteristics
| Operation | Complexity | Notes |
|---|---|---|
get_cell | O(1) | ETS hash lookup |
set_cell | O(1) | ETS insert |
set_cells | O(n) | Batch insert |
get_row | O(cols) | Match + sort |
clear | O(rows × cols) | Full buffer |
swap_buffers | O(1) | Pointer swap |
dirty? | O(1) | Atomic read |
Memory Usage
Each cell: ~100-200 bytes depending on content
For 80×24 terminal: ~200KB per buffer (400KB total) For 200×50 terminal: ~2MB per buffer (4MB total)
Cleanup
def terminate(_reason, state) do
# Remove persistent_term entries
:persistent_term.erase({__MODULE__, :current})
:persistent_term.erase({__MODULE__, :previous})
:persistent_term.erase({__MODULE__, :dirty})
# Delete ETS tables
Buffer.destroy(state.current)
Buffer.destroy(state.previous)
:ok
end
# Buffer.destroy/1
def destroy(buffer) do
:ets.delete(buffer.table)
endNext Steps
- Rendering Pipeline - How buffers are used
- Terminal Layer - Output to terminal
- Architecture Overview - System context