Files
LEDMatrix/docs/ADAPTIVE_LAYOUT.md
T
ChuckandClaude Fable 5 af96c6ffd6 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>
2026-07-11 09:00:37 -04:00

8.0 KiB

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(...):

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:

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)
  • scalemin(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(...):

# 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:

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:

"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:

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):

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".