Vegas mode: render plugins narrower, space rows by measured separation

Trimming reclaims blank margins but cannot compact a layout that genuinely
spans the display — a five-column forecast, a progress bar drawn at 100%
width, a stat block with the panel's whole width between its elements. Those
need the plugin to make different layout decisions, which means telling it the
screen is narrower while it renders.

DisplayManager.render_size() presents a smaller logical canvas for the
duration of a Vegas content fetch, reusing the same _LogicalMatrix
indirection double-sided mode already relies on so plugins see a consistent
size from every accessor. Plugins that size themselves from matrix.width need
no changes at all; one that wants to be explicit can read the new
BasePlugin.get_vegas_render_width().

Width is a percentage so a single setting travels across panel sizes:
vegas_scroll.render_width_pct globally, or vegas_width_pct in an individual
plugin's config. Measured on a 512x64 panel with real data:

  ledmatrix-weather   1536px -> 576px   (forecast becomes narrow cards)
  youtube-stats        353px -> 199px   (2% blank left, so genuinely compact)
  geochron             453px -> 153px   (ink density rises to 100%)
  ledmatrix-flights    950px -> 740px

The youtube-stats figure is the clearest evidence the layout itself changed
rather than being cropped: at full width the content had to be trimmed from
512px to 353px, whereas at 40% it arrives with almost no blank to reclaim.

Row spacing is now measured rather than added. A flat gap gets it wrong in
both directions at once — content drawn flush to its own edges ends up nearly
touching (reported for recent sports scores, which sat 8px apart), while
content already carrying wide margins gets pushed even further out.
separation_gap() measures the blank each pair already has and adds only the
shortfall, up to min_content_separation (default 24). intra_plugin_gap stays
as a floor applied regardless.

Two tests shipped in the previous commit encoded the old flat-gap arithmetic
and are updated to the measured semantics, including one renamed to reflect
that zero intra_plugin_gap alone no longer butts rows together.

