lib/scrollable/container.ex

defmodule FloUI.Scrollable.Container do
  @moduledoc """
  ## Usage in SnapFramework

  Scrollable container is used for large content that you want to scroll. It renders a component child within.
  this is meant to be as plug and play as possible. With minimal fiddling to get it to work.
  You can use the ScrollBar component directly if you want to build your own scrollable containers.

  data is an object in the form of

  ``` elixir
  %{
    frame: {460, 470},
    content: {800, 800}
  }
  ```

  You can choose which scroll bars to render via the `scroll_bars` option.

  ``` elixir
  <%= graph font_size: 20 %>

  <%= component FloUI.Scrollable.Container,
        %{
            frame: {460, 470},
            content: {800, 800}
        },
        translate: {20, 60},
        scroll_bars: %{
            vertical: %{
                show: true,
                show_buttons: true,
                theme: Scenic.Primitive.Style.Theme.preset(:dark),
                thickness: 15
            },
            horizontal: %{
                show: true,
                show_buttons: true,
                theme: Scenic.Primitive.Style.Theme.preset(:dark),
                thickness: 15
            }
        } do %>

        <%= component Basic.Component.Page4, nil %>

    <% end %>
  ```
  """

  alias Scenic.Graph
  alias Scenic.Math.Vector2

  alias FloUI.Scrollable.Hotkeys
  alias FloUI.Scrollable.Direction
  alias FloUI.Scrollable.Acceleration
  alias FloUI.Scrollable.PositionCap

  use SnapFramework.Component,
    name: :scrollable_container,
    template: "lib/scrollable/container.eex",
    controller: FloUI.Scrollable.ScrollableContainerController,
    assigns: [],
    opts: []

  defcomponent(:scrollable_container, :map)

  @default_horizontal_scroll_bar %{
    show: false,
    show_buttons: false,
    thickness: 15,
    radius: 3,
    theme: FloUI.Theme.preset(:scrollbar)
  }
  @default_vertical_scroll_bar %{
    show: true,
    show_buttons: true,
    thickness: 15,
    radius: 3,
    theme: FloUI.Theme.preset(:scrollbar)
  }
  @default_position {0, 0}
  @default_fps 30

  use_effect([assigns: [scroll_position: :any]],
    run: [:on_scroll_position_change]
  )

  @impl true
  def setup(%{assigns: %{data: data, opts: opts}} = scene) do
    {content_width, content_height} = data.content
    {frame_width, frame_height} = data.frame
    {frame_x, frame_y} = opts[:translate] || @default_position
    scroll_position = Map.get(data, :scroll_position, {0, 0})
    scroll_bars =
      case opts[:scroll_bars] do
        nil ->
          %{vertical: @default_vertical_scroll_bar, horizontal: @default_horizontal_scroll_bar}
        scroll_bars ->
          vertical = Map.get(scroll_bars, :vertical, @default_vertical_scroll_bar)
          horizontal = Map.get(scroll_bars, :horizontal, @default_horizontal_scroll_bar)
          %{
            vertical: %{
              show: Map.get(vertical, :show, true),
              show_buttons: Map.get(vertical, :show_buttons, true),
              thickness: Map.get(vertical, :thickness, 15),
              radius: Map.get(vertical, :radius, 3),
              theme: Map.get(vertical, :theme, FloUI.Theme.preset(:scrollbar))
            },
            horizontal: %{
              show: Map.get(horizontal, :show, true),
              show_buttons: Map.get(horizontal, :show_buttons, true),
              thickness: Map.get(horizontal, :thickness, 15),
              radius: Map.get(horizontal, :radius, 3),
              theme: Map.get(horizontal, :theme, FloUI.Theme.preset(:scrollbar))
            }
          }
      end

    assign(scene,
      id: opts[:id] || :scrollable,
      theme: opts[:theme] || FloUI.Theme.preset(:scrollbar),
      frame: %{x: frame_x, y: frame_y, width: frame_width, height: frame_height},
      content: %{x: 0, y: 0, width: content_width, height: content_height},
      scroll_position: scroll_position,
      fps: opts[:scroll_fps] || @default_fps,
      acceleration: Acceleration.init(opts[:scroll_acceleration]),
      hotkeys: Hotkeys.init(opts[:scroll_hotkeys]),
      scroll_direction: nil,
      scroll_bars_state: %{
        vertical: %{
          scrolling: :idle,
          wheel_state: nil,
          scroll_buttons: %{
            scroll_button_1: :released,
            scroll_button_2: :released
          },
          pid: nil
        },
        horizontal: %{
          scrolling: :idle,
          wheel_state: nil,
          scroll_buttons: %{
            scroll_button_1: :released,
            scroll_button_2: :released
          },
          pid: nil
        }
      },
      scroll_bars: scroll_bars
    )
    |> init_position_caps
  end

  @impl true
  def mounted(scene) do
    scene =
      FloUI.Scrollable.ScrollableContainerController.render_content(scene)

    Scenic.Scene.push_graph(scene, scene.assigns.graph)
  end

  @impl true
  def bounds(%{frame: {x, y}}, _opts) do
    {0.0, 0.0, x, y}
  end

  @impl true
  def process_update(data, _opts, scene) do
    scene =
      assign(scene,
        data: data,
        scroll_position: PositionCap.cap(scene.assigns.position_caps, Vector2.invert(data.scroll_position))
      )

    {:noreply, scene}
  end

  @impl true
  def process_event(
        {:register_scroll_bar, direction, scroll_bar_state},
        _pid,
        %{assigns: %{scroll_bars_state: scroll_bars_state}} = scene
      ) do
    scene =
      scene
      |> assign(
        scroll_bars_state:
          Map.update!(scroll_bars_state, direction, fn _ ->
            scroll_bar_state
          end)
      )

    {:noreply, scene}
  end

  def process_event({:scroll_bar_state_changed, direction, scroll_bar_state}, _, scene) do
    scene =
      scene
      |> assign(
        scroll_bars_state: Map.update!(
          scene.assigns.scroll_bars_state,
          direction, fn _ ->
            scroll_bar_state
          end)
      )
      |> update

    {:noreply, scene}
  end

  def process_event({:update_scroll_position, :vertical, {_, y}}, _, scene) do
    {x, _} = scene.assigns.scroll_position
    {:noreply, assign(scene, scroll_position: PositionCap.cap(scene.assigns.position_caps, {x, y}))}
  end

  def process_event({:update_scroll_position, :horizontal, {_, x}}, _, scene) do
    {_, y} = scene.assigns.scroll_position
    {:noreply, assign(scene, scroll_position: PositionCap.cap(scene.assigns.position_caps, {x, y}))}
  end

  def process_event({:update_scroll_position, pos}, _, scene) do
    {:noreply, assign(scene, scroll_position: PositionCap.cap(scene.assigns.position_caps, pos))}
  end

  def process_event(event, _, scene) do
    {:cont, event, scene}
  end

  @impl true
  def process_input(
        {:cursor_scroll, scroll_pos},
        :input_capture,
        %{assigns: %{scroll_bars_state: scroll_bars_state}} = scene
      ) do
    if not is_nil(scroll_bars_state.vertical.pid) do
      GenServer.cast(scroll_bars_state.vertical.pid, {:update_cursor_scroll, scroll_pos})
    end

    if not is_nil(scroll_bars_state.horizontal.pid) do
      GenServer.cast(scroll_bars_state.horizontal.pid, {:update_cursor_scroll, scroll_pos})
    end

    {:noreply, scene}
  end

  @impl true
  def process_info(:tick, scene) do
    {:noreply, assign(scene, animating: false) |> update}
  end

  @spec init_position_caps(Scenic.Scene.t) :: Scenic.Scene.t
  defp init_position_caps(
         %{
           assigns: %{
             frame: %{width: frame_width, height: frame_height},
             content: %{x: x, y: y, width: content_width, height: content_height}
           }
         } = scene
       ) do
    min = {x + frame_width - content_width, y + frame_height - content_height}
    max = {x, y}

    position_cap = PositionCap.init(%{min: min, max: max})

    assign(scene,
      position_caps: position_cap,
      scroll_position: PositionCap.cap(position_cap, Vector2.invert(scene.assigns.scroll_position))
    )
  end

  @spec update(Scenic.Scene.t) :: Scenic.Scene.t
  defp update(scene) do
    scene
    |> apply_force
    |> verify_cooling_down
    |> tick
  end

  @spec verify_cooling_down(Scenic.Scene.t) :: Scenic.Scene.t
  defp verify_cooling_down(%{assigns: %{scroll_bars_state: %{vertical: vertical, horizontal: horizontal}}} = scene) do
    if vertical.scrolling == :idle and
       horizontal.scrolling == :idle and not
       Acceleration.is_stationary?(scene.assigns.acceleration)
    do
      assign(scene,
        scroll_bars_state: %{
          vertical: %{vertical | scrolling: :cooling_down},
          horizontal: %{horizontal | scrolling: :cooling_down}
        }
      )
    else
      if vertical.scrolling == :cooling_down and horizontal.scrolling == :cooling_down and Acceleration.is_stationary?(scene.assigns.acceleration) do
        assign(scene,
          scroll_bars_state: %{
            vertical: %{vertical | scrolling: :idle},
            horizontal: %{horizontal | scrolling: :idle}
          }
        )
      else
        scene
      end
    end
  end

  @spec apply_force(Scenic.Scene.t) :: Scenic.Scene.t
  defp apply_force(
         %{
           assigns: %{
             scroll_bars_state: %{
               vertical: %{
                 scrolling: :idle
               },
               horizontal: %{
                 scrolling: :idle
               }
            }
          }
        } = scene
      ), do: scene

  defp apply_force(
    %{
      assigns: %{
        scroll_bars_state: %{
        vertical: %{
          scrolling: vert_scroll,
          wheel_state: %{offset: {_, offset_y}}
        },
        horizontal: %{
          scrolling: horiz_scroll,
          wheel_state: %{offset: {_, offset_x}}
        }
      }
    }
  } = scene
) when vert_scroll == :wheel or horiz_scroll == :wheel do
    {x, y} = scene.assigns.scroll_position
    scroll_position = {x + offset_x * 5, y + offset_y * 5}

    assign(scene, scroll_position: PositionCap.cap(scene.assigns.position_caps, scroll_position))
  end

  defp apply_force(scene) do
    scroll_direction = get_scroll_direction(scene)
    force =
      Hotkeys.direction(scene.assigns.hotkeys)
      |> Vector2.add(scroll_direction)

    Acceleration.apply_force(scene.assigns.acceleration, force)
    |> Acceleration.apply_counter_pressure()
    |> (&assign(scene, acceleration: &1)).()
    |> (fn scene ->
          scroll_pos =
            Acceleration.translate(scene.assigns.acceleration, scene.assigns.scroll_position)

          assign(scene, scroll_position: PositionCap.cap(scene.assigns.position_caps, scroll_pos))
        end).()
  end

  @spec get_scroll_direction(Scenic.Scene.t) :: Scenic.Math.Vector2.t
  defp get_scroll_direction(%{assigns: %{scroll_bars_state: scroll_bars_state}}) do
    case scroll_bars_state do
      %{vertical: %{scroll_buttons: %{scroll_button_1: :pressed, scroll_button_2: :released}}} ->
        Direction.return(1, :vertical)
        |> Direction.to_vector_2()

      %{vertical: %{scroll_buttons: %{scroll_button_1: :released, scroll_button_2: :pressed}}} ->
        Direction.return(-1, :vertical)
        |> Direction.to_vector_2()

      %{horizontal: %{scroll_buttons: %{scroll_button_1: :pressed, scroll_button_2: :released}}} ->
        Direction.return(1, :horizontal)
        |> Direction.to_vector_2()

      %{horizontal: %{scroll_buttons: %{scroll_button_1: :released, scroll_button_2: :pressed}}} ->
        Direction.return(-1, :horizontal)
        |> Direction.to_vector_2()

      _ ->
        {0, 0}
    end
  end

  @spec tick(Scenic.Scene.t) :: Scenic.Scene.t
  defp tick(%{assigns: %{scroll_bars_state: %{vertical: %{scrolling: :idle}, horizontal: %{scrolling: :idle}}}} = scene), do: assign(scene, animating: false)

  defp tick(%{assigns: %{scroll_bars_state: %{vertical: %{scrolling: :dragging}}}} = scene) do
    assign(scene, animating: false)
  end

  defp tick(%{assigns: %{scroll_bars_state: %{horizontal: %{scrolling: :dragging}}}} = scene) do
    assign(scene, animating: false)
  end

  defp tick(%{assigns: %{scroll_bars_state: %{vertical: %{scrolling: vert_scrolling}, horizontal: %{scrolling: horiz_scrolling}}}} = scene)
    when vert_scrolling == :wheel or horiz_scrolling == :wheel
    do
      assign(scene, animating: false)
    end

  defp tick(%{assigns: %{animating: true}} = scene), do: scene

  defp tick(scene) do
    Process.send_after(self(), :tick, tick_time(scene))
    assign(scene, animating: true)
  end

  @spec tick_time(Scenic.Scene.t) :: integer
  defp tick_time(%{assigns: %{fps: fps}}) do
    trunc(1000 / fps)
  end
end