feat(scroll): let users pick a crisp speed for their own panel

Whole-pixel motion was previously only available at multiples of the refresh
rate -- 100, 200, 300 px/s on a 100Hz panel. 100 px/s crosses a 256px panel in
2.6s, which is brisk for reading, and everything slower had to blend (blur) or
repeat frames unevenly (judder). There was no way to ask for 50 px/s and get
clean motion.

SwapOnVSync takes a framerate_fraction the display manager never passed. It
holds each frame for N panel refreshes; the panel keeps refreshing at its full
rate throughout, so holding costs nothing in flicker and only changes how often
a NEW image is presented. That turns 50 px/s into one whole pixel every second
refresh instead of half a pixel every refresh.

The crisp speeds are therefore refresh_hz / hold * pixels_per_frame, and that
ladder depends on the panel: a Pi Zero on a long chain has a different set of
good speeds from a Pi 4 on a short one. crisp_ladder() enumerates them and
solve_crisp() picks the best match for a requested speed.

solve_crisp weights motion quality rather than picking the numerically nearest
entry, which matters more than it sounds. Asked for 30 px/s, nearest-by-value
answers 28.6 -- 2px jumps at 14fps -- over 33.3, which is single-pixel motion
at 33fps and obviously better on the panel. The target is also clamped into the
ladder's range first, because relative error saturates near 1.0 for a target
far outside it and the quality penalty would otherwise answer "10000 px/s" with
the slowest entry.

configure() snaps to the ladder and applies the hold when given a display
manager. Without one the hold silently cannot happen and motion falls back to
fractional pixels, so it warns rather than failing quietly. set_frame_hold()
resets to 1 when scrolling stops, so one plugin's pacing cannot leak into
whatever is on screen next.

scripts/scroll_speeds.py is the user-facing part: it prints the ladder for the
configured rate, measures what the panel ACTUALLY manages (--measure, for
hardware that cannot reach its configured limit), highlights the nearest option
to a wanted speed, and demos one live. It never starts or stops the display
service itself -- doing that inside a script stranded the panel twice today.

Speeds below ~20 px/s remain stepped regardless. That is the pixel pitch, not a
software limit.

Also fixes the dirty-tracking test spy, which stubbed SwapOnVSync with a
single-argument function and would have masked the new call as a failed push,
and rewrites a configure() test that had started passing for the wrong reason:
it asserted a judder warning, which snapping now prevents, and was matching the
unrelated "hold could not be applied" warning instead.

