diff --git a/system/ui/lib/tests/test_scroller_interaction.py b/system/ui/lib/tests/test_scroller_interaction.py index 432f8863b3..efe4e3eefb 100644 --- a/system/ui/lib/tests/test_scroller_interaction.py +++ b/system/ui/lib/tests/test_scroller_interaction.py @@ -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) diff --git a/system/ui/widgets/scroller.py b/system/ui/widgets/scroller.py index ec310da214..9adbb0a3b6 100644 --- a/system/ui/widgets/scroller.py +++ b/system/ui/widgets/scroller.py @@ -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()