lib/harlock/render/style.ex

defmodule Harlock.Render.Style do
  @moduledoc """
  Visual attributes for a rendered cell.

      %Harlock.Render.Style{fg: :cyan, bold: true}
      %Harlock.Render.Style{reverse: true}
      %Harlock.Render.Style{bg: {:rgb, 30, 30, 40}}

  Fields:

    * `:fg` / `:bg` — foreground / background colour. Atoms for the 16
      standard colours (`:red`, `:bright_blue`, …), `{:color256, n}` for
      256-color, `{:rgb, r, g, b}` for truecolor, or `:default` for
      "no override."
    * `:bold` / `:dim` / `:italic` / `:underline` / `:reverse` — boolean
      attributes, all `false` by default.

  Construct with the struct directly, or with `from/1` for keyword/map
  input. `merge/2` layers one style on top of another — useful for
  applying a theme `:focus` token to a user-set element style without
  losing fg/bg.

  Compared by value, hashed by value — used as a key into the renderer's
  internal style table.
  """

  defstruct fg: :default,
            bg: :default,
            bold: false,
            italic: false,
            underline: false,
            dim: false,
            reverse: false

  @type color ::
          :default
          | :black
          | :red
          | :green
          | :yellow
          | :blue
          | :magenta
          | :cyan
          | :white
          | :bright_black
          | :bright_red
          | :bright_green
          | :bright_yellow
          | :bright_blue
          | :bright_magenta
          | :bright_cyan
          | :bright_white
          | {:color256, 0..255}
          | {:rgb, 0..255, 0..255, 0..255}

  @type t :: %__MODULE__{
          fg: color(),
          bg: color(),
          bold: boolean(),
          italic: boolean(),
          underline: boolean(),
          dim: boolean(),
          reverse: boolean()
        }

  @spec default() :: t()
  def default, do: %__MODULE__{}

  @spec from(keyword() | map() | t()) :: t()
  def from(%__MODULE__{} = s), do: s
  def from(opts) when is_list(opts), do: struct!(__MODULE__, opts)
  def from(opts) when is_map(opts), do: struct!(__MODULE__, opts)

  @doc """
  Layer `over` on top of `under`. Non-default colors in `over` win;
  boolean attributes OR (any `true` wins). Used to apply theme tokens on
  top of element-provided styles without losing user-set fg/bg.
  """
  @spec merge(t(), t()) :: t()
  def merge(%__MODULE__{} = under, %__MODULE__{} = over) do
    %__MODULE__{
      fg: if(over.fg == :default, do: under.fg, else: over.fg),
      bg: if(over.bg == :default, do: under.bg, else: over.bg),
      bold: over.bold or under.bold,
      italic: over.italic or under.italic,
      underline: over.underline or under.underline,
      dim: over.dim or under.dim,
      reverse: over.reverse or under.reverse
    }
  end

  @doc """
  Emit an SGR escape sequence that fully sets the cell's attributes. Starts
  with `\\e[0m` so the previous style doesn't bleed through. The diff renderer
  emits this once per style transition; we don't try to be clever about
  diffing individual attribute changes — terminals process SGR fast enough
  that the extra bytes are cheaper than the bookkeeping.
  """
  @spec to_sgr(t()) :: iodata()
  def to_sgr(%__MODULE__{} = s) do
    params =
      ["0"]
      |> append_if(s.bold, "1")
      |> append_if(s.dim, "2")
      |> append_if(s.italic, "3")
      |> append_if(s.underline, "4")
      |> append_if(s.reverse, "7")
      |> append_color(:fg, s.fg)
      |> append_color(:bg, s.bg)

    ["\e[", Enum.intersperse(Enum.reverse(params), ?;), ?m]
  end

  defp append_if(params, false, _val), do: params
  defp append_if(params, true, val), do: [val | params]

  defp append_color(params, _which, :default), do: params

  defp append_color(params, which, color) do
    Enum.reduce(color_codes(which, color), params, &[&1 | &2])
  end

  @standard_fg %{
    black: "30",
    red: "31",
    green: "32",
    yellow: "33",
    blue: "34",
    magenta: "35",
    cyan: "36",
    white: "37"
  }

  @bright_fg %{
    bright_black: "90",
    bright_red: "91",
    bright_green: "92",
    bright_yellow: "93",
    bright_blue: "94",
    bright_magenta: "95",
    bright_cyan: "96",
    bright_white: "97"
  }

  defp color_codes(which, color) do
    cond do
      color == :default ->
        []

      is_atom(color) and Map.has_key?(@standard_fg, color) ->
        [shift(@standard_fg[color], which)]

      is_atom(color) and Map.has_key?(@bright_fg, color) ->
        [shift(@bright_fg[color], which)]

      match?({:color256, _}, color) ->
        {:color256, n} = color
        [base256(which), "5", Integer.to_string(n)]

      match?({:rgb, _, _, _}, color) ->
        {:rgb, r, g, b} = color

        [
          base256(which),
          "2",
          Integer.to_string(r),
          Integer.to_string(g),
          Integer.to_string(b)
        ]
    end
  end

  defp shift("3" <> rest, :bg), do: "4" <> rest
  defp shift("9" <> rest, :bg), do: "10" <> rest
  defp shift(code, :fg), do: code

  defp base256(:fg), do: "38"
  defp base256(:bg), do: "48"
end