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
Current section
Files
lib/raxol/terminal/integration/integration_renderer.ex
defmodule Raxol.Terminal.Integration.Renderer do
@moduledoc """
Handles terminal output rendering and display management using Termbox2.
"""
alias Raxol.Core.Runtime.Log
alias Raxol.Terminal.Cursor.Manager, as: CursorManager
alias Raxol.Terminal.Integration.State
alias Raxol.Terminal.ScreenBuffer.Manager
@doc """
Initializes the underlying terminal system.
Must be called before other rendering functions.
Returns :ok or {:error, reason}.
"""
def init_terminal do
init_terminal_by_mode(Application.get_env(:raxol, :terminal_test_mode, false))
end
@doc """
Shuts down the underlying terminal system.
Must be called to restore terminal state.
Returns :ok or {:error, reason}.
"""
def shutdown_terminal do
shutdown_terminal_by_mode(Application.get_env(:raxol, :terminal_test_mode, false))
end
@doc """
Renders the current terminal state to the screen.
Returns :ok or {:error, reason}.
"""
def render(%State{} = state) do
active_buffer = Manager.get_active_buffer(state.buffer_manager)
render_buffer_if_present(active_buffer, state)
end
defp handle_cursor_and_present(state) do
handle_cursor_by_mode(
state,
Application.get_env(:raxol, :terminal_test_mode, false)
)
end
defp present_buffer do
present_buffer_by_mode(Application.get_env(:raxol, :terminal_test_mode, false))
end
@doc """
Gets the current terminal dimensions.
Returns {:ok, {width, height}} or {:error, :dimensions_unavailable}.
Note: termbox2 width/height C functions return int, not status codes.
A negative value might indicate an error (e.g., not initialized).
"""
def get_dimensions do
get_dimensions_by_mode(Application.get_env(:raxol, :terminal_test_mode, false))
end
@doc """
Clears the terminal screen (specifically, the back buffer).
Call present/0 afterwards to make it visible.
Returns :ok or {:error, reason}.
"""
def clear_screen(%State{} = _state) do
clear_screen_by_mode(Application.get_env(:raxol, :terminal_test_mode, false))
end
defp clear_and_present do
case :termbox2_nif.tb_clear() do
0 -> present_buffer()
clear_error_code -> {:error, {:clear_failed, clear_error_code}}
end
end
@doc """
Moves the hardware cursor to a specific position on the screen.
Call present/0 afterwards if you want to ensure it's shown with other changes.
The cursor position is typically updated with present/0.
Returns :ok or {:error, reason}.
"""
def move_cursor(%State{} = _state, x, y) do
move_cursor_by_mode(
x,
y,
Application.get_env(:raxol, :terminal_test_mode, false)
)
end
defp set_cursor_and_present(x, y) do
case :termbox2_nif.tb_set_cursor(x, y) do
0 ->
present_buffer()
set_cursor_error_code ->
{:error, {:set_cursor_failed, set_cursor_error_code}}
end
end
@doc """
Creates a new renderer with the given options.
"""
def new(opts \\ []) do
# Initialize terminal if not in test mode
case init_terminal() do
:ok ->
# Create initial state with configuration
config = build_initial_config(opts)
state = %State{
width: Map.get(config, :width, 80),
height: Map.get(config, :height, 24),
config: config
}
{:ok, state}
error ->
error
end
end
@doc """
Updates the renderer configuration.
"""
def update_config(%State{} = state, config) do
# Merge new config with existing config
updated_config = Map.merge(state.config || %{}, config)
# Apply configuration changes
case apply_config_changes(updated_config) do
:ok ->
%{state | config: updated_config}
{:error, reason} ->
Log.error("Failed to apply renderer configuration: #{inspect(reason)}")
state
end
end
@doc """
Sets a specific configuration value.
"""
def set_config_value(%State{} = state, key, value) do
# Update the specific key in the config
updated_config = Map.put(state.config || %{}, key, value)
# Apply the configuration change
case apply_config_value(key, value) do
:ok ->
%{state | config: updated_config}
{:error, reason} ->
Log.error("Failed to set config value #{key}: #{inspect(reason)}")
state
end
end
@doc """
Resets the renderer configuration to defaults.
"""
def reset_config(%State{} = state) do
# Get default configuration
default_config = build_initial_config([])
# Apply default configuration
case apply_config_changes(default_config) do
:ok ->
%{state | config: default_config}
{:error, reason} ->
Log.error("Failed to reset renderer configuration: #{inspect(reason)}")
state
end
end
@doc """
Resizes the renderer to the given dimensions.
"""
def resize(%State{} = state, width, height)
when is_integer(width) and is_integer(height) do
resize_by_mode(
state,
width,
height,
Application.get_env(:raxol, :terminal_test_mode, false)
)
end
@doc """
Sets the cursor visibility.
"""
def set_cursor_visibility(%State{} = state, visible)
when is_boolean(visible) do
set_cursor_visibility_by_mode(
state,
visible,
Application.get_env(:raxol, :terminal_test_mode, false)
)
end
@doc """
Sets the terminal title.
"""
def set_title(%State{} = state, title) when is_binary(title) do
set_title_by_mode(
state,
title,
Application.get_env(:raxol, :terminal_test_mode, false)
)
end
@doc """
Gets the terminal title.
"""
def get_title(%State{} = state) do
# Return the title from our state
Map.get(state.config || %{}, :title, "")
end
# Private helper functions
defp build_initial_config(opts) when is_list(opts) do
%{
width: Keyword.get(opts, :width, 80),
height: Keyword.get(opts, :height, 24),
cursor_visible: Keyword.get(opts, :cursor_visible, true),
title: Keyword.get(opts, :title, "Raxol Terminal"),
theme: Keyword.get(opts, :theme, %{foreground: :white, background: :black}),
fps: Keyword.get(opts, :fps, 60),
font_settings: Keyword.get(opts, :font_settings, %{size: 12})
}
end
defp build_initial_config(opts) when is_map(opts) do
%{
width: Map.get(opts, :width, 80),
height: Map.get(opts, :height, 24),
cursor_visible: Map.get(opts, :cursor_visible, true),
title: Map.get(opts, :title, "Raxol Terminal"),
theme: Map.get(opts, :theme, %{foreground: :white, background: :black}),
fps: Map.get(opts, :fps, 60),
font_settings: Map.get(opts, :font_settings, %{size: 12})
}
end
defp build_initial_config(_) do
%{
width: 80,
height: 24,
cursor_visible: true,
title: "Raxol Terminal",
theme: %{foreground: :white, background: :black},
fps: 60,
font_settings: %{size: 12}
}
end
defp apply_config_changes(config) do
apply_theme_if_present(config)
end
defp apply_config_value(:cursor_visible, visible) do
set_terminal_cursor_visibility(visible)
end
defp apply_config_value(:title, title) do
case :termbox2_nif.tb_set_title(title) do
{:ok, "set"} -> :ok
{:error, reason} -> {:error, reason}
other -> {:error, {:unexpected_response, other}}
end
end
defp apply_config_value(:theme, theme) do
apply_theme_config(theme)
end
defp apply_config_value(_key, _value) do
# For other config values, just return success
:ok
end
defp apply_theme_config(theme) do
# Apply theme colors to terminal
# Note: Termbox2 doesn't have a direct theme setting function,
# so we just validate the theme structure
case validate_theme(theme) do
:ok -> :ok
error -> error
end
end
defp set_terminal_cursor_visibility(visible) do
set_cursor_visibility_state(visible)
end
defp validate_dimensions(width, _height) when width <= 0 do
{:error, {:invalid_width, width}}
end
defp validate_dimensions(_width, height) when height <= 0 do
{:error, {:invalid_height, height}}
end
defp validate_dimensions(width, _height) when width > 1000 do
{:error, {:width_too_large, width}}
end
defp validate_dimensions(_width, height) when height > 1000 do
{:error, {:height_too_large, height}}
end
defp validate_dimensions(_width, _height) do
:ok
end
defp validate_theme(theme) when is_map(theme) do
# Basic theme validation
required_keys = [:foreground, :background]
case Enum.find(required_keys, fn key -> !Map.has_key?(theme, key) end) do
nil -> :ok
missing_key -> {:error, {:missing_theme_key, missing_key}}
end
end
defp validate_theme(_theme) do
{:error, :invalid_theme_format}
end
## Helper functions for refactored if statements
defp init_terminal_by_mode(true) do
Log.info("[Renderer] Test mode detected, skipping actual terminal initialization")
:ok
end
defp init_terminal_by_mode(false) do
Log.info("[Renderer] Attempting to call :termbox2_nif.tb_init()")
case Raxol.Core.ErrorHandling.safe_call(fn ->
raw_init_result = :termbox2_nif.tb_init()
case raw_init_result do
0 ->
Log.info("[Renderer] :termbox2_nif.tb_init() returned 0 (success)")
:ok
int_val when is_integer(int_val) ->
Log.warning(
"Terminal integration renderer failed to initialize: #{inspect(int_val)}",
%{error: int_val}
)
{:error, {:init_failed_with_code, int_val}}
other ->
Log.error(
"[Renderer] Termbox2 NIF tb_init() returned unexpected value: #{inspect(other)}"
)
{:error, {:init_failed_unexpected_return, other}}
end
end) do
{:ok, result} ->
result
{:error, reason} ->
Log.error("""
[Renderer] Caught exception/exit during :termbox2_nif.tb_init() call.
Reason: #{inspect(reason)}
""")
{:error, {:init_failed_exception, reason}}
end
end
defp shutdown_terminal_by_mode(true) do
Log.info("[Renderer] Test mode detected, skipping actual terminal shutdown")
:ok
end
defp shutdown_terminal_by_mode(false) do
Log.info("[Renderer] Attempting to call :termbox2_nif.tb_shutdown()")
case Raxol.Core.ErrorHandling.safe_call(fn ->
:termbox2_nif.tb_shutdown()
Log.info("[Renderer] :termbox2_nif.tb_shutdown() called.")
:ok
end) do
{:ok, result} ->
result
{:error, reason} ->
Log.error("""
[Renderer] Caught exception/exit during :termbox2_nif.tb_shutdown() call.
Reason: #{inspect(reason)}
""")
{:error, {:shutdown_failed_exception, reason}}
end
end
defp render_buffer_if_present(%{cells: cells}, state)
when not is_nil(cells) do
case Raxol.Terminal.Integration.CellRenderer.render(cells) do
:ok -> handle_cursor_and_present(state)
error -> error
end
end
defp render_buffer_if_present(_active_buffer, _state) do
:ok
end
defp handle_cursor_by_mode(_state, true) do
# In test mode, just return success
:ok
end
defp handle_cursor_by_mode(state, false) do
{cursor_x, cursor_y} = CursorManager.get_position(state.cursor_manager)
case :termbox2_nif.tb_set_cursor(cursor_x, cursor_y) do
0 -> present_buffer()
error_code -> {:error, {:set_cursor_failed, error_code}}
end
end
defp present_buffer_by_mode(true) do
# In test mode, just return success
:ok
end
defp present_buffer_by_mode(false) do
case :termbox2_nif.tb_present() do
0 -> :ok
error_code -> {:error, {:present_failed, error_code}}
end
end
defp get_dimensions_by_mode(true) do
# Return mock dimensions for tests
{:ok, {80, 24}}
end
defp get_dimensions_by_mode(false) do
width = :termbox2_nif.tb_width()
height = :termbox2_nif.tb_height()
validate_terminal_dimensions(width, height)
end
defp validate_terminal_dimensions(width, height)
when width >= 0 and height >= 0 do
{:ok, {width, height}}
end
defp validate_terminal_dimensions(width, height) do
# Assuming negative values mean error/not initialized
{:error, {:dimensions_unavailable, width: width, height: height}}
end
defp clear_screen_by_mode(true) do
# In test mode, just return success
:ok
end
defp clear_screen_by_mode(false) do
clear_and_present()
end
defp move_cursor_by_mode(_x, _y, true) do
# In test mode, just return success
:ok
end
defp move_cursor_by_mode(x, y, false) do
set_cursor_and_present(x, y)
end
defp resize_by_mode(state, width, height, true) do
# In test mode, just update the state
%{state | width: width, height: height}
end
defp resize_by_mode(state, width, height, false) do
# In real mode, we need to handle terminal resize
# Note: Termbox2 doesn't have a direct resize function, so we update our state
# and let the next render cycle handle the new dimensions
case validate_dimensions(width, height) do
:ok ->
%{state | width: width, height: height}
{:error, reason} ->
Log.error("Invalid resize dimensions: #{inspect(reason)}")
state
end
end
defp set_cursor_visibility_by_mode(state, visible, true) do
# In test mode, just update the state
%{state | config: Map.put(state.config || %{}, :cursor_visible, visible)}
end
defp set_cursor_visibility_by_mode(state, visible, false) do
# In real mode, use termbox2 to hide/show cursor
case set_terminal_cursor_visibility(visible) do
:ok ->
%{
state
| config: Map.put(state.config || %{}, :cursor_visible, visible)
}
{:error, reason} ->
Log.error("Failed to set cursor visibility: #{inspect(reason)}")
state
end
end
defp set_title_by_mode(state, title, true) do
# In test mode, just update the state
%{state | config: Map.put(state.config || %{}, :title, title)}
end
defp set_title_by_mode(state, title, false) do
# In real mode, use termbox2 to set the title
case :termbox2_nif.tb_set_title(title) do
{:ok, "set"} ->
%{state | config: Map.put(state.config || %{}, :title, title)}
{:error, reason} ->
Log.error("Failed to set terminal title: #{inspect(reason)}")
state
other ->
Log.error("Unexpected response from tb_set_title: #{inspect(other)}")
state
end
end
defp apply_theme_if_present(%{theme: theme}) do
case apply_theme_config(theme) do
:ok -> :ok
error -> error
end
end
defp apply_theme_if_present(_config) do
:ok
end
defp set_cursor_visibility_state(true) do
# Show cursor by setting it to a valid position (will be updated during render)
:ok
end
defp set_cursor_visibility_state(false) do
# Hide cursor by setting it to -1, -1
case :termbox2_nif.tb_set_cursor(-1, -1) do
:ok -> :ok
error_code -> {:error, {:hide_cursor_failed, error_code}}
end
end
end