From 695a2d783f4f1517cacfca78698fd51f50d74ba3 Mon Sep 17 00:00:00 2001 From: Shane Smiskol Date: Wed, 6 May 2026 21:13:44 -0700 Subject: [PATCH] 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 --- system/ui/lib/scroll_panel2.py | 30 +++++++++++++++++------- system/ui/widgets/scroller.py | 42 ++++++++-------------------------- 2 files changed, 32 insertions(+), 40 deletions(-) diff --git a/system/ui/lib/scroll_panel2.py b/system/ui/lib/scroll_panel2.py index 18fd8a9a6..7fae60119 100644 --- a/system/ui/lib/scroll_panel2.py +++ b/system/ui/lib/scroll_panel2.py @@ -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) diff --git a/system/ui/widgets/scroller.py b/system/ui/widgets/scroller.py index faf9dd298..13445655d 100644 --- a/system/ui/widgets/scroller.py +++ b/system/ui/widgets/scroller.py @@ -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: