mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-18 12:59:12 +00:00
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:
+32
-2
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user