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
Current section
Files
lib/raxol/terminal/ansi/kitty_parser.ex
defmodule Raxol.Terminal.ANSI.KittyParser do
@moduledoc """
Handles the parsing logic for Kitty graphics protocol sequences.
The Kitty graphics protocol uses APC (Application Program Command) sequences
with the format: `<ESC>_G<control-data>;<payload><ESC>\\`
## Control Data Format
Control data consists of key=value pairs separated by commas:
- `a` - Action: t (transmit), T (transmit+display), d (delete), etc.
- `f` - Format: 24 (RGB), 32 (RGBA), 100 (PNG)
- `o` - Compression: z (zlib)
- `t` - Transmission: d (direct), f (file), t (temp file), s (shared memory)
- `i` - Image ID
- `p` - Placement ID
- `q` - Quiet mode (0, 1, or 2)
- `s` - Width in pixels
- `v` - Height in pixels
- `m` - More data follows (0 or 1)
## Example
iex> state = KittyParser.ParserState.new()
iex> {:ok, state} = KittyParser.parse("a=t,f=32,s=100,v=100;base64data", state)
"""
alias Raxol.Core.Runtime.Log
defmodule ParserState do
@moduledoc """
Represents the state during parsing of a Kitty graphics data stream.
Tracks control parameters, chunked data, and image buffers.
"""
@type action ::
:transmit | :transmit_display | :display | :delete | :query | :frame
@type format :: :rgb | :rgba | :png | :unknown
@type compression :: :none | :zlib
@type transmission :: :direct | :file | :temp_file | :shared_memory
@type t :: %__MODULE__{
action: action(),
format: format(),
compression: compression(),
transmission: transmission(),
image_id: non_neg_integer() | nil,
placement_id: non_neg_integer() | nil,
width: non_neg_integer() | nil,
height: non_neg_integer() | nil,
x_offset: non_neg_integer(),
y_offset: non_neg_integer(),
cell_x: non_neg_integer() | nil,
cell_y: non_neg_integer() | nil,
z_index: integer(),
quiet: 0 | 1 | 2,
more_data: boolean(),
chunk_data: binary(),
pixel_buffer: binary(),
errors: [term()],
raw_control: binary()
}
defstruct action: :transmit,
format: :rgba,
compression: :none,
transmission: :direct,
image_id: nil,
placement_id: nil,
width: nil,
height: nil,
x_offset: 0,
y_offset: 0,
cell_x: nil,
cell_y: nil,
z_index: 0,
quiet: 0,
more_data: false,
chunk_data: <<>>,
pixel_buffer: <<>>,
errors: [],
raw_control: <<>>
@doc """
Create a new parser state with default values.
"""
@spec new() :: %__MODULE__{
action: :transmit,
format: :rgba,
compression: :none,
transmission: :direct,
image_id: nil,
placement_id: nil,
width: nil,
height: nil,
x_offset: 0,
y_offset: 0,
cell_x: nil,
cell_y: nil,
z_index: 0,
quiet: 0,
more_data: false,
chunk_data: <<>>,
pixel_buffer: <<>>,
errors: [],
raw_control: <<>>
}
def new, do: %__MODULE__{}
@doc """
Create a new parser state with initial dimensions.
"""
@spec new(pos_integer(), pos_integer()) :: t()
def new(width, height) do
%__MODULE__{width: width, height: height}
end
@doc """
Reset the parser state for a new image while preserving accumulated data.
"""
@spec reset(t()) :: t()
def reset(%__MODULE__{} = state) do
%{
state
| action: :transmit,
format: :rgba,
compression: :none,
more_data: false,
errors: [],
raw_control: <<>>
}
end
end
# ============================================================================
# Public API
# ============================================================================
@doc """
Parse a Kitty graphics sequence.
Expects data in the format: `<control-data>;<payload>` (without the APC wrapper).
## Examples
iex> state = KittyParser.ParserState.new()
iex> {:ok, state} = KittyParser.parse("a=t,f=32,s=100,v=100;base64data", state)
iex> state.action
:transmit
"""
@spec parse(binary(), ParserState.t()) ::
{:ok, ParserState.t()} | {:error, atom(), ParserState.t()}
def parse(<<>>, state), do: {:ok, state}
def parse(data, state) when is_binary(data) do
case split_control_payload(data) do
{control_data, payload} ->
state = %{state | raw_control: control_data}
case parse_control_data(control_data, state) do
{:ok, parsed_state} ->
handle_payload(payload, parsed_state)
end
:error ->
{:error, :invalid_format, add_error(state, :invalid_format)}
end
end
@doc """
Parse only the control data portion of a Kitty sequence.
## Examples
iex> {:ok, state} = KittyParser.parse_control_data("a=t,f=32,s=100,v=100", %ParserState{})
iex> state.action
:transmit
"""
@spec parse_control_data(binary(), ParserState.t()) ::
{:ok, ParserState.t()} | {:error, atom(), ParserState.t()}
def parse_control_data(<<>>, state), do: {:ok, state}
def parse_control_data(data, state) do
pairs = String.split(data, ",", trim: true)
parse_key_value_pairs(pairs, state)
end
@doc """
Decode a base64-encoded payload.
## Examples
iex> {:ok, decoded} = KittyParser.decode_base64_payload("SGVsbG8=")
iex> decoded
"Hello"
"""
@spec decode_base64_payload(binary()) ::
{:ok, binary()} | {:error, :invalid_base64}
def decode_base64_payload(payload) when is_binary(payload) do
case Base.decode64(payload) do
{:ok, decoded} -> {:ok, decoded}
:error -> {:error, :invalid_base64}
end
end
@doc """
Handle chunked data accumulation for multi-part transmissions.
When `m=1` is set, data is accumulated. When `m=0`, the complete
data is finalized.
## Examples
iex> state = %ParserState{more_data: true, chunk_data: "part1"}
iex> state = KittyParser.handle_chunked_data("part2", state)
iex> state.chunk_data
"part1part2"
"""
@spec handle_chunked_data(binary(), ParserState.t()) :: ParserState.t()
def handle_chunked_data(new_data, %ParserState{more_data: true} = state) do
%{state | chunk_data: state.chunk_data <> new_data}
end
def handle_chunked_data(new_data, %ParserState{more_data: false} = state) do
complete_data = state.chunk_data <> new_data
%{state | chunk_data: <<>>, pixel_buffer: complete_data}
end
@doc """
Decompress data if compression is enabled.
## Examples
iex> compressed = :zlib.compress("test data")
iex> {:ok, decompressed} = KittyParser.decompress(compressed, :zlib)
iex> decompressed
"test data"
"""
@spec decompress(binary(), ParserState.compression()) ::
{:ok, binary()} | {:error, term()}
def decompress(data, :none), do: {:ok, data}
def decompress(data, :zlib) do
{:ok, :zlib.uncompress(data)}
rescue
e -> {:error, {:decompression_failed, e}}
end
@doc """
Extract image dimensions from PNG data.
PNG header format: 8-byte signature + IHDR chunk with width/height.
"""
@spec extract_png_dimensions(binary()) ::
{:ok, {pos_integer(), pos_integer()}} | {:error, :invalid_png}
def extract_png_dimensions(
<<0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, _length::32, "IHDR", width::32,
height::32, _rest::binary>>
) do
{:ok, {width, height}}
end
def extract_png_dimensions(_), do: {:error, :invalid_png}
# ============================================================================
# Private Functions
# ============================================================================
defp split_control_payload(data) do
case :binary.split(data, ";") do
[control, payload] -> {control, payload}
[control] -> {control, <<>>}
_ -> :error
end
end
defp parse_key_value_pairs([], state), do: {:ok, state}
defp parse_key_value_pairs([pair | rest], state) do
case parse_single_pair(pair, state) do
{:ok, new_state} ->
parse_key_value_pairs(rest, new_state)
{:error, reason} ->
Log.warning("[KittyParser] Invalid key-value pair: #{pair} - #{inspect(reason)}")
# Continue parsing but record the error
parse_key_value_pairs(rest, add_error(state, {:invalid_pair, pair}))
end
end
defp parse_single_pair(pair, state) do
case String.split(pair, "=", parts: 2) do
[key, value] -> apply_key_value(key, value, state)
_ -> {:error, :malformed_pair}
end
end
defp apply_key_value("a", value, state) do
action = parse_action(value)
{:ok, %{state | action: action}}
end
defp apply_key_value("f", value, state) do
format = parse_format(value)
{:ok, %{state | format: format}}
end
defp apply_key_value("o", value, state) do
compression = parse_compression(value)
{:ok, %{state | compression: compression}}
end
defp apply_key_value("t", value, state) do
transmission = parse_transmission(value)
{:ok, %{state | transmission: transmission}}
end
defp apply_key_value("i", value, state) do
case Integer.parse(value) do
{id, ""} when id >= 0 -> {:ok, %{state | image_id: id}}
_ -> {:error, :invalid_image_id}
end
end
defp apply_key_value("p", value, state) do
case Integer.parse(value) do
{id, ""} when id >= 0 -> {:ok, %{state | placement_id: id}}
_ -> {:error, :invalid_placement_id}
end
end
defp apply_key_value("s", value, state) do
case Integer.parse(value) do
{width, ""} when width > 0 -> {:ok, %{state | width: width}}
_ -> {:error, :invalid_width}
end
end
defp apply_key_value("v", value, state) do
case Integer.parse(value) do
{height, ""} when height > 0 -> {:ok, %{state | height: height}}
_ -> {:error, :invalid_height}
end
end
defp apply_key_value("x", value, state) do
case Integer.parse(value) do
{x, ""} when x >= 0 -> {:ok, %{state | x_offset: x}}
_ -> {:error, :invalid_x_offset}
end
end
defp apply_key_value("y", value, state) do
case Integer.parse(value) do
{y, ""} when y >= 0 -> {:ok, %{state | y_offset: y}}
_ -> {:error, :invalid_y_offset}
end
end
defp apply_key_value("X", value, state) do
case Integer.parse(value) do
{x, ""} when x >= 0 -> {:ok, %{state | cell_x: x}}
_ -> {:error, :invalid_cell_x}
end
end
defp apply_key_value("Y", value, state) do
case Integer.parse(value) do
{y, ""} when y >= 0 -> {:ok, %{state | cell_y: y}}
_ -> {:error, :invalid_cell_y}
end
end
defp apply_key_value("z", value, state) do
case Integer.parse(value) do
{z, ""} -> {:ok, %{state | z_index: z}}
_ -> {:error, :invalid_z_index}
end
end
defp apply_key_value("q", value, state) do
case Integer.parse(value) do
{q, ""} when q in [0, 1, 2] -> {:ok, %{state | quiet: q}}
_ -> {:error, :invalid_quiet}
end
end
defp apply_key_value("m", value, state) do
more_data = value in ["1", "true"]
{:ok, %{state | more_data: more_data}}
end
# Ignore unknown keys (forward compatibility)
defp apply_key_value(_key, _value, state) do
{:ok, state}
end
defp parse_action("t"), do: :transmit
defp parse_action("T"), do: :transmit_display
defp parse_action("p"), do: :display
defp parse_action("d"), do: :delete
defp parse_action("q"), do: :query
defp parse_action("f"), do: :frame
defp parse_action("a"), do: :frame
defp parse_action("c"), do: :frame
defp parse_action("s"), do: :frame
defp parse_action(_), do: :transmit
defp parse_format("24"), do: :rgb
defp parse_format("32"), do: :rgba
defp parse_format("100"), do: :png
defp parse_format(_), do: :unknown
defp parse_compression("z"), do: :zlib
defp parse_compression(_), do: :none
defp parse_transmission("d"), do: :direct
defp parse_transmission("f"), do: :file
defp parse_transmission("t"), do: :temp_file
defp parse_transmission("s"), do: :shared_memory
defp parse_transmission(_), do: :direct
defp handle_payload(<<>>, state), do: {:ok, state}
defp handle_payload(payload, state) do
case decode_base64_payload(payload) do
{:ok, decoded} ->
decompressed_result = decompress(decoded, state.compression)
handle_decompressed_payload(decompressed_result, state)
{:error, :invalid_base64} ->
{:error, :invalid_base64, add_error(state, :invalid_base64)}
end
end
defp handle_decompressed_payload({:ok, data}, state) do
updated_state = handle_chunked_data(data, state)
{:ok, updated_state}
end
defp handle_decompressed_payload({:error, reason}, state) do
{:error, :decompression_failed, add_error(state, {:decompression_failed, reason})}
end
defp add_error(state, error) do
%{state | errors: [error | state.errors]}
end
end