Files
LEDMatrix/test/fixtures/plugins/vegas-live-stub/manager.py
T
ChuckandClaude Opus 5.5 701c220ad0 feat(vegas): live elements -- a plugin API for content that changes while it scrolls
Vegas bakes each plugin's pictures into one strip, so a card already on its
way across the panel keeps what it showed when it was drawn. This adds the
API and bookkeeping for content that can be updated in place; the worker
that redraws and swaps it follows separately. No shipped plugin implements
the hook yet, so nothing changes for users.

Plugin API (core 3.8.0), all no-ops by default:
- BasePlugin.get_vegas_elements() -> [VegasElement(key, image, version,
  live, refresh_hz)]: named, fixed-width pieces of Vegas content.
- BasePlugin.redraw_vegas_element(key, width, height, at): a lock-free
  redraw for content that changes with time.
- BasePlugin.notify_vegas_data_changed(): data that lands outside update().
- src/plugin_system/vegas_elements.py (VegasElement, re-exported from
  base_plugin).

Core:
- PluginAdapter asks a plugin that implements the hook for elements on the
  background fetch only (under its lock, on its own canvas); every other
  path keeps get_vegas_content(). Live elements are pinned (padded with
  content_padding, never trimmed), tagged with their key, digest and data
  epoch in Image.info so the existing cache and group plumbing carry them
  unchanged, and untagged if a width budget crops them.
- RenderPipeline records where each live element lands (ElementRecord), in
  absolute strip columns a trim does not move; the block-start arithmetic
  is shared with the STATIC markers.
- PluginManager update listeners (add/remove_update_listener,
  notify_data_changed): told the moment update() completes, not at the
  next ~4s Vegas poll. The coordinator uses one to move each plugin's data
  epoch on.
- vegas_scroll.live_refresh (kill switch), live_max_hz, live_min_interval,
  live_lead_screens; per-plugin core-owned vegas_live. Live elements are
  off under multi-display sync, in swap mode and with offscreen_prefetch off.
- scripts/check_plugin.py checks the element contract
  (src/plugin_system/testing/vegas.py); test/fixtures/plugins/vegas-live-stub
  is a working example.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 18:36:39 -04:00

135 lines
5.5 KiB
Python

"""
Vegas live-element stub.
A fixture, not a product: it exercises every part of the live-element contract
(src/plugin_system/vegas_elements.py) with content whose changes are easy to
see and to assert on, and nothing else -- no fonts, no network.
- ``card:<n>`` -- fixed-width cards. Each draws the bits of ``tick + n`` as
lit bars, so every update() changes every card's pixels but never its width.
- ``sep`` -- a separator, ``live=False``: placed and trimmed like plain content.
- ``map`` -- one full-render-width element with ``refresh_hz``: a dot that
moves across it with time, drawn by redraw_vegas_element() from state
published in a single attribute store, so it is safe to call without the
plugin's lock.
update() only advances the tick. display() draws the tick's bars full screen
so the plugin also passes the ordinary rendering harness.
"""
import time
from typing import List, Optional, Tuple
from PIL import Image, ImageDraw
from src.plugin_system.base_plugin import BasePlugin
try:
from src.plugin_system.vegas_elements import VegasElement
except ImportError: # core older than 3.8.0: the hooks are never called
VegasElement = None
_COLOURS = [(255, 64, 64), (64, 255, 64), (64, 128, 255), (255, 200, 0),
(255, 64, 255), (0, 220, 220)]
class VegasLiveStub(BasePlugin):
"""Keyed cards, a separator and an animated element for the Vegas ticker."""
def __init__(self, plugin_id, config, display_manager, cache_manager, plugin_manager):
super().__init__(plugin_id, config, display_manager, cache_manager, plugin_manager)
self.tick = 0
# Everything the lock-free redraw reads, published in one store.
self._snapshot: Tuple[int, float] = (0, time.monotonic())
# -- data -------------------------------------------------------------
def update(self) -> None:
self.tick += 1
self._snapshot = (self.tick, time.monotonic())
# -- drawing ----------------------------------------------------------
def _bars(self, image: Image.Image, value: int, colour, box) -> None:
x0, y0, x1, y1 = box
draw = ImageDraw.Draw(image)
draw.rectangle([x0, y0, x1, y1], outline=colour)
bits = 8
span = max(1, (x1 - x0 - 2) // bits)
for bit in range(bits):
if value >> bit & 1:
left = x0 + 1 + bit * span
draw.rectangle([left, y0 + 2, left + max(0, span - 2), y1 - 2],
fill=colour)
def _card_width(self) -> int:
configured = int(self.config.get('card_width', 0) or 0)
if configured > 0:
return configured
return max(24, min(64, self.get_vegas_render_width() // 4))
def _card(self, index: int, tick: int) -> Image.Image:
width, height = self._card_width(), self.display_manager.height
image = Image.new('RGB', (width, height), (0, 0, 0))
self._bars(image, tick + index, _COLOURS[index % len(_COLOURS)],
(0, 0, width - 1, height - 1))
return image
def _map(self, width: int, height: int, at: float) -> Image.Image:
tick, _published = self._snapshot
image = Image.new('RGB', (width, height), (0, 0, 16))
draw = ImageDraw.Draw(image)
draw.rectangle([0, 0, width - 1, height - 1], outline=(40, 40, 80))
speed = float(self.config.get('dot_speed', 20) or 0)
x = int(at * speed) % max(1, width - 4) + 2
y = 2 + tick % max(1, height - 4)
draw.rectangle([x - 1, y - 1, x + 1, y + 1], fill=(255, 255, 255))
return image
def _dot_column(self, width: int, at: float) -> int:
speed = float(self.config.get('dot_speed', 20) or 0)
return int(at * speed) % max(1, width - 4) + 2
def display(self, force_clear: bool = False) -> bool:
width, height = self.display_manager.width, self.display_manager.height
self.display_manager.clear()
self._bars(self.display_manager.image, self.tick, _COLOURS[0],
(0, 0, width - 1, height - 1))
self.display_manager.update_display()
return True
# -- Vegas ------------------------------------------------------------
def get_vegas_content(self) -> Optional[List[Image.Image]]:
cards = int(self.config.get('cards', 6))
return [self._card(i, self.tick) for i in range(cards)] or None
def get_vegas_elements(self):
if VegasElement is None:
return None
tick = self.tick
elements = []
for i in range(int(self.config.get('cards', 6))):
elements.append(VegasElement(
key=f"card:{i}", image=self._card(i, tick),
version=(tick, i, self._card_width())))
if i == 0:
separator = Image.new('RGB', (4, self.display_manager.height), (0, 0, 0))
ImageDraw.Draw(separator).rectangle(
[1, 0, 2, self.display_manager.height - 1], fill=(90, 90, 90))
elements.append(VegasElement(key="sep", image=separator, live=False))
hz = float(self.config.get('map_hz', 4) or 0)
if hz > 0:
width, height = self.get_vegas_render_width(), self.display_manager.height
now = time.monotonic()
elements.append(VegasElement(
key="map", image=self._map(width, height, now),
version=(tick, width, self._dot_column(width, now)),
refresh_hz=hz))
return elements
def redraw_vegas_element(self, key, width, height, at):
if key != "map":
return None
return self._map(width, height, at)