Runtime Internals
View SourceThe Runtime (TermUI.Runtime) is the central orchestrator of a TermUI application. This guide explains its internal workings.
Overview
The Runtime is a GenServer that:
- Manages component state
- Dispatches events to components
- Processes messages through the update cycle
- Executes commands
- Schedules and performs rendering
State Structure
%TermUI.Runtime.State{
# Component configuration
root_module: MyApp.Counter, # Root component module
root_state: %{count: 0}, # Root component state
# Component registry
components: %{
root: %{module: MyApp.Counter, state: %{count: 0}}
},
# Message processing
message_queue: %MessageQueue{}, # Pending messages
pending_commands: %{}, # Executing commands
# Rendering
dirty: false, # Needs re-render?
render_interval: 16, # ~60 FPS
buffer_manager: #PID<...>, # BufferManager process
dimensions: {80, 24}, # {cols, rows}
# Terminal
terminal_started: true, # Terminal available?
input_reader: #PID<...>, # InputReader process
# Lifecycle
focused_component: :root, # Currently focused
shutting_down: false # Shutdown in progress?
}Lifecycle
stateDiagram-v2
[*] --> Initializing: start_link/1
Initializing --> Running: init complete
Running --> Running: events/messages
Running --> ShuttingDown: shutdown/1
ShuttingDown --> [*]: terminate/2Initialization
def init(opts) do
# 1. Trap exits for cleanup
Process.flag(:trap_exit, true)
# 2. Initialize terminal
{terminal_started, buffer_manager, dimensions} = initialize_terminal()
# 3. Initialize root component
root_state = root_module.init(opts)
# 4. Start input reader
{:ok, reader} = InputReader.start_link(target: self())
# 5. Schedule first render
schedule_render(render_interval)
{:ok, state}
endMain Loop
The Runtime handles these message types:
graph TD
subgraph "GenServer Callbacks"
CI[handle_cast :event] --> DE[dispatch_event]
CM[handle_cast :message] --> EM[enqueue_message]
CR[handle_cast :shutdown] --> IS[initiate_shutdown]
IR[handle_info :render] --> PR[process_render_tick]
II[handle_info :input] --> DE
end
DE --> ETM[event_to_msg]
ETM --> EM
EM --> MQ[MessageQueue]
MQ --> PM[process_messages]
PM --> UP[component.update]
UP --> EC[execute_commands]
UP --> MD[mark_dirty]
PR --> PM
PR --> DR[do_render]
PR --> SR[schedule_render]Event Dispatch
Events are routed based on type:
defp dispatch_event(%Event.Key{} = event, state) do
# Keyboard → focused component
dispatch_to_component(state.focused_component, event, state)
end
defp dispatch_event(%Event.Mouse{} = event, state) do
# Mouse → component at position (future: spatial index)
dispatch_to_component(:root, event, state)
end
defp dispatch_event(%Event.Resize{} = event, state) do
# Resize → broadcast to all
broadcast_event(event, state)
endComponent Dispatch
defp dispatch_to_component(component_id, event, state) do
%{module: module, state: component_state} = state.components[component_id]
case module.event_to_msg(event, component_state) do
{:msg, message} ->
enqueue_message(component_id, message, state)
:ignore ->
state
:propagate ->
# Would bubble to parent
state
end
endMessage Processing
Messages are processed in FIFO order:
sequenceDiagram
participant Q as MessageQueue
participant RT as Runtime
participant C as Component
RT->>Q: flush()
Q->>RT: [messages]
loop For each message
RT->>C: update(msg, state)
C->>RT: {new_state, commands}
RT->>RT: Update component state
RT->>RT: Mark dirty if changed
RT->>RT: Collect commands
end
RT->>RT: execute_commands(all_commands)defp process_messages(state) do
{messages, queue} = MessageQueue.flush(state.message_queue)
{state, commands} =
Enum.reduce(messages, {state, []}, fn {component_id, msg}, {acc, cmds} ->
{new_state, new_cmds} = process_message(component_id, msg, acc)
{new_state, cmds ++ new_cmds}
end)
execute_commands(commands, state)
endCommand Execution
Commands are side effects returned from update/2:
defp execute_commands(commands, state) do
# Check for quit command
if has_quit_command?(commands) do
GenServer.cast(self(), :shutdown)
%{state | shutting_down: true}
else
# Track pending commands
pending = Enum.reduce(commands, state.pending_commands, fn cmd, acc ->
command_id = make_ref()
Map.put(acc, command_id, cmd)
end)
%{state | pending_commands: pending}
end
endTimer Commands
Timer commands use Process.send_after/3:
# When timer fires, result delivered as message
def handle_info({:command_result, component_id, cmd_id, result}, state) do
state = handle_command_result(component_id, cmd_id, result, state)
{:noreply, state}
endRender Cycle
Rendering is scheduled at a fixed interval (default 16ms ≈ 60 FPS):
defp process_render_tick(state) do
# 1. Process pending messages
state = process_messages(state)
# 2. Render if dirty
state = if state.dirty and not state.shutting_down do
do_render(state)
else
state
end
# 3. Schedule next tick
unless state.shutting_down do
schedule_render(state.render_interval)
end
state
end
defp schedule_render(interval) do
Process.send_after(self(), :render, interval)
endRender Flow
defp do_render(state) do
# 1. Get render tree from component
%{module: module, state: comp_state} = state.components[:root]
render_tree = module.view(comp_state)
# 2. Clear current buffer
BufferManager.clear_current(state.buffer_manager)
# 3. Render tree to buffer
NodeRenderer.render_to_buffer(render_tree, state.buffer_manager)
# 4. Diff against previous
current = BufferManager.get_current_buffer(state.buffer_manager)
previous = BufferManager.get_previous_buffer(state.buffer_manager)
operations = Diff.diff(current, previous)
# 5. Output to terminal
render_operations(operations)
# 6. Swap buffers
BufferManager.swap_buffers(state.buffer_manager)
%{state | dirty: false}
endShutdown
Graceful shutdown preserves terminal state:
sequenceDiagram
participant App as Application
participant RT as Runtime
participant IR as InputReader
participant Term as Terminal
App->>RT: shutdown()
RT->>RT: shutting_down = true
RT->>RT: Stop render scheduling
RT->>IR: stop()
RT->>RT: Clear components
RT->>RT: send(:stop_runtime)
RT->>Term: restore()
Term->>Term: Disable raw mode
Term->>Term: Leave alt screen
Term->>Term: Show cursor
RT->>App: :normal exitdef terminate(_reason, state) do
# Stop input reader
if state.input_reader do
InputReader.stop(state.input_reader)
end
# Restore terminal
if state.terminal_started do
Terminal.restore()
end
:ok
endError Handling
The Runtime protects against component crashes:
# In event_to_msg
try do
module.event_to_msg(event, component_state)
rescue
error ->
Logger.error("Component crashed in event_to_msg: #{inspect(error)}")
state # Return unchanged
end
# In update
try do
module.update(message, component_state)
rescue
error ->
Logger.error("Component crashed in update: #{inspect(error)}")
{state, []} # Return unchanged, no commands
end
# In view
try do
module.view(component_state)
rescue
error ->
Logger.error("Component crashed in view: #{inspect(error)}")
{:text, "[Render Error]"} # Fallback render
endPerformance Considerations
Message Batching
Multiple events arriving between render ticks are batched:
Event 1 → Queue
Event 2 → Queue
Event 3 → Queue
Render tick → Process all 3 → Single renderDirty Tracking
Components are only re-rendered when state changes:
dirty = state.dirty or new_component_state != component_stateBuffer Swapping
Double buffering avoids copying:
# O(1) pointer swap, not O(rows*cols) copy
def swap_buffers(state) do
%{state | current: state.previous, previous: state.current}
endTesting the Runtime
# Start without terminal for testing
{:ok, runtime} = Runtime.start_link(
root: TestComponent,
skip_terminal: true
)
# Send events
Runtime.send_event(runtime, Event.key(:enter))
# Wait for processing
Runtime.sync(runtime)
# Check state
state = Runtime.get_state(runtime)
assert state.root_state.submitted == trueNext Steps
- Rendering Pipeline - Detailed render flow
- Event System - Input handling
- Buffer Management - ETS buffers