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

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

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