a plugin API for content that changes while it scrolls (#696)

* perf(timing): say which render-thread work a late frame followed

The soak already says how often a moving frame reached the panel late, but
not what the render thread was doing just before it. Vegas does two kinds of
work there between frames -- building its strip (compose, extend) and, with
live elements, patching changed pixels into it -- and deciding whether either
is affordable needs their own numbers.

- FrameTimingRecorder.note_op(kind, nbytes) tags the next presented frame.
  Totals gain op_frames, late_op_frames, op_freezes and op_bytes per kind;
  aggregate() still takes frames without ops. The file schema is unchanged.
- Vegas tags compose and every strip extension (with the bytes it copied).
- frame_soak prints an "after work" table: frames, late %, freezes and MB
  moved per kind, only when something tagged its work.
- render_bench gains --strip-screens (Vegas-sized strips), --patch-bytes /
  --patch-every / --patch-where (in-place column writes, as a live element
  update does) and --extend-every-screens / --extend-width (append + trim on
  a fixed cadence that holds the strip's width).

No runtime behaviour changes: this is the measurement gate for live Vegas
elements.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(changelog): note the frame-op attribution and bench modes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* perf(scroll): build the strip's PIL image only when something reads it

Every Vegas strip extension rebuilt ScrollHelper.cached_image from
cached_array in full, twice (append, then trim), on the render thread:
Image.fromarray is 1.7ms for an 8,000px strip and 3.8ms for 20,000px on a
Pi 4 (measured on ledpi), about two thirds of an extension's render-thread
cost. Nothing on the frame path reads the image's pixels; every frame is cut
from the array.

cached_image is now a property. append_content and drop_scrolled_prefix
defer it; the first read builds it from the array it started with and keeps
it only if the strip has not changed meanwhile, so a sync push racing an
extension cannot leave a stale image cached. Assigning cached_image stores
exactly what was assigned, as before. has_strip() says whether there is a
strip without building its image; the helper's frame path, Vegas and the
adapter's scroll-cache invalidation use it. The strip is also no longer held
in memory twice.

In Vegas the image is now built only by a multi-display sync push.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 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>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Chuck
2026-09-30 20:59:16 -04:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 596809acc3
commit 56947298d6
25 changed files with 2578 additions and 31 deletions
+84
View File
@@ -15,6 +15,8 @@ import os
import sys
from src.deprecation import deprecated, warn_deprecated
from src.logging_config import get_logger
# Re-exported: a plugin may import it from here beside VegasDisplayMode.
from src.plugin_system.vegas_elements import VegasElement # noqa: F401
_shared_fallback_font_manager: Optional[Any] = None
@@ -986,6 +988,88 @@ class BasePlugin(ABC):
"""
return None
def get_vegas_elements(self) -> Optional[List[Any]]:
"""
Vegas content as live elements: named, fixed-width pieces the ticker
can swap in place while they are on screen.
get_vegas_content() hands the ticker pictures, and a picture already
in the scrolling strip keeps what it showed when it was drawn. Return
a list of ``VegasElement`` (src/plugin_system/vegas_elements.py)
instead and the ticker records where each one is; after your update()
it calls this again, compares each element's ``version`` (or pixels)
with what the strip holds, and swaps the changed ones in between two
frames -- a score changes on a card already crossing the panel, and
nothing next to it moves.
The contract:
- Called only on the ticker's background thread, under this plugin's
lock (never while update() runs), on a canvas of its own and told
its width (get_vegas_render_width()), like get_vegas_content().
- Called often -- after every update() while any of your elements is
on or ahead of the screen -- so it must be cheap when nothing
changed (cache images by version), idempotent, and must not fetch.
- A live element's width must not depend on its data: a redraw at a
different width is not swapped in (it shows the next time the
plugin comes round).
- Keys must be unique in the list and stable for the same logical
item.
Return None (the default) to use get_vegas_content(). A core older
than 3.8.0 never calls this, so keep get_vegas_content() working and
floor ``ledmatrix_min_version`` at 3.8.0 only if you rely on it.
Example (scoreboard)::
def get_vegas_elements(self):
return [VegasElement(key=f"game:{g['id']}",
image=self._card_for(g), # cached by fingerprint
version=self._fingerprint(g))
for g in self.games]
Returns:
A list of VegasElement, or None.
"""
return None
def redraw_vegas_element(self, key: str, width: int, height: int,
at: float) -> Optional[Any]:
"""
Redraw one live element for a moment in time, without the plugin lock.
Only for elements returned with ``refresh_hz > 0``: content that
changes with time rather than with data, such as an aircraft moving
between position reports. The ticker calls it up to that often while
the element is on or near the screen.
- Called WITHOUT this plugin's lock, possibly while update() runs, so
read only state that update() replaces in a single assignment (an
immutable snapshot), never state it mutates in place.
- ``at`` is the time.monotonic() at which the pixels are expected to
reach the panel; draw the element as it should look then.
- Return an image of exactly ``width`` x ``height``, or None to skip
this tick.
Returns:
PIL Image of exactly (width, height), or None.
"""
return None
def notify_vegas_data_changed(self) -> None:
"""
Tell the Vegas ticker this plugin's data changed outside update().
The ticker redraws a plugin's live elements when its update()
completes. Data that lands some other way -- a background thread, a
push callback -- calls this so the change reaches the screen without
waiting for the next update(). Cheap and safe from any thread.
"""
notify = getattr(getattr(self, 'plugin_manager', None),
'notify_data_changed', None)
if callable(notify):
notify(self.plugin_id)
def get_vegas_participation(self) -> str:
"""
How this plugin takes part in Vegas mode: ``'scroll'``, ``'pause'`` or
+53 -4
View File
@@ -16,7 +16,7 @@ import time
import threading
import types
from pathlib import Path
from typing import Dict, List, NamedTuple, Optional, Any, Tuple, Union
from typing import Callable, Dict, List, NamedTuple, Optional, Any, Tuple, Union
import logging
from src import display_watchdog
from src.exceptions import PluginError, ConfigError
@@ -197,6 +197,11 @@ class PluginManager:
# run_scheduled_updates_with_changes().
self._completed_updates: set = set()
self._completed_updates_lock = threading.Lock()
# Called with a plugin id the moment its data may have changed: its
# update() completed, or it called notify_vegas_data_changed(). See
# add_update_listener(). A tuple, replaced rather than mutated, so the
# worker can iterate it without a lock.
self._update_listeners: Tuple[Callable[[str], None], ...] = ()
# Config changes that found the plugin's lock busy, latest per plugin,
# with the instance they were meant for. See apply_config_change().
self._deferred_config_changes: Dict[str, Tuple[Any, Dict[str, Any]]] = {}
@@ -589,8 +594,8 @@ class PluginManager:
#: prefix rule would silently stop validating it.
#:
#: Read by: ``vegas_mode/plugin_adapter.py`` (``vegas_width_pct``,
#: ``vegas_overflow``) and ``base_plugin.py`` (``vegas_max_width_screens``,
#: ``vegas_participation``).
#: ``vegas_overflow``, ``vegas_live``) and ``base_plugin.py``
#: (``vegas_max_width_screens``, ``vegas_participation``).
#:
#: The list itself lives with the other core-owned per-plugin properties in
#: ``schema_manager.CORE_PLUGIN_PROPERTIES``, which the web save path also
@@ -1723,9 +1728,53 @@ class PluginManager:
return self.drain_completed_updates()
def _note_update_completed(self, plugin_id: str) -> None:
"""Record that a plugin's update() finished, for the next poll."""
"""Record that a plugin's update() finished, for the next poll.
Also tells the update listeners at once, so Vegas live elements are
redrawn the moment new data lands instead of at the next ~4s poll.
This runs while the plugin's lock is still held (see _finish), which
is what makes the listeners' contract strict.
"""
with self._completed_updates_lock:
self._completed_updates.add(plugin_id)
self._fire_update_listeners(plugin_id)
def add_update_listener(self, listener: Callable[[str], None]) -> None:
"""Call ``listener(plugin_id)`` whenever a plugin's data may have changed.
That is: its update() completed successfully, or it called
notify_vegas_data_changed(). The listener runs on the thread that
noticed -- the update worker, with the plugin's lock still held, or
the plugin's own thread -- so it must return at once and take no lock
a plugin could hold: record the id and hand off (a dict store, a
queue put). An exception from it is logged and does not reach the
plugin. Adding the same listener twice has no effect.
"""
# __dict__.get: tests build bare managers with PluginManager.__new__.
listeners = self.__dict__.get('_update_listeners', ())
if listener not in listeners:
self._update_listeners = listeners + (listener,)
def remove_update_listener(self, listener: Callable[[str], None]) -> None:
"""Stop calling a listener added with add_update_listener()."""
self._update_listeners = tuple(
fn for fn in self.__dict__.get('_update_listeners', ()) if fn != listener)
def notify_data_changed(self, plugin_id: str) -> None:
"""A plugin's data changed outside update(); tell the update listeners.
BasePlugin.notify_vegas_data_changed() lands here.
"""
self._fire_update_listeners(plugin_id)
def _fire_update_listeners(self, plugin_id: str) -> None:
for listener in self.__dict__.get('_update_listeners', ()):
try:
listener(plugin_id)
except Exception as exc: # pylint: disable=broad-except
self._warn_rate_limited(
"update-listener",
"An update listener failed for plugin %s: %r", plugin_id, exc)
def drain_completed_updates(self) -> List[str]:
"""Return and clear the plugin ids whose update() has since finished."""
+13 -1
View File
@@ -155,6 +155,18 @@ CORE_PLUGIN_PROPERTIES: Dict[str, Dict[str, Any]] = {
"Leave unset to use the plugin's own default."
),
},
# Read by vegas_mode/plugin_adapter.py (PluginAdapter.is_live_capable).
# No default, for the same reason: unset means on.
"vegas_live": {
"type": "boolean",
"title": "Update in the Vegas ticker",
"description": (
"Vegas mode: for a plugin with live elements (scores, the flight "
"map), change what is already scrolling when its data changes. "
"Off shows each card as it was when it was drawn, as before. "
"Leave unset for on."
),
},
}
#: The keys of CORE_PLUGIN_PROPERTIES that are Vegas tuning rather than plugin
@@ -162,7 +174,7 @@ CORE_PLUGIN_PROPERTIES: Dict[str, Dict[str, Any]] = {
#: PluginManager.CORE_OWNED_CONFIG_KEYS).
CORE_VEGAS_TUNING_KEYS = frozenset({
'vegas_width_pct', 'vegas_overflow', 'vegas_max_width_screens',
'vegas_participation',
'vegas_participation', 'vegas_live',
})
+3 -1
View File
@@ -19,7 +19,7 @@ from datetime import timedelta
import socket
import ssl
import urllib.error
from dataclasses import dataclass
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
@@ -84,6 +84,8 @@ class RenderResult:
fill_checked: bool = False
fill_ok: Optional[bool] = None # False only in strict mode
fill_extent: Optional[Tuple[float, float]] = None # (extent_x, extent_y)
# warnings worth printing that do not fail the result
notes: List[str] = field(default_factory=list)
@property
def size_label(self) -> str:
+307
View File
@@ -0,0 +1,307 @@
"""Offline checks for a plugin's live Vegas elements.
A plugin that implements ``get_vegas_elements()`` promises the ticker a few
things it cannot check for itself until they go wrong on a panel: unique,
stable keys; images at the display's height; the same width for the same key
until the data changes; the same result when nothing changed; and, for an
element with ``refresh_hz``, a ``redraw_vegas_element()`` that returns exactly
the size asked for, quickly. :func:`check_vegas_elements` exercises each of
those the way the Vegas ticker calls the hooks -- on a canvas of the plugin's
own, told its render width -- and says what failed.
``scripts/check_plugin.py`` runs it for every plugin that implements the hook.
See "Live Vegas elements" in docs/PLUGIN_API_REFERENCE.md.
"""
from __future__ import annotations
import time
from contextlib import contextmanager, nullcontext
from dataclasses import dataclass, field
from typing import Any, Iterator, List, Optional
from PIL import Image
#: A warm get_vegas_elements() slower than this holds the ticker's single
#: background worker, and the plugin's lock, for longer than it should.
SLOW_ELEMENTS_SECONDS = 0.2
#: A redraw_vegas_element() slower than this cannot keep up with a few Hz.
SLOW_REDRAW_SECONDS = 0.02
#: The narrowed render width the check also tries, as a share of the panel.
NARROW_PCT = 60
@dataclass
class VegasElementReport:
"""What :func:`check_vegas_elements` found."""
implemented: bool
elements: int = 0
live: int = 0
errors: List[str] = field(default_factory=list)
warnings: List[str] = field(default_factory=list)
@property
def ok(self) -> bool:
return not self.errors
def implements_vegas_elements(plugin: Any) -> bool:
"""Whether the plugin's class overrides BasePlugin.get_vegas_elements."""
from src.plugin_system.base_plugin import BasePlugin
method = getattr(type(plugin), 'get_vegas_elements', None)
return method is not None and method is not getattr(
BasePlugin, 'get_vegas_elements', None)
@contextmanager
def _as_vegas_canvas(plugin: Any, display_manager: Any, width: int) -> Iterator[None]:
"""Run a hook the way Vegas does: told its width, on a canvas of its own."""
plugin._vegas_render_width = width
try:
offscreen = getattr(display_manager, 'offscreen', None)
with offscreen(width) if offscreen is not None else nullcontext():
yield
finally:
plugin._vegas_render_width = None
def render_vegas_elements(plugin: Any, display_manager: Any,
width: Optional[int] = None) -> Optional[list]:
"""Call ``plugin.get_vegas_elements()`` as the Vegas ticker does."""
render_width = int(width or display_manager.width)
with _as_vegas_canvas(plugin, display_manager, render_width):
return plugin.get_vegas_elements()
def redraw_vegas_element(plugin: Any, display_manager: Any, key: str,
width: int, height: int, at: Optional[float] = None,
render_width: Optional[int] = None) -> Any:
"""Call ``plugin.redraw_vegas_element()`` as the Vegas ticker does."""
with _as_vegas_canvas(plugin, display_manager,
int(render_width or display_manager.width)):
return plugin.redraw_vegas_element(
key, width, height, time.monotonic() if at is None else at)
def _refresh_hz(element: Any) -> Optional[float]:
"""An element's refresh_hz as a number (None counts as 0), or None if it is not one."""
try:
return float(element.refresh_hz or 0.0)
except (TypeError, ValueError):
return None
def _usable(element: Any) -> bool:
"""A VegasElement the checks can read: a str key and an image."""
from src.plugin_system.vegas_elements import VegasElement
return (isinstance(element, VegasElement) and isinstance(element.key, str)
and bool(element.key) and isinstance(element.image, Image.Image))
def check_vegas_elements(plugin: Any, display_manager: Any) -> VegasElementReport:
"""Exercise a plugin's live-element hooks and report what breaks the contract.
Errors are what the ticker would refuse or show wrongly; warnings are what
it would cope with but should not have to (slow calls, an element wider
than the width the plugin was asked to render at).
"""
report = VegasElementReport(implemented=implements_vegas_elements(plugin))
if not report.implemented:
return report
from src.plugin_system.vegas_elements import VegasElement
height = int(display_manager.height)
full = int(display_manager.width)
def fetch(width: int, label: str):
started = time.perf_counter()
try:
result = render_vegas_elements(plugin, display_manager, width)
except Exception as exc: # noqa: BLE001 - a plugin hook can raise anything
report.errors.append(f"get_vegas_elements() raised {exc!r} ({label})")
return None, 0.0
return result, time.perf_counter() - started
first, _ = fetch(full, "full width")
if first is None:
if not report.errors:
report.warnings.append(
"get_vegas_elements() returned None: the ticker will use "
"get_vegas_content() instead")
return report
if not isinstance(first, (list, tuple)):
report.errors.append(
f"get_vegas_elements() returned {type(first).__name__}, expected a list")
return report
seen = set()
widths = {}
for index, element in enumerate(first):
where = f"element[{index}]"
if not isinstance(element, VegasElement):
report.errors.append(f"{where} is a {type(element).__name__}, not a VegasElement")
continue
key = element.key
if not isinstance(key, str) or not key:
report.errors.append(f"{where} has no key (a non-empty str is required)")
continue
where = f"element {key!r}"
if key in seen:
report.errors.append(f"{where} appears twice; keys must be unique")
continue
seen.add(key)
if not isinstance(element.image, Image.Image):
report.errors.append(f"{where} image is a {type(element.image).__name__}")
continue
if element.image.height != height:
report.errors.append(
f"{where} is {element.image.height}px tall; the display is {height}px")
if element.image.width <= 0 or element.image.height <= 0:
report.errors.append(f"{where} image is empty ({element.image.width}x"
f"{element.image.height})")
continue
if element.image.width > full:
report.warnings.append(
f"{where} is {element.image.width}px wide, wider than the "
f"{full}px it was asked to render at")
hz = _refresh_hz(element)
if hz is None:
report.errors.append(
f"{where} refresh_hz {element.refresh_hz!r} is not a number")
elif hz < 0:
report.errors.append(f"{where} has a negative refresh_hz")
report.elements += 1
if element.live:
report.live += 1
widths[key] = element.image.width
if report.errors:
return report
# The same data twice must give the same keys, widths and versions: the
# ticker redraws on every update and swaps in only what changed.
second, warm = fetch(full, "second call")
if second is not None and not isinstance(second, (list, tuple)):
report.errors.append(
f"get_vegas_elements() returned {type(second).__name__} on a second "
"call, expected a list")
elif isinstance(second, (list, tuple)):
again = {e.key: e for e in second if _usable(e)}
for element in first:
other = again.get(element.key)
if other is None:
report.errors.append(
f"element {element.key!r} disappeared on a second call with "
"no new data")
continue
if element.live and other.image.width != element.image.width:
report.errors.append(
f"element {element.key!r} changed width with no new data "
f"({element.image.width} -> {other.image.width}px); a live "
"element's width must not depend on when it is drawn")
if element.version is not None and other.version != element.version \
and not _refresh_hz(element):
report.warnings.append(
f"element {element.key!r} changed version with no new data; "
"every update will redraw it")
if warm > SLOW_ELEMENTS_SECONDS:
report.warnings.append(
f"get_vegas_elements() took {warm * 1000:.0f}ms with nothing new "
f"(over {SLOW_ELEMENTS_SECONDS * 1000:.0f}ms); cache what has not "
"changed")
narrow = max(1, full * NARROW_PCT // 100)
if narrow < full:
narrowed, _ = fetch(narrow, f"{NARROW_PCT}% width")
if isinstance(narrowed, (list, tuple)):
for element in narrowed:
if isinstance(element, VegasElement) and isinstance(element.image, Image.Image) \
and element.image.width > narrow:
report.warnings.append(
f"element {element.key!r} is {element.image.width}px wide at "
f"a {narrow}px render width; read get_vegas_render_width() "
"or display_manager.width when sizing it")
break
has_redraw = type(plugin).redraw_vegas_element is not _base_redraw()
for element in first:
hz = _refresh_hz(element) or 0.0
if not (element.live and hz > 0):
continue
if not has_redraw:
report.warnings.append(
f"element {element.key!r} asks for {hz:g}Hz but "
"redraw_vegas_element() is not implemented; the ticker re-runs "
"get_vegas_elements() under the plugin's lock instead")
continue
w, h = element.image.width, element.image.height
started = time.perf_counter()
try:
redrawn = redraw_vegas_element(plugin, display_manager, element.key, w, h)
except Exception as exc: # noqa: BLE001 - a plugin hook can raise anything
report.errors.append(f"redraw_vegas_element({element.key!r}) raised {exc!r}")
continue
took = time.perf_counter() - started
if redrawn is not None:
if not isinstance(redrawn, Image.Image):
report.errors.append(
f"redraw_vegas_element({element.key!r}) returned "
f"{type(redrawn).__name__}, expected an Image or None")
elif redrawn.size != (w, h):
report.errors.append(
f"redraw_vegas_element({element.key!r}) returned "
f"{redrawn.width}x{redrawn.height}, asked for {w}x{h}")
if took > SLOW_REDRAW_SECONDS:
report.warnings.append(
f"redraw_vegas_element({element.key!r}) took {took * 1000:.1f}ms "
f"(over {SLOW_REDRAW_SECONDS * 1000:.0f}ms); the ticker will "
"slow its refresh")
return report
def _base_redraw():
from src.plugin_system.base_plugin import BasePlugin
return BasePlugin.redraw_vegas_element
def check_plugin_vegas_elements(plugin_id: str, plugin_dir: Any, config: dict,
mock_data: dict, width: int, height: int,
run_update: bool = True) -> VegasElementReport:
"""Load a plugin from its directory at one panel size and check its elements.
What ``scripts/check_plugin.py`` runs: the plugin gets the same mocked
managers as the rendering harness, and its update() is run first (a
network error there is tolerated, as in the harness) so the elements are
drawn from data rather than from an empty start.
"""
from pathlib import Path
from src.plugin_system.testing.harness import _TOLERATED_UPDATE_ERRORS, _instantiate
from src.plugin_system.testing.loading import load_manifest
from src.plugin_system.testing.visual_display_manager import VisualTestDisplayManager
plugin_dir = Path(plugin_dir)
display_manager = VisualTestDisplayManager(width=width, height=height)
try:
plugin = _instantiate(plugin_id, load_manifest(plugin_dir), plugin_dir,
config, mock_data, display_manager)
except Exception as exc: # noqa: BLE001 - the matrix run reports load errors
report = VegasElementReport(implemented=False)
report.warnings.append(f"not checked: the plugin did not load ({exc!r})")
return report
report = VegasElementReport(implemented=implements_vegas_elements(plugin))
if not report.implemented:
return report
if run_update:
try:
plugin.update()
except _TOLERATED_UPDATE_ERRORS as exc:
report.warnings.append(f"update() had no network ({exc!r}); checked "
"with whatever data the plugin starts with")
except Exception as exc: # noqa: BLE001 - a plugin's update can raise anything
report.errors.append(f"update() raised {exc!r}")
return report
checked = check_vegas_elements(plugin, display_manager)
checked.warnings[:0] = report.warnings
return checked
+73
View File
@@ -0,0 +1,73 @@
"""Live elements: Vegas content that can change while it is on screen.
A plugin's ``get_vegas_content()`` hands the Vegas ticker pictures, and the
ticker bakes them into its strip: a score drawn when the plugin's turn was
prefetched scrolls past with that score, however many goals are scored while
it crosses the panel. A plugin that returns **elements** instead gives each
picture a name and a fixed width. The ticker then keeps track of where each
one is in the strip, and when the plugin's data changes it asks for just the
changed elements and swaps their pixels in place -- on screen included,
between two frames, without anything next to them moving.
A plugin opts in by implementing ``BasePlugin.get_vegas_elements()``, and, for
content that changes with time rather than with data (an aircraft moving
between position reports), ``BasePlugin.redraw_vegas_element()``. See "Live
Vegas elements" in docs/PLUGIN_API_REFERENCE.md.
Added in LEDMatrix 3.8.0. Import it guarded, so the plugin still loads on an
older core (which never calls the hooks)::
try:
from src.plugin_system.vegas_elements import VegasElement
except ImportError: # core older than 3.8.0
VegasElement = None
def get_vegas_elements(self):
if VegasElement is None:
return None
return [VegasElement(key=f"game:{g['id']}", image=self._card(g),
version=self._fingerprint(g))
for g in self._games]
"""
from dataclasses import dataclass
from typing import Hashable, Optional
from PIL import Image
@dataclass(frozen=True, eq=False)
class VegasElement:
"""One named, fixed-width piece of a plugin's Vegas content.
Attributes:
key: Names the element across redraws, unique within one list the
plugin returns: ``"game:nfl:401547417"``, ``"sep:0:nfl"``,
``"map"``. The ticker matches a redraw to the pixels already in
its strip by this key, so it must stay the same for the same
logical thing and must not be reused for a different one.
image: The element as drawn now, at the display's height. For a
``live`` element its width is fixed for as long as the key is on
the strip: a redraw at a different width is never swapped in (it
appears the next time the plugin comes round instead), because
nothing on screen may move. Draw live elements at a width that
does not depend on the data -- a fixed card width, not the
width of the text.
version: Anything hashable that changes exactly when the pixels
would, such as the tuple of fields the element draws. The ticker
skips work for an unchanged version. ``None`` means "compare the
pixels", which is always correct and costs a checksum.
live: False places the element exactly as plain content is placed
(trimmed to its ink, never refreshed): separators, decoration.
refresh_hz: More than 0 asks for ``redraw_vegas_element()`` about
this often while the element is on or near the screen, for
content that changes with time rather than with data. The ticker
caps the rate (``vegas_scroll.live_max_hz``, 1 Hz on a display
without the rebuilt rgbmatrix binding) and slows it for an
element that is slow to draw.
"""
key: str
image: Image.Image
version: Optional[Hashable] = None
live: bool = True
refresh_hz: float = 0.0