defmodule FloUI.Scrollable.ScrollBar do @moduledoc """ Scroll bars are meant to be used within the Scrollable.Container component, but you can use them to build your own scrollable containers. The following events are emitted. ``` elixir {:register_scroll_bar, direction, scroll_bar_state} {:update_scroll_position, direction, scroll_position} {:scroll_bar_state_changed, direction, scroll_bar_state} ``` additionally you can cast a vector2 offset to a scroll bar ``` elixir GenServer.cast(scroll_bar_pid, {:update_cursor_scroll, offset}) ``` data is an object in the form of ``` elixir %{ direction: :vertical, content_size: {200, 200}, width: 15, height: 500, scroll_position: {0, 0} } ``` The following options are accepted ``` elixir [ show_buttons: true, theme: Scenic.Primitive.Style.Theme.preset(:dark), border: 1, radius: 3, thickness: 15 ] ``` """ alias Scenic.Graph alias FloUI.Scrollable.Direction alias FloUI.Scrollable.Drag alias FloUI.Scrollable.Wheel alias FloUI.Scrollable.PositionCap use SnapFramework.Component, name: :scroll_bar, template: "lib/scrollable/scroll_bar.eex", controller: FloUI.Scrollable.ScrollBarController, assigns: [], opts: [] defcomponent(:scroll_bar, :map) @default_drag_settings %{mouse_buttons: [:btn_left, :btn_right, :btn_middle]} @default_thickness 15 @default_radius 3 @default_id :scroll_bar use_effect([assigns: [scroll_position: :any]], run: [:on_scroll_position_change] ) @impl true def setup(%{assigns: %{data: data, opts: opts}} = scene) do scene = assign(scene, id: opts[:id] || @default_id, thickness: opts[:thickness] || @default_thickness, radius: opts[:radius] || @default_radius, width: Direction.as_horizontal(data.width), height: Direction.as_vertical(data.height), direction: data.direction, content_size: Direction.from_vector_2(data.content_size, data.direction), frame_size: Direction.from_vector_2({data.width, data.height}, data.direction), scroll_position: Direction.from_vector_2(data.scroll_position, data.direction), scroll_bar_slider_background: :released, last_scroll_position: Direction.from_vector_2(data.scroll_position, data.direction), scroll_bar_state: %{ scrolling: :idle, drag_state: Drag.init(opts[:scroll_drag] || @default_drag_settings), wheel_state: %Wheel{}, scroll_buttons: nil, pid: self() } ) |> init_scroll_buttons |> init_size |> init_position_cap |> init_scroll_bar_background |> init_scroll_bar_drag_control |> init_scroll_bar_buttons send_parent_event( scene, {:register_scroll_bar, scene.assigns.direction, scene.assigns.scroll_bar_state} ) scene end @impl true def bounds(data, _opts) do {0.0, 0.0, data.width, data.height} end @impl true def process_update(data, _opts, scene) do IO.puts("updating scroll bar position") {:noreply, assign(scene, last_scroll_position: scene.assigns.scroll_position, scroll_position: Direction.from_vector_2(data.scroll_position, scene.assigns.direction) )} end @impl true def process_input( {:cursor_button, {button, action, _, position}}, :scroll_bar_slider_drag_control, %{assigns: %{direction: direction, scroll_bar_state: scroll_bar_state}} = scene ) do case action do 0 -> unrequest_input(scene, [:cursor_pos, :cursor_button]) scrolling = :idle {_, content_start} = Direction.from_vector_2(scroll_bar_state.drag_state.drag_start_content_position, direction) {_, drag_start} = Direction.from_vector_2(scroll_bar_state.drag_state.drag_start, direction) scroll_position = Direction.from_vector_2(position, direction) |> Direction.map_horizontal(fn pos -> pos - drag_start + content_start end) |> Direction.map_vertical(fn pos -> pos - drag_start + content_start end) scroll_position = local_to_world(scene, scroll_position) drag_state = Drag.handle_mouse_release( scroll_bar_state.drag_state, button, position ) scroll_bar_state = %{ scroll_bar_state | scrolling: scrolling, drag_state: drag_state } scene = assign(scene, scroll_bar_state: scroll_bar_state, last_scroll_position: scene.assigns.scroll_position, scroll_position: scroll_position ) send_parent_event(scene, {:update_scroll_position, direction, scroll_position}) {:noreply, scene} 1 -> request_input(scene, [:cursor_pos, :cursor_button]) scrolling = :dragging drag_state = Drag.handle_mouse_click( scroll_bar_state.drag_state, button, position, local_scroll_position_vector2(scene) ) scroll_bar_state = %{ scroll_bar_state | scrolling: scrolling, drag_state: drag_state } scene = assign(scene, scroll_bar_state: scroll_bar_state ) {:noreply, scene} end end def process_input( {:cursor_pos, position}, _, %{assigns: %{direction: direction, scroll_bar_state: scroll_bar_state}} = scene ) do {_, content_start} = Direction.from_vector_2(scroll_bar_state.drag_state.drag_start_content_position, direction) {_, drag_start} = Direction.from_vector_2(scroll_bar_state.drag_state.drag_start, direction) scroll_position = Direction.from_vector_2(position, direction) |> Direction.map_horizontal(fn pos -> pos - drag_start + content_start end) |> Direction.map_vertical(fn pos -> pos - drag_start + content_start end) scroll_position = local_to_world(scene, scroll_position) drag_state = Drag.handle_mouse_move(scroll_bar_state.drag_state, position) scroll_bar_state = %{ scroll_bar_state | drag_state: drag_state } scene = assign(scene, scroll_bar_state: scroll_bar_state, last_scroll_position: scene.assigns.scroll_position, scroll_position: scroll_position ) send_parent_event(scene, {:update_scroll_position, direction, scroll_position}) {:noreply, scene} end def process_input( {:cursor_pos, _}, _, scene ) do {:noreply, scene} end def process_input( {:cursor_button, {_button, 1, _, _}}, :scroll_bar_slider_background, scene ) do {:noreply, scene} end def process_input( {:cursor_button, {_button, 0, _, position}}, :scroll_bar_slider_background, %{assigns: %{direction: direction}} = scene ) do scroll_position = Direction.from_vector_2(position, direction) |> Direction.map_vertical(fn pos -> pos - button_height(scene) / 2 + pos end) |> Direction.map_horizontal(fn pos -> pos - button_width(scene) / 2 + pos end) scroll_position = local_to_world(scene, scroll_position) scene = scene |> assign( last_scroll_position: scene.assigns.scroll_position, scroll_position: scroll_position ) send_parent_event(scene, {:update_scroll_position, direction, scroll_position}) {:noreply, scene} end def process_input( {:cursor_button, {button, 0, _, position}}, nil, %{assigns: %{direction: direction, scroll_bar_state: scroll_bar_state}} = scene ) do unrequest_input(scene, [:cursor_pos, :cursor_button]) scrolling = :idle drag_state = Drag.handle_mouse_release( scroll_bar_state.drag_state, button, position ) scroll_bar_state = %{ scroll_bar_state | scrolling: scrolling, drag_state: drag_state } scene = assign(scene, scroll_bar_state: scroll_bar_state) send_parent_event(scene, {:scroll_bar_state_changed, direction, scroll_bar_state}) {:noreply, scene} end def process_input( {:cursor_button, {_button, 1, _, _}}, button, %{assigns: %{direction: direction, scroll_bar_state: scroll_bar_state}} = scene ) do scroll_buttons = Map.update!(scroll_bar_state.scroll_buttons, button, fn _ -> :pressed end) scrolling = :scrolling scroll_bar_state = %{ scroll_bar_state | scrolling: scrolling, scroll_buttons: scroll_buttons } scene = scene |> assign(scroll_bar_state: scroll_bar_state) send_parent_event(scene, {:scroll_bar_state_changed, direction, scroll_bar_state}) {:noreply, scene} end def process_input( {:cursor_button, {_button, 0, _, _}}, button, %{assigns: %{direction: direction, scroll_bar_state: scroll_bar_state}} = scene ) do scroll_buttons = Map.update!(scroll_bar_state.scroll_buttons, button, fn _ -> :released end) scrolling = :idle scroll_bar_state = %{ scroll_bar_state | scrolling: scrolling, scroll_buttons: scroll_buttons } scene = scene |> assign(scroll_bar_state: scroll_bar_state) send_parent_event(scene, {:scroll_bar_state_changed, direction, scroll_bar_state}) {:noreply, scene} end @impl true def process_cast( {:update_cursor_scroll, {{_, offset_y}, _}}, %{assigns: %{direction: :vertical = direction, scroll_bar_state: scroll_bar_state}} = scene ) do scene = if Float.floor(offset_y) == 0 or Float.ceil(offset_y) == 0 do scroll_bar_state = %{ scroll_bar_state | wheel_state: Wheel.stop_scrolling(scroll_bar_state.wheel_state, {direction, 0}), scrolling: :idle, } send_parent_event(scene, {:scroll_bar_state_changed, direction, scroll_bar_state}) assign(scene, scroll_bar_state: scroll_bar_state) else scroll_bar_state = %{ scroll_bar_state | wheel_state: Wheel.start_scrolling(scroll_bar_state.wheel_state, {direction, offset_y}), scrolling: :wheel, } send_parent_event(scene, {:scroll_bar_state_changed, direction, scroll_bar_state}) assign(scene, scroll_bar_state: scroll_bar_state) end {:noreply, scene} end def process_cast( {:update_cursor_scroll, {{offset_x, _}, _}}, %{assigns: %{direction: :horizontal = direction, scroll_bar_state: scroll_bar_state}} = scene ) do scene = if Float.floor(offset_x) == 0 or Float.ceil(offset_x) == 0 do scroll_bar_state = %{ scroll_bar_state | wheel_state: Wheel.stop_scrolling(scroll_bar_state.wheel_state, {direction, offset_x}), scrolling: :idle, } send_parent_event(scene, {:scroll_bar_state_changed, direction, scroll_bar_state}) assign(scene, scroll_bar_state: scroll_bar_state) else scroll_bar_state = %{ scroll_bar_state | wheel_state: Wheel.start_scrolling(scroll_bar_state.wheel_state, {direction, offset_x}), scrolling: :wheel, } send_parent_event(scene, {:scroll_bar_state_changed, direction, scroll_bar_state}) assign(scene, scroll_bar_state: scroll_bar_state) end {:noreply, scene} end @spec init_scroll_bar_background(Scenic.Scene.t) :: Scenic.Scene.t defp init_scroll_bar_background( %{assigns: %{direction: :vertical, thickness: thickness, height: height}} = scene ) do scroll_bar_background_width = thickness scroll_bar_background_height = Direction.unwrap(height) scroll_bar_background_pos = scene.assigns.scroll_bar_displacement assign( scene, scroll_bar_background_width: scroll_bar_background_width, scroll_bar_background_height: scroll_bar_background_height, scroll_bar_background_pos: scroll_bar_background_pos ) end defp init_scroll_bar_background( %{assigns: %{direction: :horizontal, thickness: thickness, width: width}} = scene ) do scroll_bar_background_height = thickness scroll_bar_background_width = Direction.unwrap(width) scroll_bar_background_pos = scene.assigns.scroll_bar_displacement assign( scene, scroll_bar_background_width: scroll_bar_background_width, scroll_bar_background_height: scroll_bar_background_height, scroll_bar_background_pos: scroll_bar_background_pos ) end @spec init_scroll_bar_drag_control(Scenic.Scene.t) :: Scenic.Scene.t defp init_scroll_bar_drag_control(scene) do scroll_bar_drag_control_width = button_width(scene) scroll_bar_drag_control_height = button_height(scene) scroll_bar_drag_control_pos = local_scroll_position_vector2(scene) assign( scene, scroll_bar_drag_control_width: scroll_bar_drag_control_width, scroll_bar_drag_control_height: scroll_bar_drag_control_height, scroll_bar_drag_control_pos: scroll_bar_drag_control_pos ) end @spec init_scroll_bar_buttons(Scenic.Scene.t) :: Scenic.Scene.t defp init_scroll_bar_buttons(%{assigns: %{direction: :vertical = direction}} = scene) do size = scroll_button_size(scene) {button_2_x, button_2_y} = Direction.return(size, direction) |> Direction.add(scene.assigns.width) |> Direction.add(scene.assigns.height) |> Direction.to_vector_2() scroll_button_1_width = size scroll_button_1_height = size scroll_button_1_pos = {0, -2} scroll_button_1_icon_rotation = :math.pi() scroll_button_1_icon_pos = {size / 2 - 48 / 2, size / 2 - 48 / 2} scroll_button_2_width = size scroll_button_2_height = size scroll_button_2_pos = {button_2_x, button_2_y + 2} scroll_button_2_icon_rotation = 0 scroll_button_2_icon_pos = Scenic.Math.Vector2.add(scroll_button_2_pos, scroll_button_1_icon_pos) assign(scene, scroll_button_1_width: scroll_button_1_width, scroll_button_1_height: scroll_button_1_height, scroll_button_1_pos: scroll_button_1_pos, scroll_button_1_icon_rotation: scroll_button_1_icon_rotation, scroll_button_1_icon_pos: scroll_button_1_icon_pos, scroll_button_2_width: scroll_button_2_width, scroll_button_2_height: scroll_button_2_height, scroll_button_2_pos: scroll_button_2_pos, scroll_button_2_icon_rotation: scroll_button_2_icon_rotation, scroll_button_2_icon_pos: scroll_button_2_icon_pos ) end defp init_scroll_bar_buttons(%{assigns: %{direction: :horizontal = direction}} = scene) do size = scroll_button_size(scene) {button_2_x, button_2_y} = Direction.return(size, direction) |> Direction.add(scene.assigns.width) |> Direction.add(scene.assigns.height) |> Direction.to_vector_2() scroll_button_1_width = size scroll_button_1_height = size scroll_button_1_pos = {-2, 0} scroll_button_1_icon_rotation = :math.pi() / 2 scroll_button_1_icon_pos = {size / 2 - 48 / 2, size / 2 - 48 / 2} scroll_button_2_width = size scroll_button_2_height = size scroll_button_2_pos = {button_2_x + 2, button_2_y} scroll_button_2_icon_rotation = -:math.pi() / 2 scroll_button_2_icon_pos = Scenic.Math.Vector2.add(scroll_button_2_pos, scroll_button_1_icon_pos) assign(scene, scroll_button_1_width: scroll_button_1_width, scroll_button_1_height: scroll_button_1_height, scroll_button_1_pos: scroll_button_1_pos, scroll_button_1_icon_rotation: scroll_button_1_icon_rotation, scroll_button_1_icon_pos: scroll_button_1_icon_pos, scroll_button_2_width: scroll_button_2_width, scroll_button_2_height: scroll_button_2_height, scroll_button_2_pos: scroll_button_2_pos, scroll_button_2_icon_rotation: scroll_button_2_icon_rotation, scroll_button_2_icon_pos: scroll_button_2_icon_pos ) end @spec init_scroll_buttons(Scenic.Scene.t) :: Scenic.Scene.t defp init_scroll_buttons(%{assigns: %{scroll_bar_state: scroll_bar_state, opts: opts}} = scene) do scroll_buttons = if opts[:show_buttons] do %{ scroll_button_1: :released, scroll_button_2: :released } else nil end assign(scene, scroll_bar_state: %{scroll_bar_state | scroll_buttons: scroll_buttons}) end @spec init_size(Scenic.Scene.t) :: Scenic.Scene.t defp init_size(%{assigns: %{scroll_bar_state: %{scroll_buttons: nil}}} = scene) do assign(scene, scroll_bar_displacement: Direction.to_vector_2(scroll_bar_displacement(scene))) end defp init_size(%{assigns: %{width: width, height: height}} = scene) do displacement = scroll_bar_displacement(scene) button_size_difference = Direction.map(displacement, &(&1 * 2)) assign(scene, width: Direction.subtract(width, button_size_difference), height: Direction.subtract(height, button_size_difference), scroll_bar_displacement: Direction.to_vector_2(displacement) ) end @spec init_position_cap(Scenic.Scene.t) :: Scenic.Scene.t defp init_position_cap(%{assigns: %{direction: direction}} = scene) do max = Direction.return(0, direction) |> Direction.add(scene.assigns.width) |> Direction.add(scene.assigns.height) |> Direction.map_horizontal(fn width -> width - button_width(scene) + scroll_button_size(scene) end) |> Direction.map_vertical(fn height -> height - button_height(scene) + scroll_button_size(scene) end) |> Direction.to_vector_2() min = # scroll_bar_displacement(scene) scene.assigns.scroll_bar_displacement assign(scene, position_cap: PositionCap.init(%{min: min, max: max})) end @spec scroll_button_size(Scenic.Scene.t) :: number defp scroll_button_size(%{assigns: %{scroll_bar_state: %{scroll_buttons: nil}}}), do: 0 defp scroll_button_size(%{assigns: %{width: width, height: height, direction: direction}}) do Direction.return(1, direction) |> Direction.invert() |> Direction.multiply(width) |> Direction.multiply(height) |> Direction.unwrap() end @spec button_width(Scenic.Scene.t) :: number defp button_width(%{assigns: %{direction: :horizontal}} = scene) do Direction.divide(scene.assigns.frame_size, scene.assigns.content_size) |> Direction.multiply(scene.assigns.width) |> Direction.unwrap() end defp button_width(scene), do: scene.assigns.thickness @spec button_height(Scenic.Scene.t) :: number defp button_height(%{assigns: %{direction: :vertical}} = scene) do Direction.divide(scene.assigns.frame_size, scene.assigns.content_size) |> Direction.multiply(scene.assigns.height) |> Direction.unwrap() end defp button_height(scene), do: scene.assigns.opts[:thickness] @spec width_factor(Scenic.Scene.t) :: number defp width_factor(%{assigns: %{content_size: {:horizontal, size}, width: {_, width}}}) do width / size end defp width_factor(_), do: 1 @spec height_factor(Scenic.Scene.t) :: number defp height_factor(%{assigns: %{content_size: {:vertical, size}, height: {_, height}}}) do height / size end defp height_factor(_), do: 1 # POSITION CALCULATIONS @spec scroll_bar_displacement(Scenic.Scene.t) :: Direction.t defp scroll_bar_displacement(%{assigns: %{direction: direction}} = scene) do scroll_button_size(scene) |> Direction.return(direction) end @spec scroll_position_vector2(Scenic.Scene.t) :: Vector2.t defp scroll_position_vector2(scene) do Direction.to_vector_2(scene.assigns.scroll_position) end @spec local_scroll_position_vector2(Scenic.Scene.t) :: Vector2.t defp local_scroll_position_vector2(scene) do world_to_local(scene, scroll_position_vector2(scene)) end @spec local_to_world(Scenic.Scene.t, Direction.t() | Vector2.t | number) :: Direction.t() | Vector2.t | number defp local_to_world(%{assigns: %{direction: :horizontal}} = scene, {:horizontal, x}) do {:horizontal, local_to_world(scene, x)} end defp local_to_world(%{assigns: %{direction: :vertical}} = scene, {:vertical, y}) do {:vertical, local_to_world(scene, y)} end defp local_to_world(_, {:horizontal, _}), do: {:horizontal, 0} defp local_to_world(_, {:vertical, _}), do: {:vertical, 0} defp local_to_world(scene, {x, y}) do {local_to_world(scene, x), local_to_world(scene, y)} end defp local_to_world(_, 0), do: 0 defp local_to_world(%{assigns: %{direction: :horizontal}} = scene, x) do {x, _} = PositionCap.cap(scene.assigns.position_cap, {x, 0}) -(x - scroll_button_size(scene)) / width_factor(scene) end defp local_to_world(%{assigns: %{direction: :vertical}} = scene, y) do {_, y} = PositionCap.cap(scene.assigns.position_cap, {0, y}) -(y - scroll_button_size(scene)) / height_factor(scene) end @spec world_to_local(Scenic.Scene.t, Vector2.t | number) :: number | Vector2.t defp world_to_local(%{assigns: %{direction: direction}} = scene, {x, y}) do position = Direction.from_vector_2({x, y}, direction) |> Direction.map(&world_to_local(scene, &1)) |> Direction.to_vector_2() PositionCap.cap(scene.assigns.position_cap, position) end defp world_to_local(%{assigns: %{direction: :horizontal}} = scene, x), do: -x * width_factor(scene) + scroll_button_size(scene) defp world_to_local(%{assigns: %{direction: :vertical}} = scene, y), do: -y * height_factor(scene) + scroll_button_size(scene) end