lib/scenic/component/input/dropdown.ex

#
#  Created by Boyd Multerer 2018-07-15.
#  Copyright © 2018 Kry10 Limited. All rights reserved.
#

defmodule Scenic.Component.Input.Dropdown do
  @moduledoc """
  Add a dropdown to a graph

  ## Data

  `{items, initial_item}`

  * `items` - must be a list of items, each of which is: `{text, id}`. See below...
  * `initial_item` - the `id` of the initial selected item. It can be any term
  you want, however it must be an `item_id` in the `items` list. See below.

  Per item data:

  `{text, item_id}`

  * `text` - a string that will be shown in the dropdown.
  * `item_id` - any term you want. It will identify the item that is
  currently selected in the dropdown and will be passed back to you during
  event messages.

  ## Messages

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

  `{:value_changed, id, selected_item_id}`

  ## Options

  Dropdowns honor the following list of options.

  ## 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`

  ## Additional Styles

  Buttons honor the following list of additional styles.

  * `:width` - pass in a number to set the width of the button.
  * `:height` - pass in a number to set the height of the button.
  * `:direction` - what direction should the menu drop. Can be either `:down`
  or `:up`. The default is `:down`.

  ## Theme

  Dropdowns 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
  * `:background` - the background of the component
  * `:border` - the border of the component
  * `:active` - the background of selected item in the dropdown list
  * `:thumb` - the color of the item being hovered over

  ## Usage

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

  ## Examples

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

      graph
      |> dropdown({[
        {"Dashboard", :dashboard},
        {"Controls", :controls},
        {"Primitives", :primitives}
      ], :controls}, id: :dropdown_id, translate: {20, 20})
  """
  use Scenic.Component, has_children: false

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

  require Logger

  # import IEx

  @default_direction :down

  @default_font :roboto
  @default_font_size 20

  @drop_click_window_ms 400

  @caret {{0, 0}, {12, 0}, {6, 6}}
  @text_id :_dropbox_text_
  @caret_id :_caret_
  @dropbox_id :_dropbox_
  @button_id :_dropbox_btn_

  @rotate_neutral :math.pi() / 2
  @rotate_down 0
  @rotate_up :math.pi()

  @border_width 2

  # --------------------------------------------------------
  @impl Scenic.Component
  def validate({items, _} = data) when is_list(items) do
    # confirm all the entries
    Enum.reduce(items, {:ok, data}, fn
      _, {:error, _} = error -> error
      {text, _}, acc when is_bitstring(text) -> acc
      item, _ -> err_bad_item(item, data)
    end)
    |> case do
      {:error, _} = err ->
        err

      {:ok, {items, initial}} ->
        # confirm that initial is in the items list
        items
        |> Enum.any?(fn {_, id} -> id == initial end)
        |> case do
          true -> {:ok, data}
          false -> err_initial(data)
        end
    end
  end

  def validate(data) do
    {
      :error,
      """
      #{IO.ANSI.red()}Invalid Dropdown specification
      Received: #{inspect(data)}
      #{IO.ANSI.yellow()}
      Dropdown data must formed like: {[{text, id}], initial_id}

      This is a list of text/id pairs, and the id of the pair that is initially selected.#{IO.ANSI.default_color()}
      """
    }
  end

  defp err_bad_item(item, data) do
    {
      :error,
      """
      #{IO.ANSI.red()}Invalid Dropdown specification
      Received: #{inspect(data)}
      Invalid Item: #{inspect(item)}
      #{IO.ANSI.yellow()}
      Dropdown data must formed like: {[{text, id}], initial_id}

      This is a list of text/id pairs, and the id of the pair that is initially selected.#{IO.ANSI.default_color()}
      """
    }
  end

  defp err_initial({_, initial} = data) do
    {
      :error,
      """
      #{IO.ANSI.red()}Invalid Dropdown specification
      Received: #{inspect(data)}
      The initial id #{inspect(initial)} is not in the listed items
      #{IO.ANSI.yellow()}
      Dropdown data must formed like: {[{text, id}], initial_id}

      This is a list of text/id pairs, and the id of the pair that is initially selected.#{IO.ANSI.default_color()}
      """
    }
  end

  # --------------------------------------------------------
  @doc false
  # credo:disable-for-next-line Credo.Check.Refactor.CyclomaticComplexity
  @impl Scenic.Scene
  def init(scene, {items, initial_id}, 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)
    descent = FontMetrics.descent(@default_font_size, fm)

    # find the width of the widest item
    fm_width =
      Enum.reduce(items, 0, fn {text, _}, w ->
        width = FontMetrics.width(text, @default_font_size, fm)

        max(w, width)
      end)

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

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

    # get the initial text
    initial_text =
      Enum.find_value(items, "", fn
        {text, ^initial_id} -> text
        _ -> false
      end)

    # calculate the drop box measures
    item_count = Enum.count(items)
    drop_height = item_count * height

    # get the drop direction
    direction = opts[:direction] || @default_direction

    # calculate the where to put the drop box. Depends on the direction
    translate_menu =
      case direction do
        :down -> {0, height + 1}
        :up -> {0, height * -item_count - 1}
      end

    # get the direction to rotate the caret
    rotate_caret =
      case direction do
        :down -> @rotate_down
        :up -> -@rotate_up
      end

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

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

    graph =
      Graph.build(font: @default_font, font_size: @default_font_size, t: {dx, dy})
      |> rect(
        {width, height},
        fill: theme.background,
        stroke: {@border_width, theme.border},
        id: @button_id,
        input: :cursor_button
      )
      |> text(initial_text,
        fill: theme.text,
        translate: {8, text_vpos},
        text_align: :left,
        id: @text_id
      )
      |> triangle(@caret,
        fill: theme.text,
        translate: {width - 18, height * 0.5},
        pin: {6, 0},
        rotate: @rotate_neutral,
        id: @caret_id
      )

      # the drop box itself
      |> group(
        fn g ->
          g = rect(g, {width, drop_height}, fill: theme.background, stroke: {2, theme.border})

          {g, _} =
            Enum.reduce(items, {g, 0}, fn {text, id}, {g, i} ->
              g =
                group(
                  g,
                  # credo:disable-for-next-line Credo.Check.Refactor.Nesting
                  fn g ->
                    rect(
                      g,
                      {width, height},
                      fill:
                        if id == initial_id do
                          theme.active
                        else
                          theme.background
                        end,
                      id: id,
                      input: :cursor_button
                    )
                    |> text(text,
                      fill: theme.text,
                      text_align: :left,
                      translate: {8, text_vpos}
                    )
                  end,
                  translate: {0, height * i}
                )

              {g, i + 1}
            end)

          g
        end,
        translate: translate_menu,
        id: @dropbox_id,
        hidden: true
      )

    scene =
      scene
      |> assign(
        graph: graph,
        selected_id: initial_id,
        theme: theme,
        id: id,
        down: false,
        hover_id: nil,
        items: items,
        drop_time: 0,
        rotate_caret: rotate_caret
      )
      |> push_graph(graph)

    {:ok, scene}
  end

  @impl Scenic.Component
  def bounds({items, _initial_id}, styles) do
    # font related info
    {:ok, {Static.Font, fm}} = Static.meta(@default_font)
    ascent = FontMetrics.ascent(@default_font_size, fm)

    # find the width of the widest item
    fm_width =
      Enum.reduce(items, 0, fn {text, _}, w ->
        width = FontMetrics.width(text, @default_font_size, fm)

        max(w, width)
      end)

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

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

    {0, 0, width, height}
  end

  # ============================================================================
  # tracking when the dropdown is UP

  # --------------------------------------------------------
  @doc false
  @impl Scenic.Scene
  # --------------------------------------------------------
  # mouse is moving around
  def handle_input(
        {:cursor_pos, _},
        nil,
        %Scene{
          assigns: %{
            down: true,
            items: items,
            graph: graph,
            selected_id: selected_id,
            theme: theme
          }
        } = scene
      ) do
    # set the appropriate hilighting for each of the items
    graph = update_highlighting(graph, items, selected_id, nil, theme)

    scene =
      scene
      |> assign(hover_id: nil, graph: graph)
      |> push_graph(graph)

    {:noreply, scene}
  end

  # over the @button_id
  def handle_input(
        {:cursor_pos, _},
        @button_id,
        %Scene{
          assigns: %{
            down: true,
            items: items,
            graph: graph,
            selected_id: selected_id,
            theme: theme
          }
        } = scene
      ) do
    # set the appropriate hilighting for each of the items
    graph = update_highlighting(graph, items, selected_id, nil, theme)

    scene =
      scene
      |> assign(hover_id: nil, graph: graph)
      |> push_graph(graph)

    {:noreply, scene}
  end

  # over an item
  def handle_input(
        {:cursor_pos, _},
        id,
        %Scene{
          assigns: %{
            down: true,
            items: items,
            graph: graph,
            selected_id: selected_id,
            theme: theme
          }
        } = scene
      ) do
    # set the appropriate hilighting for each of the items
    graph = update_highlighting(graph, items, selected_id, id, theme)

    scene =
      scene
      |> assign(hover_id: nil, graph: graph)
      |> push_graph(graph)

    {:noreply, scene}
  end

  # --------------------------------------------------------
  def handle_input(
        {:cursor_button, {:btn_left, 1, _, _}},
        @button_id,
        %Scene{assigns: %{down: false, graph: graph, rotate_caret: rotate_caret}} = scene
      ) do
    # capture input
    :ok = capture_input(scene, [:cursor_button, :cursor_pos])

    # drop the menu
    graph =
      graph
      |> Graph.modify(@caret_id, &update_opts(&1, rotate: rotate_caret))
      |> Graph.modify(@dropbox_id, &update_opts(&1, hidden: false))

    scene =
      scene
      |> assign(down: true, graph: graph, drop_time: :os.system_time(:milli_seconds))
      |> push_graph(graph)

    # IO.inspect(scene, label: "Press IN")
    {:noreply, scene}
  end

  # pressing the button when down, raises it back up without doing anything else
  def handle_input(
        {:cursor_button, {:btn_left, 1, _, _}},
        @button_id,
        %Scene{
          assigns: %{
            down: true,
            theme: theme,
            items: items,
            graph: graph,
            selected_id: selected_id
          }
        } = scene
      ) do
    # we are outside the window, raise it back up
    graph =
      graph
      |> update_highlighting(items, selected_id, nil, theme)
      |> Graph.modify(@caret_id, &update_opts(&1, rotate: @rotate_neutral))
      |> Graph.modify(@dropbox_id, &update_opts(&1, hidden: true))

    :ok = release_input(scene)

    scene =
      scene
      |> assign(down: false, hover_id: nil, graph: graph)
      |> push_graph(graph)

    {:noreply, scene}
  end

  # releasing the button when down, raises it back up without doing anything else
  def handle_input(
        {:cursor_button, {:btn_left, 0, _, _}},
        @button_id,
        %Scene{
          assigns: %{
            down: true,
            drop_time: drop_time,
            theme: theme,
            items: items,
            graph: graph,
            selected_id: selected_id
          }
        } = scene
      ) do
    if :os.system_time(:milli_seconds) - drop_time <= @drop_click_window_ms do
      # we are still in the click window, leave the menu down.
      {:noreply, scene}
    else
      # we are outside the window, raise it back up
      graph =
        graph
        |> update_highlighting(items, selected_id, nil, theme)
        |> Graph.modify(@caret_id, &update_opts(&1, rotate: @rotate_neutral))
        |> Graph.modify(@dropbox_id, &update_opts(&1, hidden: true))

      :ok = release_input(scene)

      scene =
        scene
        |> assign(down: false, hover_id: nil, graph: graph)
        |> push_graph(graph)

      {:noreply, scene}
    end
  end

  # --------------------------------------------------------
  # the button is pressed or released over an item with the drop open
  def handle_input(
        {:cursor_button, {:btn_left, _, _, _}},
        item_id,
        %Scene{
          assigns: %{
            down: true,
            id: id,
            items: items,
            theme: theme,
            graph: graph
          }
        } = scene
      )
      when item_id != nil do
    # send the value_changed message
    send_parent_event(scene, {:value_changed, id, item_id})

    # find the newly selected item's text
    {text, _} = Enum.find(items, fn {_, id} -> id == item_id end)

    graph =
      graph
      # update the main button text
      |> Graph.modify(@text_id, &text(&1, text))
      # restore standard highliting
      |> update_highlighting(items, item_id, nil, theme)
      # raise the dropdown
      |> Graph.modify(@caret_id, &update_opts(&1, rotate: @rotate_neutral))
      |> Graph.modify(@dropbox_id, &update_opts(&1, hidden: true))

    :ok = release_input(scene)

    scene =
      scene
      |> assign(down: false, graph: graph, selected_id: item_id)
      |> push_graph(graph)

    {:noreply, scene}
  end

  # --------------------------------------------------------
  # the button is pressed or released outside the dropdown space
  def handle_input(
        {:cursor_button, {:btn_left, _, _, _}},
        _,
        %Scene{
          assigns: %{
            # down: true,
            items: items,
            theme: theme,
            selected_id: selected_id,
            graph: graph
          }
        } = scene
      ) do
    graph = handle_cursor_button(graph, items, selected_id, theme)

    :ok = release_input(scene)

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

    {:noreply, scene}
  end

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

  def handle_input({:cursor_pos, _}, _id, scene) do
    {:noreply, scene}
  end

  # ============================================================================
  # internal

  defp update_highlighting(graph, items, selected_id, hover_id, theme) do
    # set the appropriate hilighting for each of the items
    Enum.reduce(items, graph, fn
      # this is the item the user is hovering over
      {_, ^hover_id}, g ->
        Graph.modify(g, hover_id, &update_opts(&1, fill: theme.thumb))

      # this is the currently selected item
      {_, ^selected_id}, g ->
        Graph.modify(g, selected_id, &update_opts(&1, fill: theme.active))

      # not selected, not hovered over
      {_, regular_id}, g ->
        Graph.modify(g, regular_id, &update_opts(&1, fill: theme.background))
    end)
  end

  defp handle_cursor_button(graph, items, selected_id, theme) do
    graph
    # restore standard highliting
    |> update_highlighting(items, selected_id, nil, theme)
    # raise the dropdown
    |> Graph.modify(@caret_id, &update_opts(&1, rotate: @rotate_neutral))
    |> Graph.modify(@dropbox_id, &update_opts(&1, hidden: true))
  end

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

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

  def handle_put(
        s_id,
        %{
          assigns: %{
            id: id,
            items: items,
            theme: theme,
            graph: graph
          }
        } = scene
      ) do
    # find the newly selected item's text
    scene =
      case Enum.find(items, fn {_, id} -> id == s_id end) do
        nil ->
          Logger.warn(
            "Attempted to put an invalid value on Dropdown id: #{inspect(id)}, value: #{inspect(s_id)}"
          )

          scene

        {text, _} ->
          # send the value_changed message
          send_parent_event(scene, {:value_changed, id, s_id})

          graph =
            graph
            # update the main button text
            |> Graph.modify(@text_id, &text(&1, text))
            # restore standard highliting
            |> update_highlighting(items, s_id, nil, theme)
            # raise the dropdown
            |> Graph.modify(@caret_id, &update_opts(&1, rotate: @rotate_neutral))
            |> Graph.modify(@dropbox_id, &update_opts(&1, hidden: true))

          :ok = release_input(scene)

          scene
          |> assign(down: false, graph: graph, selected_id: s_id)
          |> push_graph(graph)
      end

    {:noreply, scene}
  end

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

    {:noreply, scene}
  end

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