mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-08-01 08:48:05 +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:
+174
-19
@@ -277,54 +277,64 @@ class TestPluginBoundaryGaps:
|
||||
return RenderPipeline(VegasModeConfig(**cfg), DM(), FakeStream())
|
||||
|
||||
def test_separator_only_at_plugin_boundaries(self):
|
||||
# Two plugins, two rows each. Expect: row row [sep] row row, with the
|
||||
# small intra gap inside each pair.
|
||||
# Two plugins, two rows each. Expect: row row [sep] row row. These rows
|
||||
# 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)]
|
||||
pipeline = self._pipeline(
|
||||
[('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()
|
||||
|
||||
ink = column_has_ink(pipeline.scroll_helper.cached_image)
|
||||
assert ink[:100].all()
|
||||
assert not ink[100:108].any() # intra gap inside plugin a
|
||||
assert ink[108:208].all()
|
||||
assert not ink[208:240].any() # separator between a and b
|
||||
assert ink[240:340].all()
|
||||
assert not ink[340:348].any() # intra gap inside plugin b
|
||||
assert ink[348:448].all()
|
||||
assert not ink[100:124].any() # measured gap inside plugin a
|
||||
assert ink[124:224].all()
|
||||
assert not ink[224:256].any() # separator between a and b
|
||||
assert ink[256:356].all()
|
||||
assert not ink[356:380].any() # measured gap inside plugin b
|
||||
assert ink[380:480].all()
|
||||
|
||||
def test_total_width_uses_both_gap_sizes(self):
|
||||
rows = [Image.new('RGB', (100, DISPLAY_H), (255, 255, 255)) for _ in range(4)]
|
||||
pipeline = self._pipeline(
|
||||
[('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()
|
||||
# 4 rows + 2 intra gaps + 1 separator
|
||||
assert pipeline.scroll_helper.cached_image.width == 400 + 16 + 32
|
||||
# 4 rows + 2 measured intra gaps (24 each) + 1 separator
|
||||
assert pipeline.scroll_helper.cached_image.width == 400 + 48 + 32
|
||||
|
||||
def test_f1_shaped_case_reclaims_the_chasms(self):
|
||||
# 12 rows from one plugin: previously 11 separators at 32px = 352px of
|
||||
# gap; now 11 intra gaps at 8px = 88px.
|
||||
def test_f1_shaped_case_stays_below_the_separator_width(self):
|
||||
# 12 rows from one plugin. Previously each boundary got the full 32px
|
||||
# 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)]
|
||||
pipeline = self._pipeline(
|
||||
[('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()
|
||||
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):
|
||||
row = Image.new('RGB', (100, DISPLAY_H), (255, 255, 255))
|
||||
pipeline = self._pipeline([('solo', [row])], lead_in_width=0)
|
||||
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)]
|
||||
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()
|
||||
assert pipeline.scroll_helper.cached_image.width == 150
|
||||
assert column_has_ink(pipeline.scroll_helper.cached_image).all()
|
||||
@@ -601,3 +611,148 @@ class TestConfigSurface:
|
||||
trim_threshold=10, content_padding=8,
|
||||
min_plugin_width=8, lead_in_width=0,
|
||||
).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
|
||||
|
||||
@@ -9,6 +9,8 @@ from src.vegas_mode.geometry import (
|
||||
column_has_ink,
|
||||
content_bounds,
|
||||
dead_window_stats,
|
||||
edge_blank,
|
||||
separation_gap,
|
||||
trim_to_content,
|
||||
window_coverage_stats,
|
||||
)
|
||||
@@ -271,3 +273,49 @@ class TestLongestRunHelper:
|
||||
def test_run_lengths(self, flags, expected):
|
||||
from src.vegas_mode.geometry import _longest_true_run
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user