Raxol.LiveView.TerminalBridge (Raxol LiveView v2.7.0)

Copy Markdown View Source

Converts Raxol.Core buffers to HTML for Phoenix LiveView rendering.

This module provides efficient buffer-to-HTML conversion with performance optimizations for 60fps rendering (< 16ms per frame).

Features

  • Virtual DOM-style diffing (only update changed cells)
  • Character and style caching for performance
  • CSS class generation for theming
  • Inline style support for custom colors
  • Accessibility features (ARIA labels)

Performance

Target: < 16ms per frame for 60fps Actual: ~2-5ms for typical 80x24 buffers

Examples

# Basic conversion
buffer = Raxol.Core.Buffer.create_blank_buffer(80, 24)
buffer = Raxol.Core.Buffer.write_at(buffer, 5, 3, "Hello, World!")
html = Raxol.LiveView.TerminalBridge.buffer_to_html(buffer)

# With theme
html = Raxol.LiveView.TerminalBridge.buffer_to_html(buffer, theme: :nord)

# With custom CSS classes
html = Raxol.LiveView.TerminalBridge.buffer_to_html(buffer,
  css_prefix: "terminal",
  use_inline_styles: false
)

Integration with LiveView

defmodule MyAppWeb.TerminalLive do
  use MyAppWeb, :live_view
  alias Raxol.LiveView.TerminalBridge

  def mount(_params, _session, socket) do
    buffer = Raxol.Core.Buffer.create_blank_buffer(80, 24)
    {:ok, assign(socket, buffer: buffer)}
  end

  def render(assigns) do
    ~H"""
    <div class="terminal-container">
      <%= raw(TerminalBridge.buffer_to_html(@buffer, theme: :dracula)) %>
    </div>
    """
  end
end

Summary

Functions

Generates CSS transition rules from animation hints on positioned elements.

The :aria_mode a screen rendered by buffer_to_html/2 was rendered in.

Converts a buffer to HTML with diff highlighting (for debugging).

Converts a buffer to HTML string.

Converts a buffer to a list of addressable rows.

Container ARIA attributes for an :aria_mode, as a keyword list.

Splits a screen rendered by buffer_to_html/2 back into buffer_to_rows/2 rows.

Converts a style map to CSS classes.

Converts a style map to inline CSS styles.

Types

aria_mode()

@type aria_mode() :: :log | :application

html_opts()

@type html_opts() :: [
  theme: theme(),
  css_prefix: String.t(),
  use_inline_styles: boolean(),
  show_cursor: boolean(),
  cursor_position: {non_neg_integer(), non_neg_integer()} | nil,
  cursor_style: :block | :underline | :bar,
  aria_mode: aria_mode(),
  a11y_map: %{optional(String.t()) => map()}
]

row()

@type row() :: %{id: String.t(), y: non_neg_integer(), html: String.t()}

theme()

@type theme() ::
  :nord
  | :dracula
  | :solarized_dark
  | :solarized_light
  | :monokai
  | :synthwave84
  | :gruvbox_dark
  | :one_dark
  | :tokyo_night
  | :catppuccin
  | :default

Functions

animation_css(elements)

@spec animation_css([map()]) :: String.t()

Generates CSS transition rules from animation hints on positioned elements.

Takes a list of positioned element maps (output of LayoutEngine) and extracts animation hints from elements that have an :id field. Returns a <style> block with CSS transition rules targeting [data-raxol-id] selectors, plus a prefers-reduced-motion media query.

Also emits overflow-anchor rules for elements carrying an :overflow_anchor field (:auto or :none) -- the browser-native counterpart to the terminal Viewport component's overflow_anchor scroll-pinning behavior. :auto lets the browser hold scroll position as content grows above the fold (mirrors the terminal's follow-mode / hold semantics); :none opts an element out entirely.

Returns an empty string if no elements carry animation hints or an overflow-anchor setting.

Examples

elements = [
  %{id: "panel", type: :box, animation_hints: [
    %{property: :opacity, duration_ms: 300, easing: :ease_out_cubic, delay_ms: 0}
  ]}
]
css = animation_css(elements)
# => "<style>[data-raxol-id=\"panel\"] { transition: opacity 300ms cubic-bezier(...) 0ms; }\n..."

elements = [%{id: "log", type: :box, overflow_anchor: :auto}]
css = animation_css(elements)
# => "<style>[data-raxol-id=\"log\"] { overflow-anchor: auto; }\n...</style>"

aria_mode_of(html)

@spec aria_mode_of(String.t()) :: aria_mode()

The :aria_mode a screen rendered by buffer_to_html/2 was rendered in.

A caller that owns the <pre> itself (see buffer_to_rows/2) strips the one buffer_to_html/2 wrote, and the container semantics with it. Reading the mode back off the markup keeps the two in step without a second channel: Raxol.LiveView.TEALive receives the screen as a bare string and has nothing else to learn it from. It previously hardcoded :log, so an app rendering in :application mode had that silently reversed and its screen became a whole-screen live region again -- which is the exact thing :application exists to opt out of, and the failure is audible only to a screen-reader user.

