mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-10-01 03:43:46 +08:00
Settle UI page transitions exactly and keep animation timing consistent
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user