mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-08-01 16:58:06 +00:00
* feat(layout): adaptive layout & font scaling system for plugins Add src/adaptive_layout.py — opt-in core helpers so plugins render legibly on any panel size without hand-tuned per-display layouts: - Region: integer rect algebra (bands/columns/weighted splits/centering) that partitions space so text bands can't overlap by construction - Font ladders: ordered (family, size) steps known to render crisply (LADDER_GRID: X11 BDFs at native sizes; LADDER_ARCADE: PressStart2P at 8px multiples) — fitting walks the ladder instead of scaling pixel fonts fractionally - LayoutContext: breakpoint tiers, geometry scale vs. a declared design size, and cached fit_text/fit_lines/font_for_rows queries Generalizes the three patterns proven in the field: f1-scoreboard's scale factor, masters-tournament's tiers, baseball-scoreboard's font fallback ladder. Wiring: BasePlugin gains a lazy .layout property and draw_fit(); FontManager gains get_native_bdf_size() and a cache_generation counter; manifest schema gains display.design_size and requires.display_size max_width/max_height; 96x48 joins DEFAULT_TEST_SIZES; the bounds-check harness records negative-coordinate draws; TextHelper's broken measurement helpers are fixed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(layout): adaptive image fitting + composite region helpers Add src/adaptive_images.py — the image counterpart to fit_text: - fit_image(img, box, mode=contain|cover|fill_height|stretch, crop_to_ink, anchor, resample, upscale) promoting the proven plugin patterns (football's crop-to-ink fill-height logos, masters' cover crop + NEAREST flags, static-image's letterbox). Upscales by default — thumbnail()'s downscale-only behavior is why imagery stays tiny on big panels. - draw_fitted_image() pastes aligned within a Region with alpha mask. - One central Pillow>=9.1 RESAMPLE shim replacing ~15 plugin copies. LayoutContext.fit_image() caches results per (identity, box size, options) with a 64-entry LRU; id()-keyed entries pin the source image. BasePlugin.draw_image() is the one-liner adoption path beside draw_fit. Composites in adaptive_layout.py: Region.offset() (user x/y-offset passthrough), scoreboard_regions() (the two-logos-plus-score card math duplicated across six sports plugins, logo_slot = min(H, W//2)), and media_row() (art-left/text-right). Fix LogoHelper's size-blind cache key (stale sizes on panel change); deprecation note on dead image_utils.py. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(harness): scale-up fill check, config variants, multi-size dev gallery Quality gates for adaptive layout: - fill_metrics()/check_scale_up() in the safety harness: overflow catches content too big for a panel, but nothing caught content that stays tiny on panels >= 2x the plugin's declared design size. The check measures lit-content extents and warns (or fails, when a plugin opts into "fill_check": "strict" in test/harness.json) below 50% coverage on the doubled axis. Warn-only by default so no existing plugin breaks. - harness.json "variants": extra runs with config overlays and their own golden dirs, so an opt-in mode (e.g. layout_mode: adaptive) is golden- tested beside the classic default. check_plugin.py loops base + variants and labels variant results mode@name. - Dev preview server: GET /api/sizes (harness size sample), POST /api/render-matrix (render at up to 12 sizes in one call), size-preset dropdown, and an "All Sizes" side-by-side gallery in the preview UI. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(plugins): adaptive-lib discoverability + advisory version compat warning Discoverability: re-export the adaptive layout/image API from src.common (the blessed-helpers package plugin authors already know) — canonical paths stay src.adaptive_layout / src.adaptive_images so nothing breaks. Document it in src/common/README.md and cross-link ADAPTIVE_LAYOUT.md from the developer docs authors actually read (quick reference, API reference, advanced dev, font manager, dev preview, plugin dev guide); ADAPTIVE_LAYOUT.md gains adaptive-images, composite-layouts and preserving-user-customization sections. Compat: PluginLoader now logs one advisory warning (never raises) when a plugin's manifest declares a min LEDMatrix version newer than the running core, checking the min_ledmatrix_version / requires.* / versions[] spellings found in the wild. Guarded against stale core version numbers. src/__init__.py __version__ bumped 1.0.0 -> 3.1.0 to match the latest release tag (v3.1.0) — it had never been updated and the compat check needs a truthful number. NOTE: verify this matches the intended release numbering before the next tag. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(layout): add measure_font_crispness — verify a ladder rung isn't blurry PIL antialiases TTF outlines by default; a 'pixel-style' font only rasterizes without antialiasing at specific sizes (for PressStart2P: exact multiples of its 8px design grid). A ladder rung at an unverified size silently renders blurry on an LED panel — this exact bug shipped in both text-display's and football-scoreboard's custom TTF ladders (non-8-multiple PressStart2P sizes, and '5by7.regular'/'4x6-font' at sizes that were never actually crisp). measure_font_crispness(font, sample_text) renders the sample and reports the fraction of ink-bbox pixels that are neither pure black nor pure white. BDF fonts (real bitmaps) always score 0.0; TTF ladders should be verified against this before shipping — see the new TestFontFitting::test_ladder_arcade_is_crisp pattern. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(layout): add fit_text_proportional — proportional sizing vs. always-maximize fit_text always picks the largest ladder rung that fits its box. That's right when an element owns dedicated space, but wrong when several independently-fitted elements need to stay visually harmonious as the panel grows: a score's box might have generous room while a neighboring logo scales by a fixed geometry factor via px() — fit_text lets the score balloon out of proportion (even overlapping the logo) even though its individual pick is technically correct. fit_text_proportional(text, box, base_size_px, ladder) instead targets base_size_px * self.scale (the same scale factor px() already uses), picking the nearest ladder rung at or below that target, still capped to what fits the box, floored at the smallest rung when the target is below every rung. Refactored the shared largest-that-fits/ellipsize walk into _walk_ladder() so fit_text and fit_text_proportional don't duplicate it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(layout): fit_text_proportional gains an axis-specific scale override self.scale (min(width_ratio, height_ratio)) is the right conservative default for anything whose aspect ratio matters, but a caller whose surrounding composition already scales along a single axis — e.g. football-scoreboard's logo_slot = min(height, width // 2), which tracks height alone — needs text sized the same way, or it reads as under-scaled next to logos that grew on a panel that only got taller (128x32 -> 128x64: self.scale stays 1.0 since width didn't grow, but logos still double). fit_text_proportional(..., scale=None) now accepts an explicit override; None keeps the existing self.scale default. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * 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> * docs: document scoreboard_regions' center-reserve and score-bleed params Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: address CodeRabbit review on PR #393 - docs: scope the self.layout note to BasePlugin subclasses (others build a LayoutContext directly) and make explicit that adaptive layout is opt-in — classic rendering stays unless a plugin adopts the APIs. - dev_server: broaden the render-request catch (a bad manifest.json now returns a clean 400 instead of an unhandled 500) and stop echoing raw exception text in the loader-failure responses — full tracebacks go to the dev server's console log instead. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FqzC1nzTWL4kaqgMaQZFam * fix(dev-server): allowlist plugin_id before any path lookup CodeQL (py/path-injection): plugin_id arrives in request input and flows into filesystem paths via find_plugin_dir. Gate it with the same ^[a-zA-Z0-9_-]{1,64}$ allowlist the web UI's pages_v3 uses, at the single choke point every route resolves through. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FqzC1nzTWL4kaqgMaQZFam * fix(dev-server): lexical containment check on resolved plugin dirs CodeQL doesn't recognize the interprocedural allowlist as a path-injection barrier; add the canonical one — normalize (without following symlinks, since dev plugins are commonly symlinked into plugins/) and require the result to stay inside the search dir. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FqzC1nzTWL4kaqgMaQZFam * fix(dev-server): inline normpath containment barrier before render CodeQL doesn't credit the sanitization inside find_plugin_dir along this flow; apply its documented barrier (normpath + startswith against the allowed roots) inline in _parse_render_request, on the exact path that reaches the render/load sinks. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FqzC1nzTWL4kaqgMaQZFam * fix(dev-server): derive plugin dir from trusted directory listings CodeQL's barrier-guard recognition doesn't see a startswith check inside an any() comprehension, so the normalize-and-prefix approach still flagged. Break the taint outright instead: after lookup, re-derive the directory by enumerating the search dirs (iterdir) and matching by path equality — the Path used for all downstream file access is built solely from trusted listings, never from request input. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FqzC1nzTWL4kaqgMaQZFam * fix(dev-server): use os.scandir for path-injection barrier, redact stack traces from render responses CodeQL doesn't model Path.iterdir() as a taint-clearing enumeration the way it does os.scandir() -- _trusted_plugin_dir's iterdir-based rebuild still traced plugin_id through to the manifest.json open(). Switched to scandir, matching the pattern already verified clean on PR #396. Also stops surfacing raw exception text (update()/display() failures) in the JSON render response -- logs full detail server-side via exc_info instead, returning only the exception class name to the client. And drops path values from three plugin_loader debug/error logs that CodeQL flags as clear-text-logging of externally-influenced data, keeping plugin_id (not flagged) for context. * fix(dev-server): remove conditional-reassignment ambiguity in plugin_dir resolution CodeQL's path-injection flow still traced through _parse_render_request after the scandir fix -- the tainted find_plugin_dir() result and the scandir-derived _trusted_plugin_dir() result shared the same variable name (plugin_dir), reassigned only on the truthy branch. That merge point apparently isn't treated as a barrier by the flow analysis, so it kept tracing the pre-reassignment value through to the manifest open(). Split into two distinct names -- candidate_dir (tainted, used only to call _trusted_plugin_dir) and trusted_dir (the only name used for any downstream file access) -- so there's no reassigned variable for the flow to walk through. * fix: remove unused imports flagged by Codacy Union in adaptive_images.py and field in adaptive_layout.py are both imported but never used -- the last two Codacy findings on this PR, matching the same fix already applied on PR #396. * fix(layout): bound the fit cache; never alias the source image in fits Two latent issues found in a self-review pass: - LayoutContext._fit_cache was an unbounded dict (the image cache got an LRU cap, the text-fit cache didn't). Cache keys embed the fitted TEXT, so a plugin fitting changing strings — a live game clock, a ticker — on a 24/7 service grows it forever. Now LRU-bounded at 512 entries via the same pattern as the image cache. - fit_image returned the caller's ORIGINAL image object when the source was already RGBA at target size (contain/fill_height, no ink crop). ImageFitResult is documented as an independent copy, and LayoutContext caches results — an aliased image lets later mutations of the source corrupt cached fits (or vice versa). Copy in that branch. Both covered by new regression tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FqzC1nzTWL4kaqgMaQZFam --------- Co-authored-by: Chuck <chuck@example.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
747 lines
32 KiB
Python
747 lines
32 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
|
|
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). Entries hold a strong reference to
|
|
# the source image when keyed by id() so the id can't be recycled
|
|
# out from under the cache.
|
|
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 is safe (the entry pins the
|
|
source image) but misses across reloads of the same content.
|
|
"""
|
|
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)
|
|
|
|
cached = self._image_cache.get(key)
|
|
if cached is not None:
|
|
self._image_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)
|
|
# Pin the source only for id()-keyed entries (see docstring).
|
|
self._image_cache[key] = (result, img if cache_key is None else None)
|
|
while len(self._image_cache) > self._IMAGE_CACHE_MAX:
|
|
self._image_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)
|