FrenchCurve.Capability (FrenchCurve v0.1.3)

Copy Markdown View Source

Chooses a graphics protocol from the environment and maps it to a backend module.

Detection reads KITTY_WINDOW_ID, TERM, TERM_PROGRAM, WEZTERM_PANE, LC_TERMINAL and KONSOLE_VERSION. A variable exported empty counts as unset. The first matching rule wins, in this order:

environmentprotocol
KITTY_WINDOW_ID set:kitty
TERM contains kitty or ghostty:kitty
TERM_PROGRAM is WezTerm:kitty
WEZTERM_PANE set:kitty
TERM_PROGRAM is iTerm.app:iterm2
LC_TERMINAL is iTerm2:iterm2
KONSOLE_VERSION set:sixel
TERM contains foot or mlterm:sixel
anything else:braille

:braille is the fallback because it needs no graphics support at all.

WEZTERM_PANE and LC_TERMINAL cover terminals whose TERM_PROGRAM did not survive the trip: multiplexers drop it, and ssh forwards only the variables its client is configured to send, which for iTerm2 is LC_TERMINAL under the usual SendEnv LC_*. Detecting a remote client therefore means passing that session's environment to detect/1, not the environment of the host the program runs on.

A terminal detected as :kitty does not necessarily speak all of the protocol — ask placements?/1 before storing an image under an id and placing it.

Why iTerm is not :kitty

iTerm from 3.5 does understand kitty's transmit-and-display, and was detected as :kitty for a while on the strength of it. That is a trap. A kitty image is an overlay: it is not made of cells, so writing text over it does not rub it out, leaving the alternate screen does not discard it, and the only thing that removes it is an explicit delete addressed to its id — which iTerm does not implement. Images pile up over the text while the program runs and are still on the screen after it exits.

Its own protocol has none of that problem, because an inline image is cells: it scrolls, gets overwritten and disappears with the screen it was drawn on, exactly like text.

case FrenchCurve.Capability.detect() do
  :braille -> IO.write(FrenchCurve.to_terminal(raster, :braille))
  protocol -> IO.write(FrenchCurve.to_terminal(raster, protocol))
end

Summary

Functions

Returns the backend module implementing protocol.

Detects the protocol for the current OS environment.

Detects the protocol for env, a map of environment variable name to string value.

The protocol replies implies, or nil when they name nothing usable.

Whether this terminal keeps images under an id and draws them again by placement.

Whether the terminal described by env keeps images under an id and places them.

The bytes to write to a terminal to ask what it supports.

Whether replies contains the device attributes answer that ends the exchange.

Functions

backend(atom)

@spec backend(atom()) :: module()

Returns the backend module implementing protocol.

Accepts :kitty, :sixel, :iterm2 and :braille; any other value raises FunctionClauseError. Every returned module exports render/2 taking a FrenchCurve.Raster and a keyword list.

detect()

@spec detect() :: atom()

Detects the protocol for the current OS environment.

The result is computed once and cached in :persistent_term under {FrenchCurve.Capability, :detected} for the life of the VM, so a later change to the environment is not picked up. Use detect/1 to detect against a specific environment.

Returns :kitty, :sixel, :iterm2 or :braille.

detect(env)

@spec detect(map()) :: atom()

Detects the protocol for env, a map of environment variable name to string value.

Uncached, and missing keys are treated as unset. Returns :kitty, :sixel, :iterm2 or :braille; :braille when no rule matches.

iex> FrenchCurve.Capability.detect(%{"TERM" => "xterm-kitty"})
:kitty

from_probe(replies)

@spec from_probe(binary()) :: atom() | nil

The protocol replies implies, or nil when they name nothing usable.

The terminal's own name wins: a terminal that says it is kitty, ghostty or WezTerm gets :kitty, and one that says it is iTerm2 gets :iterm2 even though it answers kitty's queries. Only when the name is absent or unrecognised do the device attributes decide, where a 4 among them is sixel support.

iex> FrenchCurve.Capability.from_probe("\eP>|kitty(0.32.2)\e\\\e[?62;22c")
:kitty

iex> FrenchCurve.Capability.from_probe("\e[?62;4;6c")
:sixel

iex> FrenchCurve.Capability.from_probe("\e[?62;22c")
nil

placements?()

@spec placements?() :: boolean()

Whether this terminal keeps images under an id and draws them again by placement.

detect/1 saying :kitty does not have to mean the whole protocol is there: a terminal can understand transmit-and-display (a=T) without keeping images under an id for FrenchCurve.Backend.Kitty.place/2 to draw again. Sending one a store and then a placement draws nothing at all — it stores a picture it never shows, then is handed a command it does not understand.

FrenchCurve.frame/3 asks this and picks accordingly; ask it yourself only if you are building the sequence by hand.

iex> FrenchCurve.Capability.placements?(%{"TERM" => "xterm-kitty"})
true
iex> FrenchCurve.Capability.placements?(%{"TERM_PROGRAM" => "iTerm.app"})
false
iex> FrenchCurve.Capability.placements?(%{"TERM" => "xterm-256color"})
false

placements?(env)

@spec placements?(map()) :: boolean()

Whether the terminal described by env keeps images under an id and places them.

As placements?/0, against a given environment map rather than the OS one. Uncached.

probe()

@spec probe() :: binary()

The bytes to write to a terminal to ask what it supports.

Two queries: XTVERSION (CSI > q), which a terminal answers with its name, and primary device attributes (CSI c), which it answers with a list of features. The attributes reply is what probe_complete?/1 watches for, because nearly every terminal answers it while only some answer XTVERSION — so it arrives last and marks the end of the exchange.

A terminal that answers neither says nothing at all, which is why a caller must also give up on a timeout.

iex> FrenchCurve.Capability.probe()
"\e[>q\e[c"

probe_complete?(replies)

@spec probe_complete?(binary()) :: boolean()

Whether replies contains the device attributes answer that ends the exchange.

iex> FrenchCurve.Capability.probe_complete?("\e[?62;4c")
true

iex> FrenchCurve.Capability.probe_complete?("\eP>|kitty(0.32.2)\e\\")
false