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

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