8.3 KiB · text History b868907
defmodule GitGud.Badges.Svg do
  @moduledoc """
  Draws a badge as SVG, in one of three styles:

    * `split`: shields-style blocks: an optional GG block, the label on
      `neutral`, the value on its status color
    * `dot`: one plate with a status dot, the text in the page's own
      text color, so status is never carried by text contrast alone
    * `outline`: one plate outlined in the status color, the value
      written in it where that reads, in the text color where it doesn't

  And in one of three color modes (`GitGud.Badges.Palette` explains the
  roles behind them):

    * `{:theme, name}`: hex values on the shapes. Self-contained and safe
      to inline next to other badges; what the PNG is rendered from
    * `:auto`: a `<style>` that follows the reader's light/dark setting,
      for an `<img>` on another site
    * `:inline`: classes only, colored by `Palette.inline_stylesheet/0`
      from the page's `data-theme`, for GitGud's own pages

  Lettering is outlines from `GitGud.Badges.Native` (B612 Mono, with
  Noto Emoji for what it lacks), so nothing depends on the reader's fonts.
  """

  alias GitGud.Badges.Native
  alias GitGud.Badges.Palette
  alias GitGud.Badges.Spec

  @height 20
  @pad 6
  @font_size 11.0
  @baseline 14.0

  @type content :: %{label: String.t(), value: String.t(), slot: String.t()} | :mark
  @type mode :: {:theme, String.t()} | :auto | :inline

  @doc """
  The badge `content` drawn per `spec`, colored per `mode`.

  `:id` names the badge's one internal element (its clip path). It
  defaults to a hash of everything drawn, which is unique enough for a
  file on its own; a page that inlines several badges must pass one per
  badge, since two identical badges would otherwise share it.
  """
  @spec render(content(), Spec.t(), mode(), keyword()) :: String.t()
  def render(content, %Spec{} = spec, mode, opts \\ []) do
    r = radius(spec, mode)
    id = Keyword.get_lazy(opts, :id, fn -> default_id(content, spec, mode) end)
    ctx = %{r: r, mode: mode, rounded?: spec.corners == "round", id: id}

    {width, body} = draw(content, spec, ctx)
    title = title(content)
    zoom = Spec.zoom(spec)

    [
      ~s(<svg xmlns="http://www.w3.org/2000/svg" width="#{num(width * zoom)}" height="#{num(@height * zoom)}" ),
      ~s(viewBox="0 0 #{width} #{@height}" role="img" aria-label="#{title}"),
      if(mode == :inline, do: ~s( class="ggb"), else: ""),
      ">",
      "<title>#{title}</title>",
      style(mode, body),
      body,
      "</svg>"
    ]
    |> IO.iodata_to_binary()
  end

  # ── layouts ───────────────────────────────────────────────────────────

  defp draw(:mark, %Spec{style: "split"}, ctx), do: blocks([{"GG", "gg-bg", "gg-fg", true}], ctx)

  defp draw(:mark, %Spec{}, ctx) do
    x = @pad
    text = glyphs("GG", true, x, "mark", ctx)
    plate(round(x + tw("GG", true) + @pad), "edge", 1, [text], ctx)
  end

  defp draw(%{label: label, value: value, slot: slot}, %Spec{style: "split", gg: gg}, ctx) do
    blocks(
      if(gg, do: [{"GG", "gg-bg", "gg-fg", true}], else: []) ++
        [
          {label, "label-bg", "label-fg", false},
          {value, "#{slot}-bg", "#{slot}-fg", false}
        ],
      ctx
    )
  end

  defp draw(%{label: label, value: value, slot: slot}, %Spec{style: style, gg: gg}, ctx)
       when style in ["dot", "outline"] do
    {x, mark} =
      if gg do
        x = @pad
        mark = glyphs("GG", true, x, "mark", ctx)
        x = x + tw("GG", true) + @pad
        divider = ~s(<rect x="#{num(x)}" y="5" width="1" height="10" #{paint(ctx, "divider")}/>)
        {x + 1 + @pad, [mark, divider]}
      else
        {@pad, []}
      end

    {x, dot} =
      if style == "dot" do
        circle =
          ~s(<circle cx="#{num(x + 3.5)}" cy="10" r="3.5" stroke-width="1" ) <>
            paint(ctx, "#{slot}-dot", "#{slot}-ring") <> "/>"

        {x + 7 + 5, [circle]}
      else
        {x, []}
      end

    label_path = glyphs(label, false, x, "text", ctx)
    x = x + tw(label, false) + @pad
    value_role = if style == "outline", do: "#{slot}-text", else: "text"
    value_path = glyphs(value, true, x, value_role, ctx)
    width = round(x + tw(value, true) + @pad)

    {edge, stroke_width} = if style == "outline", do: {"#{slot}-line", 1.5}, else: {"edge", 1}
    plate(width, edge, stroke_width, mark ++ dot ++ [label_path, value_path], ctx)
  end

  # Side-by-side blocks, clipped to one rounded rectangle.
  defp blocks(segments, ctx) do
    {width, rects, texts} =
      Enum.reduce(segments, {0, [], []}, fn {text, bg, fg, bold}, {x, rects, texts} ->
        w = round(tw(text, bold) + 2 * @pad)
        rect = ~s(<rect x="#{x}" width="#{w}" height="#{@height}" #{paint(ctx, bg)}/>)
        text_x = x + (w - tw(text, bold)) / 2
        {x + w, [rect | rects], [glyphs(text, bold, text_x, fg, ctx) | texts]}
      end)

    id = ctx.id

    body = [
      ~s(<clipPath id="#{id}"><rect width="#{width}" height="#{@height}" rx="#{num(ctx.r)}"#{radius_class(ctx)}/></clipPath>),
      ~s|<g clip-path="url(##{id})">|,
      Enum.reverse(rects),
      "</g>",
      Enum.reverse(texts)
    ]

    {width, body}
  end

  defp plate(width, edge, sw, inner, ctx) do
    half = sw / 2

    colors =
      case ctx do
        %{mode: :inline, rounded?: true} -> ~s(class="f-plate s-#{edge} g-r")
        _ -> paint(ctx, "plate", edge)
      end

    rect =
      ~s(<rect x="#{num(half)}" y="#{num(half)}" width="#{num(width - sw)}" height="#{num(@height - sw)}" ) <>
        ~s(rx="#{num(max(ctx.r - half, 0))}" stroke-width="#{num(sw)}" #{colors}/>)

    {width, [rect | inner]}
  end

  defp tw(text, bold), do: Native.text_width(text, bold, @font_size)

  defp glyphs(text, bold, x, role, ctx) do
    d = Native.text_path(text, bold, @font_size, x * 1.0, @baseline)
    ~s(<path d="#{d}" #{paint(ctx, role)}/>)
  end

  # ── color ─────────────────────────────────────────────────────────────

  defp paint(%{mode: {:theme, theme}}, fill), do: ~s(fill="#{Palette.hex(theme, fill)}")
  defp paint(_ctx, fill), do: ~s(class="f-#{fill}")

  defp paint(%{mode: {:theme, theme}}, fill, stroke),
    do: ~s(fill="#{Palette.hex(theme, fill)}" stroke="#{Palette.hex(theme, stroke)}")

  defp paint(_ctx, fill, stroke), do: ~s(class="f-#{fill} s-#{stroke}")

  # Only the roles this badge uses, for both schemes.
  defp style(:auto, body) do
    flat = IO.iodata_to_binary(body)
    fills = used(~r/\bf-([a-z0-9-]+)/, flat)
    strokes = used(~r/\bs-([a-z0-9-]+)/, flat)

    rules = fn theme ->
      Enum.map_join(fills, &".f-#{&1}{fill:#{Palette.hex(theme, &1)}}") <>
        Enum.map_join(strokes, &".s-#{&1}{stroke:#{Palette.hex(theme, &1)}}")
    end

    "<style>#{rules.(GitGud.Themes.system_dark())}" <>
      "@media (prefers-color-scheme: light){#{rules.(GitGud.Themes.system_light())}}</style>"
  end

  defp style(_mode, _body), do: ""

  defp used(regex, text),
    do: regex |> Regex.scan(text, capture: :all_but_first) |> List.flatten() |> Enum.uniq()

  # ── geometry ──────────────────────────────────────────────────────────

  # Pills are as round as a 20px badge gets.
  defp radius(%Spec{corners: "square"}, _mode), do: 0
  defp radius(_spec, {:theme, theme}), do: min(Palette.radius_px(theme), @height / 2)

  # `auto` resolves to `dark` or `light`, which share a radius; inline
  # starts there and lets the page's theme override it (`g-r`).
  defp radius(_spec, _mode), do: min(Palette.radius_px(GitGud.Themes.system_dark()), @height / 2)

  defp radius_class(%{mode: :inline, rounded?: true}), do: ~s( class="g-r")
  defp radius_class(_ctx), do: ""

  defp default_id(content, spec, mode) do
    hash = :erlang.phash2({content, %{spec | format: nil}, mode}) |> Integer.to_string(36)
    "ggb-" <> hash
  end

  defp title(:mark), do: "GitGud"
  defp title(%{label: label, value: value}), do: Plug.HTML.html_escape("#{label}: #{value}")

  defp num(n) when is_integer(n), do: Integer.to_string(n)

  defp num(n) when is_float(n) do
    rounded = Float.round(n, 2)

    if rounded == trunc(rounded),
      do: Integer.to_string(trunc(rounded)),
      else: Float.to_string(rounded)
  end
end