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/image_cache.ex
defmodule Raxol.Terminal.ImageCache do
@moduledoc """
ETS-backed cache for decoded and encoded terminal images.
Caches expensive operations:
- PNG decode results (raw pixel data)
- Sixel/Kitty encoded output for given parameters
Keys are `{content_hash, opts_hash}` tuples. Entries expire after a
configurable TTL (default 5 minutes). The cache is created lazily on
first access and owned by the calling process (or an explicit owner).
## Usage
ImageCache.start()
ImageCache.put("img.png", png_bytes, %{max_colors: 64})
{:ok, cached} = ImageCache.get("img.png", %{max_colors: 64})
ImageCache.evict("img.png")
"""
@table :raxol_image_cache
@default_ttl_ms 5 * 60 * 1000
@max_entries 256
@type cache_key :: {binary(), binary()}
@type cache_entry :: {cache_key(), term(), integer()}
@doc """
Creates the ETS table if it doesn't already exist.
Safe to call multiple times.
"""
@spec start() :: :ok
def start do
case :ets.whereis(@table) do
:undefined ->
_ = :ets.new(@table, [:set, :public, :named_table, read_concurrency: true])
_ref ->
:ok
end
:ok
end
@doc """
Stores a value in the cache keyed by source identifier and options.
"""
@spec put(binary(), term(), map()) :: :ok
def put(source_id, value, opts \\ %{}) do
ensure_table()
key = cache_key(source_id, opts)
now = System.monotonic_time(:millisecond)
:ets.insert(@table, {key, value, now})
maybe_evict_oldest()
:ok
end
@doc """
Retrieves a cached value. Returns `{:ok, value}` or `:miss`.
Expired entries are transparently deleted.
"""
@spec get(binary(), map()) :: {:ok, term()} | :miss
def get(source_id, opts \\ %{}) do
ensure_table()
key = cache_key(source_id, opts)
case :ets.lookup(@table, key) do
[{^key, value, inserted_at}] ->
if expired?(inserted_at) do
:ets.delete(@table, key)
:miss
else
{:ok, value}
end
[] ->
:miss
end
end
@doc """
Fetches from cache or computes and caches the value.
The `compute_fn` is called only on cache miss and must return
`{:ok, value}` or `{:error, reason}`.
"""
@spec fetch(binary(), map(), (-> {:ok, term()} | {:error, term()})) ::
{:ok, term()} | {:error, term()}
def fetch(source_id, opts, compute_fn) do
case get(source_id, opts) do
{:ok, value} ->
{:ok, value}
:miss ->
case compute_fn.() do
{:ok, value} = ok ->
put(source_id, value, opts)
ok
{:error, _} = err ->
err
end
end
end
@doc """
Removes all entries matching a source identifier (any opts).
"""
@spec evict(binary()) :: :ok
def evict(source_id) do
ensure_table()
hash = content_hash(source_id)
:ets.foldl(
fn {key, _value, _ts}, acc ->
case key do
{^hash, _} -> :ets.delete(@table, key)
_ -> :ok
end
acc
end,
:ok,
@table
)
end
@doc """
Removes all expired entries from the cache.
"""
@spec prune() :: non_neg_integer()
def prune do
ensure_table()
now = System.monotonic_time(:millisecond)
ttl = ttl_ms()
:ets.foldl(
fn {key, _value, inserted_at}, count ->
if now - inserted_at > ttl do
:ets.delete(@table, key)
count + 1
else
count
end
end,
0,
@table
)
end
@doc """
Deletes all entries from the cache.
"""
@spec clear() :: :ok
def clear do
ensure_table()
:ets.delete_all_objects(@table)
:ok
end
@doc """
Returns the number of entries currently in the cache.
"""
@spec size() :: non_neg_integer()
def size do
ensure_table()
:ets.info(@table, :size)
end
# -- Private --
defp cache_key(source_id, opts) do
{content_hash(source_id), opts_hash(opts)}
end
defp content_hash(source_id) when is_binary(source_id) do
:crypto.hash(:sha256, source_id)
end
defp opts_hash(opts) when is_map(opts) do
opts
|> :erlang.term_to_binary()
|> then(&:crypto.hash(:md5, &1))
end
defp expired?(inserted_at) do
System.monotonic_time(:millisecond) - inserted_at > ttl_ms()
end
defp ttl_ms do
Application.get_env(:raxol, :image_cache_ttl_ms, @default_ttl_ms)
end
defp ensure_table do
case :ets.whereis(@table) do
:undefined -> start()
_ref -> :ok
end
end
defp maybe_evict_oldest do
if :ets.info(@table, :size) > @max_entries do
# Find and delete the oldest entry
{oldest_key, _} =
:ets.foldl(
fn {key, _value, ts}, {_best_key, best_ts} = best ->
if ts < best_ts, do: {key, ts}, else: best
end,
{nil, System.monotonic_time(:millisecond)},
@table
)
if oldest_key, do: :ets.delete(@table, oldest_key)
end
end
end