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 renderer.ex
Raw

lib/raxol/terminal/renderer.ex

defmodule Raxol.Terminal.Renderer do
@moduledoc """
Terminal renderer module.
This module handles rendering of terminal output using ANSI escape codes,
including:
- Character cell rendering
- Text styling (colors, bold, italic, underline)
- Cursor rendering
- Performance optimizations (style batching, caching)
## Integration with Other Modules
The Renderer module works closely with several specialized modules:
### Manipulation Module
- Receives text and style updates from the Manipulation module
- Renders text with proper styling and positioning
- Handles text insertion, deletion, and modification
### Selection Module
- Renders text selections with visual highlighting
- Supports multiple selections
- Handles selection state changes
### Validation Module
- Renders validation errors and warnings
- Applies visual indicators for invalid input
- Shows validation state through styling
## Performance Optimizations
The renderer includes several optimizations:
- Only renders changed cells
- Batches style updates for consecutive cells
- Minimizes terminal output
- Caches rendered output when possible
## Usage
```elixir
# Create a new renderer
buffer = ScreenBuffer.new(80, 24)
renderer = Renderer.new(buffer)
# Render with selection
selection = %{selection: {0, 0, 0, 5}}
output = Renderer.render(renderer, selection: selection)
# Render with validation
validation = Validation.validate_input(buffer, 0, 0, "text")
output = Renderer.render(renderer, validation: validation)
```
"""
alias Raxol.Terminal.ScreenBuffer
@type t :: %__MODULE__{
screen_buffer: ScreenBuffer.t(),
cursor: {non_neg_integer(), non_neg_integer()} | nil,
theme: map(),
font_settings: map(),
style_cache: map()
}
defstruct [
:screen_buffer,
:cursor,
:theme,
:font_settings,
:style_batching,
style_cache: %{}
]
require Logger
# ANSI escape code constants
@ansi_reset "\e[0m"
@ansi_bold "\e[1m"
@ansi_italic "\e[3m"
@ansi_underline "\e[4m"
# Standard foreground color codes
@fg_color_codes %{
black: "30",
red: "31",
green: "32",
yellow: "33",
blue: "34",
magenta: "35",
cyan: "36",
white: "37",
bright_black: "90",
bright_red: "91",
bright_green: "92",
bright_yellow: "93",
bright_blue: "94",
bright_magenta: "95",
bright_cyan: "96",
bright_white: "97"
}
# Standard background color codes
@bg_color_codes %{
black: "40",
red: "41",
green: "42",
yellow: "43",
blue: "44",
magenta: "45",
cyan: "46",
white: "47",
bright_black: "100",
bright_red: "101",
bright_green: "102",
bright_yellow: "103",
bright_blue: "104",
bright_magenta: "105",
bright_cyan: "106",
bright_white: "107"
}
@doc """
Creates a new renderer with the given screen buffer.
## Examples
iex> screen_buffer = ScreenBuffer.new(80, 24)
iex> renderer = Renderer.new(screen_buffer)
iex> renderer.screen_buffer
%ScreenBuffer{}
"""
def new(
screen_buffer,
theme \\ %{},
font_settings \\ %{},
style_batching \\ false
) do
%__MODULE__{
screen_buffer: screen_buffer,
cursor: nil,
theme: theme,
font_settings: font_settings,
style_batching: style_batching,
style_cache: %{}
}
end
@doc """
Renders the terminal content without additional options.
"""
def render(%__MODULE__{} = renderer) do
render(renderer, %{}, %{})
end
@doc """
Renders the terminal content.
"""
def render(%__MODULE__{} = renderer, opts) do
render(renderer, opts, %{})
end
@doc """
Renders the terminal content with additional options.
"""
def render(%__MODULE__{} = renderer, _opts \\ %{}, _additional_opts \\ %{}) do
renderer.screen_buffer
|> get_styled_content_optimized(renderer.theme, renderer.style_batching)
|> apply_font_settings(renderer.font_settings)
|> maybe_apply_cursor(renderer.cursor)
end
defp get_styled_content_optimized(buffer, theme, style_batching) do
buffer.cells
|> Enum.map_join("\n", fn row ->
render_row_optimized(row, theme, style_batching)
end)
end
defp render_row_optimized(row, theme, style_batching) do
case style_batching do
true -> render_batched_optimized(row, theme)
false -> render_individual_optimized(row, theme)
end
end
# Batched rendering: group consecutive cells with the same style
defp render_batched_optimized(row, theme) do
row
|> Enum.chunk_by(& &1.style)
|> Enum.map_join("", fn cells_with_same_style ->
style = hd(cells_with_same_style).style
chars = Enum.map_join(cells_with_same_style, "", & &1.char)
ansi_prefix = build_ansi_prefix(style, theme)
case ansi_prefix do
"" -> chars
prefix -> prefix <> chars <> @ansi_reset
end
end)
end
# Individual cell rendering
defp render_individual_optimized(row, theme) do
row
|> Enum.map_join("", fn cell ->
ansi_prefix = build_ansi_prefix(cell.style, theme)
case ansi_prefix do
"" -> cell.char
prefix -> prefix <> cell.char <> @ansi_reset
end
end)
end
# Build ANSI escape prefix from style and theme
defp build_ansi_prefix(style, theme) do
style_map = normalize_style(style)
codes = []
# Foreground color
codes =
case Map.get(style_map, :foreground) do
nil ->
case get_default_fg_ansi(theme) do
nil -> codes
code -> [code | codes]
end
color ->
case resolve_fg_ansi(color, theme) do
nil -> codes
code -> [code | codes]
end
end
# Background color
codes =
case Map.get(style_map, :background) do
nil ->
case get_default_bg_ansi(theme) do
nil -> codes
code -> [code | codes]
end
color ->
case resolve_bg_ansi(color, theme) do
nil -> codes
code -> [code | codes]
end
end
# Text attributes
codes =
if Map.get(style_map, :bold, false),
do: [@ansi_bold | codes],
else: codes
codes =
if Map.get(style_map, :italic, false),
do: [@ansi_italic | codes],
else: codes
codes =
if Map.get(style_map, :underline, false),
do: [@ansi_underline | codes],
else: codes
case codes do
[] -> ""
_ -> codes |> Enum.reverse() |> Enum.join("")
end
end
# Resolve foreground color to ANSI code
defp resolve_fg_ansi(color, theme) when is_atom(color) do
# Check theme first
theme_color = get_in(theme, [:foreground, color])
case theme_color do
nil ->
# Use standard ANSI color code
case Map.get(@fg_color_codes, color) do
nil -> nil
code -> "\e[#{code}m"
end
hex when is_binary(hex) ->
hex_to_ansi_fg(hex)
end
end
defp resolve_fg_ansi(%{r: r, g: g, b: b}, _theme) do
"\e[38;2;#{r};#{g};#{b}m"
end
defp resolve_fg_ansi(color, _theme)
when is_integer(color) and color >= 0 and color <= 255 do
"\e[38;5;#{color}m"
end
defp resolve_fg_ansi(color, _theme) when is_binary(color) do
hex_to_ansi_fg(color)
end
defp resolve_fg_ansi(_, _), do: nil
# Resolve background color to ANSI code
defp resolve_bg_ansi(color, theme) when is_atom(color) do
theme_color = get_in(theme, [:background, color])
case theme_color do
nil ->
case Map.get(@bg_color_codes, color) do
nil -> nil
code -> "\e[#{code}m"
end
hex when is_binary(hex) ->
hex_to_ansi_bg(hex)
end
end
defp resolve_bg_ansi(%{r: r, g: g, b: b}, _theme) do
"\e[48;2;#{r};#{g};#{b}m"
end
defp resolve_bg_ansi(color, _theme)
when is_integer(color) and color >= 0 and color <= 255 do
"\e[48;5;#{color}m"
end
defp resolve_bg_ansi(color, _theme) when is_binary(color) do
hex_to_ansi_bg(color)
end
defp resolve_bg_ansi(_, _), do: nil
# Get default foreground ANSI from theme
defp get_default_fg_ansi(theme) do
case get_in(theme, [:foreground, :default]) do
nil -> nil
hex when is_binary(hex) -> hex_to_ansi_fg(hex)
_ -> nil
end
end
# Get default background ANSI from theme
defp get_default_bg_ansi(theme) do
case get_in(theme, [:background, :default]) do
nil -> nil
hex when is_binary(hex) -> hex_to_ansi_bg(hex)
_ -> nil
end
end
# Convert hex color string to ANSI 24-bit foreground escape
defp hex_to_ansi_fg(hex) do
case parse_hex_color(hex) do
{:ok, r, g, b} -> "\e[38;2;#{r};#{g};#{b}m"
:error -> nil
end
end
# Convert hex color string to ANSI 24-bit background escape
defp hex_to_ansi_bg(hex) do
case parse_hex_color(hex) do
{:ok, r, g, b} -> "\e[48;2;#{r};#{g};#{b}m"
:error -> nil
end
end
# Parse "#RRGGBB" or "#RGB" hex color strings
defp parse_hex_color("#" <> hex), do: parse_hex_digits(hex)
defp parse_hex_color(hex) when is_binary(hex), do: parse_hex_digits(hex)
defp parse_hex_digits(<<r::binary-size(2), g::binary-size(2), b::binary-size(2)>>) do
with {r_val, ""} <- Integer.parse(r, 16),
{g_val, ""} <- Integer.parse(g, 16),
{b_val, ""} <- Integer.parse(b, 16) do
{:ok, r_val, g_val, b_val}
else
_ -> :error
end
end
defp parse_hex_digits(<<r::binary-size(1), g::binary-size(1), b::binary-size(1)>>) do
with {r_val, ""} <- Integer.parse(r <> r, 16),
{g_val, ""} <- Integer.parse(g <> g, 16),
{b_val, ""} <- Integer.parse(b <> b, 16) do
{:ok, r_val, g_val, b_val}
else
_ -> :error
end
end
defp parse_hex_digits(_), do: :error
defp normalize_style(%{__struct__: _} = style) do
Map.from_struct(style)
end
defp normalize_style(style) when is_map(style) do
style
end
defp normalize_style(_style) do
%{}
end
defp apply_font_settings(content, _font_settings), do: content
defp maybe_apply_cursor(content, nil), do: content
defp maybe_apply_cursor(content, _cursor), do: content
@doc """
Sets the cursor position.
## Examples
iex> screen_buffer = ScreenBuffer.new(80, 24)
iex> renderer = Renderer.new(screen_buffer)
iex> renderer = Renderer.set_cursor(renderer, {10, 5})
iex> renderer.cursor
{10, 5}
"""
def set_cursor(%__MODULE__{} = renderer, position) do
%{renderer | cursor: position}
end
@doc """
Clears the cursor position.
## Examples
iex> screen_buffer = ScreenBuffer.new(80, 24)
iex> renderer = Renderer.new(screen_buffer)
iex> renderer = Renderer.set_cursor(renderer, {10, 5})
iex> renderer = Renderer.clear_cursor(renderer)
iex> renderer.cursor
nil
"""
def clear_cursor(%__MODULE__{} = renderer) do
%{renderer | cursor: nil}
end
@doc """
Updates the theme settings.
## Examples
iex> screen_buffer = ScreenBuffer.new(80, 24)
iex> renderer = Renderer.new(screen_buffer)
iex> theme = %{foreground: %{default: "#FFF"}}
iex> renderer = Renderer.set_theme(renderer, theme)
iex> renderer.theme
%{foreground: %{default: "#FFF"}}
"""
def set_theme(%__MODULE__{} = renderer, theme) do
%{renderer | theme: theme}
end
@doc """
Updates the font settings.
## Examples
iex> screen_buffer = ScreenBuffer.new(80, 24)
iex> renderer = Renderer.new(screen_buffer)
iex> settings = %{family: "Fira Code"}
iex> renderer = Renderer.set_font_settings(renderer, settings)
iex> renderer.font_settings
%{family: "Fira Code"}
"""
def set_font_settings(%__MODULE__{} = renderer, settings) do
%{renderer | font_settings: settings}
end
@doc """
Starts a new renderer process.
"""
def start_link(opts \\ []) do
screen_buffer = Keyword.get(opts, :screen_buffer, ScreenBuffer.new(80, 24))
theme = Keyword.get(opts, :theme, %{})
font_settings = Keyword.get(opts, :font_settings, %{})
renderer = new(screen_buffer, theme, font_settings)
{:ok, renderer}
end
@doc """
Stops the renderer process.
"""
def stop(_renderer) do
:ok
end
@doc """
Gets the current content of the screen buffer.
## Parameters
* `renderer` - The renderer to get content from
* `opts` - Options for content retrieval
* `:include_style` - Whether to include style information (default: false)
* `:include_cursor` - Whether to include cursor position (default: false)
## Returns
* `{:ok, content}` - The current content
* `{:error, reason}` - If content retrieval fails
## Examples
iex> get_content(renderer)
{:ok, "Hello, World!"}
"""
def get_content(renderer, opts \\ [])
def get_content(%__MODULE__{} = renderer, opts) do
_include_style = Keyword.get(opts, :include_style, true)
include_cursor = Keyword.get(opts, :include_cursor, true)
content =
renderer.screen_buffer
|> ScreenBuffer.get_content()
apply_cursor_option(content, renderer.cursor, include_cursor)
end
# Handle ScreenBuffer structs (updated after buffer consolidation)
def get_content(%Raxol.Terminal.ScreenBuffer{} = buffer, opts) do
_include_style = Keyword.get(opts, :include_style, false)
include_cursor = Keyword.get(opts, :include_cursor, false)
content =
buffer
|> Raxol.Terminal.ScreenBuffer.get_lines()
|> Enum.map_join("\n", &Enum.join/1)
|> maybe_add_cursor(buffer.cursor_position, include_cursor)
{:ok, content}
end
# Handle buffer manager PIDs (legacy support - deprecated after buffer consolidation)
def get_content(manager_pid, _opts) when is_pid(manager_pid) do
{:error, :deprecated_buffer_manager}
end
defp apply_cursor_option(content, cursor, true) do
content |> maybe_add_cursor(cursor, true)
end
defp apply_cursor_option(content, _cursor, false), do: content
defp maybe_add_cursor(content, nil, _include_cursor), do: content
defp maybe_add_cursor(content, cursor, true), do: {content, cursor}
defp maybe_add_cursor(content, _cursor, false), do: content
end