mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-08-01 16:58:06 +00:00
235 lines
10 KiB
Markdown
235 lines
10 KiB
Markdown
# Adaptive Layout & Font Scaling
|
||
|
||
`src/adaptive_layout.py` lets a plugin render legibly on **any** panel size
|
||
(64x32, 128x32, 96x48, 128x64, 256x64, ...) without hand-tuned per-display
|
||
layouts. It is **opt-in**: nothing changes for plugins that don't use it.
|
||
|
||
It generalizes three patterns proven in the plugin ecosystem:
|
||
|
||
| Pattern | Origin | Core API |
|
||
|---|---|---|
|
||
| Geometry scale factor vs. a design size | f1-scoreboard | `ctx.px(base)` / `ctx.scale` |
|
||
| Breakpoint tiers | masters-tournament | `ctx.tier` / `ctx.by_tier({...})` |
|
||
| "Largest crisp font that fits" ladder | baseball-scoreboard | `ctx.fit_text(...)` and friends |
|
||
|
||
## Quick start
|
||
|
||
Every `BasePlugin` has a lazy `self.layout` (a `LayoutContext` for the
|
||
current logical display size, rebuilt automatically if the size changes)
|
||
and a one-liner `self.draw_fit(...)`:
|
||
|
||
```python
|
||
def display(self, force_clear=False):
|
||
from src.adaptive_layout import LADDER_ARCADE
|
||
|
||
b = self.layout.bounds.inset(1) # Region(0,0,W,H) minus 1px margin
|
||
rows = b.split_v(3, 1, 1, gap=1) # 3/5 for time, 1/5 each for the rest
|
||
|
||
self.draw_fit(self.time_str, rows[0], ladder=LADDER_ARCADE)
|
||
self.draw_fit(self.weekday, rows[1]) # default LADDER_GRID
|
||
self.draw_fit(self.date_str, rows[2])
|
||
self.display_manager.update_display()
|
||
```
|
||
|
||
On 128x64 the time renders at press_start 24px; on 64x32 it steps down to
|
||
8px. The rows partition the height, so bands can never overlap — no more
|
||
`y = height - 7` magic numbers.
|
||
|
||
## Region — rect algebra
|
||
|
||
`Region(x, y, w, h)` is a frozen dataclass. All carving clamps to
|
||
non-negative dimensions, so degenerate panels behave.
|
||
|
||
- Carving: `inset(dx, dy)`, `top_band(h)`, `bottom_band(h)`,
|
||
`middle(top_h, bottom_h)`, `left_col(w)`, `right_col(w)`,
|
||
`split_h(*weights, gap=0)`, `split_v(*weights, gap=0)`
|
||
- Placement: `align_xy(w, h, align, valign)`, `center_xy(w, h)`,
|
||
`contains(w, h)`, `.center`, `.right`, `.bottom`
|
||
|
||
Scoreboard-style layout:
|
||
|
||
```python
|
||
b = self.layout.bounds
|
||
status = b.top_band(self.layout.px(7))
|
||
detail = b.bottom_band(self.layout.px(7))
|
||
score_area = b.middle(status.h, detail.h)
|
||
away_slot, home_slot = b.left_col(b.h), b.right_col(b.h)
|
||
```
|
||
|
||
## Font ladders — discrete, never fractional
|
||
|
||
Pixel fonts (BDF, PressStart2P) only look right at native/integer sizes, so
|
||
fonts are never scaled continuously. A `FontLadder` is an ordered tuple of
|
||
`FontStep(family, size_px)` rungs, largest first; fitting walks down until
|
||
the measured text fits.
|
||
|
||
- `LADDER_GRID` (default): X11 BDFs at native sizes — 10x20 → 9x18 → 9x15 →
|
||
8x13 → 7x13 → 6x13 → 6x12 → 6x10 → 6x9 → 5x8 → 5x7 → 4x6 → tom-thumb.
|
||
Body text, labels, multi-row content.
|
||
- `LADDER_ARCADE`: PressStart2P at 32/24/16/8 (integer multiples of its 8px
|
||
grid). Headline text: clocks, scores.
|
||
|
||
Custom ladders are just tuples — e.g. to add your plugin's registered font
|
||
on top: `(FontStep("myplugin::digits", 16),) + LADDER_GRID`.
|
||
|
||
## LayoutContext
|
||
|
||
Built per (width, height); exposes facts and fit queries:
|
||
|
||
- `bounds`, `width`, `height`, `aspect`
|
||
- `tier` by height (`xs`≤16, `sm`≤32, `md`≤48, `lg`≤64, `xl`) and
|
||
`width_tier` (`narrow`≤64, `normal`≤128, `wide`≤256, `ultrawide`)
|
||
- `is_wide_short` — aspect ≥ 2.5 and height ≤ 32 (the classic 128x32 shape)
|
||
- `scale` — `min(w/design_w, h/design_h)` vs. your manifest's
|
||
`display.design_size` (default 128x32). **Geometry only** — gaps, icon
|
||
and logo sizes via `px(base, minimum, maximum)`; fonts use ladders.
|
||
- `by_tier({"sm": 10, "lg": 18})` — value for the nearest defined tier
|
||
at-or-below the panel's tier.
|
||
- `fit_text(text, box, ladder, ellipsis=True)` → `FitResult` — largest rung
|
||
that fits; ellipsizes as a last resort. Cached per (text, box, ladder).
|
||
- `fit_text_proportional(text, box, base_size_px, ladder, ellipsis=True, scale=None)` —
|
||
rung closest to (not exceeding) `base_size_px * scale`, still capped to
|
||
what fits the box. Use this instead of `fit_text` when several
|
||
independently-fitted elements need to stay visually harmonious as the
|
||
panel grows — `fit_text` maximizes *each one* within its own region,
|
||
which can make one element (e.g. a score with a generous box) balloon
|
||
out of proportion to a neighbor that scales by geometry (e.g. logos
|
||
sized via `px()`), even though each individual pick is "correct" in
|
||
isolation. `base_size_px` is normally the element's existing classic/
|
||
fixed font size. `scale` defaults to `self.scale` (the conservative
|
||
min-of-both-axes factor `px()` uses); pass an axis-specific value when
|
||
the surrounding composition already scales that way — e.g. a scoreboard
|
||
whose logo slots track height alone (`min(height, width // 2)`) should
|
||
size its text by `height / design_height` too, or the text reads as
|
||
under-scaled next to bigger logos on a panel that only grew taller.
|
||
- `fit_lines(lines, box, ladder, spacing)` — every line fits the width and
|
||
the stack fits the height (measures the actual strings).
|
||
- `font_for_rows(rows, box_h, ladder)` — largest rung whose line height
|
||
fits `rows` rows.
|
||
|
||
`FitResult` carries the ready-to-use `font` (drops straight into
|
||
`display_manager.draw_text(font=...)`), the possibly-ellipsized `text`,
|
||
ink `width`/`height`, `baseline`, `y_offset`, `line_height`, and `fits`.
|
||
|
||
## Adaptive images
|
||
|
||
`src/adaptive_images.py` is the image counterpart to `fit_text`, exposed as
|
||
`self.layout.fit_image(...)` (cached per panel size) and the one-liner
|
||
`self.draw_image(...)`:
|
||
|
||
```python
|
||
# Team logo: trim its transparent padding, fill the slot height (the
|
||
# football/hockey pattern), cached across frames by a stable key
|
||
self.draw_image(logo, regs.away_slot, mode="fill_height",
|
||
crop_to_ink=True, cache_key=f"logo:{abbr}")
|
||
|
||
# Album art: cover-crop a square, faces kept by the top anchor
|
||
self.draw_image(art, row.art, mode="cover", anchor="top")
|
||
|
||
# Pixel flags / sprite icons: NEAREST keeps hard edges
|
||
from src.adaptive_images import RESAMPLE_NEAREST
|
||
self.draw_image(flag, box, resample=RESAMPLE_NEAREST)
|
||
```
|
||
|
||
Modes: `contain` (letterbox, default), `cover` (crop-to-fill),
|
||
`fill_height` (logo-style), `stretch`. Unlike PIL's `thumbnail()`
|
||
(downscale-only — why imagery stays tiny on big panels) fitting **upscales
|
||
by default**; pass `upscale=False` for the legacy behavior. Results are
|
||
cached per (image, box size, options) with a bounded LRU — always pass a
|
||
stable `cache_key` (e.g. `"logo:KC"`) for images you reload. The module
|
||
also exports the Pillow-compat `RESAMPLE_LANCZOS`/`RESAMPLE_NEAREST`
|
||
constants so plugins can drop their local shims.
|
||
|
||
## Composite layouts
|
||
|
||
Pre-carved Region arrangements for the layouts plugins keep rebuilding:
|
||
|
||
```python
|
||
from src.adaptive_layout import scoreboard_regions, media_row
|
||
|
||
regs = scoreboard_regions(self.layout.bounds, ctx=self.layout)
|
||
# regs.away_slot / home_slot — logo slots (logo_slot = min(H, W // 2),
|
||
# capped so a center reserve always exists —
|
||
# see below)
|
||
# regs.status_band — top band (replaces the magic y = 1)
|
||
# regs.score_area — center gap, plus a controlled bleed into
|
||
# each logo slot (replaces y = H//2 - 3)
|
||
# regs.detail_band — bottom band (replaces y = H - 7)
|
||
# regs.bottom_left / bottom_right — record/timeout corners
|
||
|
||
row = media_row(self.layout.bounds, ctx=self.layout) # art left, text right
|
||
```
|
||
|
||
Both work on the full panel or on a scroll-mode card Region. They return
|
||
Regions and never draw — compose them with `draw_fit`/`draw_image`.
|
||
|
||
**`scoreboard_regions`'s center reserve.** The raw `logo_slot = min(H, W//2)`
|
||
formula has a blind spot: at exactly 2:1 aspect ratio (width = 2×height —
|
||
two, four, or more square modules stacked into a taller panel, e.g.
|
||
96x48, 128x64, 256x128) the two logo slots mathematically claim the
|
||
*entire* width, leaving zero pixels for a center column no matter how
|
||
big the panel gets. Wide panels (the 128x32 design baseline, 192x48,
|
||
256x32) never hit this, since height is already the tighter constraint
|
||
there. Two parameters fix it without any plugin-side code:
|
||
`min_center_fraction`/`min_center_design_px` guarantee a real minimum
|
||
center reserve at any aspect ratio, and `score_bleed_fraction` lets the
|
||
score's *fit box* extend a controlled amount into each logo slot — the
|
||
same way a real broadcast scoreboard's numbers cross slightly into the
|
||
team marks flanking them — so a short score string never has to truncate
|
||
even on the tightest aspect ratios. All three have sane defaults; override
|
||
them per call if a plugin's card proportions genuinely differ.
|
||
|
||
## Preserving user customization
|
||
|
||
Adaptive layout supplies *defaults*; explicit user configuration wins:
|
||
|
||
- **User-set fonts win.** If the plugin's config has an explicit
|
||
`font`/`font_size` for an element, load it as before and skip the ladder —
|
||
fit only when the user hasn't overridden (see the football-scoreboard
|
||
`_resolve_element_fit` pattern).
|
||
- **Offsets apply on top.** `customization.layout.<element>.{x_offset,y_offset}`
|
||
style knobs translate the *computed* region as a final step:
|
||
`region.offset(user_dx, user_dy)`. `draw_image(..., offset=(dx, dy))`
|
||
does the same for images.
|
||
- **Colors pass through.** `draw_fit`/`draw_fitted_text` take explicit
|
||
`color=` params; adaptive mode never repaints semantic or user-chosen
|
||
colors.
|
||
|
||
## Manifest declaration
|
||
|
||
Declare the size your layout was authored against so `ctx.scale` means
|
||
something:
|
||
|
||
```json
|
||
"display": { "design_size": { "width": 128, "height": 32 } }
|
||
```
|
||
|
||
Also available under `requires.display_size`: `min_width`, `min_height`,
|
||
`max_width`, `max_height`.
|
||
|
||
## Performance notes (Pi)
|
||
|
||
Fit queries are cached, so cost is O(unique strings). For per-second text
|
||
(clocks, live scores), fit on a **shape placeholder** and reuse the font:
|
||
|
||
```python
|
||
fit = self.layout.fit_text("00:00", box, ladder=LADDER_ARCADE) # cached once
|
||
self.display_manager.draw_text(current_time, font=fit.font, ...)
|
||
```
|
||
|
||
## Testing across sizes
|
||
|
||
The harness already renders every plugin at a spread of sizes (now
|
||
including 96x48):
|
||
|
||
```bash
|
||
python scripts/check_plugin.py <plugin-dir> --sizes 64x32,128x32,96x48,128x64,256x64
|
||
python scripts/render_plugin.py <plugin-dir> --width 96 --height 48
|
||
```
|
||
|
||
`BoundsCheckingDisplayManager` flags right/bottom overflow and now records
|
||
mediated draw calls with negative coordinates in
|
||
`negative_coordinate_calls` (raw-PIL draws remain uncovered).
|
||
|
||
Reference migration: the **text-display** plugin's `font_mode: "auto"`.
|