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

lib/raxol/terminal/image.ex

# Mogrify is an optional dep; guarded at runtime
defmodule Raxol.Terminal.Image do
@compile {:no_warn_undefined, Mogrify}
@moduledoc """
Unified facade for terminal inline image display.
Auto-detects the best available graphics protocol (Kitty, iTerm2, Sixel)
and encodes image data as a terminal escape sequence.
## Usage
# Display from file path
{:ok, escape_seq} = Image.display("logo.png", width: 20, height: 10)
IO.write(escape_seq)
# Display raw PNG bytes
{:ok, escape_seq} = Image.display(png_binary, format: :png)
# Force a specific protocol
{:ok, escape_seq} = Image.display("photo.jpg", protocol: :iterm2)
# Check support
Image.supported?() #=> true
Image.detect_protocol() #=> :kitty
"""
alias Raxol.Terminal.ANSI.KittyGraphics
alias Raxol.Terminal.ImageCache
@type protocol :: :kitty | :iterm2 | :sixel | :unsupported
@type display_opts :: [
width: pos_integer(),
height: pos_integer(),
protocol: protocol(),
format: :png | :jpeg | :gif,
preserve_aspect: boolean(),
z_index: integer()
]
@doc """
Detects the best available image protocol for the current terminal.
Priority: Kitty > iTerm2 > Sixel > :unsupported.
"""
@spec detect_protocol() :: protocol()
def detect_protocol do
term = System.get_env("TERM", "")
term_program = System.get_env("TERM_PROGRAM", "")
cond do
kitty_supported?(term, term_program) -> :kitty
iterm2_supported?(term_program) -> :iterm2
sixel_supported?(term) -> :sixel
true -> :unsupported
end
end
@doc """
Returns true if any image protocol is supported.
"""
@spec supported?() :: boolean()
def supported?, do: detect_protocol() != :unsupported
@doc """
Encodes an image for display in the terminal.
The source can be a file path (string) or raw image bytes (binary).
Returns the escape sequence to write to the terminal.
## Options
* `:width` - Width in terminal cells
* `:height` - Height in terminal cells
* `:protocol` - Override auto-detected protocol
* `:format` - Image format hint (:png, :jpeg, :gif)
* `:preserve_aspect` - Preserve aspect ratio (default: true)
* `:z_index` - Z-index layer for Kitty protocol (default: 0)
"""
@spec display(binary(), display_opts()) :: {:ok, binary()} | {:error, term()}
def display(source, opts \\ []) do
protocol = Keyword.get(opts, :protocol) || detect_protocol()
cache_opts = %{
protocol: protocol,
width: opts[:width],
height: opts[:height]
}
ImageCache.fetch(source, cache_opts, fn ->
with {:ok, data} <- load_image(source),
{:ok, resized} <- maybe_resize(data, opts) do
encode(resized, protocol, opts)
end
end)
end
# -- Protocol detection --
defp kitty_supported?(term, term_program) do
String.contains?(term, "kitty") or
term_program in ["kitty", "WezTerm", "ghostty"]
end
defp iterm2_supported?(term_program) do
term_program == "iTerm.app"
end
defp sixel_supported?(term) do
term in ["xterm-sixel", "mintty"] or String.contains?(term, "sixel")
end
# -- Image loading --
defp load_image(source) when is_binary(source) do
case File.read(source) do
{:ok, data} -> {:ok, data}
{:error, :enoent} -> {:ok, source}
{:error, :eisdir} -> {:error, :eisdir}
{:error, _reason} -> {:ok, source}
end
end
# -- Resize --
defp maybe_resize(data, opts) do
width = Keyword.get(opts, :width)
height = Keyword.get(opts, :height)
if width || height do
resize_with_mogrify(data, width, height)
else
{:ok, data}
end
end
defp resize_with_mogrify(data, width, height) do
if Code.ensure_loaded?(Mogrify) do
try do
tmp_dir = System.tmp_dir!()
id = :erlang.unique_integer([:positive])
input_path = Path.join(tmp_dir, "raxol_img_#{id}_in")
output_path = Path.join(tmp_dir, "raxol_img_#{id}_out.png")
File.write!(input_path, data)
geometry =
case {width, height} do
{w, nil} -> "#{w * 8}x"
{nil, h} -> "x#{h * 16}"
{w, h} -> "#{w * 8}x#{h * 16}"
end
_ =
Mogrify.open(input_path)
|> Mogrify.resize(geometry)
|> Mogrify.format("png")
|> Mogrify.save(path: output_path)
result = File.read(output_path)
_ = File.rm(input_path)
_ = File.rm(output_path)
result
rescue
e -> {:error, {:resize_failed, Exception.message(e)}}
end
else
{:ok, data}
end
end
# -- Protocol encoding --
defp encode(data, :kitty, opts) do
z_index = Keyword.get(opts, :z_index, 0)
image =
KittyGraphics.new(1, 1)
|> KittyGraphics.set_data(data)
|> KittyGraphics.set_format(:png)
|> KittyGraphics.transmit_image(%{})
|> KittyGraphics.place_image(%{z: z_index})
case KittyGraphics.encode(image) do
<<>> -> {:error, :empty_image}
sequence -> {:ok, sequence}
end
end
defp encode(data, :iterm2, opts) do
width = Keyword.get(opts, :width)
height = Keyword.get(opts, :height)
preserve = Keyword.get(opts, :preserve_aspect, true)
w_param = if width, do: "#{width}", else: "auto"
h_param = if height, do: "#{height}", else: "auto"
aspect_flag = if preserve, do: "1", else: "0"
b64 = Base.encode64(data)
size = byte_size(data)
sequence =
"\e]1337;File=inline=1;width=#{w_param};height=#{h_param};" <>
"preserveAspectRatio=#{aspect_flag};size=#{size};" <>
"name=image;base64,#{b64}\a"
{:ok, sequence}
end
defp encode(data, :sixel, _opts) do
alias Raxol.Terminal.ANSI.{SixelGraphics, SixelRenderer}
with {:ok, img} <-
SixelGraphics.from_image_data(data, :png, %{max_colors: 64}) do
SixelRenderer.render_image(%{
pixel_buffer: img.pixel_buffer,
palette: img.palette,
attributes: %{width: img.width, height: img.height}
})
end
end
defp encode(_data, :unsupported, _opts) do
{:error, :unsupported_protocol}
end
defp encode(_data, protocol, _opts) do
{:error, {:unknown_protocol, protocol}}
end
end