lib/scenic/component/input/checkbox.ex

defmodule Scenic.Component.Input.Checkbox do
  @moduledoc """
  Add a checkbox to a graph

  ## Data

  `{text, checked?}`

  * `text` - must be a bitstring
  * `checked?` - must be a boolean and indicates if the checkbox is set.

  ## Messages

  When the state of the checkbox, it sends an event message to the host scene
  in the form of:

  `{:value_changed, id, checked?}`

  ## Styles

  Buttons honor the following 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 `:dark`

  ## Theme

  Checkboxes work well with the following predefined themes: `: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 background of the box
  * `:border` - the border of the box
  * `:active` - the border of the box while the button is pressed
  * `:thumb` - the color of the check mark itself

  ## Usage

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

  ### Examples

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

      graph
      |> checkbox({"Example", true}, id: :checkbox_id, translate: {20, 20})
  """

  use Scenic.Component, has_children: false

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

  require Logger

  import Scenic.Primitives

  # import IEx

  @default_font :roboto
  @default_font_size 20

  @border_width 2

  # --------------------------------------------------------
  @impl Scenic.Component
  def validate({text, checked}) when is_bitstring(text) and is_boolean(checked) do
    {:ok, {text, checked}}
  end

  def validate(data) do
    {
      :error,
      """
      #{IO.ANSI.red()}Invalid Checkbox specification
      Received: #{inspect(data)}
      #{IO.ANSI.yellow()}
      The data for a Checkbox is {text, checked?}#{IO.ANSI.default_color()}
      """
    }
  end

  # --------------------------------------------------------
  @doc false
  @impl Scenic.Scene
  def init(scene, {text, checked?}, opts) do
    id = opts[:id]

    # theme is passed in as an inherited style
    theme =
      (opts[:theme] || Theme.preset(:dark))
      |> Theme.normalize()

    # font related info
    {:ok, {Static.Font, fm}} = Static.meta(@default_font)
    ascent = FontMetrics.ascent(@default_font_size, fm)
    fm_width = FontMetrics.width(text, @default_font_size, fm)
    space_width = FontMetrics.width(' ', @default_font_size, fm)
    # box_width = fm_width + ascent + space_width * 2 + @border_width

    box_height = ascent
    box_width = fm_width + box_height + space_width + @border_width

    # build the checkmark script
    # I'd build it at compile time if it weren't for the theme.thumb color...
    chx_script =
      Script.start()
      |> Script.push_state()
      |> Script.join(:round)
      |> Script.stroke_width(@border_width + 1)
      |> Script.stroke_color(theme.thumb)
      |> Script.begin_path()
      |> Script.move_to(0, 8)
      |> Script.line_to(5, 13)
      |> Script.line_to(12, 1)
      |> Script.stroke_path()
      |> Script.pop_state()
      |> Script.finish()

    chx_id = scene.id <> "_chk"
    scene = push_script(scene, chx_script, chx_id)

    # tune final position
    dx = @border_width / 2
    dy = @border_width / 2

    graph =
      Graph.build(font: @default_font, font_size: @default_font_size, t: {dx, dy})
      |> group(fn graph ->
        graph
        |> rect(
          {box_width, box_height},
          id: :btn,
          input: :cursor_button
        )
        |> rrect({box_height, box_height, 3},
          fill: theme.background,
          stroke: {@border_width, theme.border},
          id: :box
        )
        |> script(chx_id, id: :chx, hidden: !checked?, t: {3, 2})
      end)
      |> text(text,
        fill: theme.text,
        translate: {box_height + space_width + @border_width, ascent - @border_width}
      )
      |> update_highlight(theme, Scene.get(scene, :pressed, false))

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

    {:ok, scene}
  end

  @impl Scenic.Component
  def bounds({text, chk?}, _styles) when is_bitstring(text) and is_boolean(chk?) do
    {:ok, {Static.Font, fm}} = Static.meta(@default_font)
    ascent = FontMetrics.ascent(@default_font_size, fm)
    descent = FontMetrics.descent(@default_font_size, fm)
    fm_width = FontMetrics.width(text, @default_font_size, fm)
    space_width = FontMetrics.width(' ', @default_font_size, fm)
    box_width = fm_width + ascent + space_width + @border_width
    {0, 0, box_width, ascent - descent}
  end

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

    graph = update_highlight(graph, theme, true)

    scene =
      scene
      |> assign(graph: graph, 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,
        %{assigns: %{graph: graph, theme: theme}} = scene
      ) do
    :ok = release_input(scene)

    graph = update_highlight(graph, theme, false)

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

    {:noreply, scene}
  end

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

    graph =
      graph
      |> Graph.modify(:chx, &Primitive.put_style(&1, :hidden, checked))
      |> update_highlight(theme, false)

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

    {:noreply, scene}
  end

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

    graph = update_highlight(graph, theme, false)

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

    {:noreply, scene}
  end

  # ignore other button press events
  def handle_input({:cursor_button, {_, _, _, _}}, _id, scene) do
    {:noreply, scene}
  end

  # --------------------------------------------------------
  defp update_highlight(graph, theme, true) do
    Graph.modify(graph, :box, &update_opts(&1, fill: theme.active))
  end

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

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

  @doc false
  @impl Scenic.Scene
  def handle_put(chk?, %{assigns: %{checked: checked}} = scene) when chk? == checked do
    # no change
    {:noreply, scene}
  end

  def handle_put(chk?, %{assigns: %{graph: graph, id: id}} = scene) when is_boolean(chk?) do
    send_parent_event(scene, {:value_changed, id, chk?})

    graph =
      graph
      |> Graph.modify(:chx, &Primitive.put_style(&1, :hidden, !chk?))

    scene =
      scene
      |> assign(graph: graph, checked: chk?)
      |> push_graph(graph)

    {:noreply, scene}
  end

  def handle_put(v, %{assigns: %{id: id}} = scene) do
    Logger.warn(
      "Attempted to put an invalid value on Checkbox id: #{inspect(id)}, value: #{inspect(v)}"
    )

    {:noreply, scene}
  end

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