fix(layout): scoreboard_regions reserves real center space at 2:1 aspect ratios

logo_slot = min(height, width // 2) has a blind spot: at exactly 2:1
aspect ratio (width == 2 * height -- a very common shape: two, four, or
more square modules stacked into a taller panel) width // 2 and height
are equal, so the two logo slots claim the ENTIRE width and leave zero
pixels for a center column, no matter how large the panel gets. Not a
'small panel' problem -- 96x48, 128x64, and 256x128 (all exactly 2:1) hit
it identically, while the 128x32 design baseline and panels like 192x48
or 256x32 never do, because height is already the tighter constraint
there.

Two new parameters fix it in the one shared helper every scoreboard-style
plugin composes through:

- min_center_fraction / min_center_design_px reserve at least
  max(width * fraction, design_px * ctx.scale) for the center column,
  capping logo_slot further when needed. The scaled design-px term
  matters on small panels where a flat fraction alone reserves too little
  absolute space.
- score_bleed_fraction extends the score's own fit box (not the logo
  slots themselves) a controlled amount into each side -- the same way
  real broadcast scoreboards let a big score number's edges cross into
  the team marks flanking it. Without this the reserve alone can still be
  too narrow for a short score to render without truncating.

score_area is now genuinely narrower than the full card width (previously
identical to status_band/detail_band, which still span the full width and
overlay the logos -- short text there was never the problem).

