Packages

Phoenix LiveView integration for Raxol. Render the same TEA app in web browsers with real-time updates, keyboard/mouse events, and themeable CSS.

Current section

Files

Jump to
raxol_liveview lib raxol live_view terminal_component.ex
Raw

lib/raxol/live_view/terminal_component.ex

defmodule Raxol.LiveView.TerminalComponent do
@moduledoc """
Phoenix LiveComponent for embedding terminal buffers in LiveView.
Provides a drop-in terminal component with event handling, theming,
and performance optimizations for 60fps rendering.
## Features
- Event handling (keyboard, mouse, clipboard)
- Multiple themes (Nord, Dracula, Solarized, Monokai)
- Cursor rendering and styling
- Focus management
- Accessibility (ARIA labels, keyboard navigation)
- Performance monitoring (logs if > 16ms)
## Usage
# In your LiveView template
<.live_component
module={Raxol.LiveView.TerminalComponent}
id="terminal-1"
buffer={@buffer}
theme={:nord}
on_keypress={&handle_terminal_input/1}
/>
## Event Handling
The component can emit the following events:
- `on_keypress` - Keyboard input (includes key, modifiers)
- `on_click` - Mouse click (includes x, y coordinates)
- `on_paste` - Clipboard paste (includes text)
- `on_focus` - Terminal gained focus
- `on_blur` - Terminal lost focus
## Complete Example
defmodule MyAppWeb.TerminalLive do
use MyAppWeb, :live_view
alias Raxol.Core.{Buffer, Box}
alias Raxol.LiveView.TerminalComponent
def mount(_params, _session, socket) do
buffer = Buffer.create_blank_buffer(80, 24)
buffer = Box.draw_box(buffer, 0, 0, 80, 24, :double)
buffer = Buffer.write_at(buffer, 2, 2, "Welcome to Raxol!", %{bold: true})
{:ok, assign(socket,
buffer: buffer,
cursor_pos: {2, 4}
)}
end
def render(assigns) do
~H\"\"\"
<div class="terminal-container">
<.live_component
module={TerminalComponent}
id="my-terminal"
buffer={@buffer}
theme={:dracula}
cursor_position={@cursor_pos}
on_keypress={fn event -> send(self(), {:keypress, event}) end}
/>
</div>
\"\"\"
end
def handle_info({:keypress, %{key: key}}, socket) do
# Handle keyboard input
new_buffer = process_input(socket.assigns.buffer, key)
{:noreply, assign(socket, buffer: new_buffer)}
end
end
"""
use Phoenix.LiveComponent
import Phoenix.HTML, only: [raw: 1]
alias Raxol.LiveView.TerminalBridge
@impl true
def mount(socket) do
{:ok, socket}
end
@impl true
def update(assigns, socket) do
start_time = System.monotonic_time(:millisecond)
socket =
socket
|> assign(:buffer, Map.get(assigns, :buffer))
|> assign(:id, Map.get(assigns, :id))
|> assign(:theme, Map.get(assigns, :theme, :default))
|> assign(:cursor_position, Map.get(assigns, :cursor_position))
|> assign(:cursor_style, Map.get(assigns, :cursor_style, :block))
|> assign(:css_prefix, Map.get(assigns, :css_prefix, "raxol"))
|> assign(:show_cursor, Map.get(assigns, :show_cursor, true))
|> assign(:on_keypress, Map.get(assigns, :on_keypress))
|> assign(:on_click, Map.get(assigns, :on_click))
|> assign(:on_paste, Map.get(assigns, :on_paste))
|> assign(:on_focus, Map.get(assigns, :on_focus))
|> assign(:on_blur, Map.get(assigns, :on_blur))
|> assign(:focusable, Map.get(assigns, :focusable, true))
|> assign(:debug_diff, Map.get(assigns, :debug_diff, false))
# Performance monitoring
render_time = System.monotonic_time(:millisecond) - start_time
if render_time > 16 do
require Logger
Logger.warning("Terminal component update exceeded 60fps budget: #{render_time}ms")
end
{:ok, socket}
end
@impl true
def render(assigns) do
~H"""
<div
id={"#{@id}-container"}
class="raxol-container"
phx-hook="RaxolTerminal"
data-terminal-id={@id}
>
<%= if @debug_diff and assigns[:old_buffer] do %>
<%= raw(TerminalBridge.buffer_diff_to_html(@old_buffer, @buffer,
theme: @theme,
css_prefix: @css_prefix
)) %>
<% else %>
<%= raw(TerminalBridge.buffer_to_html(@buffer,
theme: @theme,
css_prefix: @css_prefix,
show_cursor: @show_cursor,
cursor_position: @cursor_position,
cursor_style: @cursor_style
)) %>
<% end %>
<%= if @focusable do %>
<input
type="text"
id={"#{@id}-input"}
class="raxol-input-capture"
style="position: absolute; left: -9999px;"
phx-keydown="terminal_keydown"
phx-target={@myself}
phx-blur="terminal_blur"
phx-focus="terminal_focus"
autocomplete="off"
autocorrect="off"
autocapitalize="off"
spellcheck="false"
/>
<% end %>
</div>
"""
end
@impl true
def handle_event("terminal_keydown", %{"key" => key} = params, socket) do
event = %{
key: key,
code: Map.get(params, "code"),
alt: Map.get(params, "altKey", false),
ctrl: Map.get(params, "ctrlKey", false),
shift: Map.get(params, "shiftKey", false),
meta: Map.get(params, "metaKey", false)
}
if handler = socket.assigns.on_keypress do
handler.(event)
end
{:noreply, socket}
end
@impl true
def handle_event("terminal_click", %{"x" => x, "y" => y}, socket) do
event = %{x: x, y: y}
if handler = socket.assigns.on_click do
handler.(event)
end
{:noreply, socket}
end
@impl true
def handle_event("terminal_paste", %{"text" => text}, socket) do
event = %{text: text}
if handler = socket.assigns.on_paste do
handler.(event)
end
{:noreply, socket}
end
@impl true
def handle_event("terminal_focus", _params, socket) do
if handler = socket.assigns.on_focus do
handler.()
end
{:noreply, socket}
end
@impl true
def handle_event("terminal_blur", _params, socket) do
if handler = socket.assigns.on_blur do
handler.()
end
{:noreply, socket}
end
end