183 tests pass on the Pi.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Chuck
2026-09-04 16:32:52 -04:00
co-authored by Claude Opus 5
parent 6031e70525
commit 6e8a9dd24b
6 changed files with 729 additions and 16 deletions
+143 -4
View File
@@ -10,6 +10,9 @@ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from src.common.scroll_config import ( # noqa: E402
DEFAULT_PIXELS_PER_SECOND,
MAX_PIXELS_PER_FRAME,
crisp_ladder,
solve_crisp,
MAX_PIXELS_PER_SECOND,
MIN_PIXELS_PER_SECOND,
ScrollSettings,
@@ -40,6 +43,16 @@ class FakeHelper:
self.target_fps = fps
class FakeDisplayManager:
"""Records the frame hold configure() applies."""
def __init__(self):
self.hold = None
def set_frame_hold(self, refreshes):
self.hold = refreshes
class MinimalHelper:
"""An older helper exposing only set_scroll_speed."""
@@ -189,11 +202,60 @@ class TestConfigure:
configure(helper, {"display_options": {"scroll_pixels_per_second": 100.0}})
assert helper.speed == 100.0
def test_logs_the_warning_when_speed_will_judder(self, caplog):
helper = FakeHelper()
def test_snapping_removes_the_judder_warning(self, caplog):
"""50px/s cannot be shown in whole pixels at 100Hz *per refresh*, but it
can as 1px every 2nd refresh -- so once snapped there is nothing to warn
about. resolve() alone still warns; configure() resolves it."""
assert "judder" in resolve(
{"display_options": {"scroll_pixels_per_second": 50.0}},
refresh_hz=100).warning
helper, dm = FakeHelper(), FakeDisplayManager()
with caplog.at_level(logging.WARNING):
configure(helper, {"display_options": {"scroll_pixels_per_second": 50.0}})
assert any("judder" in r.getMessage() for r in caplog.records)
settings = configure(
helper, {"display_options": {"scroll_pixels_per_second": 50.0}},
display_manager=dm)
assert settings.warning is None
assert not [r for r in caplog.records if "judder" in r.getMessage()]
def test_warns_when_a_hold_is_needed_but_cannot_be_applied(self, caplog):
"""Without a display manager the hold silently does not happen, and the
motion falls back to fractional pixels. That must not pass quietly."""
with caplog.at_level(logging.WARNING):
configure(FakeHelper(), {"display_options": {"scroll_pixels_per_second": 50.0}})
assert any("frame hold" in r.getMessage() for r in caplog.records)
def test_applies_the_frame_hold_to_the_display_manager(self):
dm = FakeDisplayManager()
configure(FakeHelper(), {"display_options": {"scroll_pixels_per_second": 25.0}},
display_manager=dm)
assert dm.hold == 4, "25px/s at 100Hz is 1px every 4th refresh"
def test_full_speed_needs_no_hold(self):
dm = FakeDisplayManager()
configure(FakeHelper(), {"display_options": {"scroll_pixels_per_second": 100.0}},
display_manager=dm)
assert dm.hold == 1
def test_snaps_to_the_nearest_crisp_speed_and_reports_both(self):
s = configure(FakeHelper(), {"display_options": {"scroll_pixels_per_second": 45.0}},
display_manager=FakeDisplayManager())
assert s.requested_pixels_per_second == 45.0
assert s.pixels_per_second == 50.0
assert s.crisp is not None and s.crisp.pixels_per_frame == 1
def test_snapping_can_be_turned_off(self):
s = configure(FakeHelper(), {"display_options": {"scroll_pixels_per_second": 45.0}},
snap_to_crisp=False)
assert s.pixels_per_second == 45.0
assert s.crisp is None
def test_helper_target_fps_matches_the_presentation_rate(self):
"""At a hold of 4 the panel still refreshes at 100Hz, but frames are
presented at 25/s -- that is the rate the helper should pace to."""
helper = FakeHelper()
configure(helper, {"display_options": {"scroll_pixels_per_second": 25.0}},
display_manager=FakeDisplayManager())
assert helper.target_fps == pytest.approx(25.0)
def test_returns_settings_describing_the_source(self):
s = configure(FakeHelper(), {"display_options": {"scroll_speed": 2.0, "scroll_delay": 0.01}})
@@ -211,3 +273,80 @@ class TestRefreshFromConfig:
"nonsense", {"display": {"hardware": {"limit_refresh_rate_hz": None}}}])
def test_falls_back_to_the_default(self, cfg):
assert refresh_hz_from_config(cfg) == 100.0
class TestCrispLadder:
"""Whole-pixel speeds available on a given panel."""
def test_every_entry_is_exactly_reachable(self):
for c in crisp_ladder(100):
assert c.pixels_per_second == pytest.approx(
c.refresh_hz / c.frame_hold * c.pixels_per_frame)
def test_sorted_slowest_first(self):
speeds = [c.pixels_per_second for c in crisp_ladder(100)]
assert speeds == sorted(speeds)
def test_no_duplicate_speeds(self):
speeds = [round(c.pixels_per_second, 3) for c in crisp_ladder(100)]
assert len(speeds) == len(set(speeds))
def test_duplicates_resolve_to_the_smaller_step(self):
"""100px/s is 1px every refresh or 2px every 2nd; prefer the former."""
entry = next(c for c in crisp_ladder(100)
if c.pixels_per_second == pytest.approx(100.0))
assert entry.pixels_per_frame == 1
assert entry.frame_hold == 1
def test_ladder_scales_with_the_panel(self):
assert any(c.pixels_per_second == pytest.approx(60.0)
for c in crisp_ladder(60))
assert any(c.pixels_per_second == pytest.approx(30.0)
for c in crisp_ladder(60))
def test_full_refresh_speed_is_present(self):
for hz in (60, 75, 100, 120):
assert any(c.pixels_per_second == pytest.approx(float(hz))
for c in crisp_ladder(hz))
class TestSolveCrisp:
def test_exact_targets_are_matched_exactly(self):
for target in (100, 50, 25, 20):
assert solve_crisp(target, 100).pixels_per_second == pytest.approx(target)
def test_prefers_smooth_motion_over_raw_proximity(self):
"""30 -> 33.3 (1px, 33fps), not 28.6 (2px at 14fps) which is nearer."""
got = solve_crisp(30, 100)
assert got.pixels_per_second == pytest.approx(33.333, abs=0.01)
assert got.pixels_per_frame == 1
def test_does_not_chase_a_jumpy_exact_match(self):
"""45 -> 50 (1px, smooth) beats 42.9 (3px at 14fps)."""
assert solve_crisp(45, 100).pixels_per_frame == 1
def test_allows_a_two_pixel_step_when_it_is_clearly_closer(self):
"""60 -> 66.7 (2px at 33fps) rather than 50 (1px) which is 17% slow."""
got = solve_crisp(60, 100)
assert got.pixels_per_second == pytest.approx(66.667, abs=0.01)
assert got.pixels_per_frame == 2
def test_result_is_always_on_the_ladder(self):
ladder = {round(c.pixels_per_second, 3) for c in crisp_ladder(100)}
for target in range(5, 205, 5):
assert round(solve_crisp(target, 100).pixels_per_second, 3) in ladder
def test_adapts_to_a_slower_panel(self):
got = solve_crisp(30, 60)
assert got.pixels_per_second == pytest.approx(30.0)
assert got.pixels_per_frame == 1
assert got.frame_hold == 2
def test_speeds_beyond_the_panel_clamp_to_the_fastest_available(self):
got = solve_crisp(10_000, 100)
assert got.frame_hold == 1
assert got.pixels_per_frame == MAX_PIXELS_PER_FRAME
def test_steppiness_labels_are_sane(self):
assert solve_crisp(100, 100).steppiness == "smooth"
assert crisp_ladder(100)[0].steppiness == "stepped"