defmodule FloUI.Dropdown do
@moduledoc """
## Usage in SnapFramework
Dropdown component which renders a scroll bar when when the list becomes greater then the height of the dropdown.
data is a tuple in the form of ` elixir {items, selected}`
item is a tuple in the form of
``` elixir
{%{
label: "label",
value: value
}, :id}
```
style opts
`width: :integer`
`height: :integer`
``` elixir
<%= component FloUI.Dropdown,
{@dropdown_opts, @selected_opt},
id: @opts[:id]
%>
```
"""
use Scenic.Component
require Logger
import Scenic.Primitives
alias Scenic.Graph
alias Scenic.Primitive
alias Scenic.Math.Vector2
alias FloUI.Icon
alias FloUI.Scrollable.Acceleration
alias FloUI.Scrollable.Hotkeys
alias FloUI.Scrollable.Drag
alias FloUI.Scrollable.Wheel
alias FloUI.Scrollable.Direction
alias FloUI.Scrollable.PositionCap
alias FloUI.Scrollable.ScrollBar
alias FloUI.Util.FontMetricsHelper
@max_frame_height 300
@button_id :btn_dropdown
@selected_label_id :txt_selected
@dropbox_id :dropbox
@font_size 24
@graph Graph.build(font_size: @font_size)
def validate(nil), do: :invalid_data
def validate(data), do: {:ok, data}
def init(scene, {items, selected}, opts) do
id = opts[:id]
width = get_dropdown_width(items)
height = opts[:styles][:height] || 50
state =
%{
graph: @graph,
id: id,
items: items,
selected: selected,
width: width,
height: height,
dropdown_hidden: true,
fps: 30,
scrolling: :idle,
animating: false,
scroll_bar_pid: nil,
scroll_bar: :none,
scroll_state: :idle,
position_cap: %PositionCap{},
hotkeys: %Hotkeys{},
drag_state: %Drag{},
wheel_state: %Wheel{},
acceleration: %Acceleration{},
focused: false,
scroll_position: {0, 0},
content: %{x: 0, y: 0, width: width, height: get_dropdown_scroll_height(items)}
}
|> init_position_cap
|> render_button
|> render_clickout_bg
|> render_dropdown
|> update_highlighting(selected)
scene =
scene
|> assign(state: state)
|> push_graph(state.graph)
{:ok, scene}
end
# ---------------------------------------------------------------------------
# handle scroll bar events
def handle_event({:scroll_bar_initialized, _id, scroll_bar_state}, from, scene) do
state = %{scene.assigns.state | scroll_bar_pid: from, scroll_bar: OptionEx.return(scroll_bar_state)}
{:noreply, assign(scene, state: state)}
end
def handle_event(
{:scroll_bar_position_change, _, _scroll_bar_state},
_from,
%{assigns: %{scroll_state: :scrolling}} = scene
) do
{:noreply, scene}
end
def handle_event({:scroll_bar_position_change, _id, scroll_bar_state}, _from, scene) do
{x, y} = scene.assigns.state.scroll_position
state =
ScrollBar.new_position(scroll_bar_state)
|> Direction.from_vector_2(scroll_bar_state.direction)
|> Direction.map_horizontal(&{&1, y})
|> Direction.map_vertical(&{x, &1})
|> Direction.unwrap()
|> (&Map.put(scene.assigns.state, :scroll_position, &1)).()
|> update
scene =
scene
|> assign(state: state)
|> push_graph(state.graph)
{:noreply, scene}
end
def handle_event({:scroll_bar_scroll_end, _id, scroll_bar_state}, _from, scene) do
state =
%{scene.assigns.state | scroll_bar: OptionEx.return(scroll_bar_state)}
|> update
scene =
scene
|> assign(state: state)
|> push_graph(state.graph)
{:noreply, scene}
end
def handle_event({:scroll_bar_button_pressed, _id, scroll_bar_state}, _from, scene) do
state =
%{scene.assigns.state | scroll_bar: OptionEx.return(scroll_bar_state)}
|> update
scene =
scene
|> assign(state: state)
|> push_graph(state.graph)
{:noreply, scene}
end
def handle_event({:scroll_bar_button_released, _id, scroll_bar_state}, _from, scene) do
state =
%{scene.assigns.state | scroll_bar: OptionEx.return(scroll_bar_state)}
|> update
scene =
scene
|> assign(state: state)
|> push_graph(state.graph)
{:noreply, scene}
end
def handle_event({:cursor_scroll_started, _id, scroll_bar_state}, _from, %{assigns: %{state: state}} = scene) do
state =
%{state | scroll_bar: OptionEx.return(scroll_bar_state), wheel_state: scroll_bar_state.wheel_state}
|> update()
scene =
scene
|> assign(state: state)
|> push_graph(state.graph)
{:noreply, scene}
end
def handle_event({:cursor_scroll_stopped, _id, scroll_bar_state}, _from, %{assigns: %{state: state}} = scene) do
state =
%{state | scroll_bar: OptionEx.return(scroll_bar_state), wheel_state: scroll_bar_state.wheel_state}
|> update()
scene =
scene
|> assign(state: state)
|> push_graph(state.graph)
{:noreply, scene}
end
def handle_event(event, _, scene) do
{:cont, event, scene}
end
# ---------------------------------------------------------------------------
# handle button clicks
def handle_input(
{:cursor_button, {:btn_left, 0, _, _}},
@button_id,
%{assigns: %{state: %{selected: selected, dropdown_hidden: true}}} = scene
) do
state =
scene.assigns.state
|> toggle_dropdown()
|> update_highlighting(selected)
scene =
scene
|> assign(state: state)
|> push_graph(state.graph)
{:noreply, scene}
end
def handle_input(
{:cursor_button, {:btn_left, 0, _, _}},
@button_id,
%{assigns: %{state: %{selected: selected, dropdown_hidden: false}}} = scene
) do
state =
scene.assigns.state
|> toggle_dropdown()
|> update_highlighting(selected)
scene =
scene
|> assign(state: state)
|> push_graph(state.graph)
{:noreply, scene}
end
def handle_input(
{:cursor_button, {:btn_left, 0, _, _}},
:scroll_bar,
scene
) do
{:noreply, scene}
end
# ---------------------------------------------------------------------------
# handle click outside
# close dropdown and release input
def handle_input(
{:cursor_button, {:btn_left, 0, _, _}},
:clickout,
%{assigns: %{state: %{scroll_bar_pid: scroll_pid, selected: selected, dropdown_hidden: false}}} = scene
) do
GenServer.cast(scroll_pid, :unrequest_cursor_scroll)
state =
scene.assigns.state
|> toggle_dropdown()
|> update_highlighting(selected)
scene =
scene
|> assign(state: state)
|> push_graph(state.graph)
{:noreply, scene}
end
# def handle_input(
# {:cursor_button, {0, :release, _, _}},
# @dropbox_id,
# scene
# ) do
# {:noreply, scene}
# end
# ---------------------------------------------------------------------------
# Handle item clicks
def handle_input(
{:cursor_button, {:btn_left, 0, _, _}},
item_id,
%{assigns: %{state: %{id: id, items: items}}} = scene
) do
state =
%{scene.assigns.state | selected: item_id}
|> toggle_dropdown
|> update_selected_label
|> update_highlighting(item_id)
send_parent_event(scene, {:value_changed, id, get_selected_value(items, item_id)})
scene =
scene
|> assign(state: state)
|> push_graph(state.graph)
{:noreply, scene}
end
# ---------------------------------------------------------------------------
# Ignore button mouse overs
def handle_input({:cursor_pos, _}, @button_id, scene) do
{:noreply, scene}
end
# def handle_input({:cursor_pos, _}, @button_id, scene) do
# {:noreply, scene}
# end
# ---------------------------------------------------------------------------
# Handle item mouse over highlighting
def handle_input({:cursor_pos, _}, item_id, scene) do
state = update_highlighting(scene.assigns.state, item_id)
scene =
scene
|> assign(state: state)
|> push_graph(state.graph)
{:noreply, scene}
end
# def handle_input({:cursor_pos, _}, item_id, scene) do
# state = update_highlighting(scene.assigns.state, item_id)
# scene =
# scene
# |> assign(state: state)
# |> push_graph(state.graph)
# {:noreply, scene}
# end
def handle_input({:cursor_scroll, scroll_pos}, :scroll_capture, %{assigns: %{state: state}} = scene) do
GenServer.cast(state.scroll_bar_pid, {:update_cursor_scroll, scroll_pos})
{:noreply, scene}
end
# ---------------------------------------------------------------------------
# Catch unhandled events
def handle_input(_event, _context, scene) do
{:noreply, scene}
end
def handle_info(:tick, scene) do
state =
%{scene.assigns.state | animating: false}
|> update
scene =
scene
|> assign(state: state)
|> push_graph(state.graph)
{:noreply, scene}
end
defp toggle_dropdown(%{graph: graph, dropdown_hidden: true, items: items, content: content} = state) do
show_scroll_bar = if get_dropdown_frame_height(items) < content.height, do: false, else: true
graph =
graph
|> Graph.modify(:content_container, &Primitive.put_style(&1, :hidden, false))
|> Graph.modify(:dropdown_bg, &Primitive.put_style(&1, :hidden, false))
|> Graph.modify(:clickout, &Primitive.put_style(&1, :hidden, false))
|> Graph.modify(:icon, &Primitive.put_transform(&1, :rotate, :math.pi()))
|> Graph.modify(:scroll_bar, &Primitive.put_style(&1, :hidden, show_scroll_bar))
%{state | graph: graph, dropdown_hidden: false}
end
defp toggle_dropdown(%{graph: graph, dropdown_hidden: false} = state) do
graph =
graph
|> Graph.modify(:content_container, &Primitive.put_style(&1, :hidden, true))
|> Graph.modify(:dropdown_bg, &Primitive.put_style(&1, :hidden, true))
|> Graph.modify(:clickout, &Primitive.put_style(&1, :hidden, true))
|> Graph.modify(:icon, &Primitive.put_transform(&1, :rotate, 0))
|> Graph.modify(:scroll_bar, &Primitive.put_style(&1, :hidden, true))
%{state | graph: graph, dropdown_hidden: true}
end
defp render_button(
%{graph: graph, width: width, height: height, items: items, selected: selected} = state
) do
graph
|> rect({width, height - 1},
stroke: {1, :white},
fill: :black
)
|> text(get_selected_label(items, selected),
translate: {5, 30},
id: @selected_label_id
)
|> Icon.add_to_graph({:flo_ui, "icons/arrow_drop_down_white.png"},
id: :icon,
translate: {width - 48, 0},
pin: {48 / 2, 48 / 2}
)
|> rect({width, height - 1},
id: @button_id,
input: :cursor_button
)
|> (&%{state | graph: &1}).()
end
defp render_clickout_bg(%{graph: graph} = state) do
graph
|> rect({8000, 8000}, t: {-4000, -4000}, id: :clickout, input: :cursor_button, hidden: true)
|> (&%{state | graph: &1}).()
end
defp render_dropdown(%{graph: graph, width: width, height: height, items: items, scroll_position: {_x, y}} = state) do
graph
|> rect({width, get_dropdown_frame_height(items)+5},
id: :dropdown_bg,
translate: {0, height},
fill: :black,
stroke: {1, :white},
hidden: true
)
|> group(fn g ->
g
|> group(fn g ->
g
|> (&Enum.reduce(Enum.with_index(items), &1, fn {{item, id}, i}, g ->
g
|> group(
fn g ->
g
|> rect(
{width, height},
translate: {0, 0},
id: id,
input: [:cursor_pos, :cursor_button]
)
|> text(
item.label,
translate: {5, 25},
text_align: :left
)
|> rect(
{width, get_dropdown_frame_height(items)+5},
translate: {0, 0},
id: :scroll_capture,
input: :cursor_scroll
)
end,
translate: {0, height * i}
)
end)).()
end,
id: @dropbox_id,
translate: {0, 0}
) end,
scissor: {width, get_dropdown_frame_height(items)},
id: :content_container,
translate: {0, height},
width: width,
hidden: true,
height: get_dropdown_frame_height(items)
)
|> ScrollBar.add_to_graph(
%{
width: 15,
height: get_dropdown_frame_height(items),
content_size: get_dropdown_scroll_height(items),
scroll_position: y,
direction: :vertical
},
id: :scroll_bar,
translate: {width-16, height + 2},
scroll_buttons: true,
scroll_bar_theme: Scenic.Primitive.Style.Theme.preset(:dark),
scroll_bar_thickness: 15,
hidden: true
)
|> (&%{state | graph: &1}).()
end
defp update(state) do
state
|> update_scroll_state
|> apply_force
|> translate_content
|> update_scroll_bars
|> tick
end
defp update_scroll_bars(%{scroll_position: {_x, y}} = state) do
GenServer.call(state.scroll_bar_pid, {:update_scroll_position, y})
state
end
defp update_scroll_state(state) do
verify_idle_state(state)
|> OptionEx.or_try(fn -> verify_dragging_state(state) end)
|> OptionEx.or_try(fn -> verify_scrolling_state(state) end)
|> OptionEx.or_try(fn -> verify_wheel_state(state) end)
|> OptionEx.or_try(fn -> verify_cooling_down_state(state) end)
|> OptionEx.map(&%{state | scrolling: &1})
|> OptionEx.or_else(state)
end
defp apply_force(%{scrolling: :idle} = state), do: state
defp apply_force(%{scrolling: :dragging} = state) do
state.scroll_bar
|> OptionEx.bind(&OptionEx.from_bool(ScrollBar.dragging?(&1), &1))
|> OptionEx.bind(&ScrollBar.new_position/1)
|> OptionEx.map(fn new_position ->
Vector2.add(new_position, {state.content.x, state.content.y})
end)
|> OptionEx.or_try(fn ->
OptionEx.from_bool(Drag.dragging?(state.drag_state), state.drag_state)
|> OptionEx.bind(&Drag.new_position/1)
end)
|> OptionEx.map(&%{state | scroll_position: PositionCap.cap(state.position_cap, &1)})
|> OptionEx.or_else(state)
end
defp apply_force(%{scrolling: :wheel, wheel_state: %{offset: {_, offset_y}}} = state) do
{_, y} = state.scroll_position
scroll_position = {0, y + offset_y * 10}
%{state | scroll_position: PositionCap.cap(state.position_cap, scroll_position)}
end
defp apply_force(state) do
force =
Hotkeys.direction(state.hotkeys)
|> Vector2.add(get_scroll_bar_direction(state))
Acceleration.apply_force(state.acceleration, force)
|> Acceleration.apply_counter_pressure()
|> (&%{state | acceleration: &1}).()
|> (fn state ->
Map.update(state, :scroll_position, {0, 0}, fn scroll_pos ->
scroll_pos = Acceleration.translate(state.acceleration, scroll_pos)
PositionCap.cap(state.position_cap, scroll_pos)
end)
end).()
end
defp verify_idle_state(state) do
result =
Hotkeys.direction(state.hotkeys) == {0, 0} and not Drag.dragging?(state.drag_state) and
state.wheel_state.wheel_state != :scrolling and
get_scroll_bar_direction(state) == {0, 0} and not scroll_bars_dragging?(state) and
Acceleration.is_stationary?(state.acceleration)
OptionEx.from_bool(result, :idle)
end
defp verify_dragging_state(state) do
result = Drag.dragging?(state.drag_state) or scroll_bars_dragging?(state)
OptionEx.from_bool(result, :dragging)
end
defp verify_scrolling_state(state) do
result =
Hotkeys.direction(state.hotkeys) != {0, 0} or
(get_scroll_bar_direction(state) != {0, 0} and not (state.scrolling == :dragging))
OptionEx.from_bool(result, :scrolling)
end
defp verify_wheel_state(state) do
{_, offset} = state.wheel_state.offset
result =
not Hotkeys.is_any_key_pressed?(state.hotkeys) and
not Drag.dragging?(state.drag_state) and
offset > 0 or offset < 0 and
get_scroll_bar_direction(state) == {0, 0} and
not scroll_bars_dragging?(state)
OptionEx.from_bool(result, :wheel)
end
defp verify_cooling_down_state(state) do
result =
not Hotkeys.is_any_key_pressed?(state.hotkeys) and not Drag.dragging?(state.drag_state) and
get_scroll_bar_direction(state) == {0, 0} and not scroll_bars_dragging?(state) and
not Acceleration.is_stationary?(state.acceleration)
OptionEx.from_bool(result, :cooling_down)
end
defp update_selected_label(%{graph: graph, items: items, selected: selected} = state) do
graph
|> Graph.modify(@selected_label_id, &text(&1, get_selected_label(items, selected)))
|> (&%{state | graph: &1}).()
end
defp update_highlighting(%{graph: graph, items: items, selected: selected} = state, hovered) do
Enum.reduce(items, graph, fn
# this is the item the user is hovering over
{_, ^hovered}, g ->
Graph.modify(g, hovered, &update_opts(&1, fill: :steel_blue))
# this is the currently selected item
{_, ^selected}, g ->
Graph.modify(g, selected, &update_opts(&1, fill: :steel_blue))
# not selected, not hovered over
{_, regular_id}, g ->
Graph.modify(g, regular_id, &update_opts(&1, fill: :black))
end)
|> (&%{state | graph: &1}).()
end
defp get_selected_label(items, selected) do
Enum.find_value(items, "", fn
{%{label: label}, ^selected} -> label
_ -> false
end)
end
defp get_selected_value(items, selected) do
Enum.find_value(items, "", fn
{%{value: value}, ^selected} -> value
_ -> nil
end)
end
def get_dropdown_width(items) do
Enum.reduce(items, 0, fn {item, _id}, acc ->
width = FontMetricsHelper.get_text_width(item.label, @font_size) + 48
if width > acc, do: width, else: acc
end)
end
defp translate_content(%{content: %{x: x, y: y}} = state) do
Map.update!(state, :graph, fn graph ->
graph
|> Graph.modify(@dropbox_id, fn primitive ->
Map.update(primitive, :transforms, %{}, fn styles ->
Map.put(styles, :translate, Vector2.add(state.scroll_position, {x, y}))
end)
end)
end)
end
defp init_position_cap(
%{
items: items,
width: width,
content: %{x: x, y: y}
} = state
) do
min = {x + width - width, y + get_dropdown_frame_height(items) - get_dropdown_scroll_height(items)}
max = {x, y}
position_cap = PositionCap.init(%{min: min, max: max})
Map.put(state, :position_cap, position_cap)
|> Map.update(:scroll_position, {0, 0}, &PositionCap.cap(position_cap, &1))
end
defp get_dropdown_scroll_height(items) do
length(items) * 50
end
defp get_dropdown_frame_height(items) do
frame_height = length(items) * 50
if frame_height > @max_frame_height do
@max_frame_height
else
frame_height
end
end
defp tick(%{scrolling: :idle} = state), do: %{state | animating: false}
defp tick(%{scrolling: :dragging} = state), do: %{state | animating: false}
defp tick(%{scrolling: :wheel} = state), do: %{state | animating: false}
defp tick(%{animating: true} = state), do: state
defp tick(state) do
Process.send_after(self(), :tick, tick_time(state))
%{state | animating: true}
end
defp tick_time(%{fps: fps}) do
trunc(1000 / fps)
end
defp get_scroll_bar_direction(%{scroll_bar: :none}), do: {0, 0}
defp get_scroll_bar_direction(%{scroll_bar: {:some, scroll_bar}}),
do: ScrollBar.direction(scroll_bar)
defp scroll_bars_dragging?(%{scroll_bar: :none}), do: false
defp scroll_bars_dragging?(%{scroll_bar: {:some, scroll_bar}}),
do: ScrollBar.dragging?(scroll_bar)
end