fix(display): end the scroll state at scroller-to-static handovers (#716)

A static plugin screen that follows a scroller no longer starts with the ticker's lagging rows on scan-compensated panels, and the 1 Hz loop's second frame is no longer recorded as a ~1 s mid-scroll freeze / Render stall. The display controller calls DisplayManager.end_scroll_for_static_screen() before a static screen's first display() (clears the scan history; _scan_segments passes its frames through in one swap) and set_scrolling_state(False) after it; the scroller's hold stays until then, so late-frame counts are unchanged. A screen's first frame is tagged 'handover': gaps of 250 ms or more before it go to the additive handover_freezes (frame_soak prints 'Handover gaps'), not freezes. The display thread is named display-<plugin id>. The WiFi notice and the schedule-off blank are not covered yet (docs list them as a follow-up).

ledpi A B B A soak (20 min each, --preview): main 0.118% / 0.113% late with 6 / 3 freezes; with this and #717 0.107% / 0.104% late, 0 freezes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Chuck
2026-10-01 20:13:57 -04:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 41db488c73
commit 34be83d595
12 changed files with 1041 additions and 38 deletions
+53 -2
View File
@@ -344,6 +344,10 @@ class DisplayManager:
# 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
# True while a static screen draws its first frame after a scroll,
# whose state is left set until then: those frames go out without
# scan-order compensation. See end_scroll_for_static_screen().
self._static_handover = False
# A src.common.render_gate.RenderGate while Vegas runs with
# vegas_scroll.prefetch_gate on: opened around each swap so the
@@ -1060,11 +1064,15 @@ class DisplayManager:
rows catch up, so those rows step a refresh after the rest. The split
needs a second blit inside the refresh that follows the first swap, so
it is skipped when a blit is too slow to fit. A static screen goes out
as it is, and drops the history.
as it is, and drops the history. So does a static screen's first frame
after a scroll, while the scroll state is still set (see
end_scroll_for_static_screen): one segment, held for the scroll's
hold, with no rows from the scroller's frames.
"""
hold = self._frame_hold
bands = getattr(self, '_scan_lag_bands', None)
if not bands or not self.is_currently_scrolling():
if (not bands or not self.is_currently_scrolling()
or self._static_handover):
if bands:
self._scan_history.clear()
return [(image, hold)]
@@ -1524,6 +1532,9 @@ class DisplayManager:
# A plugin captured for Vegas calls this from its own display();
# it must not change the live scroll's state or frame hold.
return
# A scroll starting or ending also ends a static screen's handover;
# see end_scroll_for_static_screen.
self._static_handover = False
current_time = time.time()
# Scrolling callers set this every frame; log transitions only.
changed = self._scrolling_state['is_scrolling'] != is_scrolling
@@ -1536,6 +1547,46 @@ class DisplayManager:
if changed:
logger.debug("Scrolling state set to: %s", is_scrolling)
def end_scroll_for_static_screen(self) -> None:
"""Ready the panel for a static screen's first frame after a scroll.
The display controller calls this just before it dispatches the first
frame of a screen that runs its 1 Hz loop, and
``set_scrolling_state(False)`` once that dispatch returns. Nothing
else ends a scroll at a handover: the state belongs to the screen
before, and would only expire 2 s after its last frame.
Until then, the frames that dispatch presents go out as drawn, not
scan-order composed: each as one segment, held for the scroll's hold.
With the state still "scrolling", ``_scan_segments`` would take their
lagging rows from the frame before: for the first, the scroller's last
frame -- the bottom half of the old ticker under the new screen on a
96x48 panel. At hold 1 that frame stays up for a whole second; at a
longer hold its first refresh flashes the old rows. For a second frame
in the same call, the rows would come from the first. Dirty tracking
compares frames as drawn, so once the scroll is over it skips every
identical 1 Hz redraw of such a frame, and nothing would replace it.
The rest of that scroll is left on purpose, until the controller ends
it:
* the scroll state, so the gap from the scroller's last frame to this
screen's first is still timed by the frame-timing recorder and
watched by the stall watchdog, which is where a slow first
``display()`` shows up;
* its frame hold. On a frame that stays up for a second it only moves
the swap to the scroll's next hold boundary, and it is the pacing
that gap is due at: judged at hold 1, a handover that kept the
scroller's own schedule would count as frames late.
The next ``set_scrolling_state()`` call, whoever makes it, ends this.
One attribute store, so no lock: ``update_display`` reads it once per
frame, under its own, and the history is dropped there.
"""
if self._writes_suppressed():
return # a thread drawing off-screen cannot end the live scroll
self._static_handover = True
def is_currently_scrolling(self) -> bool:
"""Check if the display is currently in a scrolling state."""
current_time = time.time()