mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-08-01 16:58:06 +00:00
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>
201 lines
8.0 KiB
Markdown
201 lines
8.0 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_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))
|
|
# regs.status_band — top band (replaces the magic y = 1)
|
|
# regs.score_area — center region (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`.
|
|
|
|
## 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"`.
|