defmodule FloUI.Scrollable.ScrollBars do
use Scenic.Component
# use FloUI.Scrollable.SceneInspector, env: [:test, :dev]
import FloUI.Scrollable.Components, only: [scroll_bar: 3]
alias Scenic.Graph
alias FloUI.Scrollable.ScrollBar
alias FloUI.Scrollable.Direction
@moduledoc """
The scroll bars component can be used to add a horizontal, and a vertical scroll bar pair to the graph. This component is used internally by the `Scenic.Scrollable` component, and for most cases it is recommended to use the `Scenic.Scrollable` component instead.
## Data
`t:Scenic.Scrollable.ScrollBars.settings/0`
The scroll bars require the following data for initialization:
- width: number
- height: number
- content_size: `t:Scenic.Scrollable.ScrollBars.v2/0`
- scroll_position: number
- direction: :horizontal | :vertical
With and height define the size of the frame, and thus correspond to the width of the horizontal, and the height of the vertical scroll bars.
## Styles
`t:Scenic.Scrollable.ScrollBars.styles/0`
The scroll bars can be customized by using the following styles:
### scroll_bar
`t:Scenic.Scrollable.ScrollBar.styles/0`
The styles to customize both scrollbars as defined in the corresponding module `Scenic.Scrollable.Scrollbar`.
If different styles for the horizontal and vertical scroll bars are preffered, use the horizontal_scroll_bar and vertical_scroll_bar styles instead.
### horizontal_scroll_bar
`t:Scenic.Scrollable.ScrollBar.styles/0`
The styles to customize the horizontal scroll bar.
### vertical_scroll_bar
`t:Scenic.Scrollable.ScrollBar.styles/0`
The styles to customize the vertical scroll bar.
### scroll_drag
`t:Scenic.Scrollable.Drag/0`
Settings to specify which mouse buttons can be used in order to drag the scroll bar sliders.
### scroll_bar_thickness
number
Specify the height of the horizontal, and the width of the vertical scroll bars.
## Examples
iex> graph = Scenic.Scrollable.Components.scroll_bars(
...> Scenic.Graph.build(),
...> %{
...> width: 200,
...> height: 200,
...> content_size: {1000, 1000},
...> scroll_position: {0, 0}
...> },
...> [
...> scroll_bar: [
...> scroll_buttons: true,
...> scroll_bar_theme: Scenic.Primitive.Style.Theme.preset(:light),
...> scroll_bar_radius: 2,
...> scroll_bar_border: 2,
...> scroll_drag: %{
...> mouse_buttons: [:left, :right, :middle]
...> }
...> ],
...> scroll_drag: %{
...> mouse_buttons: [:left, :right, :middle]
...> },
...> id: :scroll_bars_component_1
...> ]
...> )
...> graph.primitives[1].id
:scroll_bars_component_1
"""
@typedoc """
Data structure representing a vector 2, in the form of an {x, y} tuple.
"""
@type v2 :: Scenic.Scrollable.v2()
@type content_size :: {number, number}
@typedoc """
The required settings to initialize a scroll bars component.
For more information see the top of this module.
"""
@type settings :: %{
width: number,
height: number,
content_size: v2,
scroll_position: v2
}
@typedoc """
The optional styles to customize the scroll bars.
For more information see the top of this module.
"""
@type style ::
{:scroll_bar, Scenic.Scrollable.ScrollBar.styles()}
| {:horizontal_scroll_bar, Scenic.Scrollable.ScrollBar.styles()}
| {:vertical_scroll_bar, Scenic.Scrollable.ScrollBar.styles()}
| {:scroll_drag, Scenic.Scrollable.Drag.settings()}
| {:scroll_bar_thickness, number}
@typedoc """
A collection of optional styles to customize the scroll bars.
For more information see `t:Scenic.Scrollable.ScrollBars.style/0` and the top of this module.
"""
@type styles :: [style]
@typedoc """
An atom describing the state the scroll bars are in.
- idle: none of the scroll bars are currently being clicked or dragged.
- dragging: one of the scroll bars is being dragged.
- scrolling: one of the scroll bars is being scrolled using a scroll button.
"""
@type scroll_state ::
:idle
| :dragging
| :scrolling
@typedoc """
The state with which the scrollable components GenServer is running.
"""
@type t :: %__MODULE__{
id: atom,
graph: Graph.t(),
scroll_position: v2,
content_size: content_size,
scroll_state: scroll_state,
pid: pid,
horizontal_scroll_bar_pid: {:some, pid} | :none,
vertical_scroll_bar_pid: {:some, pid} | :none
}
defstruct id: :scroll_bars,
graph: Graph.build(),
scroll_position: {0, 0},
content_size: {0, 0},
scroll_state: :idle,
pid: nil,
horizontal_scroll_bar_pid: :none,
vertical_scroll_bar_pid: :none
@default_id :scroll_bars
@default_thickness 10
# PUBLIC API
@doc """
Find the direction the content should be scrolling in, depending on the scroll bar buttons pressed states.
"""
@spec direction(t) :: v2
def direction(state) do
{x, _} =
state.horizontal_scroll_bar_pid
|> OptionEx.map(&ScrollBar.direction/1)
|> OptionEx.or_else({0, 0})
{_, y} =
state.vertical_scroll_bar_pid
|> OptionEx.map(&ScrollBar.direction/1)
|> OptionEx.or_else({0, 0})
{x, y}
end
@doc """
Find out if one of the scroll bars is currently being dragged.
"""
@spec dragging?(t) :: boolean
def dragging?(%{scroll_state: :dragging}), do: true
def dragging?(_), do: false
@doc """
Find out if one of the scroll bars wheel is currently being scrolled.
"""
@spec wheel_scrolling?(t) :: boolean
def wheel_scrolling?(%{wheel_state: :scrolling}), do: true
def wheel_scrolling?(_), do: false
@doc """
Find the latest position the scrollable content should be updated with.
The position corresponds to the contents translation, rather than the scroll bars drag control translation.
"""
@spec new_position(t) :: {:some, v2} | :none
def new_position(%{scroll_position: position}), do: {:some, position}
# CALLBACKS
@impl Scenic.Scene
def init(scene, settings, opts) do
id = opts[:id] || @default_id
styles = Enum.into(opts || %{}, [])
shared_styles = Keyword.take(styles, [:scroll_bar, :scroll_drag])
horizontal_bar_styles =
(styles[:horizontal_scroll_bar] || styles[:scroll_bar])
|> OptionEx.return()
|> OptionEx.map(&Keyword.merge(&1, shared_styles))
|> OptionEx.map(&Keyword.put(&1, :id, :horizontal_scroll_bar))
|> OptionEx.map(&Keyword.put(&1, :translate, {0, settings.height}))
vertical_bar_styles =
(styles[:vertical_scroll_bar] || styles[:scroll_bar])
|> OptionEx.return()
|> OptionEx.map(&Keyword.merge(&1, shared_styles))
|> OptionEx.map(&Keyword.put(&1, :id, :vertical_scroll_bar))
|> OptionEx.map(&Keyword.put(&1, :translate, {settings.width, 0}))
{content_width, content_height} = settings.content_size
{x, y} = settings.scroll_position
graph = Graph.build()
graph =
horizontal_bar_styles
|> OptionEx.map(fn styles ->
graph
|> FloUI.Scrollable.ScrollBar.add_to_graph(
%{
width: settings.width,
height: styles[:scroll_bar_thickness] || @default_thickness,
content_size: content_width,
scroll_position: x,
direction: :horizontal
},
styles
)
end)
|> OptionEx.or_else(graph)
graph =
vertical_bar_styles
|> OptionEx.map(fn styles ->
graph
|> FloUI.Scrollable.ScrollBar.add_to_graph(
%{
width: styles[:scroll_bar_thickness] || @default_thickness,
height: settings.height,
content_size: content_height,
scroll_position: y,
direction: :vertical
},
styles
)
end)
|> OptionEx.or_else(graph)
state = %__MODULE__{
id: id,
graph: graph,
scroll_position: {x, y},
content_size: {content_width, content_height},
pid: self()
}
scene =
scene
|> assign(state: state)
|> push_graph(state.graph)
send_parent_event(scene, {:scroll_bars_initialized, state.id, state})
{:ok, scene}
end
@impl Scenic.Component
def validate(
%{
content_size: {content_x, content_y},
scroll_position: {x, y}
} = settings
)
when is_number(content_x) and is_number(content_y) and is_number(x) and is_number(y) do
{:ok, settings}
end
def validate(_), do: :invalid_input
@impl Scenic.Scene
def handle_event(
{:scroll_bar_initialized, :horizontal_scroll_bar, scroll_bar_state},
_from,
%{assigns: %{state: state}} = scene
) do
state = %{state | horizontal_scroll_bar_pid: OptionEx.return(scroll_bar_state.pid)}
{:noreply, assign(scene, state: state)}
end
def handle_event(
{:scroll_bar_initialized, :vertical_scroll_bar, scroll_bar_state},
_from,
%{assigns: %{state: state}} = scene
) do
state = %{state | vertical_scroll_bar_pid: OptionEx.return(scroll_bar_state.pid)}
{:noreply, assign(scene, state: state)}
end
def handle_event({:scroll_bar_button_pressed, _, scroll_bar_state}, _from, %{assigns: %{state: state}} = scene) do
state = update_scroll_state(state, scroll_bar_state)
{:cont, {:scroll_bars_button_pressed, state.id, state}, assign(scene, state: state)}
end
def handle_event({:scroll_bar_button_released, _, scroll_bar_state}, _from, %{assigns: %{state: state}} = scene) do
state = update_scroll_state(state, scroll_bar_state)
{:cont, {:scroll_bars_button_released, state.id, state, scroll_bar_state.wheel_state}, assign(scene, state: state)}
end
def handle_event({:cursor_scroll_started, _, scroll_bar_state}, _from, %{assigns: %{state: state}} = scene) do
state = update_scroll_state(state, scroll_bar_state)
{:cont, {:cursor_scroll_started, state.id, state, scroll_bar_state.wheel_state}, assign(scene, state: state)}
end
def handle_event({:cursor_scroll_stopped, _, scroll_bar_state}, _from, %{assigns: %{state: state}} = scene) do
state = update_scroll_state(state, scroll_bar_state)
{:cont, {:cursor_scroll_stopped, state.id, state, scroll_bar_state.wheel_state}, assign(scene, state: state)}
end
def handle_event(
{:scroll_bar_position_change, _, _scroll_bar_state},
_from,
%{assigns: %{state: %{scroll_state: :scrolling} = state}} = scene
) do
{:noreply, scene}
end
def handle_event(
{:scroll_bar_position_change, _, %{direction: direction} = scroll_bar_state},
_from,
%{assigns: %{state: state}} = scene
) do
{x, y} = state.scroll_position
state =
ScrollBar.new_position(scroll_bar_state)
|> Direction.from_vector_2(direction)
|> Direction.map_horizontal(&{&1, y})
|> Direction.map_vertical(&{x, &1})
|> Direction.unwrap()
|> (&Map.put(state, :scroll_position, &1)).()
|> update_scroll_state(scroll_bar_state)
{:cont, {:scroll_bars_position_change, state.id, state}, assign(scene, state: state)}
end
def handle_event({:scroll_bar_scroll_end, _id, scroll_bar_state}, _from, %{assigns: %{state: state}} = scene) do
state = update_scroll_state(state, scroll_bar_state)
{:cont, {:scroll_bars_scroll_end, state.id, state}, assign(scene, state: state)}
end
def handle_event(_event, _from, scene) do
{:noreply, scene}
end
# no callback on the `Scenic.Scene` and no GenServer @behaviour, so impl will not work
@spec handle_call(request :: term(), GenServer.from(), state :: term()) ::
{:reply, reply :: term(), new_state :: term()}
def handle_call({:update_scroll_position, {x, y}}, _, %{assigns: %{state: state}} = scene) do
state = %{state | scroll_position: {x, y}}
# TODO error handling
state.horizontal_scroll_bar_pid
|> OptionEx.map(fn pid -> GenServer.call(pid, {:update_scroll_position, x}) end)
state.vertical_scroll_bar_pid
|> OptionEx.map(fn pid -> GenServer.call(pid, {:update_scroll_position, y}) end)
{:reply, :ok, assign(scene, state: state)}
end
def handle_call({:update_scroll_pos, {x, y}}, _, %{assigns: %{state: state}} = scene) do
state = %{state | scroll_position: {x, y}}
# TODO error handling
state.horizontal_scroll_bar_pid
|> OptionEx.map(fn pid -> GenServer.call(pid, {:update_scroll_position, x}) end)
state.vertical_scroll_bar_pid
|> OptionEx.map(fn pid -> GenServer.call(pid, {:update_scroll_position, y}) end)
{:reply, :ok, assign(scene, state: state)}
end
def handle_call({:update_content_size, {width, height}}, _, %{assigns: %{state: state}} = scene) do
state = %{state | content_size: {width, height}}
state.horizontal_scroll_bar_pid
|> OptionEx.map(fn pid -> GenServer.call(pid, {:update_content_size, width}) end)
state.vertical_scroll_bar_pid
|> OptionEx.map(fn pid -> GenServer.call(pid, {:update_content_size, height}) end)
{:reply, :ok, assign(scene, state: state)}
end
def handle_call(msg, _, scene) do
{:reply, {:error, {:unexpected_message, msg}}, scene}
end
def handle_cast({:update_cursor_scroll, scroll_pos}, %{assigns: %{state: state}} = scene) do
# TODO error handling
state.horizontal_scroll_bar_pid
|> OptionEx.map(fn pid -> GenServer.cast(pid, {:update_cursor_scroll, scroll_pos}) end)
state.vertical_scroll_bar_pid
|> OptionEx.map(fn pid -> GenServer.cast(pid, {:update_cursor_scroll, scroll_pos}) end)
{:noreply, scene}
end
def handle_cast(:unrequest_cursor_scroll, %{assigns: %{state: state}} = scene) do
# TODO error handling
state.horizontal_scroll_bar_pid
|> OptionEx.map(fn pid -> GenServer.cast(pid, :unrequest_cursor_scroll) end)
state.vertical_scroll_bar_pid
|> OptionEx.map(fn pid -> GenServer.cast(pid, :unrequest_cursor_scroll) end)
{:noreply, scene}
end
# UTILITY
@spec update_scroll_state(t, ScrollBar.t()) :: t
defp update_scroll_state(state, scroll_bar_state) do
%{state | scroll_state: scroll_bar_state.scroll_state}
end
end