Current section
Files
Jump to
Current section
Files
lib/esc/color.ex
defmodule Esc.Color do
@moduledoc """
Color handling for terminal output.
## Color Formats
Esc supports multiple color formats:
- **Named ANSI colors**: `:red`, `:green`, `:blue`, etc.
- **ANSI 256 palette**: integers 0-255
- **True color (24-bit)**: `{r, g, b}` tuples or hex strings like `"#ff0000"`
## Adaptive Colors
Adaptive colors automatically select between two color options based on
whether the terminal has a light or dark background:
color = Color.adaptive("#000000", "#ffffff")
# Uses dark text on light backgrounds, light text on dark backgrounds
## Complete Colors
Complete colors specify exact values for each color profile level,
preventing automatic degradation:
color = Color.complete(
ansi: :red,
ansi256: 196,
true_color: {255, 0, 0}
)
## Color Degradation
When a terminal doesn't support a color profile, colors are automatically
degraded to the best available alternative using `rgb_to_ansi256/3` and
`ansi256_to_ansi16/1`.
"""
@ansi_colors %{
black: 0,
red: 1,
green: 2,
yellow: 3,
blue: 4,
magenta: 5,
cyan: 6,
white: 7,
bright_black: 8,
bright_red: 9,
bright_green: 10,
bright_yellow: 11,
bright_blue: 12,
bright_magenta: 13,
bright_cyan: 14,
bright_white: 15
}
# Adaptive color struct
defmodule Adaptive do
@moduledoc """
Represents an adaptive color that changes based on terminal background.
"""
defstruct [:light, :dark]
@type t :: %__MODULE__{
light: Esc.Style.color(),
dark: Esc.Style.color()
}
end
# Complete color struct
defmodule Complete do
@moduledoc """
Represents a color with explicit values for each profile level.
"""
defstruct [:ansi, :ansi256, :true_color]
@type t :: %__MODULE__{
ansi: atom() | nil,
ansi256: integer() | nil,
true_color: {integer(), integer(), integer()} | nil
}
end
@doc """
Creates an adaptive color that selects between light and dark variants.
The `light` variant is used when the terminal has a light background.
The `dark` variant is used when the terminal has a dark background.
## Examples
iex> Color.adaptive("#000000", "#ffffff")
%Color.Adaptive{light: "#000000", dark: "#ffffff"}
"""
@spec adaptive(Esc.Style.color(), Esc.Style.color()) :: Adaptive.t()
def adaptive(light, dark) do
%Adaptive{light: light, dark: dark}
end
@doc """
Resolves an adaptive color based on the background mode.
## Examples
iex> color = Color.adaptive(:black, :white)
iex> Color.resolve_adaptive(color, :light)
:black
iex> Color.resolve_adaptive(color, :dark)
:white
"""
@spec resolve_adaptive(Adaptive.t(), :light | :dark) :: Esc.Style.color()
def resolve_adaptive(%Adaptive{light: light}, :light), do: light
def resolve_adaptive(%Adaptive{dark: dark}, :dark), do: dark
@doc """
Creates a complete color with explicit values for each profile level.
## Options
- `:ansi` - Color for basic 16-color terminals (atom like `:red`)
- `:ansi256` - Color for 256-color terminals (integer 0-255)
- `:true_color` - Color for true color terminals (RGB tuple or hex string)
## Examples
iex> Color.complete(ansi: :red, ansi256: 196, true_color: {255, 0, 0})
%Color.Complete{ansi: :red, ansi256: 196, true_color: {255, 0, 0}}
"""
@spec complete(keyword()) :: Complete.t()
def complete(opts) do
%Complete{
ansi: Keyword.get(opts, :ansi),
ansi256: Keyword.get(opts, :ansi256),
true_color: Keyword.get(opts, :true_color)
}
end
@doc """
Resolves a complete color for a specific profile level.
Falls back to lower profile levels if the requested level is not specified.
## Examples
iex> color = Color.complete(ansi: :red, true_color: {255, 0, 0})
iex> Color.resolve_complete(color, :ansi256)
:red # Falls back to ansi since ansi256 not specified
"""
@spec resolve_complete(Complete.t(), :ansi | :ansi256 | :true_color) :: Esc.Style.color() | nil
def resolve_complete(%Complete{} = color, :true_color) do
color.true_color || color.ansi256 || color.ansi
end
def resolve_complete(%Complete{} = color, :ansi256) do
color.ansi256 || color.ansi
end
def resolve_complete(%Complete{} = color, :ansi) do
color.ansi
end
@doc """
Converts a color value to ANSI escape sequence for foreground.
"""
@spec foreground(atom() | integer() | {integer(), integer(), integer()} | String.t()) ::
String.t()
def foreground(color) when is_atom(color) do
case Map.get(@ansi_colors, color) do
nil -> ""
n when n < 8 -> "\e[#{30 + n}m"
n -> "\e[38;5;#{n}m"
end
end
def foreground(n) when is_integer(n) and n >= 0 and n <= 255 do
"\e[38;5;#{n}m"
end
def foreground({r, g, b}) when r in 0..255 and g in 0..255 and b in 0..255 do
"\e[38;2;#{r};#{g};#{b}m"
end
def foreground("#" <> hex) when byte_size(hex) == 6 do
case hex_to_rgb(hex) do
{:ok, rgb} -> foreground(rgb)
:error -> ""
end
end
def foreground(_), do: ""
@doc """
Converts a color value to ANSI escape sequence for background.
"""
@spec background(atom() | integer() | {integer(), integer(), integer()} | String.t()) ::
String.t()
def background(color) when is_atom(color) do
case Map.get(@ansi_colors, color) do
nil -> ""
n when n < 8 -> "\e[#{40 + n}m"
n -> "\e[48;5;#{n}m"
end
end
def background(n) when is_integer(n) and n >= 0 and n <= 255 do
"\e[48;5;#{n}m"
end
def background({r, g, b}) when r in 0..255 and g in 0..255 and b in 0..255 do
"\e[48;2;#{r};#{g};#{b}m"
end
def background("#" <> hex) when byte_size(hex) == 6 do
case hex_to_rgb(hex) do
{:ok, rgb} -> background(rgb)
:error -> ""
end
end
def background(_), do: ""
@doc """
Converts an RGB color to the nearest ANSI 256 palette color.
The ANSI 256 palette consists of:
- 0-15: Standard colors (same as ANSI 16)
- 16-231: 6x6x6 color cube
- 232-255: Grayscale ramp
## Examples
iex> Color.rgb_to_ansi256(255, 0, 0)
196 # Bright red in the color cube
iex> Color.rgb_to_ansi256(128, 128, 128)
244 # Gray in the grayscale ramp
"""
@spec rgb_to_ansi256(integer(), integer(), integer()) :: integer()
def rgb_to_ansi256(r, g, b) when r in 0..255 and g in 0..255 and b in 0..255 do
# Check if it's close to grayscale
if grayscale?(r, g, b) do
rgb_to_grayscale(r, g, b)
else
rgb_to_color_cube(r, g, b)
end
end
defp grayscale?(r, g, b) do
avg = div(r + g + b, 3)
abs(r - avg) < 10 && abs(g - avg) < 10 && abs(b - avg) < 10
end
defp rgb_to_grayscale(r, g, b) do
avg = div(r + g + b, 3)
cond do
avg < 8 -> 16 # Black
avg > 248 -> 231 # White
true ->
# Grayscale ramp is 232-255 (24 shades)
# Each step is about 10 units (256/24 ≈ 10.67)
232 + div(avg - 8, 10)
end
end
defp rgb_to_color_cube(r, g, b) do
# 6x6x6 color cube starts at index 16
# Each channel maps 0-255 to 0-5
ri = color_cube_index(r)
gi = color_cube_index(g)
bi = color_cube_index(b)
16 + (36 * ri) + (6 * gi) + bi
end
defp color_cube_index(value) do
# The 6 levels are: 0, 95, 135, 175, 215, 255
cond do
value < 48 -> 0
value < 115 -> 1
value < 155 -> 2
value < 195 -> 3
value < 235 -> 4
true -> 5
end
end
@doc """
Converts an ANSI 256 palette color to the nearest ANSI 16 color.
## Examples
iex> Color.ansi256_to_ansi16(196)
1 # Red
iex> Color.ansi256_to_ansi16(21)
4 # Blue
"""
@spec ansi256_to_ansi16(integer()) :: integer()
def ansi256_to_ansi16(n) when n in 0..15, do: n
def ansi256_to_ansi16(n) when n in 232..255 do
# Grayscale ramp
gray = n - 232
cond do
gray < 6 -> 0 # Black
gray < 18 -> 7 # White (light gray)
true -> 15 # Bright white
end
end
def ansi256_to_ansi16(n) when n in 16..231 do
# Color cube - convert back to RGB then find nearest ANSI color
n = n - 16
b = rem(n, 6)
g = rem(div(n, 6), 6)
r = div(n, 36)
# Convert 0-5 levels to approximate RGB
to_rgb = fn level -> if level == 0, do: 0, else: 55 + level * 40 end
rgb = {to_rgb.(r), to_rgb.(g), to_rgb.(b)}
nearest_ansi16(rgb)
end
defp nearest_ansi16({r, g, b}) do
# Find which basic color is closest
colors = [
{0, {0, 0, 0}}, # black
{1, {170, 0, 0}}, # red
{2, {0, 170, 0}}, # green
{3, {170, 85, 0}}, # yellow/brown
{4, {0, 0, 170}}, # blue
{5, {170, 0, 170}}, # magenta
{6, {0, 170, 170}}, # cyan
{7, {170, 170, 170}}, # white
{8, {85, 85, 85}}, # bright black
{9, {255, 85, 85}}, # bright red
{10, {85, 255, 85}}, # bright green
{11, {255, 255, 85}}, # bright yellow
{12, {85, 85, 255}}, # bright blue
{13, {255, 85, 255}}, # bright magenta
{14, {85, 255, 255}}, # bright cyan
{15, {255, 255, 255}} # bright white
]
{index, _} = Enum.min_by(colors, fn {_idx, {cr, cg, cb}} ->
# Euclidean distance in RGB space
:math.sqrt(:math.pow(r - cr, 2) + :math.pow(g - cg, 2) + :math.pow(b - cb, 2))
end)
index
end
# Parse hex string to RGB tuple, returns {:ok, tuple} or :error
defp hex_to_rgb(hex) do
with {r, ""} <- Integer.parse(String.slice(hex, 0, 2), 16),
{g, ""} <- Integer.parse(String.slice(hex, 2, 2), 16),
{b, ""} <- Integer.parse(String.slice(hex, 4, 2), 16) do
{:ok, {r, g, b}}
else
_ -> :error
end
end
end