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
neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
1
Log in to mark this repository.
8.3 KiB · text
History
b868907