fix(display): end the scroll before the schedule-off blank and WiFi notice (#721)

The schedule-off blank and the WiFi notice are drawn by the display controller, not dispatched to a plugin, so #716's handover never reached them. Drawn while the last scroll's state was still set, the blank went out with the ticker's lagging rows on a scan-compensated panel and stayed up for its 60 s dwell, and the notice's redraws (which #712 now shows over a running scroller or Vegas) were timed as 0.5-1 s freezes and logged as a mid-scroll Render stall. The controller now calls set_scrolling_state(False) before drawing either; a scroller that resumes sets the state again on its next frame.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Chuck
2026-10-01 20:46:01 -04:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 0e78e06eb9
commit 85be4bf25d
4 changed files with 100 additions and 17 deletions
+9
View File
@@ -147,6 +147,15 @@ policies are unchanged.
### Fixes ### Fixes
- The schedule-off blank and the WiFi notice no longer start with a
scroller's leftovers. Both are drawn by the display controller rather than
dispatched to a plugin, so #716's handover never reached them: drawn while
the last scroll's state was still set, the blank went out with the
ticker's lagging rows on a scan-compensated panel and stayed up for its
60 s dwell, and the notice's redraws (which #712 now shows over a running
scroller or Vegas) were counted as 0.5-1 s freezes and logged as a
`Render stall ... mid-scroll`. The controller now ends the scroll state
before drawing either.
- A plugin whose `display()` raises now opens its circuit breaker. The first - A plugin whose `display()` raises now opens its circuit breaker. The first
frame of each screen goes through the plugin executor, which caught the frame of each screen goes through the plugin executor, which caught the
exception and returned False. The display read that as "no content" and exception and returned False. The display read that as "no content" and
+10 -17
View File
@@ -554,24 +554,17 @@ its first `display()`, so the frames that call presents go out as drawn, in one
swap each, instead of with the lagging half taken from the scroller's last swap each, instead of with the lagging half taken from the scroller's last
frame. frame.
Other screens that follow a scroll still are, while the scroll state lasts (it The controller's own screens -- the blank shown when the schedule turns the
expires 2 s after the scroller's last frame). Their first frame takes its panel off, and the WiFi status message -- end the scroll state before they are
lagging half from the scroller's last frame: for one refresh after a held drawn, so they go out as drawn and are timed as static frames, not as freezes
scroll, and after a scroll at one frame per refresh until the next frame of the old scroll. A scroller that resumes after a WiFi notice sets the state
replaces it. They are: again on its next frame.
- the blank shown when the schedule turns the panel off. It is redrawn once a One screen that follows a scroll is still composed while the scroll state lasts
minute while the panel is off, so half of the scroller's last frame can stay (it expires 2 s after the scroller's last frame): a screen that runs the
lit for up to 60 s; high-FPS loop without scrolling (an older `static-image`, which is forced into
- the WiFi status message, until the next pass half a second later; it). Its first frame takes its lagging half from the scroller's last frame, for
- a screen that runs the high-FPS loop without scrolling (an older one refresh after a held scroll and otherwise until its next frame.
`static-image`, which is forced into it), until its next frame.
The frame stats still time those two as frames of the old scroll: a WiFi
notice that preempts a scroller records up to three 0.5-1 s freezes, and the
schedule-off blank a `Render stall ... mid-scroll`. Ending the scroll state
before the schedule-off blank and the WiFi message is a follow-up, the
schedule-off blank first.
Checked on hdpi (4×128×64 on one chain, rotated 180, 2026-09-24) before it was Checked on hdpi (4×128×64 on one chain, rotated 180, 2026-09-24) before it was
written: `scan_mode: 1` (interlaced) made the step vanish but turned moving written: `scan_mode: 1` (interlaced) made the step vanish but turned moving
+18
View File
@@ -2592,6 +2592,7 @@ class DisplayController:
""" """
# Clear display when schedule makes it inactive to ensure blank screen # Clear display when schedule makes it inactive to ensure blank screen
# (not showing initialization screen) # (not showing initialization screen)
self._end_scroll_before_core_screen()
try: try:
self.display_manager.clear() self.display_manager.clear()
self.display_manager.update_display() self.display_manager.update_display()
@@ -2692,6 +2693,7 @@ class DisplayController:
wifi_status_data = self._check_wifi_status_message() wifi_status_data = self._check_wifi_status_message()
if not wifi_status_data: if not wifi_status_data:
return False return False
self._end_scroll_before_core_screen()
if not self._display_wifi_status_message(wifi_status_data): if not self._display_wifi_status_message(wifi_status_data):
# Display failed, clear the status and continue normally # Display failed, clear the status and continue normally
return False return False
@@ -2798,6 +2800,22 @@ class DisplayController:
end_scroll() end_scroll()
return static_screen return static_screen
def _end_scroll_before_core_screen(self) -> None:
"""End any scroll before the controller draws a screen of its own.
The schedule-off blank and the WiFi notice are drawn by the
controller, not by a plugin, so they never pass through
_dispatch_first_frame and its handover. Drawn while the last scroll's state is still set, the blank
went out with the scroller's lagging rows (on a scan-compensated
panel) for its 60 s dwell, and the notice's redraws were timed as
freezes of the old scroll. Ending the state first sends them out as
drawn, at hold 1, as static frames. A scroller that resumes sets the
state again on its next frame.
"""
set_scrolling_state = getattr(self.display_manager, 'set_scrolling_state', None)
if set_scrolling_state is not None:
set_scrolling_state(False)
def _note_screen_handover(self) -> None: def _note_screen_handover(self) -> None:
"""Tag the frame the first dispatch is about to present. """Tag the frame the first dispatch is about to present.
+63
View File
@@ -639,3 +639,66 @@ class TestRunLoop:
# Undecidable, so the broken screen's turn left the scroll state alone. # Undecidable, so the broken screen's turn left the scroll state alone.
_, turn = _turn(c.events, "broken") _, turn = _turn(c.events, "broken")
assert ("scrolling", False) not in turn assert ("scrolling", False) not in turn
def _core_screen_controller(dm):
"""Just enough of a DisplayController to draw its own screens on ``dm``."""
from src.display_controller import DisplayController
c = MagicMock()
c.display_manager = dm
c.on_demand_active = False
c._end_scroll_before_core_screen = types.MethodType(
DisplayController._end_scroll_before_core_screen, c)
return c
def _record_scrolling(dm, monkeypatch):
"""The ``scrolling`` flag each presented frame is timed with."""
flags = []
real = dm.frame_timing.record
def record(*args, **kwargs):
flags.append(args[3])
return real(*args, **kwargs)
monkeypatch.setattr(dm.frame_timing, "record", record)
return flags
class TestTheControllersOwnScreens:
"""The schedule-off blank and the WiFi notice are drawn by the controller,
not dispatched to a plugin, so they end the scroll themselves."""
def test_the_schedule_off_blank_shows_none_of_the_ticker(self, dm, monkeypatch):
from src.display_controller import DisplayController
dm._scan_lag_bands = [(24, 48, 1)]
dm.set_scrolling_state(True, 1)
_push(dm, (255, 0, 0)) # the ticker's last frame
flags = _record_scrolling(dm, monkeypatch)
c = _core_screen_controller(dm)
DisplayController._blank_while_scheduled_off(c)
assert dm._presented[-1].getpixel((10, 30)) == (0, 0, 0)
assert flags == [False] # a static frame, not a freeze
assert not dm.is_currently_scrolling()
c._sleep_with_plugin_updates.assert_called_once_with(60)
def test_the_wifi_notice_shows_none_of_the_ticker(self, dm, monkeypatch):
from src.display_controller import DisplayController
dm._scan_lag_bands = [(24, 48, 1)]
dm.set_scrolling_state(True, 1)
_push(dm, (255, 0, 0))
flags = _record_scrolling(dm, monkeypatch)
c = _core_screen_controller(dm)
c._check_wifi_status_message.return_value = {"message": "x", "expires_at": 1e12}
c._display_wifi_status_message.side_effect = lambda _s: _push(dm, (0, 0, 255)) and True
assert DisplayController._show_wifi_notice(c) is True
assert dm._presented[-1].getpixel((10, 30)) == (0, 0, 255)
assert flags == [False]
def test_no_notice_leaves_the_scroll_alone(self, dm):
from src.display_controller import DisplayController
dm.set_scrolling_state(True, 2)
c = _core_screen_controller(dm)
c._check_wifi_status_message.return_value = None
assert DisplayController._show_wifi_notice(c) is False
assert dm.is_currently_scrolling()
assert dm._frame_hold == 2