feat(display): cancel the half-panel scan offset on slower, held-frame scrolls (#711)

Scan-order compensation only ran at one frame per refresh, so a crisp scroll
like 60 px/s on a 120 Hz panel (1px every 2 refreshes) showed a half-pixel
step across the middle of the panel. A held frame is now presented as a
sequence of swaps (scan_order.refresh_plan): the lagging half shows the
previous frame for its first refresh and the new one for the rest, so it
steps one refresh after the rest. Skipped when a blit takes over half a
refresh, since the second blit has to land before the next vsync.

Soaked on ledpi (60 px/s, 120 Hz): 0.16% late frames, as before the change.

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Chuck
2026-10-01 14:32:24 -04:00
committed by GitHub
co-authored by Claude Sonnet 5.5
parent 16b566e14f
commit eb8128a981
4 changed files with 142 additions and 43 deletions
+42 -3
View File
@@ -73,6 +73,27 @@ def _frame(shade):
return Image.new("RGB", (8, 64), (shade, shade, shade))
class TestRefreshPlan:
BANDS = [(32, 64, 1)]
def test_one_refresh_per_frame_is_a_plain_lag(self):
assert scan_order.refresh_plan(self.BANDS, 1) == [((1,), 1)]
def test_a_held_frame_lags_only_its_first_refresh(self):
assert scan_order.refresh_plan(self.BANDS, 2) == [((1,), 1), ((0,), 1)]
assert scan_order.refresh_plan(self.BANDS, 5) == [((1,), 1), ((0,), 4)]
def test_a_lag_longer_than_the_hold_reaches_further_back(self):
# Three halves down a stack, held for two refreshes.
plan = scan_order.refresh_plan([(0, 8, 3)], 2)
assert plan == [((2,), 1), ((1,), 1)]
def test_every_refresh_of_the_frame_is_accounted_for(self):
for hold in range(1, 9):
plan = scan_order.refresh_plan([(0, 8, 1), (8, 16, 2)], hold)
assert sum(count for _, count in plan) == hold
class TestCompose:
def test_a_band_comes_from_the_frame_that_many_refreshes_back(self):
now, previous = _frame(30), _frame(20)
@@ -133,10 +154,28 @@ class TestUpdateDisplay:
assert shown.getpixel((0, 0)) == (20, 0, 0)
assert shown.getpixel((0, 31)) == (10, 0, 0)
def test_not_on_a_static_screen_or_a_held_frame(self, dm):
def test_not_on_a_static_screen(self, dm):
dm._scan_lag_bands = [(16, 32, 1)]
self._push(dm, 10)
assert self._push(dm, 20).getpixel((0, 31)) == (20, 0, 0) # not scrolling
def test_a_held_frame_is_split_so_the_lagging_half_steps_a_refresh_late(self, dm):
dm._scan_lag_bands = [(16, 32, 1)]
dm.set_scrolling_state(True, 2)
self._push(dm, 30)
assert self._push(dm, 40).getpixel((0, 31)) == (40, 0, 0) # hold 2
self._push(dm, 10)
before = len(dm._presented)
self._push(dm, 20)
first, second = dm._presented[before:]
assert first.getpixel((0, 0)) == (20, 0, 0)
assert first.getpixel((0, 31)) == (10, 0, 0) # lagging half: still old
assert second.getpixel((0, 31)) == (20, 0, 0) # caught up a refresh later
def test_a_slow_blit_is_not_split(self, dm):
dm._scan_lag_bands = [(16, 32, 1)]
dm.set_scrolling_state(True, 3)
self._push(dm, 10)
dm._last_blit_seconds = 1.0 # far longer than a refresh
before = len(dm._presented)
self._push(dm, 20)
assert len(dm._presented) - before == 1
assert dm._presented[-1].getpixel((0, 31)) == (20, 0, 0)