Packages

Multi-surface application runtime for Elixir. One TEA module renders to terminal, browser (LiveView), SSH, and MCP (agents). 30+ widgets, flexbox + CSS grid, AI agent runtime, distributed swarm with CRDTs, time-travel debugging, session recording, sandboxed REPL, and agentic commerce.

Current section

Files

Jump to
raxol lib raxol ui rendering pipeline.ex
Raw

lib/raxol/ui/rendering/pipeline.ex

defmodule Raxol.UI.Rendering.Pipeline do
@moduledoc """
Manages the rendering pipeline for the UI as a GenServer.
## Pipeline Stages (Planned)
1. **Diffing**: Compare the new UI tree to the previous one and compute minimal changes.
DELEGATED TO: `Raxol.UI.Rendering.TreeDiffer`
2. **Layout**: Calculate positions and sizes for all UI elements.
3. **Composition**: Build a render tree or command list from the layout.
4. **Paint**: Convert the render tree into draw commands or buffer updates.
5. **Commit**: Send the final output to the Renderer process for display.
## Data Flow
- UI state changes or events call `update_tree/1` (or `update_tree/2`) to submit a new UI tree.
- The pipeline stores the latest tree, may batch or debounce rapid updates, and triggers a render via the Renderer process.
- The GenServer holds pipeline state (current tree, previous tree, pending updates, etc.).
## Extension Points
- Custom diffing or batching strategies
- Animation frame scheduling
- Hooks for custom renderers or output backends
- Implement diffing and minimal update computation (DELEGATED: `TreeDiffer` handles core diffing. Layout stage has partial diff-awareness. Compose and Paint stages now reuse previous results if their respective inputs are unchanged, providing a level of minimal update.)
- Add batching/debouncing of rapid updates (partially implemented with send_after for debouncing)
- Integrate animation frame scheduling (request_animation_frame now uses GenServer.call with deferred reply from :animation_tick; this is more robust.)
- Store and manage pipeline state (previous tree, pending updates, etc.) (GenServer state has current_tree, previous_tree)
- Connect to the real rendering backend (commit stage calls a renderer module)
"""
use Raxol.Core.Behaviours.BaseManager
alias Raxol.Core.Runtime.Log
alias Raxol.UI.Rendering.Pipeline.{Stages, State}
alias Raxol.UI.Rendering.Renderer
alias Raxol.UI.Rendering.TreeDiffer
# Animation tick interval in milliseconds
@animation_tick_interval_ms (case Mix.env() do
:test -> 25
_ -> 16
end)
@default_renderer Raxol.UI.Rendering.Renderer
# Public API
@doc """
Child spec for supervision trees.
"""
def child_spec(opts \\ []) do
%{
id: __MODULE__,
start: {__MODULE__, :start_link, [opts]},
type: :worker,
restart: :permanent,
shutdown: 5000
}
end
@doc """
Renders a buffer directly without updating the tree.
This is a convenience function for benchmarking.
"""
def render(buffer) do
# For benchmarking purposes, just return the buffer as-is
# In a real implementation, this would go through the pipeline stages
{:ok, buffer}
end
@doc """
Updates the UI tree in the pipeline and triggers a render.
"""
def update_tree(tree) do
GenServer.cast(__MODULE__, {:update_tree, tree})
end
@doc """
Triggers a render with the current UI tree (or provided data).
"""
def trigger_render(data \\ nil) do
GenServer.cast(__MODULE__, {:trigger_render, data})
end
@doc """
Applies animation settings (delegates to Renderer).
"""
def apply_animation_settings(
animation_type,
animation_path,
fps,
loop,
blend,
opacity,
blur,
scale
) do
settings = %{
type: animation_type,
path: animation_path,
fps: fps,
loop: loop,
blend: blend,
opacity: opacity,
blur: blur,
scale: scale
}
Renderer.set_animation_settings(settings)
:ok
end
@doc """
Computes the minimal set of changes (diff) between two UI trees.
Returns:
* :no_change if trees are identical
* `{:replace, new_tree}` if the root node differs
* `{:update, path, changes}` for subtree updates (path is a list of indices)
"""
def diff_trees(old_tree, new_tree),
do: TreeDiffer.diff_trees(old_tree, new_tree)
@doc """
Requests notification on the next animation frame. The caller will receive {:animation_frame, ref}.
Returns a unique reference for the request.
"""
def request_animation_frame(pid \\ self()) do
ref = System.unique_integer([:positive])
GenServer.call(__MODULE__, {:request_animation_frame, pid, ref})
end
@doc """
Schedules a render to occur on the next animation frame.
Only one render will be scheduled per frame, regardless of how many times this is called before the next frame.
"""
def schedule_render_on_next_frame do
GenServer.cast(__MODULE__, :schedule_render_on_next_frame)
:ok
end
# --- Pipeline Stages (Stubbed) ---
@doc """
Commits the final output to the Renderer process for display.
This is where the pipeline would send the output (a list of paint operations)
to the configured renderer.
"""
def commit(
painted_output,
renderer \\ @default_renderer,
diff_result \\ nil,
new_tree \\ nil
) do
Log.debug(
"[Pipeline] commit called with painted_output=#{inspect(painted_output)}, diff_result=#{inspect(diff_result)}, new_tree=#{inspect(new_tree)}"
)
Raxol.Core.Runtime.Log.debug(
"Commit Stage: Sending #{Enum.count(painted_output)} paint operations to renderer #{inspect(renderer)}."
)
renderer_pid = get_renderer_pid(renderer)
send_to_renderer(renderer_pid, diff_result, new_tree)
:ok
end
defp get_renderer_pid(renderer) do
case renderer do
module when is_atom(module) ->
# Try to find the process by module name
case Process.whereis(module) do
nil ->
# If not found by module name, try the default renderer name
Process.whereis(Raxol.UI.Rendering.Renderer) || module
pid ->
pid
end
pid when is_pid(pid) ->
pid
other ->
other
end
end
defp send_to_renderer(renderer_pid, diff_result, new_tree) do
case diff_result do
{:update, _path, _changes} ->
GenServer.cast(renderer_pid, {:apply_diff, diff_result, new_tree})
{:replace, _} ->
GenServer.cast(renderer_pid, {:render, new_tree})
:no_change ->
:ok
_ ->
GenServer.cast(renderer_pid, {:render, new_tree})
end
end
# --- Extension Points ---
@doc """
Sets the renderer module to use for output (default: Raxol.UI.Rendering.Renderer).
This allows for custom renderers or output backends.
"""
def set_renderer(renderer_module) do
Application.put_env(:raxol, :renderer_module, renderer_module)
:ok
end
@doc """
Gets the current renderer module (default: Raxol.UI.Rendering.Renderer).
"""
def get_renderer do
Application.get_env(:raxol, :renderer_module, @default_renderer)
end
# BaseManager Implementation
@impl Raxol.Core.Behaviours.BaseManager
def init_manager(opts) do
initial_tree = Keyword.get(opts, :initial_tree, %{})
opts_with_renderer = Keyword.put(opts, :renderer, get_renderer())
state = State.new(opts_with_renderer)
state_with_tree = maybe_update_initial_tree(state, initial_tree)
# Start unified timer manager if not already running
start_unified_timer_manager()
{:ok, state_with_tree}
end
@impl Raxol.Core.Behaviours.BaseManager
def handle_manager_cast({:update_tree, new_tree}, state) do
case tree_changed?(state.current_tree, new_tree) do
false ->
Raxol.Core.Runtime.Log.debug(
"Pipeline: No change in tree, skipping render."
)
{:noreply, state}
true ->
# Delegate to TreeDiffer - use current_tree as the old tree for comparison
Raxol.Core.Runtime.Log.debug(
"Pipeline: Calling TreeDiffer with current_tree: #{inspect(state.current_tree)}, new_tree: #{inspect(new_tree)}"
)
diff_result = TreeDiffer.diff_trees(state.current_tree, new_tree)
Raxol.Core.Runtime.Log.debug(
"Pipeline: TreeDiffer returned: #{inspect(diff_result)}"
)
case diff_result do
:no_change ->
Raxol.Core.Runtime.Log.debug(
"Pipeline: Diff result is :no_change, skipping render."
)
new_state_after_diff = %{
state
| previous_tree: state.current_tree,
current_tree: new_tree
}
{:noreply, new_state_after_diff}
_ ->
Raxol.Core.Runtime.Log.debug(
"Pipeline: Tree updated, scheduling render."
)
# Store the new tree now, so a scheduled render uses the latest
updated_state = %{
state
| previous_tree: state.current_tree,
current_tree: new_tree
}
final_state =
schedule_or_execute_render(diff_result, new_tree, updated_state)
{:noreply, final_state}
end
end
end
@impl Raxol.Core.Behaviours.BaseManager
def handle_manager_cast({:trigger_render, data}, state) do
tree_to_render = data || state.current_tree
case tree_to_render do
nil ->
Raxol.Core.Runtime.Log.debug(
"Pipeline: Triggering render, but no tree available."
)
{:noreply, state}
tree_to_render ->
Raxol.Core.Runtime.Log.debug("Pipeline: Triggering render for tree.")
# If data is provided, use it as the new tree
# If data is nil, use the current tree without changing it
{diff_result, updated_state} =
prepare_render_state(data, tree_to_render, state)
final_state =
schedule_or_execute_render(diff_result, tree_to_render, updated_state)
{:noreply, final_state}
end
end
@impl Raxol.Core.Behaviours.BaseManager
def handle_manager_cast({:request_animation_frame, pid, ref}, state) do
Raxol.Core.Runtime.Log.debug(
"Pipeline: Received request_animation_frame from #{inspect(pid)} with ref #{inspect(ref)}"
)
updated_requests = :queue.in({pid, ref}, state.animation_frame_requests)
new_state = %{state | animation_frame_requests: updated_requests}
final_state = ensure_animation_ticker_running(new_state)
{:noreply, final_state}
end
@impl Raxol.Core.Behaviours.BaseManager
def handle_manager_cast(:schedule_render_on_next_frame, state) do
case state.current_tree do
nil ->
Raxol.Core.Runtime.Log.debug(
"Pipeline: schedule_render_on_next_frame called, but no current_tree to render."
)
{:noreply, state}
_current_tree ->
Raxol.Core.Runtime.Log.debug(
"Pipeline: Scheduling render for next frame."
)
new_state = %{state | render_scheduled_for_next_frame: true}
final_state = ensure_animation_ticker_running(new_state)
# In test mode, immediately send animation tick for deterministic timing
# In production, let the ticker fire naturally
case Mix.env() do
:test ->
Process.send(self(), :animation_tick, [])
{:noreply, final_state}
_ ->
{:noreply, final_state}
end
end
end
@impl Raxol.Core.Behaviours.BaseManager
def handle_manager_call({:request_animation_frame, pid, ref}, from, state) do
Raxol.Core.Runtime.Log.debug(
"Pipeline: Received request_animation_frame call from #{inspect(pid)}, ref #{inspect(ref)}, from #{inspect(from)}"
)
updated_requests =
:queue.in({pid, ref, from}, state.animation_frame_requests)
new_state = %{state | animation_frame_requests: updated_requests}
final_state = ensure_animation_ticker_running(new_state)
# Reply will be sent by :animation_tick
{:noreply, final_state}
end
@impl Raxol.Core.Behaviours.BaseManager
def handle_manager_info(
{:deferred_render, diff_result, new_tree_for_reference, timer_id},
state
) do
case timer_matches?(state.render_timer_ref, timer_id) do
true ->
render_result =
execute_render_stages(
diff_result,
new_tree_for_reference,
state.renderer,
state.previous_composed_tree,
state.previous_painted_output
)
painted_data = render_result
composed_data = render_result.content
# Commit the painted output to the renderer
commit(
painted_data,
state.renderer,
diff_result,
new_tree_for_reference
)
new_state = %{
state
| last_render_time: System.monotonic_time(:millisecond),
render_timer_ref: nil,
render_scheduled_for_next_frame: false,
previous_composed_tree: composed_data,
previous_painted_output: painted_data
}
{:noreply, new_state}
_ ->
{:noreply, state}
end
end
@impl Raxol.Core.Behaviours.BaseManager
def handle_manager_info({:render_debounce_tick}, state) do
# Handle deferred render from unified timer manager
case state.deferred_render_data do
{diff_result, new_tree_for_reference, _unique_id} ->
Raxol.Core.Runtime.Log.debug(
"Pipeline: Processing deferred render from unified timer"
)
render_result =
execute_render_stages(
diff_result,
new_tree_for_reference,
state.renderer,
state.previous_composed_tree,
state.previous_painted_output
)
painted_data = render_result
composed_data = render_result.content
commit(
painted_data,
state.renderer,
diff_result,
new_tree_for_reference
)
new_state = %{
state
| last_render_time: System.monotonic_time(:millisecond),
render_timer_ref: nil,
render_scheduled_for_next_frame: false,
previous_composed_tree: composed_data,
previous_painted_output: painted_data,
deferred_render_data: nil
}
{:noreply, new_state}
nil ->
Raxol.Core.Runtime.Log.debug(
"Pipeline: Received debounce tick but no deferred render data"
)
{:noreply, state}
end
end
# Handle direct animation tick (fallback when TimerServer unavailable)
def handle_manager_info(:animation_tick, state) do
# Delegate to the main animation tick handler
handle_manager_info({:animation_tick, :timer_ref}, state)
end
def handle_manager_info({:animation_tick, :timer_ref}, state) do
case state.animation_ticker_ref do
nil ->
{:noreply, state}
_ticker_ref ->
# Process all queued animation frame requests
responses = :queue.to_list(state.animation_frame_requests)
remaining_requests_q = :queue.new()
# Send replies for all pending animation frame requests
for {pid, ref, from} <- responses do
Raxol.Core.Runtime.Log.debug(
"Pipeline: Sending :animation_frame to #{inspect(pid)}, ref #{inspect(ref)} via GenServer.reply to #{inspect(from)}"
)
GenServer.reply(from, {:animation_frame, ref})
end
current_state = %{
state
| animation_frame_requests: remaining_requests_q,
animation_ticker_ref: nil
}
# Process render if scheduled for next frame
final_state =
case should_process_scheduled_render?(current_state) do
true ->
Raxol.Core.Runtime.Log.debug(
"Pipeline: Animation tick triggering scheduled render."
)
# Pass state to schedule_or_execute_render for state management
# The diff should be against the previous tree state before this current_tree was set.
# For simplicity in this tick, we'll treat it as a full replace of the current tree.
# A more sophisticated approach might store the pending diff that led to scheduling.
render_state =
schedule_or_execute_render(
# Treat as full render for simplicity in tick
{:replace, current_state.current_tree},
current_state.current_tree,
# Reset flag before render call
%{current_state | render_scheduled_for_next_frame: false}
)
render_state
_ ->
current_state
end
# Always reschedule the ticker if there are pending requests or if a render was scheduled
# This ensures the ticker continues running for future requests
case should_continue_ticker?(final_state) do
true ->
# Try unified timer manager first, fallback to direct timer
# TimerServer.start_animation_timer only returns :ok per spec
:ok =
Raxol.UI.Rendering.TimerServer.start_animation_timer(
self(),
@animation_tick_interval_ms
)
%{final_state | animation_ticker_ref: :timer_server}
_ ->
Raxol.Core.Runtime.Log.debug(
"Pipeline: Animation ticker stopping as no work was done or pending."
)
final_state
end
|> then(&{:noreply, &1})
end
end
## Private Helpers
defp schedule_or_execute_render(diff_result, new_tree_for_reference, state) do
Log.debug(
"[Pipeline] schedule_or_execute_render called with diff_result=#{inspect(diff_result)}, new_tree=#{inspect(new_tree_for_reference)}"
)
case diff_result do
{:update, _path, _changes} ->
handle_partial_update(state, diff_result, new_tree_for_reference)
_ ->
handle_full_render(state, diff_result, new_tree_for_reference)
end
end
defp handle_partial_update(state, diff_result, new_tree_for_reference) do
Raxol.Core.Runtime.Log.debug(
"Pipeline: Executing partial update immediately."
)
_ = cancel_timer_if_exists(state.render_timer_ref)
render_result =
execute_render_stages(
diff_result,
new_tree_for_reference,
state.renderer,
state.previous_composed_tree,
state.previous_painted_output
)
painted_data = render_result
composed_data = render_result.content
# Commit the painted output to the renderer
commit(
painted_data,
state.renderer,
diff_result,
new_tree_for_reference
)
%{
state
| last_render_time: System.monotonic_time(:millisecond),
render_timer_ref: nil,
render_scheduled_for_next_frame: false,
previous_composed_tree: composed_data,
previous_painted_output: painted_data
}
end
defp handle_full_render(state, diff_result, new_tree_for_reference) do
now = System.monotonic_time(:millisecond)
time_since_last_render =
calculate_time_since_last_render(state.last_render_time, now)
choose_render_strategy(
time_since_last_render,
state,
diff_result,
new_tree_for_reference,
now
)
end
defp execute_immediate_render(state, diff_result, new_tree_for_reference, now) do
Raxol.Core.Runtime.Log.debug("Pipeline: Executing render immediately.")
_ = cancel_timer_if_exists(state.render_timer_ref)
render_result =
execute_render_stages(
diff_result,
new_tree_for_reference,
state.renderer,
state.previous_composed_tree,
state.previous_painted_output
)
painted_data = render_result
composed_data = render_result.content
# Commit the painted output to the renderer
commit(
painted_data,
state.renderer,
diff_result,
new_tree_for_reference
)
%{
state
| last_render_time: now,
render_timer_ref: nil,
render_scheduled_for_next_frame: false,
previous_composed_tree: composed_data,
previous_painted_output: painted_data
}
end
defp schedule_deferred_render(
state,
_diff_result,
_new_tree_for_reference,
time_since_last_render
) do
delay = @animation_tick_interval_ms - time_since_last_render
Raxol.Core.Runtime.Log.debug(
"Pipeline: Debouncing render. Will render in #{delay}ms."
)
_ = cancel_timer_if_exists(state.render_timer_ref)
# Try unified timer manager, fallback to Process.send_after
# TimerServer.start_debounce_timer only returns :ok per spec
:ok = Raxol.UI.Rendering.TimerServer.start_debounce_timer(self(), delay)
timer_ref = :unified_timer
%{state | render_timer_ref: timer_ref}
end
defp calculate_time_since_last_render(last_render_time, now) do
calculate_time_diff(last_render_time, now)
end
defp cancel_timer_if_exists(:timer_server) do
# Timer is managed by TimerServer, stop debounce timer
Raxol.UI.Rendering.TimerServer.stop_timer(:render_debounce)
end
defp cancel_timer_if_exists(timer_ref) do
cancel_timer(timer_ref)
end
# Delegate stage execution to Stages module
defdelegate execute_render_stages(
diff_result,
new_tree_for_reference,
renderer_module,
previous_composed_tree,
previous_painted_output
),
to: Stages
defp ensure_animation_ticker_running(state) do
case state.animation_ticker_ref do
nil ->
Raxol.Core.Runtime.Log.debug(
"Pipeline: Animation ticker not running, starting it."
)
case Raxol.UI.Rendering.TimerServer.start_animation_timer(
self(),
@animation_tick_interval_ms
) do
:ok ->
%{state | animation_ticker_ref: :timer_server}
end
_ticker_ref ->
Raxol.Core.Runtime.Log.debug(
"Pipeline: Animation ticker already running."
)
state
end
end
# Helper functions using pattern matching instead of if statements
defp start_unified_timer_manager do
# Start TimerServer if not already running
case GenServer.whereis(Raxol.UI.Rendering.TimerServer) do
nil ->
case Raxol.UI.Rendering.TimerServer.start_link([]) do
{:ok, _pid} ->
Raxol.Core.Runtime.Log.info("Pipeline: Started TimerServer")
{:error, {:already_started, _pid}} ->
Raxol.Core.Runtime.Log.debug(
"Pipeline: TimerServer already started"
)
{:error, reason} ->
Raxol.Core.Runtime.Log.error(
"Pipeline: Failed to start TimerServer: #{inspect(reason)}"
)
end
_pid ->
Raxol.Core.Runtime.Log.debug("Pipeline: TimerServer already running")
end
:ok
end
defp maybe_update_initial_tree(state, %{}), do: state
defp maybe_update_initial_tree(state, initial_tree),
do: State.update_tree(state, initial_tree)
defp tree_changed?(current_tree, new_tree), do: current_tree != new_tree
defp timer_matches?(timer_ref, timer_id), do: timer_ref == timer_id
defp should_process_scheduled_render?(%{
render_scheduled_for_next_frame: true,
current_tree: tree
})
when not is_nil(tree),
do: true
defp should_process_scheduled_render?(_state), do: false
defp should_continue_ticker?(%{
animation_frame_requests: requests,
render_scheduled_for_next_frame: scheduled
}) do
:queue.len(requests) > 0 or scheduled
end
defp choose_render_strategy(
time_since_last_render,
state,
diff_result,
new_tree_for_reference,
now
) do
case time_since_last_render >= @animation_tick_interval_ms do
true ->
execute_immediate_render(
state,
diff_result,
new_tree_for_reference,
now
)
false ->
schedule_deferred_render(
state,
diff_result,
new_tree_for_reference,
time_since_last_render
)
end
end
defp calculate_time_diff(nil, _now), do: @animation_tick_interval_ms + 1
defp calculate_time_diff(last_render_time, now), do: now - last_render_time
defp cancel_timer(nil), do: :ok
defp cancel_timer(timer_ref), do: _ = Process.cancel_timer(timer_ref)
defp prepare_render_state(nil, tree_to_render, state) do
# No data provided - use current tree without changing state
{
{:replace, tree_to_render},
state
}
end
defp prepare_render_state(_data, tree_to_render, state) do
# Data provided - treat as full replacement
{
{:replace, tree_to_render},
%{
state
| previous_tree: state.current_tree,
current_tree: tree_to_render
}
}
end
end