defmodule FloUI.Scrollable do
use Scenic.Component
# use FloUI.Scrollable.SceneInspector, env: [:test, :dev]
import Scenic.Primitives, only: [group: 3, rect: 3]
import FloUI.Scrollable.Components, only: [scroll_bars: 3]
alias Scenic.Graph
alias Scenic.Primitive
alias Scenic.Math.Vector2
alias FloUI.Scrollable.Hotkeys
alias FloUI.Scrollable.Drag
alias FloUI.Scrollable.Wheel
alias FloUI.Scrollable.ScrollBars
alias FloUI.Scrollable.Acceleration
alias FloUI.Scrollable.PositionCap
@moduledoc """
## NOTICE
This component was originally created by zwetsloot. I have updated it to work with scenic 0.11.0, and added :cursor_scroll event support.
Work has been started to write this as a snap component, but this will work as is in snap templates.
https://hex.pm/packages/scenic_scrollable
The scrollable component offers a way to show part of a content group bounded by a fixed rectangle or frame, and change the visible part of the content without displacing the bounded rectangle by scrolling.
The scrollable component offers three ways to scroll, which can be used in conjunction:
- The content can be clicked and dragged directly using a mouse.
- Hotkeys can be set for up, down, left and right scroll directions.
- A horizontal and a vertical scroll bar can be set up.
Note that for the hotkeys to work, the scrollable component has to catch focus first by clicking it once with the left mouse button.
## Data
`t:Scenic.Scrollable.settings/0`
To initialize a scrollable component, a map containing `frame` and `content` elements, and a builder function are required. Further customization can be provided with optional styles.
### Frame
The frame contains information about the size of the fixed rectangle shaped bounding box. It is a tuple containing the width as first element, and height as second element.
### Content
The content contains information about the size and offset of the content. The offset can be used to adjust the limits of where the content can be scrolled to, and can for example be of used when the content position looks off in its {0, 0} starting position. If no offset is required, the content can be passed as a tuple containing the width as first element, and height as second element. If an offset is used, the content can be passed as a `t:Scenic.Scrollable.rect/0`, which is a map containing `x`, `y`, `width` and `height` elements.
## Builder
`t:Scenic.Scrollable.builder/0`
In addition to the required data, a scrollable component requires a builder, similar to the `Scenic.Primitive.Group` primitive. The builder is a function that takes a graph, and should return a graph with the necessary components attached to it that form the content of the scrollable component.
## Styles
`t:Scenic.Scrollable.styles/0`
Similar to the `Scenic.Primitive.Group` primitive, any style can be passed to the scrollable component, which will be passed on to the underlying components. In addition, the following styles specific to the scrollable component can be provided.
### scroll_position
`t:Scenic.Scrollable.v2/0`
The starting position of the scrollable content. This does not influence the limits to where the content can be scrolled to.
### scroll_acceleration
`t:Scenic.Scrollable.Acceleration.settings/0`
Settings regarding sensitivity of the scroll functionality. The settings are passed in a map with the following elements:
- acceleration: number
- mass: number
- counter_pressure: number
The higher number given for the acceleration, the faster the scroll movement gains speed. The default value is 20.
The higher number given for the mass, the slower the scroll movement gains speed, and the faster it loses speed. The default value is 1.
The higher number given for counter_pressure, the lower the maximum scroll speed, and the faster the scroll movement loses speed after the user input has stopped. The default value is 0.1.
### scroll_hotkeys
`t:Scenic.Scrollable.Hotkeys.settings/0`
A hotkey can be provided for every scroll direction to enable scrolling using the keyboard. The hotkey settings can be passed in a map with the following elements.
- up: `t:String.t/0`
- down: `t:String.t/0`
- left: `t:String.t/0`
- right: `t:String.t/0`
The passed string can be the letter of the intended key, such as "w" or "s", or the description of a special key, such as the arrow keys "up", "down", "left" or "right".
### scroll_fps
number
Specifies the times per second the scroll content position is recalculated when it is scrolling. For environments with limited resources, it might be prudent to set a lower value than the default 30.
### scroll_drag
`t:Scenic.Scrollable.Drag.settings/0`
Options for enabling scrolling by directly dragging the content using a mouse. Buttons events on the scrollable content will take precedence over the drag functionality. Drag settings are passed in a map with the following elements:
- mouse_buttons: [`t:Scenic.Scrollable.Drag.mouse_button/0`]
The list of mouse buttons specifies with which mouse button the content can be dragged. Available mouse buttons are `:left`, `:right` and `:middle`. By default, the drag functionality is disabled.
### scroll_bar_thickness
number
Specify the thickness of both scroll bars.
### scroll_bar
`t:Scenic.Scrollable.ScrollBar.styles/0`
Specify the styles for both horizontal and vertical scroll bars. If different styles for each scroll bar are desired, use the `vertical_scroll_bar` and `horizontal_scroll_bar` options instead. The following styles are supported"
- scroll_buttons: boolean
- scroll_bar_theme: map
- scroll_bar_radius: number
- scroll_bar_border: number
- scroll_drag: `t:Scenic.Scrollable.Drag.settings/0`
The scroll_buttons boolean can be used to specify of the scroll bar should contain buttons for scrolling, in addition to the scroll bar slider. The scroll buttons are not shown by default.
A theme can be passed using the scroll_bar_theme element to provide a set of colors for the scroll bar. For more information on themes, see the `Scenic.Primitive.Style.Theme` module. The default theme is `:light`.
The scroll bars rounding and border can be adjusted using the scroll_bar_radius and scroll_bar_border elements respectively. The default values are 3 and 1.
The scroll_drag settings can be provided in the same form the scrollable components scroll_drag style is provided, and can be used to specify by which mouse button the scroll bar slider can be dragged. By default, the `:left`, `:right` and `:middle` buttons are all enabled.
### horizontal_scroll_bar
`t:Scenic.Scrollable.ScrollBar.styles/0`
Specify styles for the horizontal scroll bar only. The available styles are exactly the same as explained in the above scroll_bar style section.
### vertical_scroll_bar
`t:Scenic.Scrollable.ScrollBar.styles/0`
Specify styles for the vertical scroll bar only. The available styles are exactly the same as explained in the above scroll_bar style section.
## Examples
iex> graph = Scenic.Scrollable.Components.scrollable(
...> Scenic.Graph.build(),
...> %{
...> frame: {200, 400},
...> content: %{x: 0, y: 10, width: 400, height: 800}
...> },
...> fn graph ->
...> Scenic.Primitives.text(graph, "scrollable text")
...> end,
...> [id: :scrollable_component_1]
...> )
...> graph.primitives[1].id
:scrollable_component_1
iex> graph = Scenic.Scrollable.Components.scrollable(
...> Scenic.Graph.build(),
...> %{
...> frame: {200, 400},
...> content: %{x: 0, y: 10, width: 400, height: 800}
...> },
...> fn graph ->
...> Scenic.Primitives.text(graph, "scrollable text")
...> end,
...> [
...> scroll_position: {-10, -50},
...> scroll_acceleration: %{
...> acceleration: 15,
...> mass: 1.2,
...> counter_pressure: 0.2
...> },
...> scroll_hotkeys: %{
...> up: "w",
...> down: "s",
...> left: "a",
...> right: "d"
...> },
...> scroll_fps: 15,
...> scroll_drag: %{
...> mouse_buttons: [:left]
...> },
...> scroll_bar_thickness: 15,
...> scroll_bar: [
...> scroll_buttons: true,
...> scroll_bar_theme: Scenic.Primitive.Style.Theme.preset(:dark)
...> ],
...> translate: {50, 50},
...> id: :scrollable_component_2
...> ]
...> )
...> graph.primitives[1].id
:scrollable_component_2
"""
@typedoc """
Data structure representing a vector 2, in the form of an {x, y} tuple.
"""
@type v2 :: Scenic.Math.vector_2()
@typedoc """
Data structure representing a rectangle.
"""
@type rect :: %{
x: number,
y: number,
width: number,
height: number
}
@typedoc """
A map with settings with which to initialize a `Scenic.Scrollable` component.
- frame: The size as {width, height} of the frame or viewport through which the content is visible.
- content: The size as {width, height}, or size and offset as `t:Scenic.Scrollable.rect/0` of the scrollable content.
The offset affects the limits of the contents position. To set the contents current position only, pass in the :scroll_position option, as defined in the `t:Scenic.Scrollable.style/0` type.
"""
@type settings :: %{
frame: v2,
content: v2 | rect
}
@typedoc """
The optional styles with which the scrollable component can be customized. See this modules top section for a more detailed explanation of every style.
"""
@type style ::
{:scroll_position, v2}
| {:scroll_acceleration, Acceleration.settings()}
| {:scroll_hotkeys, Hotkeys.settings()}
| {:scroll_fps, number}
| {:scroll_drag, Drag.settings()}
| {:scroll_bar_thickness, number}
| {:scroll_bar, Scenic.Scrollable.ScrollBar.styles()}
| {:horizontal_scroll_bar, Scenic.Scrollable.ScrollBar.styles()}
| {:vertical_scroll_bar, Scenic.Scrollable.ScrollBar.styles()}
| {:translate, v2}
| {:id, term}
# enable any input to be passed to the content
| {atom, term}
# TODO bounce
@typedoc """
A collection of optional styles with which the scrollable component can be customized. See `t:Scenic.Scrollable.style/0` and this modules top section for more information.
"""
@type styles :: [style]
@typedoc """
The states a scrollable component can be in.
- scrolling: the scrollable component is currently being scrolled using a scroll button or hotkey
- dragging: the scrollable component is currently being dragged, by using a scroll bar slider, or by dragging the content directly using a mouse button
- cooling_down: the scrollable component is still moving due to previous user input, but the user is not giving any scroll related input at the moment.
- idle: the scrollable component is not moving
"""
@type scroll_state ::
:scrolling
| :wheel
| :dragging
| :cooling_down
| :idle
@typedoc """
The builder function used to setup the content of the scrollable component. The builder function works the same as the builder function used for setting up `Scenic.Primitive.Group` primitives.
"""
@type builder :: (Graph.t() -> Graph.t())
@typedoc """
The state with which the scrollable components GenServer is running.
"""
@type t :: %__MODULE__{
id: any,
graph: Graph.t(),
frame: rect,
content: rect,
scroll_position: v2,
fps: number,
scrolling: scroll_state,
drag_state: Drag.t(),
wheel_state: Wheel.t(),
scroll_bars: {:some, ScrollBars.t()} | :none,
acceleration: Acceleration.t(),
hotkeys: Hotkeys.t(),
position_caps: PositionCap.t(),
focused: boolean,
animating: boolean
}
defstruct id: :scrollable,
graph: Graph.build(),
frame: %{x: 0, y: 0, width: 0, height: 0},
content: %{x: 0, y: 0, width: 0, height: 0},
scroll_position: {0, 0},
fps: 30,
scrolling: :idle,
drag_state: %Drag{},
wheel_state: %Wheel{},
scroll_bars: :none,
acceleration: %Acceleration{},
hotkeys: %Hotkeys{},
position_caps: %PositionCap{},
focused: false,
animating: false
@default_scroll_position {0, 0}
@default_fps 30
# CALLBACKS
@impl Scenic.Component
def validate(%{content: %{width: content_width, height: content_height, x: x, y: y}} = input)
when is_number(x) and is_number(y) do
validate(%{input | content: {content_width, content_height}})
|> ResultEx.map(fn _ -> input end)
end
def validate(
%{
frame: {frame_width, frame_height},
content: {content_width, content_height},
builder: builder
} = input
)
when is_number(frame_width) and is_number(frame_height) and is_number(content_width) and
is_number(content_height) and is_function(builder) do
{:ok, input}
end
def validate(_), do: :invalid_input
@impl Scenic.Scene
def init(scene, %{content: {content_width, content_height}} = input, opts) do
init(scene, %{input | content: %{x: 0, y: 0, width: content_width, height: content_height}}, opts)
end
def init(scene, %{frame: {frame_width, frame_height}, content: content, builder: builder}, opts) do
styles = opts || %{}
{frame_x, frame_y} = styles[:translate] || {0, 0}
scroll_position = styles[:scroll_position] || @default_scroll_position
%__MODULE__{
id: opts[:id] || :scrollable,
frame: %{x: frame_x, y: frame_y, width: frame_width, height: frame_height},
content: content,
scroll_position: Vector2.add(scroll_position, {content.x, content.y}),
fps: styles[:scroll_fps] || @default_fps,
acceleration: Acceleration.init(styles[:scroll_acceleration]),
hotkeys: Hotkeys.init(styles[:scroll_hotkeys]),
drag_state: Drag.init(styles[:scroll_drag])
}
|> init_position_caps
|> init_graph(scene, builder, styles)
end
def handle_update(%{content: content, builder: builder}, opts, %{assigns: %{state: state}} = scene) do
graph =
state.graph
|> Graph.delete(:content)
|> Graph.delete(:frame)
state =
%{state | content: content, graph: graph}
|> init_content(builder, opts)
|> update_content_size
|> init_position_caps()
scene =
scene
|> assign(state: state)
scene =
update(state, scene)
{:noreply, scene}
end
@impl Scenic.Scene
def handle_input({:cursor_scroll, {{offset_x, offset_y}, _} = scroll_pos}, :input_capture, %{assigns: %{state: state}} = scene) do
OptionEx.map(state.scroll_bars, & &1.pid)
|> OptionEx.map(&GenServer.cast(&1, {:update_cursor_scroll, scroll_pos}))
{:noreply, assign(scene, state: state)}
end
def handle_input({:key, {"escape", :release, _}}, _, %{assigns: %{state: state}} = scene) do
state = release_focus(state, scene)
{:noreply, assign(scene, state: state)}
end
def handle_input(
{:key, {key, :press, _}},
_,
%{assigns: %{state: state}} = scene
) do
state =
Map.update!(state, :hotkeys, &Hotkeys.handle_key_press(&1, key))
scene =
update(state, scene)
{:noreply, scene}
end
def handle_input(
{:key, {key, :release, _}},
_,
%{assigns: %{state: state}} = scene
) do
state =
Map.update!(state, :hotkeys, &Hotkeys.handle_key_release(&1, key))
scene =
update(state, scene)
{:noreply, scene}
end
def handle_input(_input, _, scene) do
{:noreply, scene}
end
@impl Scenic.Scene
def handle_event({:scroll_bars_initialized, _id, scroll_bars_state}, _from, %{assigns: %{state: state}} = scene) do
state = %{state | scroll_bars: OptionEx.return(scroll_bars_state)}
{:noreply, assign(scene, state: state)}
end
def handle_event(
{:scroll_bars_position_change, _id, %{scroll_state: :idle} = scroll_bars_state},
_from,
%{assigns: %{state: state}} = scene
) do
# TODO move this position update to apply force?
send_parent_event(scene, {:scroll_bars_position, ScrollBars.new_position(scroll_bars_state)})
state =
ScrollBars.new_position(scroll_bars_state)
|> OptionEx.map(&Vector2.add(&1, {state.content.x, state.content.y}))
|> OptionEx.map(&%{state | scroll_position: &1})
|> OptionEx.or_else(state)
scene =
update(state, scene)
{:noreply, scene}
end
def handle_event({:scroll_bars_position_change, _id, scroll_bars_state}, _from, %{assigns: %{state: state}} = scene) do
state =
%{state | scroll_bars: OptionEx.return(scroll_bars_state)}
scene =
update(state, scene)
{:noreply, scene}
end
def handle_event({:scroll_bars_scroll_end, _id, scroll_bars_state}, _from, %{assigns: %{state: state}} = scene) do
state =
%{state | scroll_bars: OptionEx.return(scroll_bars_state)}
scene =
update(state, scene)
{:noreply, scene}
end
def handle_event({:scroll_bars_button_pressed, _id, scroll_bars_state}, _from, %{assigns: %{state: state}} = scene) do
state =
%{state | scroll_bars: OptionEx.return(scroll_bars_state)}
scene =
update(state, scene)
{:noreply, scene}
end
def handle_event({:scroll_bars_button_released, _id, scroll_bars_state}, _from, %{assigns: %{state: state}} = scene) do
state =
%{state | scroll_bars: OptionEx.return(scroll_bars_state)}
scene =
update(state, scene)
{:noreply, scene}
end
def handle_event({:cursor_scroll_started, _id, scroll_bars_state, wheel_state}, _from, %{assigns: %{state: state}} = scene) do
state =
%{state | scroll_bars: OptionEx.return(scroll_bars_state), wheel_state: wheel_state}
scene =
update(state, scene)
{:noreply, scene}
end
def handle_event({:cursor_scroll_stopped, _id, scroll_bars_state, wheel_state}, _from, %{assigns: %{state: state}} = scene) do
state =
%{state | scroll_bars: OptionEx.return(scroll_bars_state), wheel_state: wheel_state}
scene =
update(state, scene)
{:noreply, scene}
end
def handle_event(
{:update_scroll_pos, {x, y}, {check_offset_x, check_offset_y} = check_offset},
_from,
%{assigns: %{state: state}} = scene
) do
new_pos = Vector2.sub({-1 * x, -1 * y}, {state.content.x, state.content.y})
pos_offset = Vector2.sub(state.scroll_position, new_pos)
check_vector = Vector2.add(pos_offset, check_offset)
if not Vector2.in_bounds?(check_vector, {0, 0}, {state.frame.width, state.frame.height}) do
state =
%{state | scroll_position: new_pos}
|> init_position_caps
scene =
update(state, scene)
{:noreply, scene}
else
{:noreply, scene}
end
end
def handle_event(event, _, scene) do
{:cont, event, scene}
end
# no callback on the `Scenic.Scene` and no GenServer @behaviour, so impl will not work
@spec handle_info(request :: term(), state :: term()) :: {:noreply, state :: term()}
def handle_info(:tick, %{assigns: %{state: state}} = scene) do
state =
%{state | animating: false}
scene =
update(state, scene)
{:noreply, scene}
end
def handle_info({:update_content, content}, %{assigns: %{state: state}} = scene) do
state =
%{state | content: content}
|> update_content_size
|> init_position_caps()
scene =
update(state, scene)
{: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(msg, _, scene) do
{:reply, {:error, {:unexpected_message, msg}}, scene}
end
# INITIALIZERS
defp init_graph(state, scene, builder, styles) do
state =
state
|> init_input_capture
|> init_content(builder, styles)
|> init_scroll_bars(styles)
scene =
scene
|> assign(state: state)
|> push_graph(state.graph)
send_parent_event(scene, {:register_scrollbar, self()})
{:ok, scene}
end
@spec init_input_capture(t) :: t
defp init_input_capture(%{graph: graph, frame: frame} = state) do
graph
|> rect({frame.width, frame.height}, translate: {frame.x, frame.y}, id: :input_capture, input: :cursor_scroll)
|> (&%{state | graph: &1}).()
end
@spec init_content(t, (Graph.t() -> Graph.t()), styles) :: t
defp init_content(%{graph: graph, frame: frame, content: content} = state, builder, styles) do
# MEMO: stacking up groups and scenes will result in reaching the cap prety fast when nesting scrollable elements
group(
graph,
&group(&1, builder, Keyword.merge(styles, [id: :content, translate: Vector2.add(state.scroll_position, {content.x, content.y})])),
id: :frame,
scissor: {frame.width, frame.height},
translate: {frame.x, frame.y}
)
|> (&%{state | graph: &1}).()
end
@spec init_scroll_bars(t, styles) :: t
defp init_scroll_bars(%{graph: graph} = state, styles) do
update_scroll_bars(graph, state, styles)
end
@spec init_position_caps(t) :: t
defp init_position_caps(
%{
frame: %{width: frame_width, height: frame_height},
content: %{x: x, y: y, width: content_width, height: content_height}
} = state
) do
min = {x + frame_width - content_width, y + frame_height - content_height}
max = {x, y}
position_cap = PositionCap.init(%{min: min, max: max})
Map.put(state, :position_caps, position_cap)
|> Map.update(:scroll_position, {0, 0}, &PositionCap.cap(position_cap, &1))
end
# UPDATERS
@spec update(t, scene :: any) :: t
defp update(state, scene) do
state =
state
|> update_scroll_state
|> update_input_capture_range
|> apply_force
|> translate
|> update_scroll_bars
|> tick
assign(scene, state: state)
|> push_graph(state.graph)
end
@spec update_scroll_bars(t) :: t
defp update_scroll_bars(state) do
# TODO refactor?
# MEMO due to performance issues, I am directly calling to the scroll bars, rather than modifying the graph. There might be a cleaner way to do this.
pos = Vector2.sub(state.scroll_position, {state.content.x, state.content.y})
OptionEx.map(state.scroll_bars, & &1.pid)
|> OptionEx.map(&GenServer.call(&1, {:update_scroll_position, pos}))
state
end
@spec update_scroll_bars(Graph.t() | Primitive.t(), t, styles) :: t
defp update_scroll_bars(graph_or_primitive, %{frame: frame} = state, styles) do
styles =
Enum.into(styles, [])
|> Keyword.take([:scroll_bar, :horizontal_scroll_bar, :vertical_scroll_bar, :scroll_drag])
|> Keyword.put(:id, :scroll_bars)
OptionEx.return(styles[:scroll_bar])
|> OptionEx.or_try(fn -> OptionEx.return(styles[:horizontal_scroll_bar]) end)
|> OptionEx.or_try(fn -> OptionEx.return(styles[:vertical_scroll_bar]) end)
|> OptionEx.map(fn _ ->
FloUI.Scrollable.ScrollBars.add_to_graph(
graph_or_primitive,
%{
width: frame.width,
height: frame.height,
content_size: {state.content.width, state.content.height},
scroll_position: Vector2.sub(state.scroll_position, {state.content.x, state.content.y})
},
styles
)
end)
|> OptionEx.or_else(graph_or_primitive)
|> (&%{state | graph: &1}).()
end
defp update_content_size(state) do
content_size = {state.content.width, state.content.height}
OptionEx.map(state.scroll_bars, & &1.pid)
|> OptionEx.map(&GenServer.call(&1, {:update_content_size, content_size}))
state
end
@spec update_scroll_state(t) :: t
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
@spec update_input_capture_range(t) :: t
defp update_input_capture_range(%{graph: _, scrolling: :dragging} = state) do
Map.update!(state, :graph, fn graph ->
graph
# TODO get screen res (for all monitors added up) somehow ?
|> Graph.modify(:input_capture, fn primitive ->
rect(primitive, {4000, 3000}, translate: {-2000, -1500}, id: :input_capture,
input: :cursor_scroll)
end)
end)
end
defp update_input_capture_range(%{graph: _, frame: frame} = state) do
Map.update!(state, :graph, fn graph ->
graph
|> Graph.modify(:input_capture, fn primitive ->
rect(primitive, {frame.width, frame.height},
translate: {frame.x, frame.y},
id: :input_capture,
input: :cursor_scroll
)
end)
end)
end
@spec apply_force(t) :: t
defp apply_force(%{scrolling: :idle} = state), do: state
defp apply_force(%{scrolling: :dragging} = state) do
state.scroll_bars
|> OptionEx.bind(&OptionEx.from_bool(ScrollBars.dragging?(&1), &1))
|> OptionEx.bind(&ScrollBars.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_caps, &1)})
|> OptionEx.or_else(state)
end
defp apply_force(%{scrolling: :wheel, wheel_state: %{offset: {:vertical, offset_y}}} = state) do
{x, y} = state.scroll_position
scroll_position = {x, y + offset_y * 10}
%{state | scroll_position: PositionCap.cap(state.position_caps, scroll_position)}
# Acceleration.apply_force(state.acceleration, {0, offset_y * 3})
# |> 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_caps, scroll_pos)
# end)
# end).()
end
defp apply_force(%{scrolling: :wheel, wheel_state: %{offset: {:horizontal, offset_x}}} = state) do
{x, y} = state.scroll_position
scroll_position = {x + offset_x * 10, y}
%{state | scroll_position: PositionCap.cap(state.position_caps, scroll_position)}
end
defp apply_force(state) do
force =
Hotkeys.direction(state.hotkeys)
|> Vector2.add(get_scroll_bars_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_caps, scroll_pos)
end)
end).()
end
@spec translate(t) :: t
defp translate(%{content: %{x: x, y: y}} = state) do
Map.update!(state, :graph, fn graph ->
graph
|> Graph.modify(:content, fn primitive ->
Map.update(primitive, :transforms, %{}, fn styles ->
Map.put(styles, :translate, Vector2.add(state.scroll_position, {x, y}))
end)
end)
end)
end
@spec verify_idle_state(t) :: {:some, :idle} | :none
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_bars_direction(state) == {0, 0} and not
scroll_bars_dragging?(state) and
Acceleration.is_stationary?(state.acceleration)
OptionEx.from_bool(result, :idle)
end
@spec verify_dragging_state(t) :: {:some, :dragging} | :none
defp verify_dragging_state(state) do
result = Drag.dragging?(state.drag_state) or scroll_bars_dragging?(state)
OptionEx.from_bool(result, :dragging)
end
@spec verify_scrolling_state(t) :: {:some, :scrolling} | :none
defp verify_scrolling_state(state) do
result =
Hotkeys.direction(state.hotkeys) != {0, 0} or
(get_scroll_bars_direction(state) != {0, 0} and not (state.scrolling == :dragging))
OptionEx.from_bool(result, :scrolling)
end
@spec verify_wheel_state(t) :: {:some, :scrolling} | :none
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_bars_direction(state) == {0, 0} and
not scroll_bars_dragging?(state)
OptionEx.from_bool(result, :wheel)
end
@spec verify_cooling_down_state(t) :: {:some, :cooling_down} | :none
defp verify_cooling_down_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 and
get_scroll_bars_direction(state) == {0, 0} and
not scroll_bars_dragging?(state) and
not Acceleration.is_stationary?(state.acceleration)
OptionEx.from_bool(result, :cooling_down)
end
@spec start_cooling_down(t, v2) :: t
defp start_cooling_down(state, cursor_pos) do
speed =
Drag.last_position(state.drag_state)
|> OptionEx.or_else(cursor_pos)
|> (&Vector2.sub(cursor_pos, &1)).()
|> (&Drag.amplify_speed(state.drag_state, &1)).()
Map.update!(state, :acceleration, &Acceleration.set_speed(&1, speed))
end
@spec capture_focus(t, Context.t()) :: t
defp capture_focus(%{focused: false} = state, scene) do
capture_input(scene, :key)
%{state | focused: true}
end
defp capture_focus(state, _), do: state
@spec release_focus(t, Context.t()) :: t
defp release_focus(%{focused: true} = state, scene) do
release_input(scene)
%{state | focused: false}
end
defp release_focus(state, _), do: state
@spec tick(t) :: t
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
@spec tick_time(t) :: number
defp tick_time(%{fps: fps}) do
trunc(1000 / fps)
end
# UTILITY
@spec get_scroll_bars_direction(t) :: v2
defp get_scroll_bars_direction(%{scroll_bars: :none}), do: {0, 0}
defp get_scroll_bars_direction(%{scroll_bars: {:some, scroll_bars}}),
do: ScrollBars.direction(scroll_bars)
@spec scroll_bars_dragging?(t) :: boolean
defp scroll_bars_dragging?(%{scroll_bars: :none}), do: false
defp scroll_bars_dragging?(%{scroll_bars: {:some, scroll_bars}}),
do: ScrollBars.dragging?(scroll_bars)
end