Skip to content

Commit

Permalink
Add inertial scrolling to ScrollView.
Browse files Browse the repository at this point in the history
  • Loading branch information
salt-die committed Feb 12, 2025
1 parent 3a3cbee commit c0300ac
Show file tree
Hide file tree
Showing 3 changed files with 139 additions and 4 deletions.
2 changes: 1 addition & 1 deletion src/batgrl/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
"""batgrl, the badass terminal graphics library."""

__version__ = "0.43.2"
__version__ = "0.43.3"
62 changes: 62 additions & 0 deletions src/batgrl/gadgets/recycle_view.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,30 @@ class RecycleView[T, G: Gadget](ScrollView):
----------
recycle_view_data: list[T] | None, default: None
The recycle-view's data.
allow_vertical_scroll : bool, default: True
Allow vertical scrolling.
allow_horizontal_scroll : bool, default: True
Allow horizontal scrolling.
show_vertical_bar : bool, default: True
Whether the vertical scrollbar is shown.
show_horizontal_bar : bool, default: True
Whether the horizontal scrollbar is shown.
dynamic_bars : bool, default: False
Whether bars are shown/hidden depending on the view size.
scrollwheel_enabled : bool, default: True
Allow vertical scrolling with scrollwheel.
arrow_keys_enabled : bool, default: True
Allow scrolling with arrow keys.
inertial_scrolling_enabled : bool, default: True
Whether inertial scrolling is enabled.
is_grabbable : bool, default: True
Whether grabbable behavior is enabled.
ptf_on_grab : bool, default: False
Whether the gadget will be pulled to front when grabbed.
mouse_button : MouseButton, default: "left"
Mouse button used for grabbing.
alpha : float, default: 1.0
Transparency of gadget.
size : Size, default: Size(10, 10)
Size of gadget.
pos : Point, default: Point(0, 0)
Expand All @@ -57,6 +81,42 @@ class RecycleView[T, G: Gadget](ScrollView):
----------
recycle_view_data: list[T]
The recycle-view's data.
view : Gadget | None
The scrolled gadget.
allow_vertical_scroll : bool
Allow vertical scrolling.
allow_horizontal_scroll : bool
Allow horizontal scrolling.
show_vertical_bar : bool
Whether the vertical scrollbar is shown.
show_horizontal_bar : bool
Whether the horizontal scrollbar is shown.
dynamic_bars : bool, default: False
Whether bars are shown/hidden depending on the view size.
scrollwheel_enabled : bool
Allow vertical scrolling with scrollwheel.
arrow_keys_enabled : bool
Allow scrolling with arrow keys.
inertial_scrolling_enabled : bool
Whether inertial scrolling is enabled.
vertical_proportion : float
Vertical scroll position as a proportion of total height.
horizontal_proportion : float
Horizontal scroll position as a proportion of total width.
port_height : int
Height of view port.
port_width : int
Width of view port.
is_grabbable : bool
Whether grabbable behavior is enabled.
ptf_on_grab : bool
Whether the gadget will be pulled to front when grabbed.
mouse_button : MouseButton
Mouse button used for grabbing.
is_grabbed : bool
Whether gadget is grabbed.
alpha : float
Transparency of gadget.
size : Size
Size of gadget.
height : int
Expand Down Expand Up @@ -177,6 +237,7 @@ def __init__(
dynamic_bars: bool = False,
scrollwheel_enabled: bool = True,
arrow_keys_enabled: bool = True,
inertial_scrolling_enabled: bool = True,
is_grabbable: bool = True,
ptf_on_grab: bool = False,
mouse_button: MouseButton = "left",
Expand All @@ -197,6 +258,7 @@ def __init__(
dynamic_bars=dynamic_bars,
scrollwheel_enabled=scrollwheel_enabled,
arrow_keys_enabled=arrow_keys_enabled,
inertial_scrolling_enabled=inertial_scrolling_enabled,
is_grabbable=is_grabbable,
ptf_on_grab=ptf_on_grab,
mouse_button=mouse_button,
Expand Down
79 changes: 76 additions & 3 deletions src/batgrl/gadgets/scroll_view.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
"""A scrollable view gadget."""

import asyncio
from typing import Final

from ..terminal.events import KeyEvent, MouseButton, MouseEvent
from ..text_tools import smooth_horizontal_bar, smooth_vertical_bar
from .behaviors.grabbable import Grabbable
Expand All @@ -10,6 +13,11 @@

__all__ = ["ScrollView", "Point", "Size"]

_DAMPING: Final = 0.75
"""Velocity damping for inertial scrolling."""
_VELOCITY_CUTOFF: Final = 0.001
"""Minimum velocity before stopping inertial scrolling."""


class _ScrollbarBase(Grabbable, Text):
length: int
Expand Down Expand Up @@ -202,6 +210,8 @@ class ScrollView(Themable, Grabbable, Gadget):
Allow vertical scrolling with scrollwheel.
arrow_keys_enabled : bool, default: True
Allow scrolling with arrow keys.
inertial_scrolling_enabled : bool, default: True
Whether inertial scrolling is enabled.
is_grabbable : bool, default: True
Whether grabbable behavior is enabled.
ptf_on_grab : bool, default: False
Expand Down Expand Up @@ -245,6 +255,8 @@ class ScrollView(Themable, Grabbable, Gadget):
Allow vertical scrolling with scrollwheel.
arrow_keys_enabled : bool
Allow scrolling with arrow keys.
inertial_scrolling_enabled : bool
Whether inertial scrolling is enabled.
vertical_proportion : float
Vertical scroll position as a proportion of total height.
horizontal_proportion : float
Expand Down Expand Up @@ -392,6 +404,7 @@ def __init__(
dynamic_bars: bool = False,
scrollwheel_enabled: bool = True,
arrow_keys_enabled: bool = True,
inertial_scrolling_enabled: bool = True,
is_grabbable: bool = True,
ptf_on_grab: bool = False,
mouse_button: MouseButton = "left",
Expand Down Expand Up @@ -420,6 +433,12 @@ def __init__(
self._vertical_proportion = 0.0
self._horizontal_proportion = 0.0
self._view = None
self._vertical_velocity: float = 0.0
"""Vertical velocity for inertial scrolling."""
self._horizontal_velocity: float = 0.0
"""Horizontal velocity for inertial scrolling."""
self._inertial_scroll_task: asyncio.Task | None = None
"""Task that updates inertial scroll."""

super().__init__(
is_grabbable=is_grabbable,
Expand All @@ -433,10 +452,12 @@ def __init__(
is_visible=is_visible,
is_enabled=is_enabled,
)
self.allow_vertical_scroll = allow_vertical_scroll
self.allow_vertical_scroll: bool = allow_vertical_scroll
"""Allow vertical scrolling."""
self.allow_horizontal_scroll = allow_horizontal_scroll
self.allow_horizontal_scroll: bool = allow_horizontal_scroll
"""Allow horizontal scrolling."""
self.inertial_scrolling_enabled: bool = inertial_scrolling_enabled
"""Whether inertial scrolling is enabled."""
self.scrollwheel_enabled = scrollwheel_enabled
"""Allow vertical scrolling with scrollwheel."""
self.arrow_keys_enabled = arrow_keys_enabled
Expand Down Expand Up @@ -613,6 +634,40 @@ def update_proportion():
update_proportion()
self._view_bind_uid = view.bind("size", update_proportion)

def _inertial_scroll(self, y: float = 0.0, x: float = 0.0):
if not self.inertial_scrolling_enabled:
return

if self._inertial_scroll_task is not None:
self._inertial_scroll_task.cancel()
self._inertial_scroll_task = asyncio.create_task(self._damp_velocity())

async def _damp_velocity(self):
while True:
self._vertical_velocity *= _DAMPING
if abs(self._vertical_velocity) <= _VELOCITY_CUTOFF:
self._vertical_velocity = 0
else:
self.vertical_proportion += self._vertical_velocity

self._horizontal_velocity *= _DAMPING
if abs(self._horizontal_velocity) <= _VELOCITY_CUTOFF:
self._horizontal_velocity = 0
else:
self.horizontal_proportion += self._horizontal_velocity

if self._vertical_velocity == 0 and self._horizontal_velocity == 0:
return

await asyncio.sleep(0.1)

def on_remove(self) -> None:
"""Cancel inertial scroll on remove."""
if self._inertial_scroll_task is not None:
self._inertial_scroll_task.cancel()
self._inertial_scroll_task = None
super().on_remove()

def remove_gadget(self, gadget: Gadget):
"""Unbind from the view on its removal."""
if gadget is self._view:
Expand Down Expand Up @@ -669,10 +724,28 @@ def on_key(self, key_event: KeyEvent) -> bool | None:

return True

def grab_update(self, mouse_event: MouseEvent):
def grab_update(self, mouse_event: MouseEvent) -> None:
"""Scroll on grab update."""
self.scroll_up(mouse_event.dy)
self.scroll_left(mouse_event.dx)
if self.height:
vv = -2 * mouse_event.dy / self.height
else:
vv = 0
if abs(vv) > abs(self._vertical_velocity):
self._vertical_velocity = vv

if self.width:
hv = -2 * mouse_event.dx / self.width
else:
hv = 0
if abs(hv) > abs(self._horizontal_velocity):
self._horizontal_velocity = hv

def ungrab(self, mouse_event: MouseEvent) -> None:
"""Start inertial scroll."""
super().ungrab(mouse_event)
self._inertial_scroll()

def on_mouse(self, mouse_event: MouseEvent) -> bool | None:
"""Scroll on mouse wheel."""
Expand Down

0 comments on commit c0300ac

Please sign in to comment.