lib/scenic/component/button.ex

defmodule Scenic.Component.Button do
  @default_radius 3
  @default_font :roboto
  @default_font_size 20
  @default_alignment :center

  @moduledoc """
  Add a button to a graph

  A button is a small scene that is pretty much just some text drawn over a
  rounded rectangle. The button scene contains logic to detect when the button
  is pressed, tracks it as the pointer moves around, and when it is released.

  ## Data

  `title`

  * `title` - a bitstring describing the text to show in the button

  ## Messages

  If a button press is successful, it sends an event message to the host scene
  in the form of:

      {:click, id}

  These messages can be received and handled in your scene via
  `c:Scenic.Scene.handle_event/3`. For example:

  ```
  ...

  @impl Scenic.Scene
  def init(_, _opts) do
    graph =
      Graph.build()
      |> Scenic.Components.button("Sample Button", id: :sample_btn_id, t: {10, 10})

    state = %{}

    {:ok, state, push: graph}
  end

  @impl Scenic.Scene
  def handle_event({:click, :sample_btn_id}, _from, state) do
    IO.puts("Sample button was clicked!")
    {:cont, event, state}
  end
  ```

  ## Styles

  Buttons honor the following standard styles

  * `:hidden` - If `false` the component is rendered. If `true`, it is skipped.
  The default is `false`.
  * `:theme` - The color set used to draw. See below. The default is `:primary`

  ## Sendable Styles
  Styles can be sent directly to the Button Component by adding a :styles list.

      graph
      |> button(
        "Example",
        styles: [font_size: 32, text_align: :right]
      )

  The following standard styles are supported

  * `:font` - The default is #{inspect(@default_font)}
  * `:font_size` - The default is #{inspect(@default_font_size)}
  * `:text_align` - The default is #{inspect(@default_alignment)}


  ## Options

  Buttons the following options.

  * `:width` - :auto (default) or pass in a number to set the width of the button
  * `:height` - :auto (default) or pass in a number to set the height of the button.
  * `:radius` - pass in a number to set the radius of the button's rounded
  rectangle. The default is #{inspect(@default_radius)}

  Buttons do not use the inherited `:font_size` style as they should look
  consistent regardless of what size the surrounding text is.

  ## Theme

  Buttons work well with the following predefined themes:
  `:primary`, `:secondary`, `:success`, `:danger`, `:warning`, `:info`,
  `:text`, `:light`, `:dark`

  To pass in a custom theme, supply a map with at least the following entries:

  * `:text` - the color of the text in the button
  * `:background` - the normal background of the button
  * `:border` - the border of the button
  * `:active` - the background while the button is pressed

  ## Usage

  You should add/modify components via the helper functions in
  [`Scenic.Components`](Scenic.Components.html#button/3)

  ### Examples

  The following example creates a simple button and positions it on the screen.

      graph
      |> button("Example", id: :button_id, translate: {20, 20})

  The next example makes the same button as before, but colors it as a warning
  button. See the options list above for more details.

      graph
      |> button("Example", id: :button_id, translate: {20, 20}, theme: :warning)

  The final example changes the text size and alignment

      graph
      |> button(
        "Example",
        id: :button_id,
        translate: {20, 20},
        theme: :warning,
        styles: [text_size: 32, text_align: :right]
      )

  """
  use Scenic.Component, has_children: false

  alias Scenic.Graph
  alias Scenic.Scene
  alias Scenic.Primitive.Style.Theme
  alias Scenic.Assets.Static

  import Scenic.Primitives, only: [{:rrect, 3}, {:text, 3}, {:update_opts, 2}]

  # import IEx
  @impl Scenic.Component
  def validate(text) when is_bitstring(text) do
    {:ok, text}
  end

  def validate(data) do
    {
      :error,
      """
      #{IO.ANSI.red()}Invalid Button specification
      Received: #{inspect(data)}
      #{IO.ANSI.yellow()}
      The data for a button is just the text string to be displayed in the button.#{IO.ANSI.default_color()}
      """
    }
  end

  # --------------------------------------------------------
  @doc false
  @impl Scenic.Scene
  def init(scene, text, opts) when is_bitstring(text) and is_list(opts) do
    styles = Keyword.get(opts, :styles, [])
    id = opts[:id]

    # theme is passed in as an inherited style
    theme =
      case opts[:theme] do
        nil -> Theme.preset(:primary)
        :dark -> Theme.preset(:primary)
        :light -> Theme.preset(:primary)
        theme -> theme
      end
      |> Theme.normalize()

    # font related info
    font = Keyword.get(styles, :font, @default_font)
    {:ok, {Static.Font, fm}} = Static.meta(font)
    font_size = Keyword.get(styles, :font_size, @default_font_size)
    alignment = Keyword.get(styles, :text_align, @default_alignment)

    ascent = FontMetrics.ascent(font_size, fm)
    descent = FontMetrics.descent(font_size, fm)
    fm_width = FontMetrics.width(text, font_size, fm)

    width =
      case opts[:width] || opts[:w] do
        nil -> fm_width + ascent + ascent
        :auto -> fm_width + ascent + ascent
        width when is_number(width) and width > 0 -> width
      end

    height =
      case opts[:height] || opts[:h] do
        nil -> font_size + ascent
        :auto -> font_size + ascent
        height when is_number(height) and height > 0 -> height
      end

    radius = opts[:radius] || @default_radius

    vpos = height / 2 + ascent / 2 + descent / 3

    # build the graph
    graph =
      Graph.build(font: font, font_size: font_size)
      |> rrect({width, height, radius}, fill: theme.background, id: :btn, input: :cursor_button)
      |> do_aligned_text(alignment, text, theme.text, width, vpos)
      # special case the dark and light themes to show an outline
      |> do_special_theme_outline(theme, theme.border)
      |> update_color(theme, Scene.get(scene, :pressed, false))

    scene =
      scene
      |> assign(
        vpos: vpos,
        graph: graph,
        theme: theme,
        id: id,
        text: text,
        theme: theme,
        opts: opts
      )
      |> assign_new(pressed: false)
      |> push_graph(graph)

    {:ok, scene}
  end

  @impl Scenic.Component
  def bounds(text, opts) do
    # font related info
    {:ok, {Static.Font, fm}} = Static.meta(@default_font)
    font_size = opts[:button_font_size] || @default_font_size

    ascent = FontMetrics.ascent(font_size, fm)
    fm_width = FontMetrics.width(text, font_size, fm)

    width =
      case opts[:width] || opts[:w] do
        nil -> fm_width + ascent + ascent
        :auto -> fm_width + ascent + ascent
        width when is_number(width) and width > 0 -> width
      end

    height =
      case opts[:height] || opts[:h] do
        nil -> font_size + ascent
        :auto -> font_size + ascent
        height when is_number(height) and height > 0 -> height
      end

    {0.0, 0.0, width, height}
  end

  defp do_aligned_text(graph, :center, text, fill, width, vpos) do
    text(graph, text,
      fill: fill,
      translate: {width / 2, vpos},
      text_align: :center,
      id: :title
    )
  end

  defp do_aligned_text(graph, :left, text, fill, _width, vpos) do
    text(graph, text,
      fill: fill,
      translate: {8, vpos},
      text_align: :left,
      id: :title
    )
  end

  defp do_aligned_text(graph, :right, text, fill, width, vpos) do
    text(graph, text,
      fill: fill,
      translate: {width - 8, vpos},
      text_align: :right,
      id: :title
    )
  end

  defp do_special_theme_outline(graph, :dark, border) do
    Graph.modify(graph, :btn, &update_opts(&1, stroke: {1, border}))
  end

  defp do_special_theme_outline(graph, :light, border) do
    Graph.modify(graph, :btn, &update_opts(&1, stroke: {1, border}))
  end

  defp do_special_theme_outline(graph, _, _border) do
    graph
  end

  # --------------------------------------------------------
  # pressed in the button
  @impl Scenic.Scene
  def handle_input(
        {:cursor_button, {:btn_left, 1, _, _}},
        :btn,
        %Scene{assigns: %{graph: graph, theme: theme}} = scene
      ) do
    :ok = capture_input(scene, :cursor_button)

    graph = update_color(graph, theme, true)

    scene =
      scene
      |> assign(pressed: true)
      |> push_graph(graph)

    {:noreply, scene}
  end

  # --------------------------------------------------------
  # pressed outside the button
  # only happens when input is captured
  # could happen when reconnecting to a driver...
  def handle_input(
        {:cursor_button, {:btn_left, 1, _, _}},
        _id,
        %Scene{assigns: %{graph: graph, theme: theme}} = scene
      ) do
    :ok = release_input(scene)

    graph = update_color(graph, theme, false)

    scene =
      scene
      |> assign(pressed: false)
      |> push_graph(graph)

    {:noreply, scene}
  end

  # --------------------------------------------------------
  # released inside the button
  def handle_input(
        {:cursor_button, {:btn_left, 0, _, _}},
        :btn,
        %Scene{assigns: %{pressed: true, id: id, graph: graph, theme: theme}} = scene
      ) do
    :ok = release_input(scene)
    :ok = send_parent_event(scene, {:click, id})

    graph = update_color(graph, theme, false)

    scene =
      scene
      |> assign(pressed: false)
      |> push_graph(graph)

    {:noreply, scene}
  end

  # --------------------------------------------------------
  # released outside the button
  # only happens when input is captured
  def handle_input(
        {:cursor_button, {:btn_left, 0, _, _}},
        _id,
        %Scene{assigns: %{graph: graph, theme: theme}} = scene
      ) do
    :ok = release_input(scene)

    graph = update_color(graph, theme, false)

    scene =
      scene
      |> assign(pressed: false)
      |> push_graph(graph)

    {:noreply, scene}
  end

  # ignore other input
  def handle_input(_input, _id, scene) do
    {:noreply, scene}
  end

  # ============================================================================
  # internal utilities

  defp update_color(graph, theme, true) do
    Graph.modify(graph, :btn, &update_opts(&1, fill: theme.active))
  end

  defp update_color(graph, theme, _) do
    Graph.modify(graph, :btn, &update_opts(&1, fill: theme.background))
  end

  # --------------------------------------------------------
  @doc false
  @impl Scenic.Scene
  def handle_fetch(_, %{assigns: %{text: text}} = scene) do
    {:reply, {:ok, text}, scene}
  end
end