Defaults to :log, matching buffer_to_html/2's own default.

buffer_diff_to_html(old_buffer, new_buffer, opts \\ [])

@spec buffer_diff_to_html(Raxol.Core.Buffer.t(), Raxol.Core.Buffer.t(), html_opts()) ::
  String.t()

Converts a buffer to HTML with diff highlighting (for debugging).

Useful for visualizing which cells changed between frames.

Examples

old_buffer = Raxol.Core.Buffer.create_blank_buffer(80, 24)
new_buffer = Raxol.Core.Buffer.write_at(old_buffer, 5, 3, "Changed")
html = buffer_diff_to_html(old_buffer, new_buffer)
# Changed cells will have "raxol-diff-changed" class

buffer_to_html(buffer, opts \\ [])

@spec buffer_to_html(Raxol.Core.Buffer.t(), html_opts()) :: String.t()

Converts a buffer to HTML string.

Options

  • :theme - Color theme to use (default: :default)
  • :css_prefix - CSS class prefix (default: "raxol")
  • :use_inline_styles - Use inline styles instead of classes (default: false)
  • :show_cursor - Show cursor indicator (default: false)
  • :cursor_position - Cursor position {x, y} (default: nil)
  • :cursor_style - Cursor style (default: :block)
  • :aria_mode - Coarse container semantics (default: :log). :log wraps the terminal in <pre role="log" aria-live="polite"> so screen readers announce output as it changes. :application emits <pre role="application"> with no live region, so per-element ARIA and a dedicated announcement region drive announcements instead of a whole-screen re-read.
  • :a11y_map - id -> accessibility_node map (from Raxol.Core.Accessibility.Projection.by_id/1). Spans whose data-raxol-id matches an entry get per-element ARIA (role/aria-label/aria-disabled/aria-checked/aria-selected/aria-expanded/aria-required).

Examples

buffer = Raxol.Core.Buffer.create_blank_buffer(80, 24)
html = buffer_to_html(buffer)
# => "<pre class=\"raxol-terminal\">...</pre>"

html = buffer_to_html(buffer, theme: :nord, show_cursor: true, cursor_position: {5, 3})
# => "<pre class=\"raxol-terminal raxol-theme-nord\">...</pre>"

buffer_to_rows(buffer, opts \\ [])

@spec buffer_to_rows(Raxol.Core.Buffer.t(), html_opts()) :: [row()]

Converts a buffer to a list of addressable rows.

Takes the same options as buffer_to_html/2 and produces the same per-row markup: joining the :html values with "\n" inside that function's <pre> wrapper reproduces its output byte for byte.

Rows exist so a caller can give each one its own DOM node. With the screen as a single string, a LiveView sees one changed dynamic and resends every row on every frame, even when one cell moved.

Ids are "<css_prefix>-row-<y>": stable for a given row across frames, so a DOM patch strategy can key on them, and prefixed so two terminals on one page do not collide.

Examples

buffer = Raxol.Core.Buffer.create_blank_buffer(3, 2)
buffer_to_rows(buffer)
# => [
#      %{id: "raxol-row-0", y: 0, html: "   "},
#      %{id: "raxol-row-1", y: 1, html: "   "}
#    ]

container_attrs(arg1)

@spec container_attrs(aria_mode()) :: keyword()

Container ARIA attributes for an :aria_mode, as a keyword list.

Public because a caller that renders rows itself owns the <pre> wrapper (see buffer_to_rows/2) and has to emit the same container semantics. Two hand-written copies drift, and the failure mode is a nested or duplicated live region that re-reads the whole screen.

html_to_rows(html, opts \\ [])

@spec html_to_rows(String.t(), html_opts()) :: [row()]

Splits a screen rendered by buffer_to_html/2 back into buffer_to_rows/2 rows.

The LiveView render backend broadcasts the screen as one already-rendered string, so a LiveView that wants per-row DOM nodes has no buffer to call buffer_to_rows/2 on. This is the exact inverse of the "\n" join, and it leans on the same property that join already leans on: a row's html never contains a newline, because cells hold single graphemes.

Pass the same :css_prefix used to render, or the ids will not match.

style_to_classes(style, css_prefix \\ "raxol")

@spec style_to_classes(map(), String.t()) :: String.t()

Converts a style map to CSS classes.

Examples

iex> style = %{bold: true, fg_color: :blue}
iex> style_to_classes(style, "raxol")
"raxol-bold raxol-fg-blue"

style_to_inline(style)

@spec style_to_inline(map()) :: String.t()

Converts a style map to inline CSS styles.

Examples

iex> style = %{bold: true, fg_color: {255, 0, 0}}
iex> style_to_inline(style)
"font-weight: bold; color: rgb(255, 0, 0);"