Scroller: snap logic in scroll panel (#37975)

* snap logic in scroll panel

* match previous snap speed

* velocity lookahead makes scrolling on main layout super easy

* turn off

* remove velocity lookahead

* cmts

* remove handle out of bounds

* cmts

* old style

* back

* clean up

* re-use

* revert

* finish snap when settings is clicked while moving

* rm cmt
This commit is contained in:
Shane Smiskol
2026-05-06 21:13:44 -07:00
committed by GitHub
parent 2596de8543
commit 695a2d783f
2 changed files with 32 additions and 40 deletions
+22 -8
View File
@@ -14,6 +14,7 @@ MIN_DRAG_PIXELS = 12
AUTO_SCROLL_TC_SNAP = 0.025
AUTO_SCROLL_TC = 0.18
BOUNCE_RETURN_RATE = 10.0
SNAP_RATE = 6.3 # matches previous Scroller snapping. exp rate of approach to snap target, 1/s
REJECT_DECELERATION_FACTOR = 3
MAX_SPEED = 10000.0 # px/s
@@ -44,10 +45,8 @@ class ScrollState(Enum):
class GuiScrollPanel2:
def __init__(self, horizontal: bool = True, handle_out_of_bounds: bool = True) -> None:
def __init__(self, horizontal: bool = True) -> None:
self._horizontal = horizontal
self._handle_out_of_bounds = handle_out_of_bounds
self._AUTO_SCROLL_TC = AUTO_SCROLL_TC_SNAP if not self._handle_out_of_bounds else AUTO_SCROLL_TC
self._state = ScrollState.STEADY
self._offset: rl.Vector2 = rl.Vector2(0, 0)
self._initial_click_event: MouseEvent | None = None
@@ -63,7 +62,7 @@ class GuiScrollPanel2:
def enabled(self) -> bool:
return self._enabled() if callable(self._enabled) else self._enabled
def update(self, bounds: rl.Rectangle, content_size: float) -> float:
def update(self, bounds: rl.Rectangle, content_size: float, snap_target: float | None = None) -> float:
if DEBUG:
print('Old state:', self._state)
@@ -73,7 +72,7 @@ class GuiScrollPanel2:
self._handle_mouse_event(mouse_event, bounds, bounds_size, content_size)
self._previous_mouse_event = mouse_event
self._update_state(bounds_size, content_size)
self._update_state(bounds_size, content_size, snap_target)
if DEBUG:
print('Velocity:', self._velocity)
@@ -86,7 +85,7 @@ class GuiScrollPanel2:
"""Returns (max_offset, min_offset) for the given bounds and content size."""
return 0.0, min(0.0, bounds_size - content_size)
def _update_state(self, bounds_size: float, content_size: float) -> None:
def _update_state(self, bounds_size: float, content_size: float, snap_target: float | None) -> None:
"""Runs per render frame, independent of mouse events. Updates auto-scrolling state and velocity."""
max_offset, min_offset = self._get_offset_bounds(bounds_size, content_size)
@@ -97,8 +96,9 @@ class GuiScrollPanel2:
elif self._state == ScrollState.AUTO_SCROLL:
# simple exponential return if out of bounds
# out of bounds is handled by snapping, so skip if set
out_of_bounds = self.get_offset() > max_offset or self.get_offset() < min_offset
if out_of_bounds and self._handle_out_of_bounds:
if out_of_bounds and snap_target is None:
target = max_offset if self.get_offset() > max_offset else min_offset
dt = rl.get_frame_time() or 1e-6
@@ -121,9 +121,23 @@ class GuiScrollPanel2:
# Update the offset based on the current velocity
dt = rl.get_frame_time()
self.set_offset(self.get_offset() + self._velocity * dt) # Adjust the offset based on velocity
alpha = 1 - (dt / (self._AUTO_SCROLL_TC + dt))
# fast decay in snap mode so velocity yields to the snap pull instead of fighting it
auto_scroll_tc = AUTO_SCROLL_TC_SNAP if snap_target is not None else AUTO_SCROLL_TC
alpha = 1 - (dt / (auto_scroll_tc + dt))
self._velocity *= alpha
# Ease toward snap target when not in user control. Composes with velocity coast above:
# high velocity dominates initially, snap dominates as velocity decays.
if snap_target is not None and self._state not in (ScrollState.PRESSED, ScrollState.MANUAL_SCROLL):
snap_target = max(min_offset, min(max_offset, snap_target))
dist = snap_target - self.get_offset()
if abs(dist) < 1: # finished snap
self.set_offset(snap_target)
else:
dt = rl.get_frame_time() or 1e-6
factor = 1.0 - math.exp(-SNAP_RATE * dt)
self.set_offset(self.get_offset() + dist * factor)
def _handle_mouse_event(self, mouse_event: MouseEvent, bounds: rl.Rectangle, bounds_size: float,
content_size: float) -> None:
max_offset, min_offset = self._get_offset_bounds(bounds_size, content_size)
+10 -32
View File
@@ -93,10 +93,7 @@ class _Scroller(Widget):
self._item_pos_filter = BounceFilter(0.0, 0.05, 1 / gui_app.target_fps)
# when not pressed, snap to closest item to be center
self._scroll_snap_filter = FirstOrderFilter(0.0, 0.05, 1 / gui_app.target_fps)
self.scroll_panel = GuiScrollPanel2(self._horizontal, handle_out_of_bounds=not self._snap_items)
self.scroll_panel = GuiScrollPanel2(self._horizontal)
self._scroll_enabled: bool | Callable[[], bool] = True
self._show_scroll_indicator = scroll_indicator and self._horizontal
@@ -182,42 +179,23 @@ class _Scroller(Widget):
self._scrolling_to_filter.update(self._scrolling_to[0])
self.scroll_panel.set_offset(self._scrolling_to_filter.x)
if abs(self._scrolling_to_filter.x - self._scrolling_to[0]) < 1:
if abs(self._scrolling_to_filter.x - self._scrolling_to[0]) < 1: # finished scroll
self.scroll_panel.set_offset(self._scrolling_to[0])
self._scrolling_to = None, False
def _get_scroll(self, visible_items: list[Widget], content_size: float) -> float:
scroll_enabled = self._scroll_enabled() if callable(self._scroll_enabled) else self._scroll_enabled
self.scroll_panel.set_enabled(scroll_enabled and self.enabled and not self._scrolling_to[1])
self.scroll_panel.update(self._rect, content_size)
if not self._snap_items:
return self.scroll_panel.get_offset()
# Snap closest item to center
center_pos = self._rect.x + self._rect.width / 2
closest_delta_pos = float('inf')
scroll_snap_idx: int | None = None
for idx, item in enumerate(visible_items):
delta_pos = (item.rect.x + item.rect.width / 2) - center_pos
if abs(delta_pos) < abs(closest_delta_pos):
closest_delta_pos = delta_pos
scroll_snap_idx = idx
# Snap closest item to center. Skipped while scroll_to() is animating
snap_target: float | None = None
if self._snap_items and visible_items and self._scrolling_to[0] is None:
# TODO: this doesn't handle two small buttons at the edges well
center_pos = self._rect.x + self._rect.width / 2
closest_delta_pos = min((((item.rect.x + item.rect.width / 2) - center_pos) for item in visible_items), key=abs)
snap_target = self.scroll_panel.get_offset() - closest_delta_pos
if scroll_snap_idx is not None:
snap_item = visible_items[scroll_snap_idx]
if self.is_pressed:
# no snapping until released
self._scroll_snap_filter.x = 0
else:
# TODO: this doesn't handle two small buttons at the edges well
snap_delta_pos = (center_pos - (snap_item.rect.x + snap_item.rect.width / 2)) / 10
snap_delta_pos = min(snap_delta_pos, -self.scroll_panel.get_offset() / 10)
snap_delta_pos = max(snap_delta_pos, (self._rect.width - self.scroll_panel.get_offset() - content_size) / 10)
self._scroll_snap_filter.update(snap_delta_pos)
self.scroll_panel.set_offset(self.scroll_panel.get_offset() + self._scroll_snap_filter.x)
return self.scroll_panel.get_offset()
return self.scroll_panel.update(self._rect, content_size, snap_target=snap_target)
@property
def moving_items(self) -> bool: