Skip to main content

lib/pdf/component/alert.ex

defmodule Pdf.Component.Alert do
  @moduledoc """
  Alert/Callout component for PDF documents.

  Renders a colored notification box with an icon character, title,
  and message text. Supports info, warning, error, and success variants.

  ## Examples

      doc |> Pdf.Component.Alert.render({50, 700}, %{
        type: :info,
        title: "Note",
        message: "This is an informational message.",
        width: 400
      })

      doc |> Pdf.Component.Alert.render({50, 700}, %{
        type: :error,
        title: "Error",
        message: "Something went wrong. Please try again.",
        width: 400
      })
  """

  @presets %{
    info: %{
      icon: "i",
      bg: {0.93, 0.95, 1.0},
      bar: {0.25, 0.47, 0.85},
      icon_bg: {0.25, 0.47, 0.85},
      title_color: {0.15, 0.30, 0.60}
    },
    success: %{
      icon: "!",
      bg: {0.93, 0.98, 0.93},
      bar: {0.20, 0.65, 0.30},
      icon_bg: {0.20, 0.65, 0.30},
      title_color: {0.12, 0.45, 0.18}
    },
    warning: %{
      icon: "!",
      bg: {1.0, 0.97, 0.90},
      bar: {0.85, 0.65, 0.10},
      icon_bg: {0.85, 0.65, 0.10},
      title_color: {0.60, 0.45, 0.05}
    },
    error: %{
      icon: "x",
      bg: {1.0, 0.93, 0.93},
      bar: {0.80, 0.20, 0.20},
      icon_bg: {0.80, 0.20, 0.20},
      title_color: {0.60, 0.12, 0.12}
    }
  }

  @default_font "Helvetica"
  @default_padding 12
  @default_border_radius 5

  @doc """
  Render an alert at `{x, y}`.

  ## Style options

  - `:type` — `:info` (default), `:success`, `:warning`, or `:error`
  - `:title` — bold title text
  - `:message` — body message text (required)
  - `:width` — alert width (required)
  - `:font` — font name (default `"Helvetica"`)
  - `:padding` — inner padding (default `12`)
  - `:border_radius` — corner radius (default `5`)
  - `:icon` — override icon character
  """
  def render(doc, {x, y}, style \\ %{}) do
    type = Map.get(style, :type, :info)
    preset = Map.get(@presets, type, @presets.info)
    title = Map.get(style, :title)
    message = Map.get(style, :message, "")
    width = Map.get(style, :width, 400)
    font = Map.get(style, :font, @default_font)
    padding = Map.get(style, :padding, @default_padding)
    radius = Map.get(style, :border_radius, @default_border_radius)
    icon = Map.get(style, :icon, preset.icon)

    bg = Map.get(style, :background, preset.bg)
    bar_color = Map.get(style, :bar_color, preset.bar)
    icon_bg = Map.get(style, :icon_bg, preset.icon_bg)
    title_color = Map.get(style, :title_color, preset.title_color)
    message_color = Map.get(style, :message_color, {0.25, 0.25, 0.25})

    icon_size = 18
    icon_area = icon_size + 8
    text_x = x + padding + icon_area + 8
    avail_w = width - padding * 2 - icon_area - 8

    # Calculate height
    line_h = 14
    title_h = if title, do: 16, else: 0

    chars_per_line = max(trunc(avail_w / (10 * 0.52)), 10)
    msg_lines = wrap_text(message, chars_per_line)
    msg_h = length(msg_lines) * line_h
    total_h = padding + max(title_h + msg_h, icon_area) + padding

    # Background
    doc =
      doc
      |> Pdf.save_state()
      |> Pdf.set_fill_color(bg)
      |> Pdf.rounded_rectangle({x, y - total_h}, {width, total_h}, radius)
      |> Pdf.fill()
      |> Pdf.restore_state()

    # Left accent bar
    bar_w = 4
    doc =
      doc
      |> Pdf.save_state()
      |> Pdf.set_fill_color(bar_color)
      |> Pdf.rectangle({x, y - total_h}, {bar_w, total_h})
      |> Pdf.fill()
      |> Pdf.restore_state()

    # Icon circle
    icon_cx = x + padding + icon_size / 2 + 2
    icon_cy = y - padding - icon_size / 2

    icon_r = icon_size / 2
    icon_box_x = icon_cx - icon_r
    icon_box_y = icon_cy - icon_r

    doc =
      doc
      |> Pdf.save_state()
      |> Pdf.set_fill_color(icon_bg)
      |> Pdf.rounded_rectangle({icon_box_x, icon_box_y}, {icon_size, icon_size}, icon_r)
      |> Pdf.fill()
      |> Pdf.restore_state()
      |> Pdf.set_font(font, 11, bold: true)
      |> Pdf.set_fill_color({1.0, 1.0, 1.0})
      |> Pdf.text_at({icon_cx - 3, icon_cy - 4}, icon)

    # Title
    {doc, text_y} =
      if title do
        doc =
          doc
          |> Pdf.set_font(font, 11, bold: true)
          |> Pdf.set_fill_color(title_color)
          |> Pdf.text_at({text_x, y - padding - 11}, title)

        {doc, y - padding - title_h - 11}
      else
        {doc, y - padding - 11}
      end

    # Message lines
    Enum.with_index(msg_lines)
    |> Enum.reduce(doc, fn {line, i}, d ->
      d
      |> Pdf.set_font(font, 10)
      |> Pdf.set_fill_color(message_color)
      |> Pdf.text_at({text_x, text_y - i * line_h}, line)
    end)
  end

  defp wrap_text(text, chars_per_line) do
    words = String.split(text, " ")

    {lines, current} =
      Enum.reduce(words, {[], ""}, fn word, {lines, current} ->
        candidate = if current == "", do: word, else: current <> " " <> word

        if String.length(candidate) > chars_per_line and current != "" do
          {[current | lines], word}
        else
          {lines, candidate}
        end
      end)

    Enum.reverse(if current != "", do: [current | lines], else: lines)
  end
end