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