Compare commits

...
20 Commits
Author SHA1 Message Date
Chuck 55ce13bebe Merge remote-tracking branch 'origin/main' into vegas-live/strip-in-place 2026-10-01 08:37:22 -04:00
Chuck 53f4f1fdbf Merge branch 'vegas-live/pr7-dev-server-vegas' into vegas-live/strip-in-place 2026-09-30 21:25:34 -04:00
Chuck 1a1a6f5c65 Merge branch 'vegas-live/pr6-live-in-ticker' into vegas-live/pr7-dev-server-vegas 2026-09-30 21:25:32 -04:00
Chuck 0b3fa712d0 Merge branch 'vegas-live/pr4-sports-cards' into vegas-live/pr6-live-in-ticker 2026-09-30 21:25:30 -04:00
ChuckandClaude Opus 5.5 fb9820b78e fix: review follow-ups on the shared live-card layer
- The reused Vegas renderer always gets the current rankings, empty
  included, so ranks cleared since are not kept drawn.
- render_plugin.py: --timeline refuses --no-live (a timeline shows live
  elements changing), --timeline/--no-live need --vegas, and the Vegas
  paths create the output's directory like the display path does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 21:25:29 -04:00
Chuck ad47f89066 Merge remote-tracking branch 'origin/main' into vegas-live/pr4-sports-cards
# Conflicts:
#	CHANGELOG.md
#	src/plugin_system/testing/vegas.py
#	src/vegas_mode/render_pipeline.py
#	test/test_harness_vegas_elements.py
2026-09-30 21:07:37 -04:00
ChuckandClaude Opus 5.5 3973f0c3c5 refactor(scroll): no assert in _extended_strip
An assert vanishes under python -O (Codacy); a real check says the same.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 20:26:12 -04:00
Chuck 04cad60259 Merge branch 'vegas-live/pr7-dev-server-vegas' into vegas-live/strip-in-place 2026-09-30 20:25:58 -04:00
Chuck 22b88c4677 Merge branch 'vegas-live/pr6-live-in-ticker' into vegas-live/pr7-dev-server-vegas 2026-09-30 20:25:56 -04:00
Chuck a51305bb4f Merge branch 'vegas-live/pr4-sports-cards' into vegas-live/pr6-live-in-ticker 2026-09-30 20:25:54 -04:00
ChuckandClaude Opus 5.5 d4f828d189 refactor(sports): a default _determine_game_type on SportsScrollDisplay
render_vegas_card looked the method up with getattr and a None default, which
static analysis (Codacy) reports as calling something that may not be
callable. The base class now has the default -- the card type from the game's
state -- and the plugins that define their own override it as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 20:25:52 -04:00
ChuckandClaude Opus 5.5 ec1aa34f40 perf(scroll): extend and trim the Vegas strip in place
Every strip extension rebuilt the whole strip (np.concatenate: 2-2.6 ms for
a 10-14k px strip at 512x64 on a Pi 4) and every trim copied what was left
(1.2-1.8 ms), on the render thread. With ~4 ms of slack per refresh, every
extension frame on hdpi missed its refresh (5/5 in each soak run).

The strip now lives in a buffer with spare room; cached_array is a view of
its live columns. An append writes only the new columns (~0.2 ms), a trim
only moves the view's start, and the one full copy happens when the buffer
is reallocated (STRIP_SPARE_FACTOR 3: about once every two strip-lengths
scrolled). A cached_array set from outside -- the multi-display follower's
read-only one, create_scrolling_image's -- is never written through, and a
new strip lets the old buffer go. last_copy_bytes says what was copied, and
the Vegas frame-timing attribution reports that instead of the whole strip.

