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
+32 -2
View File
@@ -240,6 +240,14 @@ class DisplayManager:
self._update_lock = threading.RLock()
# Scrolling state tracking for graceful updates
# How many panel refreshes each pushed frame is held for. 1 means a new
# frame every refresh. Higher values are how a scroll runs slower than
# one pixel per refresh WITHOUT fractional pixel positions: the panel
# keeps refreshing at full rate (so flicker is unchanged) but motion
# advances a whole pixel every Nth refresh instead of every one.
# See src/common/scroll_config.py and scripts/scroll_speeds.py.
self._frame_hold = 1
self._scrolling_state = {
'is_scrolling': False,
'last_scroll_activity': 0,
@@ -804,8 +812,10 @@ class DisplayManager:
else:
self.offscreen_canvas.SetImage(self.image)
# Swap buffers immediately
self.matrix.SwapOnVSync(self.offscreen_canvas)
# Swap buffers immediately. framerate_fraction holds the frame
# for N refreshes; SwapOnVSync blocks for all of them, which is
# what paces the render loop to the chosen frame rate.
self.matrix.SwapOnVSync(self.offscreen_canvas, self._frame_hold)
# Swap our canvas references
self.offscreen_canvas, self.current_canvas = self.current_canvas, self.offscreen_canvas
@@ -1291,12 +1301,32 @@ class DisplayManager:
return dt.strftime(f"%b %-d{suffix}")
def set_frame_hold(self, refreshes: int) -> None:
"""Hold each pushed frame for this many panel refreshes (>=1).
Set by the scroll configuration so a plugin can run at, say, 50px/s on
a 100Hz panel as one whole pixel every second refresh, rather than half
a pixel every refresh (which has to be blended or repeated unevenly).
Reset to 1 whenever scrolling stops, so one plugin's pacing cannot
leak into the next thing on screen.
"""
try:
value = int(refreshes)
except (TypeError, ValueError):
logger.warning("Ignoring unusable frame hold: %r", refreshes)
return
self._frame_hold = max(1, min(255, value))
def set_scrolling_state(self, is_scrolling: bool):
"""Set the current scrolling state. Call this when a display starts/stops scrolling."""
current_time = time.time()
self._scrolling_state['is_scrolling'] = is_scrolling
if is_scrolling:
self._scrolling_state['last_scroll_activity'] = current_time
else:
# Whatever pacing the finished scroll asked for must not carry over.
self._frame_hold = 1
logger.debug(f"Scrolling state set to: {is_scrolling}")
def is_currently_scrolling(self) -> bool: