10 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,aspecttierby height (xs≤16,sm≤32,md≤48,lg≤64,xl) andwidth_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'sdisplay.design_size(default 128x32). Geometry only — gaps, icon and logo sizes viapx(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 offit_textwhen several independently-fitted elements need to stay visually harmonious as the panel grows —fit_textmaximizes 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 viapx()), even though each individual pick is "correct" in isolation.base_size_pxis normally the element's existing classic/ fixed font size.scaledefaults toself.scale(the conservative min-of-both-axes factorpx()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 byheight / design_heighttoo, 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 fitsrowsrows.
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),
# 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_sizefor an element, load it as before and skip the ladder — fit only when the user hasn't overridden (see the football-scoreboard_resolve_element_fitpattern). - 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_texttake explicitcolor=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".