test_scroll_helper_in_place.py: the buffer is reused and only new columns
copied, trims copy nothing, reallocation when the room runs out, outside
arrays untouched, and random appends/trims/patches/scrolling checked frame
by frame against the old copying strip (mutation-checked).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 20:17:31 -04:00
ChuckandClaude Opus 5.5 f96c815e0e feat(dev): preview a plugin's Vegas strip in the dev server
The dev server's View selector gains "Vegas strip (live elements)" and
"Vegas strip (plain Vegas content)": the plugin's block of the Vegas ticker,
laid out by the ticker's own code (render_vegas_strip, as render_plugin.py
--vegas uses), with its live elements listed. /api/render takes
"vegas": "live" | "plain"; the display view is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 20:14:01 -04:00
ChuckandClaude Opus 5.5 559ef14a5c feat(vegas): keep live games in the ticker by default
display.vegas_scroll.live_in_ticker now defaults to true: through a live
game the marquee keeps running and the live scoreboard takes extra turns in
it -- its cards updating in place while they scroll -- instead of the ticker
giving way to the full-screen scoreboard.

The new default would reach nobody on its own: every existing config holds
an explicit false copied from the template (there was no control for it),
and the template merge only adds missing keys. ConfigManager therefore turns
a stored false on once, with a backup, and records live_in_ticker_migrated
so a false chosen afterwards stays. The marker is never in the template.

A "Keep live games in the ticker" checkbox under Vegas mode sets it. Tests
that pin the full-screen takeover now say live_in_ticker=false.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 18:36:39 -04:00
ChuckandClaude Opus 5.5 8cce532ea9 feat(sports): live Vegas cards for the scoreboards (shared layer)
One live element per game, drawn only when what the card shows changes, so
a score changes on a card already crossing the panel. The shared part, so
each scoreboard adopts it in a few lines:

- src/common/sports_vegas.py: game_key, game_fingerprint (the whole game
  dict, frozen: no drawn field can be missed), dedupe_games, VegasCardCache,
  StickyOdds (odds a live poll left out stay drawn), finished_games /
  with_finished_games (a game that just went final keeps its card, after its
  league's live games; one a heuristic only judged over keeps its live
  state, so a tied end of regulation never shows FINAL early).
- SportsScrollDisplay.make_vegas_renderer() is the override point;
  build_vegas_elements() and SportsScrollDisplayManager
  .get_vegas_elements_for() do the rest. A card's version includes its
  teams' ranks, which the renderer draws from the rankings cache.
- SportsLiveSharedMixin._record_finished_game() / finished_games_snapshot():
  held for FINISHED_GAME_TTL after it leaves the live list.

A sport that does not implement make_vegas_renderer keeps its ordinary Vegas
content, so no scoreboard changes until it opts in.

