mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 06:15:09 +00:00
LayoutContext.fit_image keyed images without a cache_key by id() and held a strong reference to the source so the id could not be recycled. A plugin following the documented one-liner -- draw_image(Image.open(path), box) each frame -- never hit that cache and kept the last 64 sources alive: ~64MB for 500x500 RGBA team logos (median size under assets/sports), up to ~600MB for the largest. The entry now holds a weak reference whose callback drops it when the source is freed, and a hit re-checks that the referent is the same image. Sources that cannot be weak-referenced are still pinned. Keyed entries (the only kind any plugin on ledmatrix-plugins main uses today: football-scoreboard's logo fit) are unchanged. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
769 lines
34 KiB
Python
769 lines
34 KiB
Python
"""
|
|
Adaptive layout and font scaling helpers for plugins.
|
|
|
|
Generalizes the three size-adaptation patterns proven in the plugin
|
|
ecosystem into small composable core helpers, so plugins render legibly on
|
|
any panel size (64x32, 128x32, 96x48, 128x64, 256x64, ...) without
|
|
hand-tuned per-display layouts:
|
|
|
|
- Region: integer rect algebra (bands, columns, weighted splits, centering).
|
|
Regions partition space, so text bands can't overlap by construction —
|
|
replacing the magic ``y = 1`` / ``y = height - 7`` offsets tuned for 128x32.
|
|
- Font ladders: ordered (family, size) steps known to render crisply.
|
|
Pixel fonts (BDF, PressStart2P) only look right at native/integer sizes,
|
|
so fonts are never scaled continuously — fitting walks a ladder from the
|
|
largest rung down until the measured text fits the target box. This is
|
|
baseball-scoreboard's fallback-ladder pattern promoted to core.
|
|
- LayoutContext: per-(width, height) facts — breakpoint tiers
|
|
(masters-tournament's pattern), a geometry scale factor vs. a declared
|
|
design size (f1-scoreboard's pattern), and cached fit-text queries.
|
|
|
|
Everything is opt-in: plugins get a context via ``self.layout`` on
|
|
BasePlugin (or construct one directly) and existing plugins are unaffected.
|
|
|
|
Fonts are resolved through FontManager's catalog (family names are
|
|
lowercased file stems from assets/fonts, e.g. "9x15", "tom-thumb", plus
|
|
aliases like "press_start"). FitResult.font is a plain PIL font or
|
|
freetype.Face, so it drops straight into DisplayManager.draw_text().
|
|
"""
|
|
|
|
import logging
|
|
import weakref
|
|
from collections import OrderedDict
|
|
from dataclasses import dataclass
|
|
from typing import Any, Dict, List, Optional, Sequence, Tuple, Union
|
|
|
|
import freetype
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Height-based breakpoint tiers, smallest to largest. A 32px-tall panel is
|
|
# the ecosystem baseline ("sm"); 96x48 lands in "md"; 128x64 in "lg".
|
|
_HEIGHT_TIERS: Tuple[Tuple[str, int], ...] = (
|
|
("xs", 16), ("sm", 32), ("md", 48), ("lg", 64), ("xl", 10 ** 9),
|
|
)
|
|
TIER_ORDER: Tuple[str, ...] = tuple(name for name, _ in _HEIGHT_TIERS)
|
|
|
|
_WIDTH_TIERS: Tuple[Tuple[str, int], ...] = (
|
|
("narrow", 64), ("normal", 128), ("wide", 256), ("ultrawide", 10 ** 9),
|
|
)
|
|
WIDTH_TIER_ORDER: Tuple[str, ...] = tuple(name for name, _ in _WIDTH_TIERS)
|
|
|
|
# The panel size most existing plugins were authored against.
|
|
DEFAULT_DESIGN_SIZE: Tuple[int, int] = (128, 32)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Region:
|
|
"""An integer rectangle. Carving methods return sub-Regions clamped to
|
|
non-negative dimensions, so degenerate panels never produce negative
|
|
boxes — a band request larger than the region simply consumes it all."""
|
|
|
|
x: int
|
|
y: int
|
|
w: int
|
|
h: int
|
|
|
|
def __post_init__(self):
|
|
object.__setattr__(self, "w", max(0, int(self.w)))
|
|
object.__setattr__(self, "h", max(0, int(self.h)))
|
|
object.__setattr__(self, "x", int(self.x))
|
|
object.__setattr__(self, "y", int(self.y))
|
|
|
|
@property
|
|
def right(self) -> int:
|
|
return self.x + self.w
|
|
|
|
@property
|
|
def bottom(self) -> int:
|
|
return self.y + self.h
|
|
|
|
@property
|
|
def center(self) -> Tuple[int, int]:
|
|
return (self.x + self.w // 2, self.y + self.h // 2)
|
|
|
|
# ---- carving -----------------------------------------------------
|
|
|
|
def inset(self, dx: int, dy: Optional[int] = None) -> "Region":
|
|
"""Shrink by dx horizontally and dy (default dx) vertically, each side."""
|
|
if dy is None:
|
|
dy = dx
|
|
return Region(self.x + dx, self.y + dy, self.w - 2 * dx, self.h - 2 * dy)
|
|
|
|
def offset(self, dx: int, dy: int) -> "Region":
|
|
"""Translate without resizing — the hook for user x/y-offset
|
|
customization: compute regions first, then apply the user's
|
|
configured offsets as a final translation."""
|
|
return Region(self.x + dx, self.y + dy, self.w, self.h)
|
|
|
|
def top_band(self, h: int) -> "Region":
|
|
return Region(self.x, self.y, self.w, min(h, self.h))
|
|
|
|
def bottom_band(self, h: int) -> "Region":
|
|
h = min(h, self.h)
|
|
return Region(self.x, self.bottom - h, self.w, h)
|
|
|
|
def middle(self, top_h: int = 0, bottom_h: int = 0) -> "Region":
|
|
"""What remains between a top band and a bottom band."""
|
|
return Region(self.x, self.y + top_h, self.w, self.h - top_h - bottom_h)
|
|
|
|
def left_col(self, w: int) -> "Region":
|
|
return Region(self.x, self.y, min(w, self.w), self.h)
|
|
|
|
def right_col(self, w: int) -> "Region":
|
|
w = min(w, self.w)
|
|
return Region(self.right - w, self.y, w, self.h)
|
|
|
|
def split_h(self, *weights: float, gap: int = 0) -> List["Region"]:
|
|
"""Side-by-side columns sized by weight; gaps between them."""
|
|
sizes = _weighted_sizes(self.w, weights, gap)
|
|
cols, cursor = [], self.x
|
|
for size in sizes:
|
|
cols.append(Region(cursor, self.y, size, self.h))
|
|
cursor += size + gap
|
|
return cols
|
|
|
|
def split_v(self, *weights: float, gap: int = 0) -> List["Region"]:
|
|
"""Stacked rows sized by weight; gaps between them."""
|
|
sizes = _weighted_sizes(self.h, weights, gap)
|
|
rows, cursor = [], self.y
|
|
for size in sizes:
|
|
rows.append(Region(self.x, cursor, self.w, size))
|
|
cursor += size + gap
|
|
return rows
|
|
|
|
# ---- placement ---------------------------------------------------
|
|
|
|
def align_xy(self, w: int, h: int, align: str = "center",
|
|
valign: str = "center") -> Tuple[int, int]:
|
|
"""Top-left position for a w x h box aligned within this region.
|
|
align: left|center|right; valign: top|center|bottom."""
|
|
if align == "left":
|
|
x = self.x
|
|
elif align == "right":
|
|
x = self.right - w
|
|
else:
|
|
x = self.x + (self.w - w) // 2
|
|
if valign == "top":
|
|
y = self.y
|
|
elif valign == "bottom":
|
|
y = self.bottom - h
|
|
else:
|
|
y = self.y + (self.h - h) // 2
|
|
return (x, y)
|
|
|
|
def center_xy(self, w: int, h: int) -> Tuple[int, int]:
|
|
return self.align_xy(w, h)
|
|
|
|
def contains(self, w: int, h: int) -> bool:
|
|
return w <= self.w and h <= self.h
|
|
|
|
|
|
def _weighted_sizes(total: int, weights: Sequence[float], gap: int) -> List[int]:
|
|
"""Integer sizes proportional to weights, remainder spread left-to-right."""
|
|
if not weights:
|
|
return []
|
|
usable = max(0, total - gap * (len(weights) - 1))
|
|
weight_sum = sum(weights) or 1
|
|
sizes = [int(usable * w / weight_sum) for w in weights]
|
|
remainder = usable - sum(sizes)
|
|
for i in range(remainder):
|
|
sizes[i % len(sizes)] += 1
|
|
return sizes
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Font ladders
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@dataclass(frozen=True)
|
|
class FontStep:
|
|
"""One rung: a FontManager catalog family at a size it renders crisply."""
|
|
family: str
|
|
size_px: int
|
|
|
|
|
|
FontLadder = Tuple[FontStep, ...]
|
|
|
|
# X11 BDF bitmap fonts at their native pixel sizes, largest to smallest —
|
|
# baseball-scoreboard's fallback ladder extended upward. Same-height rungs
|
|
# are ordered widest first so width-constrained text steps to a narrower
|
|
# face before dropping a size.
|
|
LADDER_GRID: FontLadder = (
|
|
FontStep("10x20", 20),
|
|
FontStep("9x18", 18),
|
|
FontStep("9x15", 15),
|
|
FontStep("8x13", 13),
|
|
FontStep("7x13", 13),
|
|
FontStep("6x13", 13),
|
|
FontStep("6x12", 12),
|
|
FontStep("6x10", 10),
|
|
FontStep("6x9", 9),
|
|
FontStep("5x8", 8),
|
|
FontStep("5x7", 7),
|
|
FontStep("4x6", 6),
|
|
FontStep("tom-thumb", 6),
|
|
)
|
|
|
|
# PressStart2P at integer multiples of its 8px pixel grid only — fractional
|
|
# sizes blur a pixel font. For headline text (clocks, scores).
|
|
LADDER_ARCADE: FontLadder = (
|
|
FontStep("press_start", 32),
|
|
FontStep("press_start", 24),
|
|
FontStep("press_start", 16),
|
|
FontStep("press_start", 8),
|
|
)
|
|
|
|
LADDER_DEFAULT: FontLadder = LADDER_GRID
|
|
|
|
ELLIPSIS = "…"
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class FitResult:
|
|
"""A fitted font plus the ink metrics of the (possibly ellipsized) text.
|
|
|
|
``y_offset`` is the gap between the y passed to draw_text() and where
|
|
ink actually starts; subtract it from the desired ink-top position when
|
|
drawing (draw_fitted_text does this for you).
|
|
"""
|
|
font: Any
|
|
family: str
|
|
size_px: int
|
|
text: str
|
|
width: int
|
|
height: int
|
|
baseline: int
|
|
y_offset: int
|
|
fits: bool
|
|
line_height: int = 0
|
|
|
|
|
|
def measure_ink(text: str, font: Any) -> Tuple[int, int, int, int]:
|
|
"""Measure the ink box of text: (width, height, baseline, y_offset).
|
|
|
|
y_offset is the distance from the y coordinate DisplayManager.draw_text()
|
|
is given to the top of the actual ink — PIL draws TTF from the em-box
|
|
top and _draw_bdf_text derives the baseline from y + ascender, so both
|
|
leave a font-dependent gap that matters when centering in short bands.
|
|
"""
|
|
if isinstance(font, freetype.Face):
|
|
width = 0
|
|
ascender = font.size.ascender >> 6
|
|
ink_top, ink_bottom = None, None
|
|
for char in text:
|
|
font.load_char(char)
|
|
width += font.glyph.advance.x >> 6
|
|
rows = font.glyph.bitmap.rows
|
|
if rows:
|
|
top = ascender - font.glyph.bitmap_top
|
|
ink_top = top if ink_top is None else min(ink_top, top)
|
|
ink_bottom = top + rows if ink_bottom is None else max(ink_bottom, top + rows)
|
|
if ink_top is None:
|
|
ink_top, ink_bottom = 0, 0
|
|
return (width, ink_bottom - ink_top, ascender, ink_top)
|
|
bbox = font.getbbox(text)
|
|
return (bbox[2] - bbox[0], bbox[3] - bbox[1], -bbox[1], bbox[1])
|
|
|
|
|
|
def font_line_height(font: Any) -> int:
|
|
"""Recommended line spacing for a font (matches DisplayManager.get_font_height)."""
|
|
if isinstance(font, freetype.Face):
|
|
return font.size.height >> 6
|
|
ascent, descent = font.getmetrics()
|
|
return ascent + descent
|
|
|
|
|
|
def measure_font_crispness(font: Any, sample_text: str = "Ay0",
|
|
canvas_size: Tuple[int, int] = (250, 60)) -> float:
|
|
"""Fraction of the rendered sample's ink-bbox pixels that are neither
|
|
pure black nor pure white — i.e. antialiased.
|
|
|
|
BDF (freetype.Face) glyphs are true bitmaps and always render at 0.0.
|
|
"Pixel-style" TTFs (PressStart2P, and similar fonts bundled for
|
|
plugins that draw through ImageDraw.text() and so can't take a BDF
|
|
face) are NOT automatically crisp at arbitrary sizes — PIL antialiases
|
|
TTF outlines by default, and a pixel-grid font only lands on whole
|
|
pixels at specific sizes (for PressStart2P: exact multiples of 8).
|
|
Requesting an unverified size silently produces soft/blurry glyphs on
|
|
an LED panel, which reads as fuzzy compared to a true BDF rung.
|
|
|
|
Use this to vet any custom FontLadder rung that mixes TTF fonts before
|
|
shipping it — see test_adaptive_layout.py::test_ladder_is_crisp for the
|
|
pattern. A rung should score 0.0 (or very close, to allow for the odd
|
|
diagonal stroke) before it belongs in a "crisp" ladder.
|
|
"""
|
|
if isinstance(font, freetype.Face):
|
|
return 0.0
|
|
from PIL import Image, ImageDraw
|
|
img = Image.new("L", canvas_size, 0)
|
|
ImageDraw.Draw(img).text((2, 2), sample_text, font=font, fill=255)
|
|
bbox = img.getbbox()
|
|
if bbox is None:
|
|
return 0.0
|
|
pixels = img.crop(bbox).tobytes()
|
|
pure = sum(1 for p in pixels if p == 0 or p == 255)
|
|
return (len(pixels) - pure) / len(pixels)
|
|
|
|
|
|
class LayoutContext:
|
|
"""Per-render-size layout facts and fit-text queries for one panel size.
|
|
|
|
Construct once per (width, height); BasePlugin.layout does this and
|
|
rebuilds automatically when the logical display size changes.
|
|
"""
|
|
|
|
def __init__(self, width: int, height: int, font_manager: Any,
|
|
design_size: Tuple[int, int] = DEFAULT_DESIGN_SIZE):
|
|
self.width = int(width)
|
|
self.height = int(height)
|
|
self.font_manager = font_manager
|
|
self.design_size = design_size
|
|
self.bounds = Region(0, 0, self.width, self.height)
|
|
self.aspect = self.width / max(1, self.height)
|
|
self.tier = _pick_tier(_HEIGHT_TIERS, self.height)
|
|
self.width_tier = _pick_tier(_WIDTH_TIERS, self.width)
|
|
self.is_wide_short = self.aspect >= 2.5 and self.height <= 32
|
|
design_w, design_h = design_size
|
|
# Geometry scale only (gaps, icon/logo sizes) — never applied to
|
|
# fonts, which step between crisp ladder rungs instead.
|
|
self.scale = min(self.width / max(1, design_w),
|
|
self.height / max(1, design_h))
|
|
# LRU-bounded: entries are small, but keys embed the fitted TEXT —
|
|
# a plugin fitting changing text (a live game clock, a ticker) on a
|
|
# 24/7 service would otherwise grow this without bound.
|
|
self._fit_cache: "OrderedDict[Any, FitResult]" = OrderedDict()
|
|
# LRU-bounded (images are big). An id()-keyed entry watches its
|
|
# source image through a weak reference and is dropped when the
|
|
# source is freed (see fit_image), so the id can't be recycled out
|
|
# from under the cache and the cache never keeps the source alive.
|
|
self._image_cache: "OrderedDict[Any, Tuple[Any, Any]]" = OrderedDict()
|
|
|
|
_IMAGE_CACHE_MAX = 64
|
|
_FIT_CACHE_MAX = 512
|
|
|
|
def _fit_cache_get(self, key: Any) -> Optional["FitResult"]:
|
|
cached = self._fit_cache.get(key)
|
|
if cached is not None:
|
|
self._fit_cache.move_to_end(key)
|
|
return cached
|
|
|
|
def _fit_cache_put(self, key: Any, result: "FitResult") -> None:
|
|
self._fit_cache[key] = result
|
|
while len(self._fit_cache) > self._FIT_CACHE_MAX:
|
|
self._fit_cache.popitem(last=False)
|
|
|
|
# ---- the three adaptation patterns --------------------------------
|
|
|
|
def px(self, base: int, minimum: int = 1, maximum: Optional[int] = None) -> int:
|
|
"""Scale a design-size pixel measurement (f1's pattern): gaps,
|
|
icon sizes, logo slots. Clamped to [minimum, maximum]."""
|
|
value = max(minimum, round(base * self.scale))
|
|
if maximum is not None:
|
|
value = min(value, maximum)
|
|
return value
|
|
|
|
def by_tier(self, mapping: Dict[str, Any], default: Any = None) -> Any:
|
|
"""Pick the value for the nearest defined tier at-or-below the
|
|
panel's height tier (masters' pattern). Falls forward to the
|
|
smallest defined tier above, then to default.
|
|
|
|
by_tier({"sm": 10, "lg": 18}) -> 10 on 128x32, 18 on 128x64.
|
|
Keys may also use width tiers ("narrow", "wide", ...)."""
|
|
order = TIER_ORDER if any(k in TIER_ORDER for k in mapping) else WIDTH_TIER_ORDER
|
|
current = self.tier if order is TIER_ORDER else self.width_tier
|
|
idx = order.index(current)
|
|
for name in reversed(order[: idx + 1]):
|
|
if name in mapping:
|
|
return mapping[name]
|
|
for name in order[idx + 1:]:
|
|
if name in mapping:
|
|
return mapping[name]
|
|
return default
|
|
|
|
def fit_text(self, text: str, box: Union[Region, Tuple[int, int]],
|
|
ladder: FontLadder = LADDER_DEFAULT,
|
|
ellipsis: bool = True) -> FitResult:
|
|
"""Largest ladder rung whose rendered text fits the box (baseball's
|
|
pattern). If even the smallest rung is too wide, the text is
|
|
ellipsized to fit (unless ellipsis=False); fits=False only when no
|
|
acceptable rendering exists."""
|
|
box_w, box_h = _box_dims(box)
|
|
key = ("text", text, box_w, box_h, ladder, ellipsis)
|
|
cached = self._fit_cache_get(key)
|
|
if cached is not None:
|
|
return cached
|
|
result = self._walk_ladder(text, ladder, box_w, box_h, ellipsis)
|
|
self._fit_cache_put(key, result)
|
|
return result
|
|
|
|
def fit_text_proportional(self, text: str, box: Union[Region, Tuple[int, int]],
|
|
base_size_px: int, ladder: FontLadder = LADDER_DEFAULT,
|
|
ellipsis: bool = True,
|
|
scale: Optional[float] = None) -> FitResult:
|
|
"""Ladder rung closest to (but not exceeding) ``base_size_px * scale``
|
|
that still fits the box — proportional sizing instead of ``fit_text``'s
|
|
"always maximize" behavior.
|
|
|
|
Use this when several independently-fitted elements need to stay
|
|
visually harmonious as the panel grows (e.g. a scoreboard's score,
|
|
status, and detail text) — ``fit_text`` maximizes each one within
|
|
its own region, which can make one element balloon out of
|
|
proportion to its neighbors (a huge score overlapping logos it fit
|
|
fine at the design size) even though every individual pick is
|
|
independently "correct". ``base_size_px`` is the size that element
|
|
renders at on the design size (``design_size``, typically 128x32)
|
|
— commonly a plugin's existing classic/fixed font size for that
|
|
element.
|
|
|
|
``scale`` defaults to ``self.scale`` (the same conservative
|
|
min(width_ratio, height_ratio) factor ``px()`` uses — safe for
|
|
content whose aspect ratio matters). Pass an explicit axis-specific
|
|
value when the surrounding composition already scales that way —
|
|
e.g. a scoreboard whose logos scale with height alone
|
|
(``logo_slot = min(height, width // 2)``) should size its score
|
|
text by ``height / design_height`` too, or its text will look
|
|
under-scaled next to bigger logos on a panel that only grew taller.
|
|
|
|
Falls back to the smallest rung when even that exceeds the target
|
|
(a tiny scale factor), and to fit_text's ordinary smaller-rung
|
|
fallback when the closest-to-target rung doesn't actually fit the
|
|
box.
|
|
"""
|
|
box_w, box_h = _box_dims(box)
|
|
effective_scale = self.scale if scale is None else scale
|
|
key = ("text_prop", text, box_w, box_h, ladder, base_size_px, ellipsis, effective_scale)
|
|
cached = self._fit_cache_get(key)
|
|
if cached is not None:
|
|
return cached
|
|
target = base_size_px * effective_scale
|
|
eligible = [step for step in ladder if step.size_px <= target]
|
|
candidates = eligible if eligible else (min(ladder, key=lambda s: s.size_px),)
|
|
result = self._walk_ladder(text, candidates, box_w, box_h, ellipsis)
|
|
self._fit_cache_put(key, result)
|
|
return result
|
|
|
|
def _walk_ladder(self, text: str, ladder: Sequence[FontStep],
|
|
box_w: int, box_h: int, ellipsis: bool) -> FitResult:
|
|
"""Shared by fit_text/fit_text_proportional: first ladder entry (in
|
|
the order given) whose rendered text fits, ellipsizing the last one
|
|
tried if none do."""
|
|
result = None
|
|
for step in ladder:
|
|
font = self.font_manager.get_font(step.family, step.size_px)
|
|
width, height, baseline, y_offset = measure_ink(text, font)
|
|
result = FitResult(font, step.family, step.size_px, text,
|
|
width, height, baseline, y_offset,
|
|
fits=(width <= box_w and height <= box_h),
|
|
line_height=font_line_height(font))
|
|
if result.fits:
|
|
break
|
|
|
|
if result is not None and not result.fits and ellipsis:
|
|
short = self.ellipsize(text, result.font, box_w)
|
|
width, height, baseline, y_offset = measure_ink(short, result.font)
|
|
result = FitResult(result.font, result.family, result.size_px,
|
|
short, width, height, baseline, y_offset,
|
|
fits=(width <= box_w and height <= box_h),
|
|
line_height=result.line_height)
|
|
return result
|
|
|
|
def fit_lines(self, lines: Sequence[str], box: Union[Region, Tuple[int, int]],
|
|
ladder: FontLadder = LADDER_DEFAULT,
|
|
spacing: int = 1) -> FitResult:
|
|
"""Largest rung where every line fits the box width and the stacked
|
|
lines (line_height + spacing apart) fit the box height. Measures the
|
|
actual strings, so a long line pushes the ladder down a rung a short
|
|
one wouldn't (baseball's multiline pattern). Text is the widest line."""
|
|
box_w, box_h = _box_dims(box)
|
|
key = ("lines", tuple(lines), box_w, box_h, ladder, spacing)
|
|
cached = self._fit_cache_get(key)
|
|
if cached is not None:
|
|
return cached
|
|
|
|
rows = max(1, len(lines))
|
|
result = None
|
|
for step in ladder:
|
|
font = self.font_manager.get_font(step.family, step.size_px)
|
|
line_h = font_line_height(font)
|
|
widest, metrics = "", (0, 0, 0, 0)
|
|
for line in lines:
|
|
m = measure_ink(line, font)
|
|
if m[0] >= metrics[0]:
|
|
widest, metrics = line, m
|
|
total_h = rows * line_h + (rows - 1) * spacing
|
|
result = FitResult(font, step.family, step.size_px, widest,
|
|
metrics[0], metrics[1], metrics[2], metrics[3],
|
|
fits=(metrics[0] <= box_w and total_h <= box_h),
|
|
line_height=line_h)
|
|
if result.fits:
|
|
break
|
|
|
|
self._fit_cache_put(key, result)
|
|
return result
|
|
|
|
def font_for_rows(self, rows: int, box_h: int,
|
|
ladder: FontLadder = LADDER_GRID) -> FitResult:
|
|
"""Largest rung whose line height lets `rows` rows fit in box_h
|
|
(baseball's traditional-scoreboard pattern). Measures a digit/cap
|
|
sample rather than specific strings."""
|
|
key = ("rows", rows, box_h, ladder)
|
|
cached = self._fit_cache_get(key)
|
|
if cached is not None:
|
|
return cached
|
|
|
|
sample = "0Ay"
|
|
result = None
|
|
for step in ladder:
|
|
font = self.font_manager.get_font(step.family, step.size_px)
|
|
line_h = font_line_height(font)
|
|
width, height, baseline, y_offset = measure_ink(sample, font)
|
|
result = FitResult(font, step.family, step.size_px, sample,
|
|
width, height, baseline, y_offset,
|
|
fits=(max(1, rows) * line_h <= box_h),
|
|
line_height=line_h)
|
|
if result.fits:
|
|
break
|
|
|
|
self._fit_cache_put(key, result)
|
|
return result
|
|
|
|
# ---- images ---------------------------------------------------------
|
|
|
|
def fit_image(self, img: Any, box: Union[Region, Tuple[int, int]], *,
|
|
mode: str = "contain", crop_to_ink: bool = False,
|
|
anchor: str = "center", resample: Any = None,
|
|
upscale: bool = True, cache_key: Any = None) -> Any:
|
|
"""Fit an image into a box (see src/adaptive_images.py for modes),
|
|
cached per (image, box size, options) for this panel size.
|
|
|
|
Prefer a stable ``cache_key`` (e.g. "logo:KC") for images that get
|
|
reloaded — the default id()-based key misses across reloads of the
|
|
same content.
|
|
|
|
An id()-keyed entry lives only as long as its source image: it holds
|
|
a weak reference and is dropped when the source is freed. It used to
|
|
pin the source instead, so a plugin passing a freshly loaded image
|
|
each frame (``draw_image(Image.open(path), box)``, the documented
|
|
one-liner) never hit and kept the last 64 sources alive — ~64MB for
|
|
500x500 RGBA team logos, the median size under assets/sports.
|
|
"""
|
|
from src.adaptive_images import fit_image as _fit_image
|
|
|
|
box_w, box_h = _box_dims(box)
|
|
resample_name = getattr(resample, "name", repr(resample)) if resample is not None else "default"
|
|
identity = cache_key if cache_key is not None else ("id", id(img))
|
|
key = ("image", identity, img.size, box_w, box_h, mode,
|
|
crop_to_ink, anchor, resample_name, upscale)
|
|
|
|
cache = self._image_cache
|
|
cached = cache.get(key)
|
|
# An id()-keyed hit must still be this very image; the callback below
|
|
# normally removes a dead source's entry before its id can recur.
|
|
if cached is not None and (cache_key is not None or cached[1]() is img):
|
|
cache.move_to_end(key)
|
|
return cached[0]
|
|
|
|
result = _fit_image(img, (box_w, box_h), mode=mode,
|
|
crop_to_ink=crop_to_ink, anchor=anchor,
|
|
resample=resample, upscale=upscale)
|
|
source = None
|
|
if cache_key is None:
|
|
def _forget(ref: Any, key: Any = key) -> None:
|
|
entry = cache.get(key)
|
|
if entry is not None and entry[1] is ref:
|
|
cache.pop(key, None)
|
|
try:
|
|
source = weakref.ref(img, _forget)
|
|
except TypeError:
|
|
# Not weak-referenceable: pin it, as before.
|
|
source = lambda img=img: img # noqa: E731
|
|
cache[key] = (result, source)
|
|
while len(cache) > self._IMAGE_CACHE_MAX:
|
|
cache.popitem(last=False)
|
|
return result
|
|
|
|
# ---- text utilities ------------------------------------------------
|
|
|
|
def ellipsize(self, text: str, font: Any, max_w: int) -> str:
|
|
"""Trim text to fit max_w, appending an ellipsis. Returns '' when
|
|
not even the ellipsis fits."""
|
|
if measure_ink(text, font)[0] <= max_w:
|
|
return text
|
|
for end in range(len(text) - 1, 0, -1):
|
|
candidate = text[:end].rstrip() + ELLIPSIS
|
|
if measure_ink(candidate, font)[0] <= max_w:
|
|
return candidate
|
|
return ELLIPSIS if measure_ink(ELLIPSIS, font)[0] <= max_w else ""
|
|
|
|
def measure(self, text: str, font: Any) -> Tuple[int, int, int]:
|
|
"""Ink (width, height, baseline) of text — see measure_ink."""
|
|
width, height, baseline, _ = measure_ink(text, font)
|
|
return (width, height, baseline)
|
|
|
|
def clear_cache(self) -> None:
|
|
"""Drop cached fit results (call after fonts are reloaded)."""
|
|
self._fit_cache.clear()
|
|
self._image_cache.clear()
|
|
|
|
|
|
def _pick_tier(tiers: Tuple[Tuple[str, int], ...], value: int) -> str:
|
|
for name, limit in tiers:
|
|
if value <= limit:
|
|
return name
|
|
return tiers[-1][0]
|
|
|
|
|
|
def _box_dims(box: Union[Region, Tuple[int, int]]) -> Tuple[int, int]:
|
|
if isinstance(box, Region):
|
|
return (box.w, box.h)
|
|
w, h = box
|
|
return (int(w), int(h))
|
|
|
|
|
|
def draw_fitted_text(display_manager: Any, fit: FitResult,
|
|
box: Union[Region, Tuple[int, int]],
|
|
color: Tuple[int, int, int] = (255, 255, 255),
|
|
align: str = "center", valign: str = "center") -> None:
|
|
"""Draw a FitResult's text aligned within a Region via
|
|
DisplayManager.draw_text(), compensating for the font's ink offset so
|
|
the ink (not the em box) is what gets aligned."""
|
|
region = box if isinstance(box, Region) else Region(0, 0, box[0], box[1])
|
|
x, y = region.align_xy(fit.width, fit.height, align, valign)
|
|
display_manager.draw_text(fit.text, x=x, y=y - fit.y_offset,
|
|
color=color, font=fit.font)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Composite layouts — the region arrangements repeated across plugins,
|
|
# expressed as Region math so migrated plugins stop hand-copying coordinate
|
|
# formulas. Deliberately tiny: these return Regions, they don't draw.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@dataclass(frozen=True)
|
|
class ScoreboardRegions:
|
|
"""The two-logos-plus-center-score card shared by the sports plugins."""
|
|
bounds: Region
|
|
logo_slot: int # width of each logo slot: min(H, W // 2), center-reserved
|
|
away_slot: Region # left logo slot
|
|
home_slot: Region # right logo slot
|
|
center_col: Region # column between the slots (>= min_center_fraction of width)
|
|
status_band: Region # top band (replaces the magic y = 1)
|
|
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)
|
|
bottom_left: Region # bottom corner: away records / timeouts
|
|
bottom_right: Region # bottom corner: home records / timeouts
|
|
|
|
|
|
def scoreboard_regions(bounds: Region, *, ctx: Optional["LayoutContext"] = None,
|
|
status_h: Optional[int] = None,
|
|
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.
|
|
|
|
Encodes the invariant duplicated across the sports plugins:
|
|
``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
|
|
the right.
|
|
|
|
That formula alone has a blind spot: at exactly 2:1 aspect ratio
|
|
(width == 2 * height — a very common shape, e.g. 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. 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:
|
|
status_h = ctx.px(9, minimum=7) if ctx else 9
|
|
if detail_h is None:
|
|
detail_h = ctx.px(8, minimum=7) if ctx else 8
|
|
|
|
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)
|
|
home_slot = bounds.right_col(logo_slot)
|
|
center_col = Region(bounds.x + logo_slot, bounds.y,
|
|
bounds.w - 2 * logo_slot, bounds.h)
|
|
status_band = bounds.top_band(status_h)
|
|
detail_band = bounds.bottom_band(detail_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)
|
|
return ScoreboardRegions(
|
|
bounds=bounds, logo_slot=logo_slot,
|
|
away_slot=away_slot, home_slot=home_slot, center_col=center_col,
|
|
status_band=status_band, score_area=score_area, detail_band=detail_band,
|
|
bottom_left=bottom.left_col(logo_slot),
|
|
bottom_right=bottom.right_col(logo_slot),
|
|
)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class MediaRow:
|
|
"""Art/icon on the left, text column on the right (music's idiom)."""
|
|
art: Region
|
|
body: Region
|
|
|
|
|
|
def media_row(bounds: Region, *, ctx: Optional["LayoutContext"] = None,
|
|
square: bool = True, gap: Optional[int] = None) -> MediaRow:
|
|
"""Split a Region into an art slot and a body column.
|
|
|
|
With ``square=True`` the art slot is bounds.h wide (album-art style);
|
|
otherwise it takes the left half. The gap defaults to 2px scaled by the
|
|
context's geometry factor.
|
|
"""
|
|
if gap is None:
|
|
gap = ctx.px(2, minimum=1) if ctx else 2
|
|
art_w = bounds.h if square else bounds.w // 2
|
|
art_w = min(art_w, bounds.w)
|
|
art = bounds.left_col(art_w)
|
|
body = Region(bounds.x + art_w + gap, bounds.y,
|
|
bounds.w - art_w - gap, bounds.h)
|
|
return MediaRow(art=art, body=body)
|