lib/scenic/component/input/toggle.ex

defmodule Scenic.Component.Input.Toggle do
  @moduledoc """
  Add toggle to a Scenic graph.

  ## Data

  `on?`

  * `on?` - `true` if the toggle is on, pass `false` if not.

  ## Styles

  Toggles honor the following styles. The `:light` and `:dark` styles look nice. The other bundled themes...not so much. You can also [supply your own theme](Scenic.Toggle.Components.html#toggle/3-theme).

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

  ## Additional Options

  Toggles also honor the following additional options.

  * `:border_width` - the border width. Defaults to `2`.
  * `:padding` - the space between the border and the thumb. Defaults to `2`
  * `:thumb_radius` - the radius of the thumb. This determines the size of the entire toggle. Defaults to `10`.
  * `:compat` - use the pre-v0.11 positioning. The default is `false`

  ## Theme

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

  * `:border` - the color of the border around the toggle
  * `:background` - the color of the track when the toggle is `off`.
  * `:text` - the color of the thumb.
  * `:thumb` - the color of the track when the toggle is `on`.

  Optionally, you can supply the following entries:

  * `:thumb_pressed` - the color of the thumb when pressed. Defaults to `:gainsboro`.

  ## Usage

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

  ## Examples

  The following example creates a toggle.
      graph
      |> toggle(true, translate: {20, 20})

  The next example makes a larger toggle.
      graph
      |> toggle(true, translate: {20, 20}, thumb_radius: 14)
  """
  use Scenic.Component, has_children: false

  alias Scenic.Scene
  alias Scenic.Graph
  alias Scenic.Primitive
  alias Scenic.Primitive.Group
  alias Scenic.Primitive.Style.Theme
  alias Scenic.ViewPort

  import Scenic.Primitives

  require Logger

  # import IEx

  @default_thumb_pressed_color :gainsboro
  @default_thumb_radius 8
  @default_padding 2
  @default_border_width 2

  defmodule State do
    @moduledoc false

    defstruct graph: nil,
              # contained?: false,
              id: nil,
              on?: false,
              pressed?: false,
              theme: nil,
              thumb_translate: nil,
              color: nil,
              viewport: nil

    @type t :: %__MODULE__{
            viewport: ViewPort.t(),
            graph: Graph.t(),
            # contained?: boolean,
            id: atom,
            on?: boolean,
            pressed?: boolean,
            theme: map,
            thumb_translate: %{on: {number, number}, off: {number, number}},
            color: %{
              thumb: %{default: term, active: term},
              border: term,
              track: %{off: term, on: term}
            }
          }
  end

  # --------------------------------------------------------
  @impl Scenic.Component
  def validate(on?) when is_boolean(on?) do
    {:ok, on?}
  end

  def validate(data) do
    {
      :error,
      """
      #{IO.ANSI.red()}Invalid Toggle specification
      Received: #{inspect(data)}
      #{IO.ANSI.yellow()}
      The data for a Toggle imust be a true or false#{IO.ANSI.default_color()}
      """
    }
  end

  # --------------------------------------------------------
  @doc false
  @impl Scenic.Scene
  @spec init(scene :: Scene.t(), param :: any, Keyword.t()) :: {:ok, Scene.t()}
  def init(scene, on?, opts) do
    id = opts[:id]

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

    # get toggle specific opts
    thumb_radius = Keyword.get(opts, :thumb_radius, @default_thumb_radius)
    padding = Keyword.get(opts, :padding, @default_padding)
    border_width = Keyword.get(opts, :border_width, @default_border_width)

    # calculate the dimensions of the track
    track_height = thumb_radius * 2 + 2 * padding + 2 * border_width
    track_width = thumb_radius * 4 + 2 * padding + 2 * border_width
    track_border_radius = thumb_radius + padding + border_width

    # tune final position
    # original behavior had the toggle higher up, use :compat for that mode
    dx = border_width / 2
    dy = border_width / 2

    color = %{
      thumb: %{
        default: theme.text,
        pressed?: Map.get(theme, :thumb_pressed, @default_thumb_pressed_color)
      },
      border: theme.border,
      track: %{
        off: theme.background,
        on: theme.thumb
      }
    }

    thumb_translate = %{
      off: {thumb_radius + padding + border_width, thumb_radius + padding + border_width},
      on: {thumb_radius * 3 + padding + border_width, thumb_radius + padding + border_width}
    }

    {initial_track_fill, initial_thumb_translate} =
      case on? do
        true -> {color.track.on, thumb_translate.on}
        false -> {color.track.off, thumb_translate.off}
      end

    graph =
      Graph.build()
      |> Group.add_to_graph(
        fn graph ->
          graph
          |> rrect({track_width, track_height, track_border_radius},
            fill: initial_track_fill,
            stroke: {border_width, theme.border},
            id: :track,
            input: :cursor_button
          )
          |> circle(thumb_radius,
            fill: color.thumb.default,
            id: :thumb,
            translate: initial_thumb_translate
          )
        end,
        translate: {dx, dy}
      )

    scene =
      scene
      |> assign(
        id: id,
        graph: graph,
        on?: on?,
        pressed?: false,
        theme: theme,
        thumb_translate: thumb_translate,
        color: color
      )
      |> push_graph(graph)

    {:ok, scene}
  end

  @impl Scenic.Component
  def bounds(_data, styles) do
    # get toggle specific styles
    thumb_radius = Map.get(styles, :thumb_radius, @default_thumb_radius)
    padding = Map.get(styles, :padding, @default_padding)
    border_width = Map.get(styles, :border_width, @default_border_width)

    # calculate the dimensions of the track
    track_height = thumb_radius * 2 + 2 * padding + 2 * border_width
    track_width = thumb_radius * 4 + 2 * padding + 2 * border_width

    {0, 0, track_width, track_height}
  end

  # --------------------------------------------------------
  # pressed in the button
  @doc false
  @impl Scenic.Scene
  def handle_input(
        {:cursor_button, {:btn_left, 1, _, _}},
        :track,
        %{assigns: %{graph: graph, color: color}} = scene
      ) do
    graph = update_highlight(graph, true, true, color)

    :ok = capture_input(scene, :cursor_button)

    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,
        %{assigns: %{graph: graph, color: color}} = scene
      ) do
    graph = update_highlight(graph, true, false, color)

    :ok = release_input(scene)

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

    {:noreply, scene}
  end

  # --------------------------------------------------------
  # released inside the button
  def handle_input(
        {:cursor_button, {:btn_left, 0, _, _}},
        :track,
        %{
          assigns: %{
            pressed?: true,
            id: id,
            graph: graph,
            on?: on?,
            color: color
          }
        } = scene
      ) do
    send_parent_event(scene, {:value_changed, id, !on?})

    :ok = release_input(scene)

    graph =
      graph
      |> update_check(!on?, scene.assigns)
      |> update_highlight(false, true, color)

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

    {:noreply, scene}
  end

  # --------------------------------------------------------
  # released, but not in the button
  def handle_input(
        {:cursor_button, {:btn_left, 0, _, _}},
        _id,
        %{assigns: %{graph: graph, color: color}} = scene
      ) do
    graph = update_highlight(graph, false, false, color)

    :ok = release_input(scene)

    scene =
      scene
      |> assign(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, pressed?, contained, color)

  defp update_highlight(graph, true, true, color) do
    Graph.modify(graph, :thumb, &Primitive.put_style(&1, :fill, color.thumb.pressed?))
  end

  defp update_highlight(graph, _, _, color) do
    Graph.modify(graph, :thumb, &Primitive.put_style(&1, :fill, color.thumb.default))
  end

  defp update_check(graph, true, %{color: color, thumb_translate: thumb}) do
    graph
    |> Graph.modify(:track, &Primitive.put_style(&1, :fill, color.track.on))
    |> Graph.modify(:thumb, &Primitive.put_transform(&1, :translate, thumb.on))
  end

  defp update_check(graph, false, %{color: color, thumb_translate: thumb}) do
    graph
    |> Graph.modify(:track, &Primitive.put_style(&1, :fill, color.track.off))
    |> Graph.modify(:thumb, &Primitive.put_transform(&1, :translate, thumb.off))
  end

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

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

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

    graph =
      graph
      |> update_check(on?, scene.assigns)

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

    {:noreply, scene}
  end

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

    {:noreply, scene}
  end

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