feat(vegas): render every plugin's ticker content off the render thread

The plugin-facing canvas (DisplayManager.image, draw, matrix) was one
shared object, so any plugin whose Vegas content needed it -- display
capture, scroll-content generation, narrowed rendering -- was deferred to
the render thread and fetched there one at a time. On hdpi that is most
plugins, and each fetch stalled the scroll: news ~320ms, hockey ~660ms,
in bursts whenever the strip extended.

DisplayManager.offscreen() gives the calling thread a canvas of its own.
image, draw and matrix are now properties that resolve to the thread's
surface while it is inside the block and to the shared canvas otherwise,
so the ~100 existing uses become thread-correct unchanged. Inside,
update_display(), the hardware half of clear(), and set_scrolling_state()/
set_frame_hold() are inert, so a plugin drawn for Vegas can neither reach
the panel nor re-pace the live scroll. render_size() is rebuilt on it.
capture_mode() now restores the previous state instead of clearing it,
so it cannot end suppression inside an offscreen block.

The adapter draws every path on its own canvas (_isolated_canvas) and
drops the copy-and-restore of the shared image, which from a background
thread would have written a stale frame back over the render loop's.
Background fetches take the plugin's update/display lock, waiting up to
2s for a running update() and skipping the plugin that round otherwise;
Vegas never took that lock, so render-thread captures already raced
update(). A background fetch that comes back empty is no longer queued
for the render thread.

vegas_scroll.offscreen_prefetch (default true) restores the old deferred
path when false. See docs/OFFSCREEN_RENDERING.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Chuck
2026-09-24 09:31:27 -04:00
co-authored by Claude Opus 5.5
parent 430e2312f8
commit 13b5264d11
9 changed files with 690 additions and 89 deletions
@@ -18,7 +18,7 @@ _draw_bdf_text, get_font_height, get_text_width, draw_text,
draw_text_with_icons, draw_weather_icon (and the _draw_sun/_draw_cloud/
_draw_rain/_draw_snow/_draw_storm family), format_date_with_ordinal,
capture_mode, set_scrolling_state, is_currently_scrolling,
process_deferred_updates, update_display, render_size. A behavior
process_deferred_updates, update_display, render_size, offscreen. A behavior
change to any of those in DisplayManager must be mirrored here, or
plugin visual tests will pass against stale behavior.
"""
@@ -237,11 +237,38 @@ class VisualTestDisplayManager:
wraps every off-screen content fetch in this context, so the harness
must provide it for that code path to be exercisable in tests.
"""
was_active = self._capture_mode_active
self._capture_mode_active = True
try:
yield
finally:
self._capture_mode_active = False
self._capture_mode_active = was_active
@contextmanager
def offscreen(self, width: Optional[int] = None, height: Optional[int] = None):
"""
Interface parity with DisplayManager.offscreen().
Vegas mode's PluginAdapter draws every plugin on a canvas of its own.
The real display manager keeps that canvas per thread; the harness is
single-threaded, so it swaps a fresh canvas in and restores the old one,
which is all a test can observe.
"""
prev = (self.image, self.draw, self._width, self._height,
self.matrix, self._capture_mode_active)
target_w = max(1, min(int(width), self._width)) if width else self._width
target_h = max(1, min(int(height), self._height)) if height else self._height
try:
self._width, self._height = target_w, target_h
self.matrix = _MatrixProxy(target_w, target_h)
self.image = Image.new('RGB', (target_w, target_h), (0, 0, 0))
self.draw = ImageDraw.Draw(self.image)
self.draw.fontmode = "1" # Match production: 1-bit text, so goldens show what the panel shows.
self._capture_mode_active = True
yield self
finally:
(self.image, self.draw, self._width, self._height,
self.matrix, self._capture_mode_active) = prev
def draw_text(self, text: str, x: Optional[int] = None, y: Optional[int] = None,
color: Tuple[int, int, int] = (255, 255, 255), small_font: bool = False,