scripts/render_plugin.py --vegas renders a plugin's Vegas block as the
ticker lays it out, and --timeline stacks it at successive moments as
the ticker would update it in place; the join is now
render_pipeline.join_plugin_rows().

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 18:36:39 -04:00
ChuckandClaude Opus 5.5 876130e93f feat(vegas): live elements update in place while they scroll
One background worker (src/vegas_mode/live_worker.py) redraws a plugin's
live elements when its data epoch moves on (update listener) or on their
refresh_hz, nearest the screen first, and hands changed pixels lock-free to
the render thread, which copies them into the strip between frames
(RenderPipeline.apply_live_patches, ScrollHelper.patch_columns): at most
four patches or two screens of bytes a frame, no drawing or locks there.
The worker takes over group prefetch once a live element is placed, runs
inside the render gate, and is supervised. Update tick 1s while live
elements exist. Web UI switch for live_refresh. OFFSCREEN_RENDERING.md
describes what was built and why SegmentStrip was not needed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 18:36:39 -04:00
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
ChuckandClaude Opus 5.5 1160eb5efe 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>
2026-09-30 18:36:39 -04:00
ChuckandClaude Opus 5.5 6efea8c19a docs(changelog): note the frame-op attribution and bench modes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 18:36:38 -04:00
ChuckandClaude Opus 5.5 1252df9bad 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>
2026-09-30 18:36:38 -04:00
4 changed files with 238 additions and 14 deletions
+9
View File
@@ -418,6 +418,15 @@ read any of them:
### Scrolling ### Scrolling
- A Vegas strip extension no longer costs a late frame. Appending the next
group rebuilt the whole strip (`np.concatenate`, 2-2.6ms for a 10-14k px
strip at 512x64 on a Pi 4) and trimming copied what was left (1.2-1.8ms),
so on hdpi every extension frame missed its refresh. The strip now lives in
a buffer with spare room (`ScrollHelper.STRIP_SPARE_FACTOR`): an append
writes only the new columns (~0.2ms), a trim only moves the start, and the
one full copy happens when the buffer is reallocated, about once every two
strip-lengths scrolled. A strip set from outside (the multi-display
follower's) is never written through.
- A Vegas strip extension costs the render thread about a third of what it - A Vegas strip extension costs the render thread about a third of what it
did. Appending the next group and trimming what has scrolled past each did. Appending the next group and trimming what has scrolled past each
rebuilt the strip's PIL image from its numpy array in full rebuilt the strip's PIL image from its numpy array in full
+71 -8
View File
@@ -114,6 +114,18 @@ class ScrollHelper:
self.cached_image = None # see the property below self.cached_image = None # see the property below
self.cached_array: Optional[np.ndarray] = None # Numpy array cache for fast operations self.cached_array: Optional[np.ndarray] = None # Numpy array cache for fast operations
self.total_scroll_width = 0 self.total_scroll_width = 0
# An extended strip lives in a buffer with spare room after it, and
# cached_array is a view of the buffer's live columns: an append writes
# only the new columns, and a trim only moves the view's start. See
# append_content. _strip_view is the view this helper last made; a
# cached_array that is anything else was set from outside and is not
# written through.
self._strip_buffer: Optional[np.ndarray] = None
self._strip_view: Optional[np.ndarray] = None
self._strip_start = 0
#: Bytes the last append_content / drop_scrolled_prefix copied, for
#: frame-timing attribution (src/common/frame_timing.py note_op).
self.last_copy_bytes = 0
# Pre-allocated buffer for output frame (reused to avoid allocations) # Pre-allocated buffer for output frame (reused to avoid allocations)
self._frame_buffer: Optional[np.ndarray] = None self._frame_buffer: Optional[np.ndarray] = None
@@ -249,6 +261,7 @@ class ScrollHelper:
self.total_scroll_width = 0 self.total_scroll_width = 0
self.cached_image = Image.new('RGB', (self.display_width, self.display_height), (0, 0, 0)) self.cached_image = Image.new('RGB', (self.display_width, self.display_height), (0, 0, 0))
self.cached_array = np.array(self.cached_image) self.cached_array = np.array(self.cached_image)
self._forget_strip_buffer()
self.scroll_position = 0.0 self.scroll_position = 0.0
self.total_distance_scrolled = 0.0 self.total_distance_scrolled = 0.0
self.scroll_complete = False self.scroll_complete = False
@@ -284,6 +297,7 @@ class ScrollHelper:
self.cached_image = full_image self.cached_image = full_image
# Convert to numpy array for fast operations # Convert to numpy array for fast operations
self.cached_array = np.array(full_image) self.cached_array = np.array(full_image)
self._forget_strip_buffer()
actual_image_width = full_image.width actual_image_width = full_image.width
self.total_scroll_width = actual_image_width self.total_scroll_width = actual_image_width
@@ -712,12 +726,12 @@ class ScrollHelper:
addition.paste(img, (x, 0)) addition.paste(img, (x, 0))
x += img.width + element_gap x += img.width + element_gap
# numpy concatenate, and no conversion back: the strip can be tens of # Written into the spare room after the strip, when there is some:
# thousands of columns wide and this runs on the render path. The PIL # the strip can be tens of thousands of columns wide and this runs on
# image is built from the array only if something reads it (see the # the render thread, where copying all of it (2-3 ms at 512x64 on a
# cached_image property). # Pi 4) cost the frame after every extension. The PIL image is built
self.cached_array = np.concatenate( # from the array only if something reads it (see cached_image).
(self.cached_array, np.array(addition)), axis=1) self.cached_array = self._extended_strip(np.asarray(addition))
self._defer_image() self._defer_image()
self.total_scroll_width = self.cached_array.shape[1] self.total_scroll_width = self.cached_array.shape[1]
self.scroll_complete = False self.scroll_complete = False
@@ -729,6 +743,44 @@ class ScrollHelper:
) )
return True return True
#: Room an extended strip's buffer is given, as a multiple of what it
#: holds when (re)allocated. Trims free columns at the front and appends
#: use them at the back, so with 3x the buffer is reallocated -- the one
#: full copy -- about once every two strip-lengths scrolled.
STRIP_SPARE_FACTOR = 3.0
def _extended_strip(self, addition: np.ndarray) -> np.ndarray:
"""The strip with ``addition`` after it, written in place when it fits."""
live = self.cached_array
if live is None:
# append_content builds a first strip itself and never comes here.
raise RuntimeError("no strip to extend")
width = live.shape[1]
added = addition.shape[1]
buffer = self._strip_buffer
if (live is self._strip_view and buffer is not None
and self._strip_start + width + added <= buffer.shape[1]):
end = self._strip_start + width
buffer[:, end:end + added] = addition
self.last_copy_bytes = addition.nbytes
else:
total = width + added
buffer = np.empty((live.shape[0], max(total + 1, int(total * self.STRIP_SPARE_FACTOR)))
+ live.shape[2:], dtype=live.dtype)
buffer[:, :width] = live
buffer[:, width:total] = addition
self._strip_buffer = buffer
self._strip_start = 0
self.last_copy_bytes = live.nbytes + addition.nbytes
self._strip_view = buffer[:, self._strip_start:self._strip_start + width + added]
return self._strip_view
def _forget_strip_buffer(self) -> None:
"""A new strip replaces the extended one: let its buffer go."""
self._strip_buffer = None
self._strip_view = None
self._strip_start = 0
def drop_scrolled_prefix(self, keep_before: int = 0) -> int: def drop_scrolled_prefix(self, keep_before: int = 0) -> int:
""" """
Discard columns that have already scrolled past, to bound memory. Discard columns that have already scrolled past, to bound memory.
@@ -766,9 +818,18 @@ class ScrollHelper:
if cut <= 0: if cut <= 0:
return 0 return 0
# .copy() so the original buffer is released rather than kept alive by if self.cached_array is self._strip_view:
# a numpy view. The PIL image is deferred, as in append_content. # Only the view's start moves; the columns behind it are reused
# when the buffer is next reallocated (append_content).
self._strip_view = self.cached_array[:, cut:]
self._strip_start += cut
self.cached_array = self._strip_view
self.last_copy_bytes = 0
else:
# Not a strip this helper extended: .copy() so the original
# buffer is released rather than kept alive by a view.
self.cached_array = self.cached_array[:, cut:].copy() self.cached_array = self.cached_array[:, cut:].copy()
self.last_copy_bytes = self.cached_array.nbytes
self._defer_image() self._defer_image()
self.total_scroll_width = self.cached_array.shape[1] self.total_scroll_width = self.cached_array.shape[1]
self.scroll_position -= cut self.scroll_position -= cut
@@ -881,6 +942,7 @@ class ScrollHelper:
# Convert to numpy array for fast operations (required for get_visible_portion) # Convert to numpy array for fast operations (required for get_visible_portion)
self.cached_array = np.array(image) self.cached_array = np.array(image)
self._forget_strip_buffer()
# Update scroll width # Update scroll width
self.total_scroll_width = image.width self.total_scroll_width = image.width
@@ -1138,6 +1200,7 @@ class ScrollHelper:
""" """
self.cached_image = None self.cached_image = None
self.cached_array = None self.cached_array = None
self._forget_strip_buffer()
self.total_scroll_width = 0 self.total_scroll_width = 0
self.scroll_position = 0.0 self.scroll_position = 0.0
self.total_distance_scrolled = 0.0 self.total_distance_scrolled = 0.0
+11 -5
View File
@@ -466,6 +466,11 @@ class RenderPipeline:
if note is not None: if note is not None:
note(kind, nbytes) note(kind, nbytes)
def _copied_bytes(self) -> int:
"""Bytes the scroll helper's last append or trim copied (the strip, if it cannot say)."""
copied = getattr(self.scroll_helper, 'last_copy_bytes', None)
return int(copied) if isinstance(copied, int) else self._strip_nbytes()
def _strip_nbytes(self) -> int: def _strip_nbytes(self) -> int:
array = self.scroll_helper.cached_array array = self.scroll_helper.cached_array
return int(array.nbytes) if array is not None else 0 return int(array.nbytes) if array is not None else 0
@@ -660,7 +665,7 @@ class RenderPipeline:
element_gap=0, element_gap=0,
) )
if appended: if appended:
self._note_op('extend', self._strip_nbytes()) self._note_op('extend', self._copied_bytes())
logger.info( logger.info(
"[%s] Appended deferred content: strip now %dpx, %dpx ahead", "[%s] Appended deferred content: strip now %dpx, %dpx ahead",
plugin_id, self.scroll_helper.total_scroll_width, plugin_id, self.scroll_helper.total_scroll_width,
@@ -770,7 +775,7 @@ class RenderPipeline:
) )
if not appended: if not appended:
return False return False
moved = self._strip_nbytes() moved = self._copied_bytes()
# Where each block starts, laid out as append_content does: a # Where each block starts, laid out as append_content does: a
# separator before every block, or -- when there was no strip to # separator before every block, or -- when there was no strip to
@@ -789,9 +794,10 @@ class RenderPipeline:
self._static_markers = tuple( self._static_markers = tuple(
(max(0, x - cut), pid) for x, pid in self._static_markers) (max(0, x - cut), pid) for x, pid in self._static_markers)
self._forget_trimmed_records(cut) self._forget_trimmed_records(cut)
# The append built the whole strip anew, and a trim copies what is # Both land in the frame after this one: the append's new columns
# left of it again: both land in the frame after this one. # (the whole strip when its buffer had to be reallocated), and a
self._note_op('extend', moved + (self._strip_nbytes() if cut else 0)) # trim's copy, if it made one.
self._note_op('extend', moved + (self._copied_bytes() if cut else 0))
self._segments_in_scroll = [pid for pid, _ in grouped] self._segments_in_scroll = [pid for pid, _ in grouped]
self.stats['composition_count'] += 1 self.stats['composition_count'] += 1
+146
View File
@@ -0,0 +1,146 @@
"""ScrollHelper extends and trims a strip in place (src/common/scroll_helper.py).
Every extension of the Vegas strip used to rebuild it whole (np.concatenate)
and every trim copied what was left: 3.5-4.5 ms on the render thread at 512x64
on a Pi 4, so the frame after each extension was late. The strip now lives in
a buffer with spare room: an append writes only the new columns, a trim only
moves the view's start, and a full copy happens only when the buffer is
reallocated. What a frame shows must not change at all.
"""
import random
import sys
from pathlib import Path
import numpy as np
import pytest
from PIL import Image
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from src.common.scroll_helper import ScrollHelper # noqa: E402
W, H = 64, 16
def _items(rng, n):
out = []
for _ in range(n):
width = rng.randint(5, 60)
out.append(Image.frombytes("RGB", (width, H),
bytes(rng.randrange(256) for _ in range(width * H * 3))))
return out
def _helper():
helper = ScrollHelper(W, H)
helper.create_scrolling_image(_items(random.Random(1), 4), item_gap=3, lead_gap=0)
return helper
def _reference_append(strip, items, gap):
"""What append_content used to do."""
width = sum(i.width for i in items) + gap * len(items)
addition = Image.new("RGB", (width, H))
x = 0
for item in items:
x += gap
addition.paste(item, (x, 0))
x += item.width
return np.concatenate((strip, np.array(addition)), axis=1)
def test_an_append_writes_into_the_buffer_and_copies_only_the_new_columns():
helper = _helper()
helper.append_content(_items(random.Random(2), 2), item_gap=3) # allocates
buffer = helper._strip_buffer
before = helper.cached_array.shape[1]
items = _items(random.Random(3), 2)
helper.append_content(items, item_gap=3)
assert helper._strip_buffer is buffer
assert np.shares_memory(helper.cached_array, buffer)
added = helper.cached_array.shape[1] - before
assert helper.last_copy_bytes == added * H * 3
def test_a_trim_copies_nothing():
helper = _helper()
helper.append_content(_items(random.Random(2), 3), item_gap=3)
helper.scroll_position = 120.0
cut = helper.drop_scrolled_prefix()
assert cut == 120 and helper.last_copy_bytes == 0
assert np.shares_memory(helper.cached_array, helper._strip_buffer)
def test_the_buffer_is_reallocated_when_the_room_runs_out():
helper = _helper()
helper.append_content(_items(random.Random(2), 1), item_gap=3)
first = helper._strip_buffer
rng = random.Random(4)
while helper._strip_buffer is first:
helper.append_content(_items(rng, 3), item_gap=3)
assert helper.last_copy_bytes == helper.cached_array.nbytes
assert helper._strip_start == 0
def test_a_strip_set_from_outside_is_never_written_through():
# The multi-display follower adopts a read-only array straight from an image.
helper = _helper()
helper.append_content(_items(random.Random(2), 1), item_gap=3)
adopted = np.asarray(Image.new("RGB", (300, H), (9, 9, 9)))
helper.cached_array = adopted
helper.total_scroll_width = 300
helper.append_content(_items(random.Random(3), 1), item_gap=3)
assert not np.shares_memory(helper.cached_array, adopted)
assert (adopted == 9).all()
helper.cached_array = adopted
helper.scroll_position = 100.0
helper.drop_scrolled_prefix()
assert not np.shares_memory(helper.cached_array, adopted)
def test_a_new_strip_lets_the_old_buffer_go():
helper = _helper()
helper.append_content(_items(random.Random(2), 1), item_gap=3)
helper.create_scrolling_image(_items(random.Random(5), 2), item_gap=3, lead_gap=0)
assert helper._strip_buffer is None
helper.append_content(_items(random.Random(2), 1), item_gap=3)
helper.clear_cache()
assert helper._strip_buffer is None and helper._strip_view is None
@pytest.mark.parametrize("seed", range(12))
def test_every_frame_matches_the_old_copying_strip(seed):
"""Random appends, trims, scrolling and patches, against a strip kept the old way."""
rng = random.Random(seed)
helper = _helper()
reference = helper.cached_array.copy()
for _ in range(60):
op = rng.random()
if op < 0.35:
items = _items(rng, rng.randint(1, 3))
gap = rng.randint(0, 6)
helper.append_content(items, item_gap=gap)
reference = _reference_append(reference, items, gap)
elif op < 0.55:
keep = rng.randint(0, W)
before = helper.scroll_position
cut = helper.drop_scrolled_prefix(keep_before=keep)
reference = reference[:, cut:].copy()
assert helper.scroll_position == before - cut
elif op < 0.7 and helper.cached_array.shape[1] > 8:
x = rng.randrange(helper.cached_array.shape[1] - 4)
pixels = np.full((H, 4, 3), rng.randrange(256), dtype=np.uint8)
helper.patch_columns(x, pixels)
reference[:, x:x + 4] = pixels
else:
limit = max(0, helper.cached_array.shape[1] - W - 1)
helper.scroll_position = float(rng.randint(0, limit)) if limit else 0.0
assert helper.cached_array.shape == reference.shape
assert (helper.cached_array == reference).all()
assert helper.total_scroll_width == reference.shape[1]
frame = np.asarray(helper.get_visible_portion())
x = int(helper.scroll_position)
if x + W <= reference.shape[1]:
assert (frame == reference[:, x:x + W]).all()
# The lazily built image is the strip as it stands.
assert (np.asarray(helper.cached_image) == reference).all()