Current section
Files
Jump to
Current section
Files
lib/term_ui/runtime.ex
defmodule TermUI.Runtime do
@moduledoc """
The central runtime orchestrator for TermUI applications.
The runtime implements The Elm Architecture dispatch loop:
1. Receive event from terminal
2. Route to appropriate component
3. Call component's event_to_msg
4. Call component's update with message
5. Collect commands from update
6. Mark component dirty
7. On render timer, call view and render
## Usage
# Start with a root component
{:ok, runtime} = Runtime.start_link(root: MyApp.Root)
# Send events (usually from terminal input)
Runtime.send_event(runtime, Event.key(:enter))
# Shutdown gracefully
Runtime.shutdown(runtime)
"""
use GenServer
alias TermUI.Elm
alias TermUI.Event
alias TermUI.MessageQueue
alias TermUI.Renderer.BufferManager
alias TermUI.Renderer.Diff
alias TermUI.Renderer.SequenceBuffer
alias TermUI.Runtime.NodeRenderer
alias TermUI.Runtime.State
alias TermUI.Terminal
alias TermUI.Terminal.InputReader
@type option ::
{:root, module()}
| {:name, GenServer.name()}
| {:render_interval, pos_integer()}
# Default render interval in milliseconds (~60 FPS)
@default_render_interval 16
# --- Public API ---
@doc """
Starts the runtime with the given options.
## Options
- `:root` - The root component module (required)
- `:name` - GenServer name (optional)
- `:render_interval` - Milliseconds between renders (default: 16)
"""
@spec start_link([option()]) :: GenServer.on_start()
def start_link(opts) do
{name, opts} = Keyword.pop(opts, :name)
if name do
GenServer.start_link(__MODULE__, opts, name: name)
else
GenServer.start_link(__MODULE__, opts)
end
end
@doc """
Sends an event to the runtime for processing.
"""
@spec send_event(GenServer.server(), Event.t()) :: :ok
def send_event(runtime, event) do
GenServer.cast(runtime, {:event, event})
end
@doc """
Sends a message directly to a component.
"""
@spec send_message(GenServer.server(), term(), term()) :: :ok
def send_message(runtime, component_id, message) do
GenServer.cast(runtime, {:message, component_id, message})
end
@doc """
Delivers a command result back to the runtime.
"""
@spec command_result(GenServer.server(), term(), term(), term()) :: :ok
def command_result(runtime, component_id, command_id, result) do
GenServer.cast(runtime, {:command_result, component_id, command_id, result})
end
@doc """
Initiates graceful shutdown of the runtime.
"""
@spec shutdown(GenServer.server()) :: :ok
def shutdown(runtime) do
GenServer.cast(runtime, :shutdown)
end
@doc """
Gets the current runtime state (for testing/debugging).
"""
@spec get_state(GenServer.server()) :: State.t()
def get_state(runtime) do
GenServer.call(runtime, :get_state)
end
@doc """
Synchronously waits for all pending events and messages to be processed.
This is primarily useful for testing to avoid race conditions from
Process.sleep. It processes all queued messages and returns when complete.
## Example
Runtime.send_event(runtime, Event.key(:up))
Runtime.send_event(runtime, Event.key(:up))
Runtime.sync(runtime) # Wait for both events to be processed
state = Runtime.get_state(runtime)
assert state.root_state.count == 2
"""
@spec sync(GenServer.server(), timeout()) :: :ok
def sync(runtime, timeout \\ 5000) do
GenServer.call(runtime, :sync, timeout)
end
@doc """
Forces an immediate render (bypassing framerate limiter).
"""
@spec force_render(GenServer.server()) :: :ok
def force_render(runtime) do
GenServer.cast(runtime, :force_render)
end
@doc """
Starts the runtime and blocks until it shuts down.
This is the main entry point for running a TUI application. It starts the
runtime, takes over the terminal, and blocks the calling process until
the application exits (e.g., user presses quit key).
## Options
Same as `start_link/1`.
## Example
# In your application entry point:
TermUI.Runtime.run(root: MyApp.Root)
# This blocks until the app exits
"""
@spec run([option()]) :: :ok | {:error, term()}
def run(opts) do
case start_link(opts) do
{:ok, runtime} ->
# Monitor the runtime process and block until it exits
ref = Process.monitor(runtime)
receive do
{:DOWN, ^ref, :process, ^runtime, _reason} ->
:ok
end
{:error, reason} ->
{:error, reason}
end
end
# --- GenServer Callbacks ---
@impl true
def init(opts) do
# Trap exits to ensure terminate/2 is called even on crashes
Process.flag(:trap_exit, true)
root_module = Keyword.fetch!(opts, :root)
render_interval = Keyword.get(opts, :render_interval, @default_render_interval)
skip_terminal = Keyword.get(opts, :skip_terminal, false)
# Initialize terminal and buffer manager (unless skipped for tests)
{terminal_started, buffer_manager, dimensions} =
if skip_terminal do
{false, nil, nil}
else
initialize_terminal()
end
# Initialize root component state
root_state = root_module.init(opts)
# Start input reader and register for resize callbacks if terminal is available
input_reader =
if terminal_started do
{:ok, reader_pid} = InputReader.start_link(target: self())
Terminal.register_resize_callback(self())
reader_pid
else
nil
end
state = %State{
root_module: root_module,
root_state: root_state,
message_queue: MessageQueue.new(),
render_interval: render_interval,
# Initial render needed
dirty: true,
focused_component: :root,
components: %{root: %{module: root_module, state: root_state}},
pending_commands: %{},
shutting_down: false,
terminal_started: terminal_started,
buffer_manager: buffer_manager,
dimensions: dimensions,
input_reader: input_reader
}
# Schedule first render
schedule_render(render_interval)
{:ok, state}
end
defp initialize_terminal do
# Start Terminal GenServer
case Terminal.start_link() do
{:ok, _pid} ->
setup_terminal_and_buffers()
{:error, {:already_started, _pid}} ->
setup_terminal_and_buffers()
{:error, _reason} ->
# Terminal not available (e.g., not a TTY)
{false, nil, nil}
end
end
defp setup_terminal_and_buffers do
# Enable raw mode and alternate screen
Terminal.enable_raw_mode()
Terminal.enter_alternate_screen()
Terminal.hide_cursor()
Terminal.enable_mouse_tracking(:click)
# Get terminal dimensions
{rows, cols} =
case Terminal.get_terminal_size() do
{:ok, {rows, cols}} -> {rows, cols}
{:error, _reason} -> {24, 80}
end
# Start BufferManager with terminal dimensions
buffer_pid =
case BufferManager.start_link(rows: rows, cols: cols) do
{:ok, pid} -> pid
{:error, {:already_started, pid}} -> pid
end
{true, buffer_pid, {cols, rows}}
end
@impl true
def handle_cast({:event, event}, state) do
if state.shutting_down do
{:noreply, state}
else
state = dispatch_event(event, state)
{:noreply, state}
end
end
@impl true
def handle_cast({:message, component_id, message}, state) do
if state.shutting_down do
{:noreply, state}
else
state = enqueue_message(component_id, message, state)
{:noreply, state}
end
end
@impl true
def handle_cast({:command_result, component_id, command_id, result}, state) do
state = handle_command_result(component_id, command_id, result, state)
{:noreply, state}
end
@impl true
def handle_cast(:shutdown, state) do
state = initiate_shutdown(state)
{:noreply, state}
end
@impl true
def handle_cast(:force_render, state) do
state = do_render(state)
{:noreply, state}
end
@impl true
def handle_info(:render, state) do
state = process_render_tick(state)
{:noreply, state}
end
@impl true
def handle_info({:input, event}, state) do
# Keyboard/mouse input from InputReader
if state.shutting_down do
{:noreply, state}
else
state = dispatch_event(event, state)
{:noreply, state}
end
end
@impl true
def handle_info({:terminal_resize, {rows, cols}}, state) do
# Terminal window was resized
if state.shutting_down do
{:noreply, state}
else
state = handle_resize(rows, cols, state)
{:noreply, state}
end
end
@impl true
def handle_info({:DOWN, _ref, :process, _pid, _reason}, state) do
# Command task completed (handled via command_result)
{:noreply, state}
end
@impl true
def handle_info(:stop_runtime, state) do
# Stop the GenServer after shutdown cleanup
{:stop, :normal, state}
end
@impl true
def handle_call(:get_state, _from, state) do
{:reply, state, state}
end
@impl true
def handle_call(:sync, _from, state) do
# Process all pending messages synchronously
state = process_messages(state)
{:reply, :ok, state}
end
@impl true
def terminate(_reason, state) do
# Wrap all cleanup in try/rescue to ensure we attempt all cleanup steps
# even if some fail
# Stop input reader first to stop receiving events
try do
if state.input_reader do
InputReader.stop(state.input_reader)
end
rescue
_ -> :ok
end
# Unregister from resize callbacks
try do
if state.terminal_started do
Terminal.unregister_resize_callback(self())
end
rescue
_ -> :ok
end
# Ensure clean shutdown
try do
if not state.shutting_down do
do_shutdown(state)
end
rescue
_ -> :ok
end
# Restore terminal - this is critical for user experience
try do
if state.terminal_started do
Terminal.restore()
end
rescue
_ -> :ok
end
:ok
end
# --- Event Dispatch ---
defp dispatch_event(%Event.Key{} = event, state) do
# Keyboard events go to focused component
dispatch_to_component(state.focused_component, event, state)
end
defp dispatch_event(%Event.Mouse{} = event, state) do
# Mouse events go to component at position
# For now, just send to root (spatial index will be added later)
dispatch_to_component(:root, event, state)
end
defp dispatch_event(%Event.Resize{} = event, state) do
# Resize broadcasts to all components
broadcast_event(event, state)
end
defp dispatch_event(%Event.Focus{} = event, state) do
# Focus broadcasts to all components
broadcast_event(event, state)
end
defp dispatch_event(%Event.Paste{} = event, state) do
# Paste goes to focused component
dispatch_to_component(state.focused_component, event, state)
end
defp dispatch_event(%Event.Tick{} = event, state) do
# Tick broadcasts to all components
broadcast_event(event, state)
end
defp dispatch_event(_event, state) do
# Unknown event type, ignore
state
end
defp dispatch_to_component(component_id, event, state) do
case Map.get(state.components, component_id) do
nil ->
state
%{module: module, state: component_state} ->
# Transform event to message, with error handling
try do
case module.event_to_msg(event, component_state) do
{:msg, message} ->
enqueue_message(component_id, message, state)
:ignore ->
state
:propagate ->
# Would propagate to parent, for now just ignore
state
end
rescue
error ->
require Logger
Logger.error("Component #{component_id} crashed in event_to_msg: #{inspect(error)}")
state
end
end
end
defp broadcast_event(event, state) do
Enum.reduce(state.components, state, fn {component_id, _}, acc ->
dispatch_to_component(component_id, event, acc)
end)
end
# --- Message Processing ---
defp enqueue_message(component_id, message, state) do
queue = MessageQueue.enqueue(state.message_queue, {component_id, message})
%{state | message_queue: queue}
end
defp process_messages(state) do
{messages, queue} = MessageQueue.flush(state.message_queue)
{state, commands} =
Enum.reduce(messages, {%{state | message_queue: queue}, []}, fn {component_id, message},
{acc_state, acc_cmds} ->
{new_state, cmds} = process_message(component_id, message, acc_state)
{new_state, acc_cmds ++ cmds}
end)
# Execute collected commands
state = execute_commands(commands, state)
state
end
defp process_message(component_id, message, state) do
case Map.get(state.components, component_id) do
nil ->
{state, []}
%{module: module, state: component_state} ->
# Call update function with error handling
try do
result = module.update(message, component_state)
{new_component_state, commands} = Elm.normalize_update_result(result, component_state)
# Update component state
components =
Map.update!(state.components, component_id, fn comp ->
%{comp | state: new_component_state}
end)
# Mark dirty if state changed
dirty = state.dirty or new_component_state != component_state
# Update root_state if this is root
state =
if component_id == :root do
%{state | root_state: new_component_state, components: components, dirty: dirty}
else
%{state | components: components, dirty: dirty}
end
# Tag commands with component_id
tagged_commands = Enum.map(commands, fn cmd -> {component_id, cmd} end)
{state, tagged_commands}
rescue
error ->
require Logger
Logger.error("Component #{component_id} crashed in update: #{inspect(error)}")
# Return unchanged state and no commands
{state, []}
end
end
end
# --- Command Execution ---
defp execute_commands([], state), do: state
defp execute_commands(commands, state) do
# Check for quit command first
# Handle both Command struct and legacy atom :quit
quit_cmd =
Enum.find(commands, fn {_component_id, cmd} ->
case cmd do
%{type: :quit} -> true
:quit -> true
_ -> false
end
end)
if quit_cmd do
# Quit command takes precedence - initiate shutdown
# Stop the GenServer after cleanup
GenServer.cast(self(), :shutdown)
%{state | shutting_down: true}
else
# Track pending commands for execution
pending =
Enum.reduce(commands, state.pending_commands, fn {component_id, cmd}, acc ->
command_id = make_ref()
Map.put(acc, command_id, %{component_id: component_id, command: cmd})
end)
%{state | pending_commands: pending}
end
end
defp handle_command_result(component_id, command_id, result, state) do
# Remove from pending
pending = Map.delete(state.pending_commands, command_id)
state = %{state | pending_commands: pending}
# Send result as message to component
enqueue_message(component_id, result, state)
end
# --- Rendering ---
defp schedule_render(interval) do
Process.send_after(self(), :render, interval)
end
defp process_render_tick(state) do
# Process any pending messages
state = process_messages(state)
# Render if dirty
state =
if state.dirty and not state.shutting_down do
do_render(state)
else
state
end
# Schedule next render unless shutting down
unless state.shutting_down do
schedule_render(state.render_interval)
end
state
end
defp do_render(state) do
# Skip rendering if terminal not available
if state.terminal_started do
# Call view on root component with error handling
%{module: module, state: component_state} = Map.get(state.components, :root)
render_tree =
try do
module.view(component_state)
rescue
error ->
require Logger
Logger.error("Component :root crashed in view: #{inspect(error)}")
# Return a simple error indicator
{:text, "[Render Error]"}
end
# Clear current buffer
BufferManager.clear_current(state.buffer_manager)
# Render tree to buffer
NodeRenderer.render_to_buffer(render_tree, state.buffer_manager)
# Get buffers for diffing
current = BufferManager.get_current_buffer(state.buffer_manager)
previous = BufferManager.get_previous_buffer(state.buffer_manager)
# Compute diff operations
operations = Diff.diff(current, previous)
# Render operations to terminal
render_operations(operations)
# Swap buffers
BufferManager.swap_buffers(state.buffer_manager)
%{state | dirty: false}
else
%{state | dirty: false}
end
end
defp render_operations([]), do: :ok
defp render_operations(operations) do
seq_buffer = SequenceBuffer.new()
seq_buffer =
Enum.reduce(operations, seq_buffer, fn op, buf ->
apply_operation(op, buf)
end)
# Reset style at end of frame to avoid bleeding into next frame
seq_buffer = SequenceBuffer.append!(seq_buffer, "\e[0m")
# Flush to terminal
{output, _buf} = SequenceBuffer.flush(seq_buffer)
IO.write(output)
end
defp apply_operation({:move, row, col}, buffer) do
# ANSI cursor position: ESC[row;colH
seq = "\e[#{row};#{col}H"
SequenceBuffer.append!(buffer, seq)
end
defp apply_operation({:style, style}, buffer) do
SequenceBuffer.append_style(buffer, style)
end
defp apply_operation({:text, text}, buffer) do
SequenceBuffer.append!(buffer, text)
end
defp apply_operation(:reset, buffer) do
buffer = SequenceBuffer.append!(buffer, "\e[0m")
# Reset style tracking so next style is emitted in full
SequenceBuffer.reset_style(buffer)
end
# --- Resize Handling ---
defp handle_resize(rows, cols, state) do
# Skip if terminal not available
if state.terminal_started do
# Update dimensions in state
new_dimensions = {cols, rows}
# Resize buffer manager
if state.buffer_manager do
BufferManager.resize(state.buffer_manager, rows, cols)
end
# Clear screen to avoid artifacts
IO.write("\e[2J")
# Create resize event and broadcast to all components
resize_event = Event.Resize.new(cols, rows)
state = broadcast_event(resize_event, %{state | dimensions: new_dimensions})
# Mark dirty and force immediate render
state = %{state | dirty: true}
do_render(state)
else
state
end
end
# --- Shutdown ---
defp initiate_shutdown(state) do
state = %{state | shutting_down: true}
state = do_shutdown(state)
# Schedule the GenServer to stop after returning from this callback
# This allows terminate/2 to run and clean up properly
Process.send_after(self(), :stop_runtime, 0)
state
end
defp do_shutdown(state) do
# Wait for pending commands to complete (with timeout)
# For now, just clear them
state = %{state | pending_commands: %{}}
# Terminate components (leaf to root)
# For now, just clear components
state = %{state | components: %{}}
state
end
end