Packages

Terminal emulation and driver infrastructure for Raxol. ANSI parsing, screen buffers, command processing, cursor management, input handling, session management, and termbox2 NIF integration.

Current section

Files

Jump to
raxol_terminal lib raxol terminal driver.ex
Raw

lib/raxol/terminal/driver.ex

defmodule Raxol.Terminal.Driver do
@moduledoc """
Handles raw terminal input/output and event generation.
Responsibilities:
- Setting terminal mode (raw, echo)
- Reading input events via termbox2_nif NIF
- Parsing input events into `Raxol.Core.Events.Event` structs
- Detecting terminal resize events
- Sending parsed events to the `Dispatcher`
- Restoring terminal state on exit
"""
alias Raxol.Core.Runtime.Log
use Raxol.Core.Behaviours.BaseManager
require Logger
require Raxol.Core.Runtime.Log
# Import Bitwise for bitwise operations
# import Bitwise
alias Raxol.Core.Events.Event
alias Raxol.Terminal.ANSI.InputParser
alias Raxol.Terminal.Driver.Dispatch
alias Raxol.Terminal.Driver.EventTranslator
alias Raxol.Terminal.Driver.InputBuffer
alias Raxol.Terminal.Driver.TermboxLifecycle
@compile {:no_warn_undefined, Raxol.Terminal.Driver.Dispatch}
@compile {:no_warn_undefined, Raxol.Terminal.Driver.EventTranslator}
@compile {:no_warn_undefined, Raxol.Terminal.Driver.InputBuffer}
@compile {:no_warn_undefined, Raxol.Terminal.Driver.TermboxLifecycle}
@input_buffer_flush_ms 50
# Check if termbox2_nif is available at compile time
@termbox2_available Code.ensure_loaded?(:termbox2_nif)
import Raxol.Terminal.TerminalUtils, only: [has_terminal_device?: 0]
alias Raxol.Terminal.Env
# Constants for retry logic
@max_init_retries 3
# ms
@init_retry_delay 1000
# Allow nil initially
@type dispatcher_pid :: pid() | nil
@type original_stty :: String.t()
@type termbox_state :: :uninitialized | :initialized | :failed
defmodule State do
@moduledoc false
defstruct dispatcher_pid: nil,
original_stty: nil,
termbox_state: :uninitialized,
init_retries: 0,
io_terminal_state: nil,
input_buffer: <<>>,
flush_timer: nil
end
# --- Public API ---
@doc """
Returns the current terminal backend being used.
## Examples
iex> Raxol.Terminal.Driver.backend()
:termbox2_nif
iex> Raxol.Terminal.Driver.backend()
:io_terminal
"""
# The spec covers both possible return values across platforms.
# On any given compilation, only one branch is reachable due to
# @termbox2_available being a compile-time constant.
@dialyzer {:nowarn_function, backend: 0}
@spec backend() :: :termbox2_nif | :io_terminal
def backend do
if @termbox2_available, do: :termbox2_nif, else: :io_terminal
end
# BaseManager provides start_link/1 and start_link/2 automatically
# We can override if needed but the dispatcher_pid is passed as init argument
# --- BaseManager Callbacks ---
# Logger.configure(level: :none) works at runtime but :none isn't in Logger's typespec
@dialyzer {:nowarn_function, init_manager: 1}
@impl true
def init_manager(opts) do
# Extract dispatcher_pid from opts - handle both keyword list and raw value
dispatcher_pid = extract_dispatcher_pid(opts)
mouse_enabled = if is_list(opts), do: Keyword.get(opts, :mouse, true), else: true
Raxol.Core.Runtime.Log.info(
"[#{__MODULE__}] init called with dispatcher: #{inspect(dispatcher_pid)}"
)
# Get original terminal settings using Erlang IO (no subprocess needed)
output =
case {:io.rows(), :io.columns()} do
{{:ok, rows}, {:ok, cols}} -> "#{rows} #{cols}"
_ -> "80 24"
end
state = %State{
dispatcher_pid: dispatcher_pid,
original_stty: output,
termbox_state: :uninitialized,
init_retries: 0
}
# Initialize terminal in raw mode only if attached to a TTY.
# Use has_terminal_device?() instead of real_tty?() because the latter
# relies on :io.columns() which fails in -noshell mode (mix run).
tty_detected = has_terminal_device?()
case {Env.test?(), tty_detected, dispatcher_pid} do
{true, _, nil} ->
Raxol.Core.Runtime.Log.info(
"[Driver] Test environment detected, sending driver_ready event"
)
Raxol.Core.Runtime.Log.warning_with_context(
"[Driver] No dispatcher_pid provided, skipping driver_ready and initial resize event",
%{}
)
state = %{state | termbox_state: :initialized}
{:ok, state}
{true, _, pid} ->
Raxol.Core.Runtime.Log.info(
"[Driver] Test environment detected, sending driver_ready event"
)
send(pid, {:driver_ready, self()})
Raxol.Core.Runtime.Log.info(
"[Driver] Sending initial resize event to dispatcher_pid: #{inspect(pid)}"
)
Dispatch.send_initial_resize_event(pid)
state = %{state | termbox_state: :initialized}
{:ok, state}
{_, _, nil} ->
# No dispatcher — this is the Application supervisor's placeholder Driver.
# Don't set up the terminal; the Lifecycle's Driver will do that.
Raxol.Core.Runtime.Log.info("[TerminalDriver] No dispatcher, skipping terminal setup.")
{:ok, state}
{_, true, _} ->
Raxol.Core.Runtime.Log.info(
"[TerminalDriver] TTY detected, initializing ANSI terminal..."
)
# Save original TTY settings via /dev/tty (System.cmd pipes stdin,
# so we must redirect from /dev/tty for stty to affect the real terminal)
original_stty = Raxol.Terminal.Driver.Stty.save()
# Raw mode on the actual terminal: no echo, no line buffering, no signals
Raxol.Terminal.Driver.Stty.raw!()
# Suppress Logger console output so it doesn't corrupt the TUI
Logger.configure(level: :none)
# Enter alternate screen, hide cursor
IO.write("\e[?1049h\e[?25l")
# Reset mouse tracking (may be left over from a crashed session)
IO.write("\e[?1003l\e[?1006l\e[?1000l")
# Enable SGR mouse mode (button events + SGR extended coordinates)
if mouse_enabled do
IO.write("\e[?1000h\e[?1006h")
end
# Enable terminal modes: focus reporting, bracketed paste
IO.write("\e[?1004h\e[?2004h")
# Send initial resize event if we have a dispatcher
if dispatcher_pid,
do: Dispatch.send_initial_resize_event(dispatcher_pid)
# Activate prim_tty reader for input. In -noshell mode, prim_tty
# was initialized with tty => false, so the reader gets no select
# notifications. start_stdin_reader triggers reinit with tty => true
# and sets up trace interception of the reader's output.
start_stdin_reader(self())
state = %{
state
| termbox_state: :initialized,
original_stty: original_stty,
io_terminal_state: %{
input_reader: Process.whereis(:user_drv_reader),
tty_fd: nil,
tty_port: nil
}
}
{:ok, state}
{_, false, _} ->
Raxol.Core.Runtime.Log.warning_with_context(
"Not attached to a TTY. Skipping Termbox2Nif.tb_init(). Terminal features will be disabled.",
%{}
)
{:ok, state}
end
end
# --- BaseManager handle_info callbacks ---
@impl true
def handle_manager_info(:retry_init, %{init_retries: retries} = state)
when retries < @max_init_retries do
case TermboxLifecycle.initialize() do
:ok ->
Raxol.Core.Runtime.Log.info("Successfully initialized termbox on retry")
{:noreply, %{state | termbox_state: :initialized}}
{:error, reason} ->
Raxol.Core.Runtime.Log.error(
"Failed to initialize termbox on retry #{retries + 1}: #{inspect(reason)}"
)
Process.send_after(self(), :retry_init, @init_retry_delay)
{:noreply, %{state | init_retries: retries + 1}}
end
end
@impl true
def handle_manager_info(:retry_init, state) do
Raxol.Core.Runtime.Log.error(
"Failed to initialize termbox after #{@max_init_retries} attempts. Terminal features will be disabled."
)
{:noreply, state}
end
@impl true
def handle_manager_info(
{:termbox_event, event_map},
%{termbox_state: :initialized, dispatcher_pid: dispatcher_pid} = state
) do
Raxol.Core.Runtime.Log.debug("Received termbox event: #{inspect(event_map)}")
case EventTranslator.translate(event_map) do
{:ok, %Event{} = event} ->
# Only send if dispatcher_pid is known
case dispatcher_pid do
nil -> :ok
pid -> Dispatch.send_event_to_dispatcher(pid, event)
end
{:noreply, state}
:ignore ->
# Event type we don't care about
Raxol.Core.Runtime.Log.debug("[Driver] Ignoring termbox event: #{inspect(event_map)}")
{:noreply, state}
{:error, reason} ->
Raxol.Core.Runtime.Log.warning_with_context(
"Failed to translate termbox event: #{inspect(reason)}. Event: #{inspect(event_map)}",
%{}
)
{:noreply, state}
end
end
@impl true
def handle_manager_info({:termbox_event, _event_map}, state) do
# Ignore events if termbox is not initialized
{:noreply, state}
end
@impl true
def handle_manager_info({:termbox_error, reason}, state) do
Raxol.Core.Runtime.Log.error(
"Received termbox error: #{inspect(reason)}. Attempting recovery..."
)
case state.termbox_state do
:initialized -> TermboxLifecycle.handle_recovery(reason, state)
_ -> {:stop, {:termbox_error, reason}, state}
end
end
@impl true
def handle_manager_info({:register_dispatcher, pid}, state)
when is_pid(pid) do
Raxol.Core.Runtime.Log.info("Registering dispatcher PID: #{inspect(pid)}")
# Send initial size event now that we have the PID
Dispatch.send_initial_resize_event(pid)
{:noreply, %{state | dispatcher_pid: pid}}
end
@impl true
def handle_manager_info(
{:test_input, input_data},
%{dispatcher_pid: nil} = state
) do
Raxol.Core.Runtime.Log.warning_with_context(
"Received test input before dispatcher registration: #{inspect(input_data)}",
%{}
)
{:noreply, state}
end
@impl true
def handle_manager_info({:test_input, input_data}, state) do
# Construct a basic event. Tests might need more specific event types later.
# We need to parse the input_data into something the MockApp expects.
Raxol.Core.Runtime.Log.debug(
"[TerminalDriver.handle_cast - :test_input] Received input_data: #{inspect(input_data)}, state: #{inspect(state)}"
)
event = Dispatch.parse_test_input(input_data)
Raxol.Core.Runtime.Log.debug(
"[TerminalDriver.handle_cast - :test_input] Parsed event: #{inspect(event)}"
)
Raxol.Core.Runtime.Log.debug("[TEST] Dispatching simulated event: #{inspect(event)}")
GenServer.cast(state.dispatcher_pid, {:dispatch, event})
{:noreply, state}
end
@impl true
def handle_manager_info({:EXIT, _pid, _reason}, state) do
{:noreply, state}
end
@impl true
def handle_manager_info({:raw_input, data}, state) when is_binary(data) do
buffer_and_dispatch(data, state)
end
# Trace messages from prim_tty reader — intercept input data
@impl true
def handle_manager_info(
{:trace, _reader, :send, {_ref, {:data, data}}, _to},
state
) do
binary =
cond do
is_binary(data) -> data
is_list(data) -> IO.iodata_to_binary(data)
true -> <<>>
end
if byte_size(binary) > 0 do
buffer_and_dispatch(binary, state)
else
{:noreply, state}
end
end
# Ignore other trace messages from the reader (signals, receives, etc.)
@impl true
def handle_manager_info({:trace, _pid, :send, _msg, _to}, state) do
{:noreply, state}
end
# Port data — accumulate and parse (buffering handles split escape sequences)
@impl true
def handle_manager_info({port, {:data, data}}, state) when is_port(port) do
buffer_and_dispatch(data, state)
end
# Flush timer fired — dispatch whatever we have
@impl true
def handle_manager_info(:flush_input_buffer, state) do
flush_buffer(%{state | flush_timer: nil})
end
# Port closed
@impl true
def handle_manager_info({port, :eof}, state) when is_port(port) do
{:noreply, state}
end
@impl true
def handle_manager_info({port, {:exit_status, _status}}, state)
when is_port(port) do
{:noreply, state}
end
@impl true
def handle_manager_info(unhandled_message, state) do
Raxol.Core.Runtime.Log.warning_with_context(
"#{__MODULE__} received unhandled message: #{inspect(unhandled_message)}",
%{}
)
{:noreply, state}
end
defp buffer_and_dispatch(data, state) do
buffer = state.input_buffer <> data
_ = if state.flush_timer, do: Process.cancel_timer(state.flush_timer)
if InputBuffer.incomplete_escape?(buffer) do
timer = Process.send_after(self(), :flush_input_buffer, @input_buffer_flush_ms)
{:noreply, %{state | input_buffer: buffer, flush_timer: timer}}
else
flush_buffer(%{state | input_buffer: buffer, flush_timer: nil})
end
end
defp dispatch_raw_input(data, state) do
events = InputParser.parse(data)
Enum.each(events, fn event ->
case state.dispatcher_pid do
nil -> :ok
pid -> Dispatch.send_event_to_dispatcher(pid, event)
end
end)
{:noreply, state}
end
# Forward cast messages to handle_info for test_input
@impl true
def handle_manager_cast({:test_input, input_data}, state) do
handle_manager_info({:test_input, input_data}, state)
end
# Private helper to extract dispatcher_pid from init opts
defp extract_dispatcher_pid(opts) when is_list(opts) do
Keyword.get(opts, :dispatcher_pid)
end
defp extract_dispatcher_pid(pid) when is_pid(pid), do: pid
defp extract_dispatcher_pid(_), do: nil
def terminate(_reason, %{termbox_state: :initialized} = state) do
Raxol.Core.Runtime.Log.info("Terminal Driver terminating.")
TermboxLifecycle.cleanup_terminal(state)
end
def terminate(_reason, _state) do
Raxol.Core.Runtime.Log.info("Terminal Driver terminating (not initialized).")
:ok
end
@doc """
Processes a terminal title change event.
"""
def process_title_change(title, state) when is_binary(title) do
_ =
if not Env.test?() and has_terminal_device?() do
if @termbox2_available do
:termbox2_nif.tb_set_title(title)
end
end
{:noreply, state}
end
@doc """
Processes a terminal position change event.
"""
def process_position_change(x, y, state)
when is_integer(x) and is_integer(y) do
_ =
if not Env.test?() and has_terminal_device?() do
if @termbox2_available do
:termbox2_nif.tb_set_position(x, y)
else
0
end
end
{:noreply, state}
end
# --- Input reader ---
# In -noshell mode (mix run), prim_tty is initialized with tty => false,
# so its reader process never receives select notifications. We trigger
# reinit via user_drv:start_shell, then trace-intercept the reader's
# data messages.
defp start_stdin_reader(_driver_pid) do
# In -noshell mode, user_drv initializes prim_tty with tty => false,
# so the NIF never sets up the terminal fd for select notifications.
# The reader process exists but is blocked waiting for events that
# never arrive.
#
# Fix: call user_drv:start_shell to trigger prim_tty:reinit with
# tty => true, which activates the terminal fd. Then trace the
# reader to intercept input data before it reaches user_drv.
reader = Process.whereis(:user_drv_reader)
user_drv = Process.whereis(:user_drv)
if user_drv do
# Activate the terminal fd by triggering prim_tty reinit.
try do
:gen_statem.call(
user_drv,
{:start_shell, %{initial_shell: {__MODULE__, :noop_shell, []}}}
)
catch
_, _ -> :ok
end
end
if reader do
# Trace the reader's sends to intercept data before user_drv
# forwards it. The reader sends {ref, {:data, bytes}} to user_drv.
:erlang.trace(reader, true, [:send])
end
# Return nil — no spawned reader pid to track. Input arrives via
# trace messages in handle_manager_info.
nil
end
@doc false
def noop_shell, do: :ok
# --- Input buffering ---
# Escape sequences may span multiple messages, so we buffer until complete.
defp flush_buffer(%{input_buffer: <<>>} = state), do: {:noreply, state}
defp flush_buffer(state) do
dispatch_raw_input(state.input_buffer, %{state | input_buffer: <<>>})
end
end