Settle UI page transitions exactly and keep animation timing consistent

This commit is contained in:
firestar5683
2026-09-22 09:20:03 -05:00
parent 9d8a523471
commit 678af78347
2 changed files with 102 additions and 23 deletions
@@ -22,6 +22,16 @@ class Item(widgets.Widget):
pass
class Page(Item):
def __init__(self):
super().__init__()
self.set_rect(rl.Rectangle(0, 0, 536, 240))
self.render_count = 0
def _render(self, _):
self.render_count += 1
@pytest.fixture
def make_scroller(monkeypatch):
monkeypatch.setattr(rl, "begin_scissor_mode", lambda *args: None)
@@ -62,6 +72,66 @@ def test_tap_interrupts_programmatic_scroll_tail(make_scroller):
assert sum(item.clicks for item in scroller.items) == 1
def page_scroller(make_scroller):
pages = [Page() for _ in range(3)]
scroller = make_scroller(items=pages, snap_items=True, spacing=0, pad=0)
scroller._scroll_snap_filter.x = 0
scroller.scroll_panel.set_offset(-1072)
scroller.render()
return scroller, pages
@pytest.mark.parametrize("fps", [20, 60])
def test_onroad_home_transition_duration_and_offscreen_culling(make_scroller, monkeypatch, fps):
monkeypatch.setattr(gui_app, "_target_fps", 60)
monkeypatch.setattr(rl, "get_frame_time", lambda: 1 / fps)
scroller, pages = page_scroller(make_scroller)
for page in (pages[1], pages[2], pages[1]):
scroller.scroll_to(page.rect.x, smooth=True)
for _frames in range(1, fps * 2):
scroller.render()
if not scroller.is_auto_scrolling:
break
assert _frames / fps == pytest.approx(1.0, abs=1 / fps)
assert page.rect.x == 0
hidden = [item for item in pages if item is not page]
previous_draws = [item.render_count for item in hidden]
for _ in range(fps * 2):
scroller.render()
assert [item.render_count for item in hidden] == previous_draws
assert page.rect.x == 0
def test_snap_settles_at_same_time_across_frame_rates(make_scroller, monkeypatch):
monkeypatch.setattr(gui_app, "_target_fps", 60)
durations = []
for fps in (20, 60):
monkeypatch.setattr(rl, "get_frame_time", lambda fps=fps: 1 / fps)
scroller, pages = page_scroller(make_scroller)
scroller.scroll_panel.set_offset(-600)
for _frames in range(1, fps * 3):
scroller.render()
if pages[1].rect.x == 0 and scroller._scroll_snap_filter.x == 0:
break
assert pages[1].rect.x == 0
assert pages[2].rect.x == 536
durations.append(_frames / fps)
assert durations[0] == pytest.approx(durations[1], abs=1 / 20)
def test_programmatic_scroll_caps_long_frame_without_overshoot(make_scroller, monkeypatch):
scroller, pages = page_scroller(make_scroller)
scroller.scroll_to(pages[1].rect.x, smooth=True)
monkeypatch.setattr(rl, "get_frame_time", lambda: 10.0)
scroller.render()
assert -1072 < scroller.scroll_panel.get_offset() < -804
assert scroller.is_auto_scrolling
assert -536 < pages[1].rect.x < -268
def test_blocking_programmatic_scroll_still_rejects_taps(make_scroller):
scroller = make_scroller()
scroller.scroll_to(30, smooth=True, block_interaction=True)
+32 -23
View File
@@ -1,3 +1,4 @@
import math
import pyray as rl
import numpy as np
from collections.abc import Callable
@@ -139,6 +140,7 @@ class _Scroller(Widget):
self._scrolling_to: tuple[float | None, bool] = (None, False) # target offset, block_interaction
self._scrolling_to_filter = FirstOrderFilter(0.0, SCROLL_RC, 1 / gui_app.target_fps)
self._animation_dt = self._scrolling_to_filter.dt
self._zoom_filter = FirstOrderFilter(1.0, 0.2, 1 / gui_app.target_fps)
self._zoom_out_t: float = 0.0
@@ -221,6 +223,7 @@ class _Scroller(Widget):
self._scroll_enabled = enabled
def _update_state(self):
self._animation_dt = min(rl.get_frame_time() or self._scrolling_to_filter.dt, 0.1)
if DO_ZOOM:
if self._scrolling_to[0] is not None or self.scroll_panel.state != ScrollState.STEADY:
self._zoom_out_t = rl.get_time() + MIN_ZOOM_ANIMATION_TIME
@@ -238,7 +241,8 @@ class _Scroller(Widget):
self._scrolling_to = None, False
if self._scrolling_to[0] is not None and len(self._pending_lift) == 0:
self._scrolling_to_filter.update(self._scrolling_to[0])
alpha = 1 - (1 - self._scrolling_to_filter.alpha) ** (self._animation_dt / self._scrolling_to_filter.dt)
self._scrolling_to_filter.x += alpha * (self._scrolling_to[0] - self._scrolling_to_filter.x)
self.scroll_panel.set_offset(self._scrolling_to_filter.x)
if abs(self._scrolling_to_filter.x - self._scrolling_to[0]) < 1:
@@ -253,38 +257,43 @@ class _Scroller(Widget):
self._scrolling_to = None, False
if not self._snap_items:
return self.scroll_panel.get_offset()
if self._scrolling_to[0] is not None:
self._scroll_snap_filter.x = 0
return self.scroll_panel.get_offset()
# Snap closest item to center
center_pos = self._rect.x + self._rect.width / 2 if self._horizontal else self._rect.y + self._rect.height / 2
bounds_size = self._rect.width if self._horizontal else self._rect.height
offset = self.scroll_panel.get_offset()
center_pos = bounds_size / 2
closest_delta_pos = float('inf')
scroll_snap_idx: int | None = None
for idx, item in enumerate(visible_items):
if self._horizontal:
delta_pos = (item.rect.x + item.rect.width / 2) - center_pos
else:
delta_pos = (item.rect.y + item.rect.height / 2) - center_pos
snap_target: float | None = None
item_pos = self._pad
for item in visible_items:
size = item.rect.width if self._horizontal else item.rect.height
item_center = item_pos + size / 2
delta_pos = item_center + offset - center_pos
if abs(delta_pos) < abs(closest_delta_pos):
closest_delta_pos = delta_pos
scroll_snap_idx = idx
snap_target = center_pos - item_center
item_pos += size + self._spacing
if scroll_snap_idx is not None:
snap_item = visible_items[scroll_snap_idx]
if snap_target is not None:
if self.scroll_panel.state in (ScrollState.PRESSED, ScrollState.MANUAL_SCROLL):
# no snapping until released
self._scroll_snap_filter.x = 0
else:
# TODO: this doesn't handle two small buttons at the edges well
if self._horizontal:
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)
else:
snap_delta_pos = (center_pos - (snap_item.rect.y + snap_item.rect.height / 2)) / 10
snap_delta_pos = min(snap_delta_pos, -self.scroll_panel.get_offset() / 10)
snap_delta_pos = max(snap_delta_pos, (self._rect.height - 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)
snap_target = max(min(0.0, bounds_size - content_size), min(0.0, snap_target))
steps = max(1, math.ceil(self._animation_dt / self._scroll_snap_filter.dt))
scale = self._animation_dt / (steps * self._scroll_snap_filter.dt)
alpha = 1 - (1 - self._scroll_snap_filter.alpha) ** scale
for _ in range(steps):
snap_delta_pos = (snap_target - offset) / 10
self._scroll_snap_filter.x += alpha * (snap_delta_pos - self._scroll_snap_filter.x)
offset += self._scroll_snap_filter.x * scale
if abs(snap_target - offset) < 1 and abs(self._scroll_snap_filter.x) < 0.5:
offset = snap_target
self._scroll_snap_filter.x = 0
self.scroll_panel.set_offset(offset)
return self.scroll_panel.get_offset()