mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-08-01 16:58:06 +00:00
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:
@@ -536,6 +536,59 @@ class DisplayManager:
|
||||
finally:
|
||||
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):
|
||||
"""Tile the logical screen across the full physical chain.
|
||||
|
||||
|
||||
@@ -505,6 +505,40 @@ class BasePlugin(ABC):
|
||||
# -------------------------------------------------------------------------
|
||||
# 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]:
|
||||
"""
|
||||
Get content for Vegas-style continuous scroll mode.
|
||||
|
||||
@@ -178,6 +178,35 @@ class VisualTestDisplayManager:
|
||||
"""No-op for hardware; marks that display was updated."""
|
||||
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
|
||||
def capture_mode(self):
|
||||
"""
|
||||
|
||||
@@ -21,6 +21,20 @@ class VegasModeConfig:
|
||||
scroll_speed: float = 50.0 # Pixels per second
|
||||
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
|
||||
# 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
|
||||
@@ -91,6 +105,9 @@ class VegasModeConfig:
|
||||
scroll_speed=float(vegas_config.get('scroll_speed', 50.0)),
|
||||
separator_width=int(vegas_config.get('separator_width', 32)),
|
||||
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),
|
||||
trim_threshold=int(vegas_config.get('trim_threshold', 10)),
|
||||
content_padding=int(vegas_config.get('content_padding', 8)),
|
||||
@@ -117,6 +134,8 @@ class VegasModeConfig:
|
||||
'scroll_speed': self.scroll_speed,
|
||||
'separator_width': self.separator_width,
|
||||
'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,
|
||||
'trim_threshold': self.trim_threshold,
|
||||
'content_padding': self.content_padding,
|
||||
@@ -209,6 +228,16 @@ class VegasModeConfig:
|
||||
if self.buffer_ahead > 5:
|
||||
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:
|
||||
errors.append(
|
||||
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'])
|
||||
if 'intra_plugin_gap' in vegas_config:
|
||||
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:
|
||||
self.auto_trim = vegas_config['auto_trim']
|
||||
if 'trim_threshold' in vegas_config:
|
||||
|
||||
@@ -139,6 +139,59 @@ def trim_to_content(
|
||||
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(
|
||||
img: Image.Image,
|
||||
target: int,
|
||||
|
||||
@@ -8,6 +8,7 @@ implement get_vegas_content() and fallback capture of display() output.
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from contextlib import nullcontext
|
||||
from typing import Optional, List, Any, Tuple, Union, TYPE_CHECKING
|
||||
from PIL import Image
|
||||
|
||||
@@ -214,6 +215,66 @@ class PluginAdapter:
|
||||
self._cache_content(plugin_id, 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:
|
||||
"""Maximum columns one plugin may occupy in a cycle. 0 means unlimited."""
|
||||
ratio = self.config.max_plugin_width_ratio
|
||||
@@ -331,7 +392,27 @@ class PluginAdapter:
|
||||
"""
|
||||
try:
|
||||
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:
|
||||
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
|
||||
# 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()
|
||||
logger.info("[%s] Fallback: display cleared, calling display()", plugin_id)
|
||||
|
||||
@@ -717,7 +810,8 @@ class PluginAdapter:
|
||||
plugin_id
|
||||
)
|
||||
# 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()
|
||||
plugin.display(force_clear=True)
|
||||
captured = self.display_manager.image.copy()
|
||||
|
||||
@@ -14,6 +14,7 @@ from PIL import Image
|
||||
|
||||
from src.common.scroll_helper import ScrollHelper
|
||||
from src.vegas_mode.config import VegasModeConfig
|
||||
from src.vegas_mode.geometry import separation_gap
|
||||
from src.vegas_mode.stream_manager import StreamManager
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -188,12 +189,14 @@ class RenderPipeline:
|
||||
|
||||
logger.info(
|
||||
"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.display_height,
|
||||
len(blocks),
|
||||
total_rows,
|
||||
self.config.separator_width,
|
||||
self.config.min_content_separation,
|
||||
self.config.intra_plugin_gap,
|
||||
)
|
||||
|
||||
@@ -219,15 +222,27 @@ class RenderPipeline:
|
||||
if len(images) == 1:
|
||||
return images[0]
|
||||
|
||||
gap = max(0, self.config.intra_plugin_gap)
|
||||
width = sum(img.width for img in images) + gap * (len(images) - 1)
|
||||
floor = max(0, self.config.intra_plugin_gap)
|
||||
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)
|
||||
|
||||
block = Image.new('RGB', (width, height), (0, 0, 0))
|
||||
x = 0
|
||||
for img in images:
|
||||
for i, img in enumerate(images):
|
||||
block.paste(img, (x, 0))
|
||||
x += img.width + gap
|
||||
x += img.width + (gaps[i] if i < len(gaps) else 0)
|
||||
return block
|
||||
|
||||
def render_frame(self) -> bool:
|
||||
|
||||
Reference in New Issue
Block a user