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 integration integration_renderer.ex
Raw

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