Also fixes a real bug found while testing: the harness display manager had no
render_size(), and because the adapter catches broadly that surfaced as "no
content" rather than an error, silently dropping five plugins. Added the
context to VisualTestDisplayManager for parity, and _render_at() now degrades
to a no-op on any display manager lacking it, so a third-party or older
harness loses the narrowing rather than the content.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ
This commit is contained in:
ChuckBuilds
2026-07-29 09:01:28 -04:00
co-authored by Claude
parent d69dfbbaee
commit 616d21c6d3
12 changed files with 574 additions and 29 deletions
+2
View File
@@ -132,6 +132,8 @@
"target_fps": 125, "target_fps": 125,
"buffer_ahead": 2, "buffer_ahead": 2,
"intra_plugin_gap": 8, "intra_plugin_gap": 8,
"render_width_pct": 100,
"min_content_separation": 24,
"auto_trim": true, "auto_trim": true,
"trim_threshold": 10, "trim_threshold": 10,
"content_padding": 8, "content_padding": 8,
+53
View File
@@ -536,6 +536,59 @@ class DisplayManager:
finally: finally:
self._capture_mode_active = False self._capture_mode_active = False
@contextmanager
def render_size(self, width: int, height: Optional[int] = None):
"""Temporarily present a smaller logical canvas to plugins.
Plugins lay out against ``display_manager.matrix.width`` (and the
``width``/``height`` properties, which defer to it), so the only way to
get a *narrower layout* rather than a cropped one is to tell the plugin
the screen is narrower while it renders. Trimming after the fact cannot
fix a forecast spread across five columns or a progress bar drawn at
100% width — those need the plugin to make different layout decisions.
Vegas mode uses this so a plugin can occupy a fraction of a wide panel
and still look deliberately composed. Reuses the same _LogicalMatrix
indirection that double-sided mode relies on, so plugins see a
consistent size from every accessor.
Only meaningful inside :meth:`capture_mode` — this swaps the shared
image buffer, so the render loop must not be writing to it concurrently.
Args:
width: Logical width to report, clamped to at least 1 and to the
real panel width (a larger canvas would overflow the hardware).
height: Logical height, defaulting to the current height.
"""
real_matrix = self.matrix
prev_image = getattr(self, 'image', None)
prev_draw = getattr(self, 'draw', None)
current_w = self.width
current_h = self.height
target_w = max(1, min(int(width), current_w))
target_h = max(1, min(int(height) if height else current_h, current_h))
if target_w == current_w and target_h == current_h:
# Nothing to do; avoid pointless wrapping and buffer churn.
yield
return
try:
if real_matrix is not None:
self.matrix = _LogicalMatrix(real_matrix, target_w, target_h)
# With no hardware, the width/height properties fall through to
# self.image, so swapping the buffer below is enough on its own.
self.image = Image.new('RGB', (target_w, target_h))
self.draw = ImageDraw.Draw(self.image)
yield
finally:
self.matrix = real_matrix
if prev_image is not None:
self.image = prev_image
if prev_draw is not None:
self.draw = prev_draw
def _composite_double_sided(self): def _composite_double_sided(self):
"""Tile the logical screen across the full physical chain. """Tile the logical screen across the full physical chain.
+34
View File
@@ -505,6 +505,40 @@ class BasePlugin(ABC):
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
# Vegas scroll mode support # Vegas scroll mode support
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
def get_vegas_render_width(self) -> int:
"""
Width the Vegas ticker wants this plugin's content to occupy.
On a wide panel a layout built to fill the screen reads as sparse in a
ticker a forecast spread over five columns, a progress bar drawn at
100% width, a stat block with the panel's whole width between its
elements. Vegas asks for a narrower render so the plugin can choose a
tighter arrangement instead of being cropped afterwards.
Vegas also narrows ``display_manager`` for the duration of the call, so
a plugin that already sizes itself from ``matrix.width`` needs no
changes. Read this only when you size content some other way.
Controlled by the plugin's own ``vegas_width_pct`` config value, else
the global ``display.vegas_scroll.render_width_pct``.
Returns:
Target width in pixels. Outside a Vegas content request, the full
display width.
"""
requested = getattr(self, '_vegas_render_width', None)
if isinstance(requested, int) and requested > 0:
return requested
display_manager = getattr(self, 'display_manager', None)
matrix = getattr(display_manager, 'matrix', None)
if matrix is not None and getattr(matrix, 'width', None):
return int(matrix.width)
width = getattr(display_manager, 'width', None)
if callable(width):
width = width()
return int(width) if width else 128
def get_vegas_content(self) -> Optional[Any]: def get_vegas_content(self) -> Optional[Any]:
""" """
Get content for Vegas-style continuous scroll mode. Get content for Vegas-style continuous scroll mode.
@@ -178,6 +178,35 @@ class VisualTestDisplayManager:
"""No-op for hardware; marks that display was updated.""" """No-op for hardware; marks that display was updated."""
self.update_called = True self.update_called = True
@contextmanager
def render_size(self, width: int, height: Optional[int] = None):
"""
Interface parity with DisplayManager.render_size().
Vegas mode narrows the canvas so plugins lay out compactly instead of
being cropped. The harness must offer the same context or that path
cannot be exercised offline and because the adapter catches broadly,
a missing method shows up as "no content" rather than an error.
"""
prev_image = self.image
prev_draw = self.draw
prev_w, prev_h = self._width, self._height
target_w = max(1, min(int(width), prev_w))
target_h = max(1, min(int(height) if height else prev_h, prev_h))
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)
yield
finally:
self._width, self._height = prev_w, prev_h
self.matrix = _MatrixProxy(prev_w, prev_h)
self.image = prev_image
self.draw = prev_draw
@contextmanager @contextmanager
def capture_mode(self): def capture_mode(self):
""" """
+34
View File
@@ -21,6 +21,20 @@ class VegasModeConfig:
scroll_speed: float = 50.0 # Pixels per second scroll_speed: float = 50.0 # Pixels per second
separator_width: int = 32 # Gap between plugins (pixels) separator_width: int = 32 # Gap between plugins (pixels)
# Fraction of the panel width a plugin is told it has while rendering for
# the ticker, as a percentage. Trimming can only remove blank margins; it
# cannot compact a layout that genuinely spans the display — a five-column
# forecast, a full-width progress bar, a centred stat block with the panel's
# whole width between its elements. Rendering at a narrower size makes the
# plugin choose a tighter layout instead. 100 disables it.
render_width_pct: int = 100
# Minimum blank columns guaranteed between adjacent content, measured from
# actual ink rather than added blindly. A flat additive gap leaves
# card-style content nearly touching when the cards are drawn flush to their
# own edges, while padding out content that already has wide margins.
min_content_separation: int = 24
# Gap between rows contributed by the *same* plugin. separator_width marks # Gap between rows contributed by the *same* plugin. separator_width marks
# the handoff from one plugin to the next; applying it between every image # the handoff from one plugin to the next; applying it between every image
# forced a 32px chasm between each row of a per-row ticker (the F1 # forced a 32px chasm between each row of a per-row ticker (the F1
@@ -91,6 +105,9 @@ class VegasModeConfig:
scroll_speed=float(vegas_config.get('scroll_speed', 50.0)), scroll_speed=float(vegas_config.get('scroll_speed', 50.0)),
separator_width=int(vegas_config.get('separator_width', 32)), separator_width=int(vegas_config.get('separator_width', 32)),
intra_plugin_gap=int(vegas_config.get('intra_plugin_gap', 8)), intra_plugin_gap=int(vegas_config.get('intra_plugin_gap', 8)),
render_width_pct=int(vegas_config.get('render_width_pct', 100)),
min_content_separation=int(
vegas_config.get('min_content_separation', 24)),
auto_trim=vegas_config.get('auto_trim', True), auto_trim=vegas_config.get('auto_trim', True),
trim_threshold=int(vegas_config.get('trim_threshold', 10)), trim_threshold=int(vegas_config.get('trim_threshold', 10)),
content_padding=int(vegas_config.get('content_padding', 8)), content_padding=int(vegas_config.get('content_padding', 8)),
@@ -117,6 +134,8 @@ class VegasModeConfig:
'scroll_speed': self.scroll_speed, 'scroll_speed': self.scroll_speed,
'separator_width': self.separator_width, 'separator_width': self.separator_width,
'intra_plugin_gap': self.intra_plugin_gap, 'intra_plugin_gap': self.intra_plugin_gap,
'render_width_pct': self.render_width_pct,
'min_content_separation': self.min_content_separation,
'auto_trim': self.auto_trim, 'auto_trim': self.auto_trim,
'trim_threshold': self.trim_threshold, 'trim_threshold': self.trim_threshold,
'content_padding': self.content_padding, 'content_padding': self.content_padding,
@@ -209,6 +228,16 @@ class VegasModeConfig:
if self.buffer_ahead > 5: if self.buffer_ahead > 5:
errors.append(f"buffer_ahead must be <= 5, got {self.buffer_ahead}") errors.append(f"buffer_ahead must be <= 5, got {self.buffer_ahead}")
if not 10 <= self.render_width_pct <= 100:
errors.append(
"render_width_pct must be between 10 and 100, "
f"got {self.render_width_pct}")
if not 0 <= self.min_content_separation <= 256:
errors.append(
"min_content_separation must be between 0 and 256, "
f"got {self.min_content_separation}")
if self.intra_plugin_gap < 0: if self.intra_plugin_gap < 0:
errors.append( errors.append(
f"intra_plugin_gap must be >= 0, got {self.intra_plugin_gap}") f"intra_plugin_gap must be >= 0, got {self.intra_plugin_gap}")
@@ -271,6 +300,11 @@ class VegasModeConfig:
self.separator_width = int(vegas_config['separator_width']) self.separator_width = int(vegas_config['separator_width'])
if 'intra_plugin_gap' in vegas_config: if 'intra_plugin_gap' in vegas_config:
self.intra_plugin_gap = int(vegas_config['intra_plugin_gap']) self.intra_plugin_gap = int(vegas_config['intra_plugin_gap'])
if 'render_width_pct' in vegas_config:
self.render_width_pct = int(vegas_config['render_width_pct'])
if 'min_content_separation' in vegas_config:
self.min_content_separation = int(
vegas_config['min_content_separation'])
if 'auto_trim' in vegas_config: if 'auto_trim' in vegas_config:
self.auto_trim = vegas_config['auto_trim'] self.auto_trim = vegas_config['auto_trim']
if 'trim_threshold' in vegas_config: if 'trim_threshold' in vegas_config:
+53
View File
@@ -139,6 +139,59 @@ def trim_to_content(
return TrimResult(cropped, img.width, left, img.width - right) return TrimResult(cropped, img.width, left, img.width - right)
def edge_blank(
img: Image.Image, threshold: int = DEFAULT_INK_THRESHOLD
) -> Tuple[int, int]:
"""
Blank column counts at the left and right edges of an image.
Used to space items by *measured* separation rather than a flat added gap.
A fixed gap gets this wrong in both directions at once: card-style content
drawn flush to its own edges ends up nearly touching its neighbour, while
content that already carries wide margins gets pushed even further apart.
Args:
img: Image to measure
threshold: Ink threshold
Returns:
(left_blank, right_blank). For an entirely blank image both are the
full width, since there is no ink to be close to.
"""
bounds = content_bounds(img, threshold)
if bounds is None:
return img.width, img.width
first, last = bounds
return first, img.width - 1 - last
def separation_gap(
left_img: Image.Image,
right_img: Image.Image,
target: int,
minimum: int = 0,
threshold: int = DEFAULT_INK_THRESHOLD,
) -> int:
"""
Columns to insert between two images so their ink is ``target`` apart.
Only the shortfall is added: if the two images already carry enough blank
at the facing edges, nothing (beyond ``minimum``) is inserted.
Args:
left_img: Image on the left
right_img: Image on the right
target: Desired blank columns between the two pieces of ink
minimum: Floor applied regardless of what the images already have
threshold: Ink threshold
Returns:
Number of columns to insert, never negative
"""
existing = edge_blank(left_img, threshold)[1] + edge_blank(right_img, threshold)[0]
return max(minimum, target - existing, 0)
def find_blank_cut( def find_blank_cut(
img: Image.Image, img: Image.Image,
target: int, target: int,
+97 -3
View File
@@ -8,6 +8,7 @@ implement get_vegas_content() and fallback capture of display() output.
import logging import logging
import threading import threading
import time import time
from contextlib import nullcontext
from typing import Optional, List, Any, Tuple, Union, TYPE_CHECKING from typing import Optional, List, Any, Tuple, Union, TYPE_CHECKING
from PIL import Image from PIL import Image
@@ -214,6 +215,66 @@ class PluginAdapter:
self._cache_content(plugin_id, kept) self._cache_content(plugin_id, kept)
return kept return kept
def _render_at(self, width: int):
"""
Context manager narrowing the plugin-facing canvas to ``width``.
Degrades to a no-op when the display manager predates render_size (a
third-party or older test harness). Losing the narrowing is a cosmetic
regression; raising here would be caught by the broad handlers upstream
and silently drop the plugin's content entirely.
"""
render_size = getattr(self.display_manager, 'render_size', None)
if render_size is None:
logger.debug(
"display_manager has no render_size(); Vegas width requests "
"will be ignored"
)
return nullcontext()
return render_size(width)
def resolve_render_width(self, plugin: 'BasePlugin', plugin_id: str) -> int:
"""
Width to tell a plugin it has while it renders for the ticker.
Resolution order, most specific first:
1. the plugin's own ``vegas_width_pct`` config value
2. the global ``vegas_scroll.render_width_pct``
3. the full panel width
A percentage rather than an absolute width so one setting travels
across panel sizes.
Args:
plugin: Plugin instance, consulted for a per-plugin override
plugin_id: Plugin identifier for logging
Returns:
Target width in pixels, never wider than the panel
"""
pct = self.config.render_width_pct
plugin_cfg = getattr(plugin, 'config', None)
if isinstance(plugin_cfg, dict):
raw = plugin_cfg.get('vegas_width_pct')
if raw not in (None, ''):
try:
candidate = int(raw)
except (TypeError, ValueError):
logger.warning(
"[%s] Invalid vegas_width_pct %r, ignoring", plugin_id, raw)
else:
if 10 <= candidate <= 100:
pct = candidate
else:
logger.warning(
"[%s] vegas_width_pct %d out of range 10-100, ignoring",
plugin_id, candidate)
if pct >= 100:
return self.display_width
return max(1, int(self.display_width * pct / 100))
def _width_budget(self) -> int: def _width_budget(self) -> int:
"""Maximum columns one plugin may occupy in a cycle. 0 means unlimited.""" """Maximum columns one plugin may occupy in a cycle. 0 means unlimited."""
ratio = self.config.max_plugin_width_ratio ratio = self.config.max_plugin_width_ratio
@@ -331,7 +392,27 @@ class PluginAdapter:
""" """
try: try:
logger.info("[%s] Native: calling get_vegas_content()", plugin_id) logger.info("[%s] Native: calling get_vegas_content()", plugin_id)
result = plugin.get_vegas_content()
# Tell the plugin how much width the ticker wants it to use, and
# narrow the canvas for the duration of the call. A plugin that
# sizes its own images from display_manager.matrix.width picks up
# the narrower value with no changes of its own; one that wants to
# be explicit can read get_vegas_render_width().
render_width = self.resolve_render_width(plugin, plugin_id)
plugin._vegas_render_width = render_width
try:
if render_width == self.display_width:
result = plugin.get_vegas_content()
else:
logger.info(
"[%s] Native: requesting %dpx instead of %dpx",
plugin_id, render_width, self.display_width
)
with self.display_manager.capture_mode(), \
self._render_at(render_width):
result = plugin.get_vegas_content()
finally:
plugin._vegas_render_width = None
if result is None: if result is None:
logger.info("[%s] Native: get_vegas_content() returned None", plugin_id) logger.info("[%s] Native: get_vegas_content() returned None", plugin_id)
@@ -683,7 +764,19 @@ class PluginAdapter:
# Clear and call plugin display — use capture_mode to suppress hardware writes # Clear and call plugin display — use capture_mode to suppress hardware writes
# that plugins may trigger internally via update_display(). # that plugins may trigger internally via update_display().
with self.display_manager.capture_mode(): #
# render_size narrows the canvas the plugin lays out against, so a
# plugin that spreads across the whole panel produces a compact
# arrangement rather than one that has to be cropped afterwards.
render_width = self.resolve_render_width(plugin, plugin_id)
if render_width != self.display_width:
logger.info(
"[%s] Fallback: rendering at %dpx instead of %dpx",
plugin_id, render_width, self.display_width
)
with self.display_manager.capture_mode(), \
self._render_at(render_width):
self.display_manager.clear() self.display_manager.clear()
logger.info("[%s] Fallback: display cleared, calling display()", plugin_id) logger.info("[%s] Fallback: display cleared, calling display()", plugin_id)
@@ -717,7 +810,8 @@ class PluginAdapter:
plugin_id plugin_id
) )
# Try once more with force_clear=True # Try once more with force_clear=True
with self.display_manager.capture_mode(): with self.display_manager.capture_mode(), \
self._render_at(render_width):
self.display_manager.clear() self.display_manager.clear()
plugin.display(force_clear=True) plugin.display(force_clear=True)
captured = self.display_manager.image.copy() captured = self.display_manager.image.copy()
+20 -5
View File
@@ -14,6 +14,7 @@ from PIL import Image
from src.common.scroll_helper import ScrollHelper from src.common.scroll_helper import ScrollHelper
from src.vegas_mode.config import VegasModeConfig from src.vegas_mode.config import VegasModeConfig
from src.vegas_mode.geometry import separation_gap
from src.vegas_mode.stream_manager import StreamManager from src.vegas_mode.stream_manager import StreamManager
if TYPE_CHECKING: if TYPE_CHECKING:
@@ -188,12 +189,14 @@ class RenderPipeline:
logger.info( logger.info(
"Composed scroll image: %dx%d, %d plugin block(s), %d rows, " "Composed scroll image: %dx%d, %d plugin block(s), %d rows, "
"separator=%dpx between plugins / %dpx within", "separator=%dpx between plugins, rows spaced to %dpx of ink "
"(min added %dpx)",
self.scroll_helper.cached_image.width if self.scroll_helper.cached_image else 0, self.scroll_helper.cached_image.width if self.scroll_helper.cached_image else 0,
self.display_height, self.display_height,
len(blocks), len(blocks),
total_rows, total_rows,
self.config.separator_width, self.config.separator_width,
self.config.min_content_separation,
self.config.intra_plugin_gap, self.config.intra_plugin_gap,
) )
@@ -219,15 +222,27 @@ class RenderPipeline:
if len(images) == 1: if len(images) == 1:
return images[0] return images[0]
gap = max(0, self.config.intra_plugin_gap) floor = max(0, self.config.intra_plugin_gap)
width = sum(img.width for img in images) + gap * (len(images) - 1) target = max(0, self.config.min_content_separation)
threshold = self.config.trim_threshold
# Space by measured separation, not a flat gap. Rows drawn flush to
# their own edges (sports score cards) would otherwise end up nearly
# touching, while rows that already carry wide margins would be pushed
# needlessly further apart.
gaps = [
separation_gap(images[i], images[i + 1], target, floor, threshold)
for i in range(len(images) - 1)
]
width = sum(img.width for img in images) + sum(gaps)
height = max(img.height for img in images) height = max(img.height for img in images)
block = Image.new('RGB', (width, height), (0, 0, 0)) block = Image.new('RGB', (width, height), (0, 0, 0))
x = 0 x = 0
for img in images: for i, img in enumerate(images):
block.paste(img, (x, 0)) block.paste(img, (x, 0))
x += img.width + gap x += img.width + (gaps[i] if i < len(gaps) else 0)
return block return block
def render_frame(self) -> bool: def render_frame(self) -> bool:
+174 -19
View File
@@ -277,54 +277,64 @@ class TestPluginBoundaryGaps:
return RenderPipeline(VegasModeConfig(**cfg), DM(), FakeStream()) return RenderPipeline(VegasModeConfig(**cfg), DM(), FakeStream())
def test_separator_only_at_plugin_boundaries(self): def test_separator_only_at_plugin_boundaries(self):
# Two plugins, two rows each. Expect: row row [sep] row row, with the # Two plugins, two rows each. Expect: row row [sep] row row. These rows
# small intra gap inside each pair. # are drawn flush to their edges, so the intra-plugin gap is the full
# min_content_separation.
rows = [Image.new('RGB', (100, DISPLAY_H), (255, 255, 255)) for _ in range(4)] rows = [Image.new('RGB', (100, DISPLAY_H), (255, 255, 255)) for _ in range(4)]
pipeline = self._pipeline( pipeline = self._pipeline(
[('a', rows[:2]), ('b', rows[2:])], [('a', rows[:2]), ('b', rows[2:])],
separator_width=32, intra_plugin_gap=8, lead_in_width=0, separator_width=32, intra_plugin_gap=8, min_content_separation=24,
lead_in_width=0,
) )
assert pipeline.compose_scroll_content() assert pipeline.compose_scroll_content()
ink = column_has_ink(pipeline.scroll_helper.cached_image) ink = column_has_ink(pipeline.scroll_helper.cached_image)
assert ink[:100].all() assert ink[:100].all()
assert not ink[100:108].any() # intra gap inside plugin a assert not ink[100:124].any() # measured gap inside plugin a
assert ink[108:208].all() assert ink[124:224].all()
assert not ink[208:240].any() # separator between a and b assert not ink[224:256].any() # separator between a and b
assert ink[240:340].all() assert ink[256:356].all()
assert not ink[340:348].any() # intra gap inside plugin b assert not ink[356:380].any() # measured gap inside plugin b
assert ink[348:448].all() assert ink[380:480].all()
def test_total_width_uses_both_gap_sizes(self): def test_total_width_uses_both_gap_sizes(self):
rows = [Image.new('RGB', (100, DISPLAY_H), (255, 255, 255)) for _ in range(4)] rows = [Image.new('RGB', (100, DISPLAY_H), (255, 255, 255)) for _ in range(4)]
pipeline = self._pipeline( pipeline = self._pipeline(
[('a', rows[:2]), ('b', rows[2:])], [('a', rows[:2]), ('b', rows[2:])],
separator_width=32, intra_plugin_gap=8, lead_in_width=0, separator_width=32, intra_plugin_gap=8, min_content_separation=24,
lead_in_width=0,
) )
pipeline.compose_scroll_content() pipeline.compose_scroll_content()
# 4 rows + 2 intra gaps + 1 separator # 4 rows + 2 measured intra gaps (24 each) + 1 separator
assert pipeline.scroll_helper.cached_image.width == 400 + 16 + 32 assert pipeline.scroll_helper.cached_image.width == 400 + 48 + 32
def test_f1_shaped_case_reclaims_the_chasms(self): def test_f1_shaped_case_stays_below_the_separator_width(self):
# 12 rows from one plugin: previously 11 separators at 32px = 352px of # 12 rows from one plugin. Previously each boundary got the full 32px
# gap; now 11 intra gaps at 8px = 88px. # separator (352px of gap); rows are now spaced by measured separation,
# which for flush rows is min_content_separation.
rows = [Image.new('RGB', (128, DISPLAY_H), (255, 255, 255)) for _ in range(12)] rows = [Image.new('RGB', (128, DISPLAY_H), (255, 255, 255)) for _ in range(12)]
pipeline = self._pipeline( pipeline = self._pipeline(
[('f1-scoreboard', rows)], [('f1-scoreboard', rows)],
separator_width=32, intra_plugin_gap=8, lead_in_width=0, separator_width=32, intra_plugin_gap=8, min_content_separation=24,
lead_in_width=0,
) )
pipeline.compose_scroll_content() pipeline.compose_scroll_content()
assert pipeline.scroll_helper.cached_image.width == 12 * 128 + 11 * 8 width = pipeline.scroll_helper.cached_image.width
assert width == 12 * 128 + 11 * 24
assert width < 12 * 128 + 11 * 32 # cheaper than the old flat separator
def test_single_row_plugin_image_is_not_copied(self): def test_single_row_plugin_image_is_not_copied(self):
row = Image.new('RGB', (100, DISPLAY_H), (255, 255, 255)) row = Image.new('RGB', (100, DISPLAY_H), (255, 255, 255))
pipeline = self._pipeline([('solo', [row])], lead_in_width=0) pipeline = self._pipeline([('solo', [row])], lead_in_width=0)
assert pipeline._join_plugin_rows([row]) is row assert pipeline._join_plugin_rows([row]) is row
def test_zero_intra_gap_butts_rows_together(self): def test_rows_butt_together_only_when_both_gap_settings_are_zero(self):
# intra_plugin_gap alone no longer decides this: min_content_separation
# would still push flush rows apart, which is the point of it.
rows = [Image.new('RGB', (50, DISPLAY_H), (255, 255, 255)) for _ in range(3)] rows = [Image.new('RGB', (50, DISPLAY_H), (255, 255, 255)) for _ in range(3)]
pipeline = self._pipeline( pipeline = self._pipeline(
[('a', rows)], separator_width=32, intra_plugin_gap=0, lead_in_width=0) [('a', rows)], separator_width=32, intra_plugin_gap=0,
min_content_separation=0, lead_in_width=0)
pipeline.compose_scroll_content() pipeline.compose_scroll_content()
assert pipeline.scroll_helper.cached_image.width == 150 assert pipeline.scroll_helper.cached_image.width == 150
assert column_has_ink(pipeline.scroll_helper.cached_image).all() assert column_has_ink(pipeline.scroll_helper.cached_image).all()
@@ -601,3 +611,148 @@ class TestConfigSurface:
trim_threshold=10, content_padding=8, trim_threshold=10, content_padding=8,
min_plugin_width=8, lead_in_width=0, min_plugin_width=8, lead_in_width=0,
).validate() == [] ).validate() == []
class TestRenderWidthResolution:
"""Vegas asks plugins to render narrower so layouts compact, not crop."""
class CfgPlugin:
def __init__(self, cfg=None):
self.config = cfg or {}
def get_vegas_content(self):
return None
def test_defaults_to_full_width(self):
adapter = adapter_with()
assert adapter.resolve_render_width(self.CfgPlugin(), 'p') == DISPLAY_W
def test_global_percentage_applies(self):
adapter = adapter_with(render_width_pct=50)
assert adapter.resolve_render_width(self.CfgPlugin(), 'p') == DISPLAY_W // 2
def test_per_plugin_override_beats_global(self):
adapter = adapter_with(render_width_pct=50)
plugin = self.CfgPlugin({'vegas_width_pct': 30})
assert adapter.resolve_render_width(plugin, 'p') == int(DISPLAY_W * 0.3)
def test_per_plugin_can_opt_back_to_full_width(self):
adapter = adapter_with(render_width_pct=30)
plugin = self.CfgPlugin({'vegas_width_pct': 100})
assert adapter.resolve_render_width(plugin, 'p') == DISPLAY_W
@pytest.mark.parametrize('bad', [0, 5, 150, -10, 'wide', None, ''])
def test_invalid_override_falls_back_to_global(self, bad):
adapter = adapter_with(render_width_pct=50)
plugin = self.CfgPlugin({'vegas_width_pct': bad})
assert adapter.resolve_render_width(plugin, 'p') == DISPLAY_W // 2
def test_plugin_without_config_is_safe(self):
adapter = adapter_with(render_width_pct=50)
class NoCfg:
pass
assert adapter.resolve_render_width(NoCfg(), 'p') == DISPLAY_W // 2
def test_never_exceeds_panel_width(self):
adapter = adapter_with(render_width_pct=100)
assert adapter.resolve_render_width(self.CfgPlugin(), 'p') <= DISPLAY_W
class TestMeasuredSeparation:
"""Rows are spaced by measured blank, not a flat additive gap."""
def _pipeline(self, grouped, **cfg):
from src.vegas_mode.render_pipeline import RenderPipeline
class FakeStream:
def get_grouped_content_for_composition(self):
return grouped
def get_active_plugin_ids(self):
return [pid for pid, _ in grouped]
class DM:
width = DISPLAY_W
height = DISPLAY_H
def set_scrolling_state(self, *a):
pass
return RenderPipeline(VegasModeConfig(**cfg), DM(), FakeStream())
def test_flush_rows_are_pushed_to_the_target(self):
# The reported problem: score cards drawn edge to edge sat 8px apart.
rows = [Image.new('RGB', (100, DISPLAY_H), (255, 255, 255)) for _ in range(3)]
p = self._pipeline([('scores', rows)],
intra_plugin_gap=8, min_content_separation=24,
lead_in_width=0)
block = p._join_plugin_rows(rows)
assert block.width == 300 + 24 * 2
ink = column_has_ink(block)
assert not ink[100:124].any()
assert ink[124:224].all()
def test_rows_with_margins_are_not_pushed_further(self):
# Each row already carries 12px blank per side = 24px facing total,
# which meets the target, so only the floor is added.
rows = [canvas([(12, 88)], width=100) for _ in range(3)]
p = self._pipeline([('padded', rows)],
intra_plugin_gap=0, min_content_separation=24,
lead_in_width=0)
block = p._join_plugin_rows(rows)
assert block.width == 300
def test_floor_still_applies_when_target_is_met(self):
rows = [canvas([(12, 88)], width=100) for _ in range(2)]
p = self._pipeline([('padded', rows)],
intra_plugin_gap=6, min_content_separation=24,
lead_in_width=0)
assert p._join_plugin_rows(rows).width == 200 + 6
def test_gaps_are_per_pair_not_uniform(self):
# Flush row then a padded row: the two gaps must differ.
flush = Image.new('RGB', (100, DISPLAY_H), (255, 255, 255))
padded = canvas([(20, 80)], width=100)
p = self._pipeline([('mixed', [flush, padded, flush])],
intra_plugin_gap=0, min_content_separation=24,
lead_in_width=0)
block = p._join_plugin_rows([flush, padded, flush])
# gap1: flush right(0) + padded left(20) = 20 -> add 4
# gap2: padded right(20) + flush left(0) = 20 -> add 4
assert block.width == 300 + 4 + 4
def test_zero_target_falls_back_to_the_floor(self):
rows = [Image.new('RGB', (50, DISPLAY_H), (255, 255, 255)) for _ in range(2)]
p = self._pipeline([('a', rows)],
intra_plugin_gap=5, min_content_separation=0,
lead_in_width=0)
assert p._join_plugin_rows(rows).width == 100 + 5
class TestNewConfigKeys:
def test_render_width_pct_parses(self):
cfg = VegasModeConfig.from_config(
{'display': {'vegas_scroll': {'render_width_pct': 40}}})
assert cfg.render_width_pct == 40
def test_min_content_separation_parses(self):
cfg = VegasModeConfig.from_config(
{'display': {'vegas_scroll': {'min_content_separation': 16}}})
assert cfg.min_content_separation == 16
def test_defaults(self):
cfg = VegasModeConfig()
assert cfg.render_width_pct == 100
assert cfg.min_content_separation == 24
@pytest.mark.parametrize('overrides,bad_key', [
({'render_width_pct': 5}, 'render_width_pct'),
({'render_width_pct': 101}, 'render_width_pct'),
({'min_content_separation': -1}, 'min_content_separation'),
({'min_content_separation': 300}, 'min_content_separation'),
])
def test_validate_rejects_out_of_range(self, overrides, bad_key):
errors = VegasModeConfig(**overrides).validate()
assert any(bad_key in e for e in errors), errors
+48
View File
@@ -9,6 +9,8 @@ from src.vegas_mode.geometry import (
column_has_ink, column_has_ink,
content_bounds, content_bounds,
dead_window_stats, dead_window_stats,
edge_blank,
separation_gap,
trim_to_content, trim_to_content,
window_coverage_stats, window_coverage_stats,
) )
@@ -271,3 +273,49 @@ class TestLongestRunHelper:
def test_run_lengths(self, flags, expected): def test_run_lengths(self, flags, expected):
from src.vegas_mode.geometry import _longest_true_run from src.vegas_mode.geometry import _longest_true_run
assert _longest_true_run(np.array(flags, dtype=bool)) == expected assert _longest_true_run(np.array(flags, dtype=bool)) == expected
class TestEdgeBlank:
def test_measures_both_edges(self):
assert edge_blank(paint(make_img(100), 20, 60)) == (20, 40)
def test_flush_content_has_no_blank(self):
assert edge_blank(paint(make_img(50), 0, 50)) == (0, 0)
def test_blank_image_reports_full_width_both_sides(self):
# No ink means nothing to be close to.
assert edge_blank(make_img(64)) == (64, 64)
class TestSeparationGap:
def test_flush_edges_get_the_full_target(self):
a = paint(make_img(50), 0, 50)
b = paint(make_img(50), 0, 50)
assert separation_gap(a, b, target=24) == 24
def test_existing_margins_reduce_the_added_gap(self):
# 8px blank on each facing edge already covers 16 of the 24 target.
a = paint(make_img(50), 0, 42)
b = paint(make_img(50), 8, 50)
assert separation_gap(a, b, target=24) == 8
def test_ample_existing_margin_adds_nothing(self):
a = paint(make_img(100), 0, 60)
b = paint(make_img(100), 40, 100)
assert separation_gap(a, b, target=24) == 0
def test_minimum_is_a_floor(self):
a = paint(make_img(100), 0, 60)
b = paint(make_img(100), 40, 100)
assert separation_gap(a, b, target=24, minimum=4) == 4
def test_never_negative(self):
a = paint(make_img(200), 0, 10)
b = paint(make_img(200), 190, 200)
assert separation_gap(a, b, target=8) == 0
def test_sports_card_case_gets_real_separation(self):
# The reported problem: cards drawn edge to edge sat 8px apart under a
# flat gap; measured separation lifts them to the 24px target.
card = paint(make_img(150), 0, 150)
assert separation_gap(card, card, target=24, minimum=8) == 24
+4 -1
View File
@@ -923,7 +923,8 @@ def save_main_config():
'vegas_min_plugin_width', 'vegas_lead_in_width', 'vegas_plugins_per_cycle', 'vegas_min_plugin_width', 'vegas_lead_in_width', 'vegas_plugins_per_cycle',
'vegas_max_plugin_width_ratio', 'vegas_dynamic_duration_enabled', 'vegas_max_plugin_width_ratio', 'vegas_dynamic_duration_enabled',
'vegas_min_cycle_duration', 'vegas_max_cycle_duration', 'vegas_min_cycle_duration', 'vegas_max_cycle_duration',
'vegas_intra_plugin_gap'] 'vegas_intra_plugin_gap', 'vegas_render_width_pct',
'vegas_min_content_separation']
if any(k in data for k in vegas_fields): if any(k in data for k in vegas_fields):
if 'display' not in current_config: if 'display' not in current_config:
@@ -974,6 +975,8 @@ def save_main_config():
'vegas_scroll_speed': ('scroll_speed', 1, 200), 'vegas_scroll_speed': ('scroll_speed', 1, 200),
'vegas_separator_width': ('separator_width', 0, 128), 'vegas_separator_width': ('separator_width', 0, 128),
'vegas_intra_plugin_gap': ('intra_plugin_gap', 0, 128), 'vegas_intra_plugin_gap': ('intra_plugin_gap', 0, 128),
'vegas_render_width_pct': ('render_width_pct', 10, 100),
'vegas_min_content_separation': ('min_content_separation', 0, 256),
'vegas_target_fps': ('target_fps', 30, 200), 'vegas_target_fps': ('target_fps', 30, 200),
'vegas_buffer_ahead': ('buffer_ahead', 1, 5), 'vegas_buffer_ahead': ('buffer_ahead', 1, 5),
'vegas_trim_threshold': ('trim_threshold', 0, 254), 'vegas_trim_threshold': ('trim_threshold', 0, 254),
@@ -438,7 +438,7 @@
<div class="grid grid-cols-1 md:grid-cols-2 gap-4"> <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-group" id="setting-display-vegas_intra_plugin_gap" data-setting-key="display.vegas_scroll.intra_plugin_gap"> <div class="form-group" id="setting-display-vegas_intra_plugin_gap" data-setting-key="display.vegas_scroll.intra_plugin_gap">
<label for="vegas_intra_plugin_gap" class="block text-sm font-medium text-gray-700">Row Gap (pixels){{ ui.help_tip('Gap between rows contributed by the same plugin (0128 px).\nDefault: 8. Multi-row plugins such as sports scoreboards, news feeds and the F1 standings return one image per row; this keeps those rows close together while Separator Width still marks the jump to the next plugin. Set 0 to butt rows directly together.', 'Row Gap') }}</label> <label for="vegas_intra_plugin_gap" class="block text-sm font-medium text-gray-700">Row Gap (pixels){{ ui.help_tip('Extra gap always added between rows contributed by the same plugin (0128 px).\nDefault: 8. This is a floor on top of Row Separation below, which does most of the work. Set both to 0 to butt rows directly together.', 'Row Gap') }}</label>
<input type="number" <input type="number"
id="vegas_intra_plugin_gap" id="vegas_intra_plugin_gap"
name="vegas_intra_plugin_gap" name="vegas_intra_plugin_gap"
@@ -447,6 +447,31 @@
max="128" max="128"
class="form-control"> class="form-control">
</div> </div>
<div class="form-group" id="setting-display-vegas_min_content_separation" data-setting-key="display.vegas_scroll.min_content_separation">
<label for="vegas_min_content_separation" class="block text-sm font-medium text-gray-700">Row Separation (pixels){{ ui.help_tip('Blank space guaranteed between rows of the same plugin, measured from the actual content rather than added blindly (0256 px).\nDefault: 24. Rows already carrying wide margins get nothing added; rows drawn right up to their own edges — sports score cards, for instance — get the full amount, so they no longer look like they are touching. Raise it if items still feel cramped.', 'Row Separation') }}</label>
<input type="number"
id="vegas_min_content_separation"
name="vegas_min_content_separation"
value="{{ main_config.display.get('vegas_scroll', {}).get('min_content_separation', 24) }}"
min="0"
max="256"
class="form-control">
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-group" id="setting-display-vegas_render_width_pct" data-setting-key="display.vegas_scroll.render_width_pct">
<label for="vegas_render_width_pct" class="block text-sm font-medium text-gray-700">Plugin Render Width (%){{ ui.help_tip('How much of the screen width each plugin is told it has while drawing for the ticker (10100%).\nDefault: 100 (unchanged). Lowering it makes plugins choose a tighter layout rather than being cropped — a weather forecast becomes narrow cards instead of five columns spread across the panel. Useful on wide displays. Override per plugin with the vegas_width_pct setting in that plugin\'s own configuration.', 'Plugin Render Width') }}</label>
<input type="number"
id="vegas_render_width_pct"
name="vegas_render_width_pct"
value="{{ main_config.display.get('vegas_scroll', {}).get('render_width_pct', 100) }}"
min="10"
max="100"
step="5"
class="form-control">
</div>
</div> </div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4"> <div class="grid grid-cols-1 md:grid-cols-2 gap-4">