Verified against the full harness size spread: a real game score like
'17-21' never needs ellipsis at any tested 2:1-or-tighter aspect ratio
(test_score_never_needs_ellipsis_for_a_short_score), and wide panels
(128x32/192x48/256x32-style) are provably unaffected.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Chuck
2026-07-11 09:00:37 -04:00
co-authored by Claude Fable 5
parent 87ff97d006
commit 6b47599c4a
2 changed files with 111 additions and 13 deletions
+61 -12
View File
@@ -607,12 +607,12 @@ def draw_fitted_text(display_manager: Any, fit: FitResult,
class ScoreboardRegions: class ScoreboardRegions:
"""The two-logos-plus-center-score card shared by the sports plugins.""" """The two-logos-plus-center-score card shared by the sports plugins."""
bounds: Region bounds: Region
logo_slot: int # width of each logo slot: min(H, W // 2) logo_slot: int # width of each logo slot: min(H, W // 2), center-reserved
away_slot: Region # left logo slot away_slot: Region # left logo slot
home_slot: Region # right logo slot home_slot: Region # right logo slot
center_col: Region # column between the slots (0-wide on square panels) center_col: Region # column between the slots (>= min_center_fraction of width)
status_band: Region # top band (replaces the magic y = 1) status_band: Region # top band (replaces the magic y = 1)
score_area: Region # center column minus the bands (replaces y = H//2 - 3) score_area: Region # center_col's true width, between the bands (replaces y = H//2 - 3)
detail_band: Region # bottom band (replaces the magic y = H - 7) detail_band: Region # bottom band (replaces the magic y = H - 7)
bottom_left: Region # bottom corner: away records / timeouts bottom_left: Region # bottom corner: away records / timeouts
bottom_right: Region # bottom corner: home records / timeouts bottom_right: Region # bottom corner: home records / timeouts
@@ -620,19 +620,55 @@ class ScoreboardRegions:
def scoreboard_regions(bounds: Region, *, ctx: Optional["LayoutContext"] = None, def scoreboard_regions(bounds: Region, *, ctx: Optional["LayoutContext"] = None,
status_h: Optional[int] = None, status_h: Optional[int] = None,
detail_h: Optional[int] = None) -> ScoreboardRegions: detail_h: Optional[int] = None,
min_center_fraction: float = 0.15,
min_center_design_px: int = 40,
score_bleed_fraction: float = 0.5) -> ScoreboardRegions:
"""Carve a game-card Region into the standard scoreboard arrangement. """Carve a game-card Region into the standard scoreboard arrangement.
Encodes the invariant duplicated across the sports plugins: Encodes the invariant duplicated across the sports plugins:
``logo_slot = min(height, width // 2)`` (capped at half the card so the ``logo_slot = min(height, width // 2)`` (capped at half the card so the
home slot never collapses), away logo centered in the left slot, home in home slot never collapses), away logo centered in the left slot, home in
the right. The text bands (status/score/detail) span the FULL card width the right.
and overlay the logo slots — exactly like the classic layouts, where
outlined text is drawn over the logos; on square-ish panels the column That formula alone has a blind spot: at exactly 2:1 aspect ratio
between the slots can be zero-wide, so full-width bands are the only (width == 2 * height — a very common shape, e.g. two, four, or more
correct home for text. Band heights default to the classic 128x32 square modules stacked into a taller panel) ``width // 2`` and
values, scaled by the context's geometry factor when one is provided. ``height`` are equal, so the two logo slots claim the *entire* width
Works on a full panel or on a scroll-mode card Region. and leave zero pixels for a center column, no matter how large the
panel gets. It isn't a "small panel" problem: 96x48, 128x64, and
256x128 (all exactly 2:1) hit it identically, while wide panels like
the 128x32 design baseline or a 192x48/256x32 panel never do, because
height is already the tighter constraint there.
Two knobs fix it, both defaulted to values verified against the full
harness size spread (see test_adaptive_layout.py::TestScoreboardRegions):
- ``min_center_fraction`` / ``min_center_design_px`` reserve at least
``max(width * min_center_fraction, min_center_design_px * ctx.scale)``
for the center column, capping ``logo_slot`` further when needed. The
design-px term (scaled by the context's geometry factor, so it grows
on bigger panels like everything else in ``px()``) matters most on
small panels where a flat fraction alone reserves too little absolute
space for even a short score string. On wide panels the height
constraint already leaves more room than either reserves, so both are
a no-op there — 128x32/192x48-style layouts are unaffected.
- ``score_bleed_fraction`` extends the score's own *fit box* (not the
logo slots themselves) an extra ``logo_slot * score_bleed_fraction``
into each side — controlled, intentional overlap with the logo art,
the same way real broadcast scoreboards let a big score number's
edges cross into the team marks flanking it. Without this, on a
square-ish panel the center reserve alone can be too narrow for even
a modest score to render without truncating (`"17-21"` -> `"17-2…"`),
which is worse than a little overlap.
status_band and detail_band span the FULL card width and overlay the
logo slots — matching the classic layouts, where short outlined status/
date text is drawn over the logos without issue; only score_area (the
one element whose size actively grows with the panel) uses the
narrower, bleed-adjusted box. Band heights default to the classic
128x32 values, scaled by the context's geometry factor when one is
provided. Works on a full panel or on a scroll-mode card Region.
""" """
if status_h is None: if status_h is None:
status_h = ctx.px(9, minimum=7) if ctx else 9 status_h = ctx.px(9, minimum=7) if ctx else 9
@@ -640,13 +676,26 @@ def scoreboard_regions(bounds: Region, *, ctx: Optional["LayoutContext"] = None,
detail_h = ctx.px(8, minimum=7) if ctx else 8 detail_h = ctx.px(8, minimum=7) if ctx else 8
logo_slot = min(bounds.h, bounds.w // 2) logo_slot = min(bounds.h, bounds.w // 2)
design_reserve = int(min_center_design_px * (ctx.scale if ctx else 1.0))
min_center_w = max(1, int(bounds.w * min_center_fraction), design_reserve)
max_logo_slot_by_center = max(1, (bounds.w - min_center_w) // 2)
logo_slot = min(logo_slot, max_logo_slot_by_center)
away_slot = bounds.left_col(logo_slot) away_slot = bounds.left_col(logo_slot)
home_slot = bounds.right_col(logo_slot) home_slot = bounds.right_col(logo_slot)
center_col = Region(bounds.x + logo_slot, bounds.y, center_col = Region(bounds.x + logo_slot, bounds.y,
bounds.w - 2 * logo_slot, bounds.h) bounds.w - 2 * logo_slot, bounds.h)
status_band = bounds.top_band(status_h) status_band = bounds.top_band(status_h)
detail_band = bounds.bottom_band(detail_h) detail_band = bounds.bottom_band(detail_h)
score_area = bounds.middle(status_band.h, detail_band.h) middle = bounds.middle(status_band.h, detail_band.h)
# score_area is the true center gap's width plus a controlled bleed
# into each logo slot (see score_bleed_fraction above) -- narrower than
# the full card width status/detail get, since it's the one element
# whose size actively grows with the panel and needs its *fit box* to
# reflect real available space, but generous enough that a short score
# string never has to truncate on a square-ish panel.
bleed = int(logo_slot * score_bleed_fraction)
score_area = Region(center_col.x - bleed, middle.y,
center_col.w + 2 * bleed, middle.h)
bottom = bounds.bottom_band(detail_h) bottom = bounds.bottom_band(detail_h)
return ScoreboardRegions( return ScoreboardRegions(
bounds=bounds, logo_slot=logo_slot, bounds=bounds, logo_slot=logo_slot,
+50 -1
View File
@@ -90,7 +90,7 @@ class TestScoreboardRegions:
@pytest.mark.parametrize("w,h", DEFAULT_TEST_SIZES + [(8, 8)]) @pytest.mark.parametrize("w,h", DEFAULT_TEST_SIZES + [(8, 8)])
def test_invariants_at_all_sizes(self, w, h): def test_invariants_at_all_sizes(self, w, h):
regs = scoreboard_regions(Region(0, 0, w, h)) regs = scoreboard_regions(Region(0, 0, w, h))
assert regs.logo_slot == min(h, w // 2) assert regs.logo_slot <= min(h, w // 2)
# slots hug the edges and never overlap the center column # slots hug the edges and never overlap the center column
assert regs.away_slot.x == 0 and regs.home_slot.right == w assert regs.away_slot.x == 0 and regs.home_slot.right == w
assert regs.away_slot.right <= regs.center_col.x or regs.center_col.w == 0 assert regs.away_slot.right <= regs.center_col.x or regs.center_col.w == 0
@@ -106,6 +106,55 @@ class TestScoreboardRegions:
assert reg.x >= 0 and reg.y >= 0 assert reg.x >= 0 and reg.y >= 0
assert reg.right <= w and reg.bottom <= h assert reg.right <= w and reg.bottom <= h
@pytest.mark.parametrize("w,h", [(96, 48), (128, 64), (256, 128), (64, 32), (128, 96)])
def test_2to1_aspect_gets_a_center_reserve(self, w, h):
"""These sizes are all <= 2:1 aspect, where the raw min(h, w//2)
formula claims the entire width for logos and leaves zero pixels
for a center column — the bug this reserve exists to fix."""
regs = scoreboard_regions(Region(0, 0, w, h))
assert regs.center_col.w >= int(w * 0.15) - 1 # -1 for int() rounding
@pytest.mark.parametrize("w,h", [(128, 32), (192, 48), (256, 32)])
def test_wide_panels_unaffected_by_center_reserve(self, w, h):
"""Wide (>= ~4:1) panels already have height as the tighter
constraint, so the center reserve must be a no-op there — the
design-size baseline's proportions shouldn't shift."""
regs = scoreboard_regions(Region(0, 0, w, h))
assert regs.logo_slot == min(h, w // 2)
def test_center_reserve_fraction_is_configurable(self):
# min_center_design_px=0 isolates the fraction term (otherwise the
# scaled absolute floor can dominate and mask a fraction change).
regs_default = scoreboard_regions(Region(0, 0, 128, 64), min_center_design_px=0)
regs_wider = scoreboard_regions(Region(0, 0, 128, 64), min_center_fraction=0.5,
min_center_design_px=0)
assert regs_wider.center_col.w > regs_default.center_col.w
assert regs_wider.logo_slot < regs_default.logo_slot
def test_score_bleed_extends_past_center_col(self):
regs = scoreboard_regions(Region(0, 0, 128, 64), score_bleed_fraction=0.5)
assert regs.score_area.w > regs.center_col.w
assert regs.score_area.x < regs.center_col.x
assert regs.score_area.right > regs.center_col.right
def test_score_bleed_zero_matches_center_col(self):
regs = scoreboard_regions(Region(0, 0, 128, 64), score_bleed_fraction=0.0)
assert regs.score_area.w == regs.center_col.w
assert regs.score_area.x == regs.center_col.x
@pytest.mark.parametrize("w,h", [(64, 32), (96, 48), (128, 64), (256, 128), (128, 96)])
def test_score_never_needs_ellipsis_for_a_short_score(self, w, h, font_manager):
"""The concrete regression this whole reserve/bleed system exists to
prevent: a real game score like '17-21' must always render in full,
never truncated, at every 2:1-or-tighter aspect ratio in the sample."""
ctx = LayoutContext(w, h, font_manager)
regs = scoreboard_regions(Region(0, 0, w, h), ctx=ctx)
height_scale = h / 32.0
fit = ctx.fit_text_proportional("17-21", regs.score_area, base_size_px=10,
ladder=LADDER_ARCADE, scale=height_scale)
assert fit.text == "17-21"
assert fit.fits
def test_ctx_scales_band_heights(self, font_manager): def test_ctx_scales_band_heights(self, font_manager):
small = scoreboard_regions(Region(0, 0, 128, 32), small = scoreboard_regions(Region(0, 0, 128, 32),
ctx=LayoutContext(128, 32, font_manager)) ctx=LayoutContext(128, 32, font_manager))