Current section

Files

Jump to
ghostty lib ghostty terminal.ex
Raw

lib/ghostty/terminal.ex

defmodule Ghostty.Terminal do
@moduledoc """
A managed terminal emulator backed by libghostty-vt.
Each terminal is a GenServer that owns a libghostty-vt terminal instance.
All operations are serialized through the GenServer to ensure thread safety
(libghostty-vt terminal instances are not thread-safe).
## Examples
{:ok, term} = Ghostty.Terminal.start_link(cols: 120, rows: 40)
Ghostty.Terminal.write(term, File.read!("recording.vt"))
{:ok, html} = Ghostty.Terminal.snapshot(term, :html)
## Supervision
children = [
{Ghostty.Terminal, name: :main, cols: 80, rows: 24, max_scrollback: 50_000}
]
## Effects
Terminal programs can trigger side effects via VT sequences (query
responses, bell, title changes). These are forwarded as messages to
the process that called `start_link/1`:
* `{:pty_write, binary}` — query responses to write back to the PTY
* `:bell` — BEL character
* `:title_changed` — title change via OSC 2
## Snapshot formats
* `:plain` — plain text with all styling stripped
* `:html` — HTML with inline styles preserving all colors and attributes
* `:vt` — raw VT escape sequences (round-trippable)
"""
use GenServer
alias Ghostty.Terminal.Nif
@type format :: :plain | :html | :vt
@type rgb :: {byte(), byte(), byte()}
@type cell ::
{binary(), rgb() | nil, rgb() | nil, non_neg_integer()}
@type cursor_style :: :bar | :block | :underline | :block_hollow
@type cursor_state :: %{
x: non_neg_integer() | nil,
y: non_neg_integer() | nil,
visible: boolean(),
blinking: boolean(),
style: cursor_style(),
wide_tail: boolean(),
color: rgb() | nil
}
@type mouse_modes :: %{
tracking: boolean(),
x10: boolean(),
normal: boolean(),
button: boolean(),
any: boolean(),
sgr: boolean()
}
@type scrollbar :: %{
total: non_neg_integer(),
offset: non_neg_integer(),
len: non_neg_integer()
}
@type render_state :: %{
cells: [[cell()]],
cursor: cursor_state(),
mouse: mouse_modes(),
scrollbar: scrollbar(),
focus_reporting: boolean()
}
@type option ::
{:cols, pos_integer()}
| {:rows, pos_integer()}
| {:max_scrollback, non_neg_integer()}
| {:name, GenServer.name()}
@unsupported_private_modes ["\e[?1034h", "\e[?1034l"]
@has_render_state_nif function_exported?(Ghostty.Terminal.Nif, :nif_render_state, 1)
@enforce_keys [:ref]
defstruct [:ref, :cols, :rows, :mouse_modes, :focus_reporting]
@doc """
Starts a terminal process linked to the caller.
Effect messages (`:bell`, `:title_changed`, `{:pty_write, data}`)
are sent to the calling process.
## Options
* `:cols` - number of columns (default: `80`)
* `:rows` - number of rows (default: `24`)
* `:max_scrollback` - maximum scrollback lines (default: `10_000`)
* `:name` - GenServer name registration
"""
@spec start_link([option()]) :: GenServer.on_start()
def start_link(opts \\ []) do
{server_opts, init_opts} = Keyword.split(opts, [:name])
init_opts = Keyword.put(init_opts, :owner, self())
GenServer.start_link(__MODULE__, init_opts, server_opts)
end
@doc "Returns a child spec for use in supervision trees."
def child_spec(opts) do
id = Keyword.get(opts, :id, Keyword.get(opts, :name, __MODULE__))
%{
id: id,
start: {__MODULE__, :start_link, [opts]},
restart: :permanent,
shutdown: 5_000
}
end
@doc """
Writes VT-encoded data to the terminal.
The terminal's VT parser processes escape sequences and updates
the internal screen, cursor, and style state. Accepts iodata.
## Examples
Ghostty.Terminal.write(term, "hello world")
Ghostty.Terminal.write(term, "\\e[31mred\\e[0m")
Ghostty.Terminal.write(term, ["line 1\\r\\n", "line 2\\r\\n"])
"""
@spec write(GenServer.server(), iodata()) :: :ok
def write(terminal, data) do
case data |> IO.iodata_to_binary() |> drop_unsupported_private_modes() do
"" -> :ok
sanitized -> GenServer.call(terminal, {:write, sanitized})
end
end
@doc """
Resizes the terminal. Text reflow is handled automatically.
## Examples
Ghostty.Terminal.resize(term, 120, 40)
"""
@spec resize(GenServer.server(), pos_integer(), pos_integer()) :: :ok
def resize(terminal, cols, rows) do
validate_pos_integer!(:cols, cols)
validate_pos_integer!(:rows, rows)
GenServer.call(terminal, {:resize, cols, rows})
end
@doc """
Performs a full terminal reset (RIS — Reset to Initial State).
"""
@spec reset(GenServer.server()) :: :ok
def reset(terminal) do
GenServer.call(terminal, :reset)
end
@doc """
Returns a snapshot of the terminal screen content.
## Formats
* `:plain` — plain text, stripped of all styling (default)
* `:html` — HTML with inline styles preserving colors and attributes
* `:vt` — raw VT escape sequences
## Examples
{:ok, text} = Ghostty.Terminal.snapshot(term)
{:ok, html} = Ghostty.Terminal.snapshot(term, :html)
{:ok, vt} = Ghostty.Terminal.snapshot(term, :vt)
"""
@spec snapshot(GenServer.server(), format()) :: {:ok, binary()}
def snapshot(terminal, format \\ :plain) do
GenServer.call(terminal, {:snapshot, format})
end
@doc """
Returns the terminal screen as a grid of cells.
Each cell is `{grapheme, fg, bg, flags}` where:
* `grapheme` — UTF-8 binary (empty for blank cells)
* `fg` / `bg` — `{r, g, b}` tuples or `nil`
* `flags` — bitmask (see `Ghostty.Terminal.Cell` for helpers)
## Examples
rows = Ghostty.Terminal.cells(term)
for row <- rows, {char, fg, _bg, flags} <- row, char != "" do
if Ghostty.Terminal.Cell.bold?({char, fg, nil, flags}), do: IO.write("*")
IO.write(char)
end
"""
@spec cells(GenServer.server()) :: [[cell()]]
def cells(terminal) do
GenServer.call(terminal, :cells)
end
@doc """
Encodes a key event into a terminal escape sequence.
Returns `{:ok, sequence}` with the bytes to send to the PTY,
or `:none` if the key event produces no output.
## Examples
Ghostty.Terminal.input_key(term, %Ghostty.KeyEvent{key: :enter})
# => {:ok, "\\r"}
Ghostty.Terminal.input_key(term, %Ghostty.KeyEvent{key: :c, mods: [:ctrl]})
# => {:ok, <<3>>}
"""
@spec input_key(GenServer.server(), Ghostty.KeyEvent.t()) :: {:ok, binary()} | :none
def input_key(terminal, %Ghostty.KeyEvent{} = event) do
GenServer.call(terminal, {:input_key, event})
end
@doc """
Encodes a mouse event into a terminal escape sequence.
Returns `{:ok, sequence}` or `:none` if mouse tracking is not
enabled by the running program.
"""
@spec input_mouse(GenServer.server(), Ghostty.MouseEvent.t()) :: {:ok, binary()} | :none
def input_mouse(terminal, %Ghostty.MouseEvent{} = event) do
GenServer.call(terminal, {:input_mouse, event})
end
@doc """
Encodes a focus event into a terminal escape sequence.
## Examples
{:ok, seq} = Ghostty.Terminal.encode_focus(true) # => "\\e[I"
{:ok, seq} = Ghostty.Terminal.encode_focus(false) # => "\\e[O"
"""
@spec encode_focus(boolean()) :: {:ok, binary()} | :none
def encode_focus(gained?) do
Nif.nif_encode_focus(gained?)
end
@doc """
Returns the scrollbar state for the terminal viewport.
"""
@spec scrollbar(GenServer.server()) :: scrollbar()
def scrollbar(terminal) do
GenServer.call(terminal, :scrollbar)
end
@doc """
Returns whether focus reporting (DEC mode 1004) is enabled.
"""
@spec focus_reporting?(GenServer.server()) :: boolean()
def focus_reporting?(terminal) do
GenServer.call(terminal, :focus_reporting?)
end
@doc """
Scrolls the terminal viewport.
Positive `delta` scrolls down (towards newer content),
negative scrolls up (towards scrollback history).
"""
@spec scroll(GenServer.server(), integer()) :: :ok
def scroll(terminal, delta) do
GenServer.call(terminal, {:scroll, delta})
end
@doc """
Returns the current cursor position as `{col, row}` (0-indexed).
"""
@spec cursor(GenServer.server()) :: {non_neg_integer(), non_neg_integer()}
def cursor(terminal) do
GenServer.call(terminal, :cursor)
end
@doc """
Returns the current terminal dimensions as `{cols, rows}`.
"""
@spec size(GenServer.server()) :: {pos_integer(), pos_integer()}
def size(terminal) do
GenServer.call(terminal, :size)
end
@doc """
Returns the current render-state cursor metadata for the visible viewport.
"""
@spec cursor_state(GenServer.server()) :: cursor_state()
def cursor_state(terminal) do
terminal
|> render_state()
|> Map.fetch!(:cursor)
end
@doc """
Returns the current visible render-state cells together with cursor metadata.
"""
@spec render_state(GenServer.server()) :: render_state()
def render_state(terminal) do
terminal
|> GenServer.call(:render_state)
|> render_state_from_nif()
end
@doc """
Returns the current terminal mouse reporting mode state.
"""
@spec mouse_modes(GenServer.server()) :: mouse_modes()
def mouse_modes(terminal) do
terminal
|> render_state()
|> Map.fetch!(:mouse)
end
# --- GenServer Callbacks ---
@impl true
def init(opts) do
cols = Keyword.get(opts, :cols, 80)
rows = Keyword.get(opts, :rows, 24)
max_scrollback = Keyword.get(opts, :max_scrollback, 10_000)
validate_pos_integer!(:cols, cols)
validate_pos_integer!(:rows, rows)
validate_non_neg_integer!(:max_scrollback, max_scrollback)
ref = Nif.nif_new(cols, rows, max_scrollback)
Nif.nif_set_effect_pid(ref, Keyword.fetch!(opts, :owner))
{:ok,
%__MODULE__{
ref: ref,
cols: cols,
rows: rows,
mouse_modes: default_mouse_modes(),
focus_reporting: false
}}
rescue
e in ErlangError ->
{:stop, {:nif_not_loaded, Exception.message(e)}}
e in ArgumentError ->
{:stop, {:invalid_option, Exception.message(e)}}
end
defp validate_pos_integer!(_name, value) when is_integer(value) and value > 0, do: :ok
defp validate_pos_integer!(name, value) do
raise ArgumentError, "expected #{name} to be a positive integer, got: #{inspect(value)}"
end
defp validate_non_neg_integer!(_name, value) when is_integer(value) and value >= 0, do: :ok
defp validate_non_neg_integer!(name, value) do
raise ArgumentError,
"expected #{name} to be a non-negative integer, got: #{inspect(value)}"
end
@impl true
def handle_call({:write, data}, _from, state) do
Nif.nif_vt_write(state.ref, data)
{:reply, :ok,
%{
state
| mouse_modes: update_mouse_modes(state.mouse_modes, data),
focus_reporting: update_focus_reporting(state.focus_reporting, data)
}}
end
def handle_call({:resize, cols, rows}, _from, state) do
Nif.nif_resize(state.ref, cols, rows)
{:reply, :ok, %{state | cols: cols, rows: rows}}
end
def handle_call(:reset, _from, state) do
Nif.nif_reset(state.ref)
{:reply, :ok, %{state | mouse_modes: default_mouse_modes(), focus_reporting: false}}
end
def handle_call({:snapshot, format}, _from, state) do
result = Nif.nif_snapshot(state.ref, Atom.to_string(format))
{:reply, {:ok, result}, state}
end
def handle_call({:scroll, delta}, _from, state) do
Nif.nif_scroll(state.ref, delta)
{:reply, :ok, state}
end
def handle_call(:cursor, _from, state) do
{:reply, Nif.nif_get_cursor(state.ref), state}
end
def handle_call(:size, _from, state) do
{:reply, {state.cols, state.rows}, state}
end
def handle_call(:cells, _from, state) do
{:reply, Nif.nif_render_cells(state.ref), state}
end
def handle_call(:scrollbar, _from, state) do
{:reply, scrollbar_from_nif(Nif.nif_scrollbar(state.ref)), state}
end
def handle_call(:focus_reporting?, _from, state) do
{:reply, state.focus_reporting, state}
end
def handle_call(:render_state, _from, state) do
raw =
if @has_render_state_nif do
Nif.nif_render_state(state.ref)
else
fallback_render_state(state.ref)
end
{:reply, {raw, state.mouse_modes, Nif.nif_scrollbar(state.ref), state.focus_reporting}, state}
end
def handle_call({:input_key, event}, _from, state) do
result =
with {:ok, action} <- Ghostty.KeyEvent.action_to_int(event.action),
{:ok, key} <- Ghostty.KeyEvent.key_to_int(event.key) do
Nif.nif_encode_key(
state.ref,
action,
key,
Ghostty.Mods.to_bitmask(event.mods),
event.utf8 || "",
event.unshifted_codepoint || 0
)
else
:error -> {:error, :invalid_key_event}
end
{:reply, result, state}
end
def handle_call({:input_mouse, event}, _from, state) do
result =
with {:ok, action} <- Ghostty.MouseEvent.action_to_int(event.action),
{:ok, button} <- Ghostty.MouseEvent.button_to_int(event.button) do
Nif.nif_encode_mouse(
state.ref,
action,
button,
Ghostty.Mods.to_bitmask(event.mods),
event.x,
event.y
)
else
:error -> {:error, :invalid_mouse_event}
end
{:reply, result, state}
end
defp drop_unsupported_private_modes(data) do
Enum.reduce(@unsupported_private_modes, data, &:binary.replace(&2, &1, "", [:global]))
end
defp render_state_from_nif({raw_render_state, mouse_modes, scrollbar_tuple, focus_reporting}) do
raw_render_state
|> render_state_from_nif_raw()
|> Map.put(:mouse, mouse_modes_from_nif(mouse_modes))
|> Map.put(:scrollbar, scrollbar_from_nif(scrollbar_tuple))
|> Map.put(:focus_reporting, focus_reporting)
end
defp render_state_from_nif_raw({cells, cursor_tuple, _mouse_tuple}) do
%{cells: cells, cursor: cursor_state_from_nif(cursor_tuple)}
end
defp render_state_from_nif_raw({cells, cursor_tuple}) do
%{cells: cells, cursor: cursor_state_from_nif(cursor_tuple)}
end
defp fallback_render_state(ref) do
{x, y} = Nif.nif_get_cursor(ref)
{Nif.nif_render_cells(ref), {true, x, y, true, false, :block, false, nil}, fallback_mouse_modes(ref)}
end
defp scrollbar_from_nif({total, offset, len}) do
%{total: total, offset: offset, len: len}
end
defp cursor_state_from_nif({has_position, x, y, visible, blinking, style, wide_tail, color}) do
%{
x: if(has_position, do: x, else: nil),
y: if(has_position, do: y, else: nil),
visible: visible,
blinking: blinking,
style: style,
wide_tail: has_position and wide_tail,
color: color
}
end
defp mouse_modes_from_nif(%{} = mouse_modes), do: mouse_modes
defp mouse_modes_from_nif({tracking, x10, normal, button, any, sgr}) do
%{
tracking: tracking,
x10: x10,
normal: normal,
button: button,
any: any,
sgr: sgr
}
end
defp fallback_mouse_modes(ref) do
Nif.nif_mouse_modes(ref) |> mouse_modes_from_nif()
rescue
ErlangError -> default_mouse_modes()
end
defp default_mouse_modes do
%{tracking: false, x10: false, normal: false, button: false, any: false, sgr: false}
end
defp update_focus_reporting(focus_reporting, data) do
focus_reporting = if String.contains?(data, "\ec"), do: false, else: focus_reporting
Regex.scan(~r/\e\[\?1004(h|l)/, data)
|> Enum.reduce(focus_reporting, fn [_, value], _acc ->
value == "h"
end)
end
defp update_mouse_modes(mouse_modes, data) do
mouse_modes =
if String.contains?(data, "\ec") do
default_mouse_modes()
else
mouse_modes
end
Regex.scan(~r/\e\[\?(9|1000|1002|1003|1006)(h|l)/, data)
|> Enum.reduce(mouse_modes, fn [_, mode, value], acc ->
enabled? = value == "h"
case mode do
"9" -> %{acc | x10: enabled?} |> normalize_mouse_modes()
"1000" -> %{acc | normal: enabled?} |> normalize_mouse_modes()
"1002" -> %{acc | button: enabled?} |> normalize_mouse_modes()
"1003" -> %{acc | any: enabled?} |> normalize_mouse_modes()
"1006" -> %{acc | sgr: enabled?} |> normalize_mouse_modes()
end
end)
end
defp normalize_mouse_modes(mouse_modes) do
%{
mouse_modes
| tracking: mouse_modes.x10 or mouse_modes.normal or mouse_modes.button or mouse_modes.any
}
end
end