mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-08-01 16:58:06 +00:00
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>
This commit is contained in:
@@ -0,0 +1,135 @@
|
|||||||
|
# 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`.
|
||||||
|
|
||||||
|
## 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"`.
|
||||||
@@ -90,11 +90,40 @@
|
|||||||
"min_height": {
|
"min_height": {
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"minimum": 1
|
"minimum": 1
|
||||||
|
},
|
||||||
|
"max_width": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 1
|
||||||
|
},
|
||||||
|
"max_height": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"display": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"design_size": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"width": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 8
|
||||||
|
},
|
||||||
|
"height": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 8
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["width", "height"],
|
||||||
|
"description": "Panel size the plugin's layout was authored against; core derives the adaptive-layout scale factor from it. Defaults to 128x32 when omitted."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Display/layout hints for the adaptive layout system"
|
||||||
|
},
|
||||||
"config_schema": {
|
"config_schema": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "Path to configuration schema file"
|
"description": "Path to configuration schema file"
|
||||||
|
|||||||
@@ -0,0 +1,464 @@
|
|||||||
|
"""
|
||||||
|
Adaptive layout and font scaling helpers for plugins.
|
||||||
|
|
||||||
|
Generalizes the three size-adaptation patterns proven in the plugin
|
||||||
|
ecosystem into small composable core helpers, so plugins render legibly on
|
||||||
|
any panel size (64x32, 128x32, 96x48, 128x64, 256x64, ...) without
|
||||||
|
hand-tuned per-display layouts:
|
||||||
|
|
||||||
|
- Region: integer rect algebra (bands, columns, weighted splits, centering).
|
||||||
|
Regions partition space, so text bands can't overlap by construction —
|
||||||
|
replacing the magic ``y = 1`` / ``y = height - 7`` offsets tuned for 128x32.
|
||||||
|
- Font ladders: ordered (family, size) steps known to render crisply.
|
||||||
|
Pixel fonts (BDF, PressStart2P) only look right at native/integer sizes,
|
||||||
|
so fonts are never scaled continuously — fitting walks a ladder from the
|
||||||
|
largest rung down until the measured text fits the target box. This is
|
||||||
|
baseball-scoreboard's fallback-ladder pattern promoted to core.
|
||||||
|
- LayoutContext: per-(width, height) facts — breakpoint tiers
|
||||||
|
(masters-tournament's pattern), a geometry scale factor vs. a declared
|
||||||
|
design size (f1-scoreboard's pattern), and cached fit-text queries.
|
||||||
|
|
||||||
|
Everything is opt-in: plugins get a context via ``self.layout`` on
|
||||||
|
BasePlugin (or construct one directly) and existing plugins are unaffected.
|
||||||
|
|
||||||
|
Fonts are resolved through FontManager's catalog (family names are
|
||||||
|
lowercased file stems from assets/fonts, e.g. "9x15", "tom-thumb", plus
|
||||||
|
aliases like "press_start"). FitResult.font is a plain PIL font or
|
||||||
|
freetype.Face, so it drops straight into DisplayManager.draw_text().
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any, Dict, List, Optional, Sequence, Tuple, Union
|
||||||
|
|
||||||
|
import freetype
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Height-based breakpoint tiers, smallest to largest. A 32px-tall panel is
|
||||||
|
# the ecosystem baseline ("sm"); 96x48 lands in "md"; 128x64 in "lg".
|
||||||
|
_HEIGHT_TIERS: Tuple[Tuple[str, int], ...] = (
|
||||||
|
("xs", 16), ("sm", 32), ("md", 48), ("lg", 64), ("xl", 10 ** 9),
|
||||||
|
)
|
||||||
|
TIER_ORDER: Tuple[str, ...] = tuple(name for name, _ in _HEIGHT_TIERS)
|
||||||
|
|
||||||
|
_WIDTH_TIERS: Tuple[Tuple[str, int], ...] = (
|
||||||
|
("narrow", 64), ("normal", 128), ("wide", 256), ("ultrawide", 10 ** 9),
|
||||||
|
)
|
||||||
|
WIDTH_TIER_ORDER: Tuple[str, ...] = tuple(name for name, _ in _WIDTH_TIERS)
|
||||||
|
|
||||||
|
# The panel size most existing plugins were authored against.
|
||||||
|
DEFAULT_DESIGN_SIZE: Tuple[int, int] = (128, 32)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Region:
|
||||||
|
"""An integer rectangle. Carving methods return sub-Regions clamped to
|
||||||
|
non-negative dimensions, so degenerate panels never produce negative
|
||||||
|
boxes — a band request larger than the region simply consumes it all."""
|
||||||
|
|
||||||
|
x: int
|
||||||
|
y: int
|
||||||
|
w: int
|
||||||
|
h: int
|
||||||
|
|
||||||
|
def __post_init__(self):
|
||||||
|
object.__setattr__(self, "w", max(0, int(self.w)))
|
||||||
|
object.__setattr__(self, "h", max(0, int(self.h)))
|
||||||
|
object.__setattr__(self, "x", int(self.x))
|
||||||
|
object.__setattr__(self, "y", int(self.y))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def right(self) -> int:
|
||||||
|
return self.x + self.w
|
||||||
|
|
||||||
|
@property
|
||||||
|
def bottom(self) -> int:
|
||||||
|
return self.y + self.h
|
||||||
|
|
||||||
|
@property
|
||||||
|
def center(self) -> Tuple[int, int]:
|
||||||
|
return (self.x + self.w // 2, self.y + self.h // 2)
|
||||||
|
|
||||||
|
# ---- carving -----------------------------------------------------
|
||||||
|
|
||||||
|
def inset(self, dx: int, dy: Optional[int] = None) -> "Region":
|
||||||
|
"""Shrink by dx horizontally and dy (default dx) vertically, each side."""
|
||||||
|
if dy is None:
|
||||||
|
dy = dx
|
||||||
|
return Region(self.x + dx, self.y + dy, self.w - 2 * dx, self.h - 2 * dy)
|
||||||
|
|
||||||
|
def top_band(self, h: int) -> "Region":
|
||||||
|
return Region(self.x, self.y, self.w, min(h, self.h))
|
||||||
|
|
||||||
|
def bottom_band(self, h: int) -> "Region":
|
||||||
|
h = min(h, self.h)
|
||||||
|
return Region(self.x, self.bottom - h, self.w, h)
|
||||||
|
|
||||||
|
def middle(self, top_h: int = 0, bottom_h: int = 0) -> "Region":
|
||||||
|
"""What remains between a top band and a bottom band."""
|
||||||
|
return Region(self.x, self.y + top_h, self.w, self.h - top_h - bottom_h)
|
||||||
|
|
||||||
|
def left_col(self, w: int) -> "Region":
|
||||||
|
return Region(self.x, self.y, min(w, self.w), self.h)
|
||||||
|
|
||||||
|
def right_col(self, w: int) -> "Region":
|
||||||
|
w = min(w, self.w)
|
||||||
|
return Region(self.right - w, self.y, w, self.h)
|
||||||
|
|
||||||
|
def split_h(self, *weights: float, gap: int = 0) -> List["Region"]:
|
||||||
|
"""Side-by-side columns sized by weight; gaps between them."""
|
||||||
|
sizes = _weighted_sizes(self.w, weights, gap)
|
||||||
|
cols, cursor = [], self.x
|
||||||
|
for size in sizes:
|
||||||
|
cols.append(Region(cursor, self.y, size, self.h))
|
||||||
|
cursor += size + gap
|
||||||
|
return cols
|
||||||
|
|
||||||
|
def split_v(self, *weights: float, gap: int = 0) -> List["Region"]:
|
||||||
|
"""Stacked rows sized by weight; gaps between them."""
|
||||||
|
sizes = _weighted_sizes(self.h, weights, gap)
|
||||||
|
rows, cursor = [], self.y
|
||||||
|
for size in sizes:
|
||||||
|
rows.append(Region(self.x, cursor, self.w, size))
|
||||||
|
cursor += size + gap
|
||||||
|
return rows
|
||||||
|
|
||||||
|
# ---- placement ---------------------------------------------------
|
||||||
|
|
||||||
|
def align_xy(self, w: int, h: int, align: str = "center",
|
||||||
|
valign: str = "center") -> Tuple[int, int]:
|
||||||
|
"""Top-left position for a w x h box aligned within this region.
|
||||||
|
align: left|center|right; valign: top|center|bottom."""
|
||||||
|
if align == "left":
|
||||||
|
x = self.x
|
||||||
|
elif align == "right":
|
||||||
|
x = self.right - w
|
||||||
|
else:
|
||||||
|
x = self.x + (self.w - w) // 2
|
||||||
|
if valign == "top":
|
||||||
|
y = self.y
|
||||||
|
elif valign == "bottom":
|
||||||
|
y = self.bottom - h
|
||||||
|
else:
|
||||||
|
y = self.y + (self.h - h) // 2
|
||||||
|
return (x, y)
|
||||||
|
|
||||||
|
def center_xy(self, w: int, h: int) -> Tuple[int, int]:
|
||||||
|
return self.align_xy(w, h)
|
||||||
|
|
||||||
|
def contains(self, w: int, h: int) -> bool:
|
||||||
|
return w <= self.w and h <= self.h
|
||||||
|
|
||||||
|
|
||||||
|
def _weighted_sizes(total: int, weights: Sequence[float], gap: int) -> List[int]:
|
||||||
|
"""Integer sizes proportional to weights, remainder spread left-to-right."""
|
||||||
|
if not weights:
|
||||||
|
return []
|
||||||
|
usable = max(0, total - gap * (len(weights) - 1))
|
||||||
|
weight_sum = sum(weights) or 1
|
||||||
|
sizes = [int(usable * w / weight_sum) for w in weights]
|
||||||
|
remainder = usable - sum(sizes)
|
||||||
|
for i in range(remainder):
|
||||||
|
sizes[i % len(sizes)] += 1
|
||||||
|
return sizes
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Font ladders
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class FontStep:
|
||||||
|
"""One rung: a FontManager catalog family at a size it renders crisply."""
|
||||||
|
family: str
|
||||||
|
size_px: int
|
||||||
|
|
||||||
|
|
||||||
|
FontLadder = Tuple[FontStep, ...]
|
||||||
|
|
||||||
|
# X11 BDF bitmap fonts at their native pixel sizes, largest to smallest —
|
||||||
|
# baseball-scoreboard's fallback ladder extended upward. Same-height rungs
|
||||||
|
# are ordered widest first so width-constrained text steps to a narrower
|
||||||
|
# face before dropping a size.
|
||||||
|
LADDER_GRID: FontLadder = (
|
||||||
|
FontStep("10x20", 20),
|
||||||
|
FontStep("9x18", 18),
|
||||||
|
FontStep("9x15", 15),
|
||||||
|
FontStep("8x13", 13),
|
||||||
|
FontStep("7x13", 13),
|
||||||
|
FontStep("6x13", 13),
|
||||||
|
FontStep("6x12", 12),
|
||||||
|
FontStep("6x10", 10),
|
||||||
|
FontStep("6x9", 9),
|
||||||
|
FontStep("5x8", 8),
|
||||||
|
FontStep("5x7", 7),
|
||||||
|
FontStep("4x6", 6),
|
||||||
|
FontStep("tom-thumb", 6),
|
||||||
|
)
|
||||||
|
|
||||||
|
# PressStart2P at integer multiples of its 8px pixel grid only — fractional
|
||||||
|
# sizes blur a pixel font. For headline text (clocks, scores).
|
||||||
|
LADDER_ARCADE: FontLadder = (
|
||||||
|
FontStep("press_start", 32),
|
||||||
|
FontStep("press_start", 24),
|
||||||
|
FontStep("press_start", 16),
|
||||||
|
FontStep("press_start", 8),
|
||||||
|
)
|
||||||
|
|
||||||
|
LADDER_DEFAULT: FontLadder = LADDER_GRID
|
||||||
|
|
||||||
|
ELLIPSIS = "…"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class FitResult:
|
||||||
|
"""A fitted font plus the ink metrics of the (possibly ellipsized) text.
|
||||||
|
|
||||||
|
``y_offset`` is the gap between the y passed to draw_text() and where
|
||||||
|
ink actually starts; subtract it from the desired ink-top position when
|
||||||
|
drawing (draw_fitted_text does this for you).
|
||||||
|
"""
|
||||||
|
font: Any
|
||||||
|
family: str
|
||||||
|
size_px: int
|
||||||
|
text: str
|
||||||
|
width: int
|
||||||
|
height: int
|
||||||
|
baseline: int
|
||||||
|
y_offset: int
|
||||||
|
fits: bool
|
||||||
|
line_height: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
def measure_ink(text: str, font: Any) -> Tuple[int, int, int, int]:
|
||||||
|
"""Measure the ink box of text: (width, height, baseline, y_offset).
|
||||||
|
|
||||||
|
y_offset is the distance from the y coordinate DisplayManager.draw_text()
|
||||||
|
is given to the top of the actual ink — PIL draws TTF from the em-box
|
||||||
|
top and _draw_bdf_text derives the baseline from y + ascender, so both
|
||||||
|
leave a font-dependent gap that matters when centering in short bands.
|
||||||
|
"""
|
||||||
|
if isinstance(font, freetype.Face):
|
||||||
|
width = 0
|
||||||
|
ascender = font.size.ascender >> 6
|
||||||
|
ink_top, ink_bottom = None, None
|
||||||
|
for char in text:
|
||||||
|
font.load_char(char)
|
||||||
|
width += font.glyph.advance.x >> 6
|
||||||
|
rows = font.glyph.bitmap.rows
|
||||||
|
if rows:
|
||||||
|
top = ascender - font.glyph.bitmap_top
|
||||||
|
ink_top = top if ink_top is None else min(ink_top, top)
|
||||||
|
ink_bottom = top + rows if ink_bottom is None else max(ink_bottom, top + rows)
|
||||||
|
if ink_top is None:
|
||||||
|
ink_top, ink_bottom = 0, 0
|
||||||
|
return (width, ink_bottom - ink_top, ascender, ink_top)
|
||||||
|
bbox = font.getbbox(text)
|
||||||
|
return (bbox[2] - bbox[0], bbox[3] - bbox[1], -bbox[1], bbox[1])
|
||||||
|
|
||||||
|
|
||||||
|
def font_line_height(font: Any) -> int:
|
||||||
|
"""Recommended line spacing for a font (matches DisplayManager.get_font_height)."""
|
||||||
|
if isinstance(font, freetype.Face):
|
||||||
|
return font.size.height >> 6
|
||||||
|
ascent, descent = font.getmetrics()
|
||||||
|
return ascent + descent
|
||||||
|
|
||||||
|
|
||||||
|
class LayoutContext:
|
||||||
|
"""Per-render-size layout facts and fit-text queries for one panel size.
|
||||||
|
|
||||||
|
Construct once per (width, height); BasePlugin.layout does this and
|
||||||
|
rebuilds automatically when the logical display size changes.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, width: int, height: int, font_manager: Any,
|
||||||
|
design_size: Tuple[int, int] = DEFAULT_DESIGN_SIZE):
|
||||||
|
self.width = int(width)
|
||||||
|
self.height = int(height)
|
||||||
|
self.font_manager = font_manager
|
||||||
|
self.design_size = design_size
|
||||||
|
self.bounds = Region(0, 0, self.width, self.height)
|
||||||
|
self.aspect = self.width / max(1, self.height)
|
||||||
|
self.tier = _pick_tier(_HEIGHT_TIERS, self.height)
|
||||||
|
self.width_tier = _pick_tier(_WIDTH_TIERS, self.width)
|
||||||
|
self.is_wide_short = self.aspect >= 2.5 and self.height <= 32
|
||||||
|
design_w, design_h = design_size
|
||||||
|
# Geometry scale only (gaps, icon/logo sizes) — never applied to
|
||||||
|
# fonts, which step between crisp ladder rungs instead.
|
||||||
|
self.scale = min(self.width / max(1, design_w),
|
||||||
|
self.height / max(1, design_h))
|
||||||
|
self._fit_cache: Dict[Any, FitResult] = {}
|
||||||
|
|
||||||
|
# ---- the three adaptation patterns --------------------------------
|
||||||
|
|
||||||
|
def px(self, base: int, minimum: int = 1, maximum: Optional[int] = None) -> int:
|
||||||
|
"""Scale a design-size pixel measurement (f1's pattern): gaps,
|
||||||
|
icon sizes, logo slots. Clamped to [minimum, maximum]."""
|
||||||
|
value = max(minimum, round(base * self.scale))
|
||||||
|
if maximum is not None:
|
||||||
|
value = min(value, maximum)
|
||||||
|
return value
|
||||||
|
|
||||||
|
def by_tier(self, mapping: Dict[str, Any], default: Any = None) -> Any:
|
||||||
|
"""Pick the value for the nearest defined tier at-or-below the
|
||||||
|
panel's height tier (masters' pattern). Falls forward to the
|
||||||
|
smallest defined tier above, then to default.
|
||||||
|
|
||||||
|
by_tier({"sm": 10, "lg": 18}) -> 10 on 128x32, 18 on 128x64.
|
||||||
|
Keys may also use width tiers ("narrow", "wide", ...)."""
|
||||||
|
order = TIER_ORDER if any(k in TIER_ORDER for k in mapping) else WIDTH_TIER_ORDER
|
||||||
|
current = self.tier if order is TIER_ORDER else self.width_tier
|
||||||
|
idx = order.index(current)
|
||||||
|
for name in reversed(order[: idx + 1]):
|
||||||
|
if name in mapping:
|
||||||
|
return mapping[name]
|
||||||
|
for name in order[idx + 1:]:
|
||||||
|
if name in mapping:
|
||||||
|
return mapping[name]
|
||||||
|
return default
|
||||||
|
|
||||||
|
def fit_text(self, text: str, box: Union[Region, Tuple[int, int]],
|
||||||
|
ladder: FontLadder = LADDER_DEFAULT,
|
||||||
|
ellipsis: bool = True) -> FitResult:
|
||||||
|
"""Largest ladder rung whose rendered text fits the box (baseball's
|
||||||
|
pattern). If even the smallest rung is too wide, the text is
|
||||||
|
ellipsized to fit (unless ellipsis=False); fits=False only when no
|
||||||
|
acceptable rendering exists."""
|
||||||
|
box_w, box_h = _box_dims(box)
|
||||||
|
key = ("text", text, box_w, box_h, ladder, ellipsis)
|
||||||
|
cached = self._fit_cache.get(key)
|
||||||
|
if cached is not None:
|
||||||
|
return cached
|
||||||
|
|
||||||
|
result = None
|
||||||
|
for step in ladder:
|
||||||
|
font = self.font_manager.get_font(step.family, step.size_px)
|
||||||
|
width, height, baseline, y_offset = measure_ink(text, font)
|
||||||
|
result = FitResult(font, step.family, step.size_px, text,
|
||||||
|
width, height, baseline, y_offset,
|
||||||
|
fits=(width <= box_w and height <= box_h),
|
||||||
|
line_height=font_line_height(font))
|
||||||
|
if result.fits:
|
||||||
|
break
|
||||||
|
|
||||||
|
if result is not None and not result.fits and ellipsis:
|
||||||
|
short = self.ellipsize(text, result.font, box_w)
|
||||||
|
width, height, baseline, y_offset = measure_ink(short, result.font)
|
||||||
|
result = FitResult(result.font, result.family, result.size_px,
|
||||||
|
short, width, height, baseline, y_offset,
|
||||||
|
fits=(width <= box_w and height <= box_h),
|
||||||
|
line_height=result.line_height)
|
||||||
|
|
||||||
|
self._fit_cache[key] = result
|
||||||
|
return result
|
||||||
|
|
||||||
|
def fit_lines(self, lines: Sequence[str], box: Union[Region, Tuple[int, int]],
|
||||||
|
ladder: FontLadder = LADDER_DEFAULT,
|
||||||
|
spacing: int = 1) -> FitResult:
|
||||||
|
"""Largest rung where every line fits the box width and the stacked
|
||||||
|
lines (line_height + spacing apart) fit the box height. Measures the
|
||||||
|
actual strings, so a long line pushes the ladder down a rung a short
|
||||||
|
one wouldn't (baseball's multiline pattern). Text is the widest line."""
|
||||||
|
box_w, box_h = _box_dims(box)
|
||||||
|
key = ("lines", tuple(lines), box_w, box_h, ladder, spacing)
|
||||||
|
cached = self._fit_cache.get(key)
|
||||||
|
if cached is not None:
|
||||||
|
return cached
|
||||||
|
|
||||||
|
rows = max(1, len(lines))
|
||||||
|
result = None
|
||||||
|
for step in ladder:
|
||||||
|
font = self.font_manager.get_font(step.family, step.size_px)
|
||||||
|
line_h = font_line_height(font)
|
||||||
|
widest, metrics = "", (0, 0, 0, 0)
|
||||||
|
for line in lines:
|
||||||
|
m = measure_ink(line, font)
|
||||||
|
if m[0] >= metrics[0]:
|
||||||
|
widest, metrics = line, m
|
||||||
|
total_h = rows * line_h + (rows - 1) * spacing
|
||||||
|
result = FitResult(font, step.family, step.size_px, widest,
|
||||||
|
metrics[0], metrics[1], metrics[2], metrics[3],
|
||||||
|
fits=(metrics[0] <= box_w and total_h <= box_h),
|
||||||
|
line_height=line_h)
|
||||||
|
if result.fits:
|
||||||
|
break
|
||||||
|
|
||||||
|
self._fit_cache[key] = result
|
||||||
|
return result
|
||||||
|
|
||||||
|
def font_for_rows(self, rows: int, box_h: int,
|
||||||
|
ladder: FontLadder = LADDER_GRID) -> FitResult:
|
||||||
|
"""Largest rung whose line height lets `rows` rows fit in box_h
|
||||||
|
(baseball's traditional-scoreboard pattern). Measures a digit/cap
|
||||||
|
sample rather than specific strings."""
|
||||||
|
key = ("rows", rows, box_h, ladder)
|
||||||
|
cached = self._fit_cache.get(key)
|
||||||
|
if cached is not None:
|
||||||
|
return cached
|
||||||
|
|
||||||
|
sample = "0Ay"
|
||||||
|
result = None
|
||||||
|
for step in ladder:
|
||||||
|
font = self.font_manager.get_font(step.family, step.size_px)
|
||||||
|
line_h = font_line_height(font)
|
||||||
|
width, height, baseline, y_offset = measure_ink(sample, font)
|
||||||
|
result = FitResult(font, step.family, step.size_px, sample,
|
||||||
|
width, height, baseline, y_offset,
|
||||||
|
fits=(max(1, rows) * line_h <= box_h),
|
||||||
|
line_height=line_h)
|
||||||
|
if result.fits:
|
||||||
|
break
|
||||||
|
|
||||||
|
self._fit_cache[key] = result
|
||||||
|
return result
|
||||||
|
|
||||||
|
# ---- text utilities ------------------------------------------------
|
||||||
|
|
||||||
|
def ellipsize(self, text: str, font: Any, max_w: int) -> str:
|
||||||
|
"""Trim text to fit max_w, appending an ellipsis. Returns '' when
|
||||||
|
not even the ellipsis fits."""
|
||||||
|
if measure_ink(text, font)[0] <= max_w:
|
||||||
|
return text
|
||||||
|
for end in range(len(text) - 1, 0, -1):
|
||||||
|
candidate = text[:end].rstrip() + ELLIPSIS
|
||||||
|
if measure_ink(candidate, font)[0] <= max_w:
|
||||||
|
return candidate
|
||||||
|
return ELLIPSIS if measure_ink(ELLIPSIS, font)[0] <= max_w else ""
|
||||||
|
|
||||||
|
def measure(self, text: str, font: Any) -> Tuple[int, int, int]:
|
||||||
|
"""Ink (width, height, baseline) of text — see measure_ink."""
|
||||||
|
width, height, baseline, _ = measure_ink(text, font)
|
||||||
|
return (width, height, baseline)
|
||||||
|
|
||||||
|
def clear_cache(self) -> None:
|
||||||
|
"""Drop cached fit results (call after fonts are reloaded)."""
|
||||||
|
self._fit_cache.clear()
|
||||||
|
|
||||||
|
|
||||||
|
def _pick_tier(tiers: Tuple[Tuple[str, int], ...], value: int) -> str:
|
||||||
|
for name, limit in tiers:
|
||||||
|
if value <= limit:
|
||||||
|
return name
|
||||||
|
return tiers[-1][0]
|
||||||
|
|
||||||
|
|
||||||
|
def _box_dims(box: Union[Region, Tuple[int, int]]) -> Tuple[int, int]:
|
||||||
|
if isinstance(box, Region):
|
||||||
|
return (box.w, box.h)
|
||||||
|
w, h = box
|
||||||
|
return (int(w), int(h))
|
||||||
|
|
||||||
|
|
||||||
|
def draw_fitted_text(display_manager: Any, fit: FitResult,
|
||||||
|
box: Union[Region, Tuple[int, int]],
|
||||||
|
color: Tuple[int, int, int] = (255, 255, 255),
|
||||||
|
align: str = "center", valign: str = "center") -> None:
|
||||||
|
"""Draw a FitResult's text aligned within a Region via
|
||||||
|
DisplayManager.draw_text(), compensating for the font's ink offset so
|
||||||
|
the ink (not the em box) is what gets aligned."""
|
||||||
|
region = box if isinstance(box, Region) else Region(0, 0, box[0], box[1])
|
||||||
|
x, y = region.align_xy(fit.width, fit.height, align, valign)
|
||||||
|
display_manager.draw_text(fit.text, x=x, y=y - fit.y_offset,
|
||||||
|
color=color, font=fit.font)
|
||||||
@@ -11,6 +11,9 @@ from typing import Dict, List, Optional, Tuple, Union
|
|||||||
|
|
||||||
from PIL import Image, ImageDraw, ImageFont
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
|
|
||||||
|
# Shared throwaway draw surface for measuring text without a target canvas.
|
||||||
|
_measure_draw = ImageDraw.Draw(Image.new("RGB", (1, 1)))
|
||||||
|
|
||||||
|
|
||||||
class TextHelper:
|
class TextHelper:
|
||||||
"""
|
"""
|
||||||
@@ -112,10 +115,10 @@ class TextHelper:
|
|||||||
Width in pixels
|
Width in pixels
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
return draw.textlength(text, font=font)
|
return int(_measure_draw.textlength(text, font=font))
|
||||||
except AttributeError:
|
except AttributeError:
|
||||||
# Fallback for older PIL versions
|
# Fallback for older PIL versions
|
||||||
bbox = draw.textbbox((0, 0), text, font=font)
|
bbox = _measure_draw.textbbox((0, 0), text, font=font)
|
||||||
return bbox[2] - bbox[0]
|
return bbox[2] - bbox[0]
|
||||||
|
|
||||||
def get_text_height(self, text: str, font: ImageFont.ImageFont) -> int:
|
def get_text_height(self, text: str, font: ImageFont.ImageFont) -> int:
|
||||||
@@ -129,13 +132,8 @@ class TextHelper:
|
|||||||
Returns:
|
Returns:
|
||||||
Height in pixels
|
Height in pixels
|
||||||
"""
|
"""
|
||||||
try:
|
bbox = _measure_draw.textbbox((0, 0), text, font=font)
|
||||||
bbox = draw.textbbox((0, 0), text, font=font)
|
return bbox[3] - bbox[1]
|
||||||
return bbox[3] - bbox[1]
|
|
||||||
except AttributeError:
|
|
||||||
# Fallback for older PIL versions
|
|
||||||
bbox = draw.textbbox((0, 0), text, font=font)
|
|
||||||
return bbox[3] - bbox[1]
|
|
||||||
|
|
||||||
def get_text_dimensions(self, text: str, font: ImageFont.ImageFont) -> Tuple[int, int]:
|
def get_text_dimensions(self, text: str, font: ImageFont.ImageFont) -> Tuple[int, int]:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -103,6 +103,10 @@ class FontManager:
|
|||||||
# Font overrides storage (for manual overrides)
|
# Font overrides storage (for manual overrides)
|
||||||
self.font_overrides_file = "config/font_overrides.json"
|
self.font_overrides_file = "config/font_overrides.json"
|
||||||
self.font_overrides: Dict[str, Dict[str, Any]] = {}
|
self.font_overrides: Dict[str, Dict[str, Any]] = {}
|
||||||
|
|
||||||
|
# Bumped whenever cached font objects are invalidated, so holders of
|
||||||
|
# derived caches (e.g. adaptive-layout fit results) know to rebuild.
|
||||||
|
self.cache_generation = 0
|
||||||
|
|
||||||
self._initialize_fonts()
|
self._initialize_fonts()
|
||||||
|
|
||||||
@@ -112,6 +116,7 @@ class FontManager:
|
|||||||
self.fonts_config = new_config.get("fonts", {})
|
self.fonts_config = new_config.get("fonts", {})
|
||||||
self.font_cache.clear() # Clear cache to force reload
|
self.font_cache.clear() # Clear cache to force reload
|
||||||
self.metrics_cache.clear() # Clear metrics cache
|
self.metrics_cache.clear() # Clear metrics cache
|
||||||
|
self.cache_generation += 1
|
||||||
self._initialize_fonts()
|
self._initialize_fonts()
|
||||||
logger.info("FontManager configuration reloaded successfully")
|
logger.info("FontManager configuration reloaded successfully")
|
||||||
|
|
||||||
@@ -482,6 +487,14 @@ class FontManager:
|
|||||||
def _load_bdf_font(self, font_path: str, size_px: int) -> freetype.Face:
|
def _load_bdf_font(self, font_path: str, size_px: int) -> freetype.Face:
|
||||||
"""Load a BDF font using FreeType."""
|
"""Load a BDF font using FreeType."""
|
||||||
try:
|
try:
|
||||||
|
native_size = self._read_bdf_native_size(font_path)
|
||||||
|
if native_size is not None and native_size != size_px:
|
||||||
|
# BDF is a fixed-strike bitmap format: FreeType renders the
|
||||||
|
# native size no matter what set_char_size asks for.
|
||||||
|
logger.debug(
|
||||||
|
"BDF font %s requested at %spx but renders at its native "
|
||||||
|
"%spx", font_path, size_px, native_size
|
||||||
|
)
|
||||||
face = freetype.Face(font_path)
|
face = freetype.Face(font_path)
|
||||||
# Set character size (width, height) in 1/64th of points
|
# Set character size (width, height) in 1/64th of points
|
||||||
face.set_char_size(size_px * 64, size_px * 64, 72, 72)
|
face.set_char_size(size_px * 64, size_px * 64, 72, 72)
|
||||||
@@ -490,6 +503,41 @@ class FontManager:
|
|||||||
logger.error(f"Error loading BDF font {font_path}: {e}")
|
logger.error(f"Error loading BDF font {font_path}: {e}")
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
def get_native_bdf_size(self, family: str) -> Optional[int]:
|
||||||
|
"""The one true pixel size of a BDF family in the catalog, or None
|
||||||
|
for scalable (TTF) families / unknown families."""
|
||||||
|
font_path = self.font_catalog.get(family)
|
||||||
|
if not font_path or not font_path.endswith('.bdf'):
|
||||||
|
return None
|
||||||
|
return self._read_bdf_native_size(font_path)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _read_bdf_native_size(bdf_path: str) -> Optional[int]:
|
||||||
|
"""Read a BDF file's own header to find its one true pixel size.
|
||||||
|
Prefers the PIXEL_SIZE property, which states the real pixel height
|
||||||
|
directly; falls back to the SIZE line's point-size only if PIXEL_SIZE
|
||||||
|
is absent, since point-size only equals pixel height at exactly
|
||||||
|
100dpi — several bundled fonts (e.g. 6x13.bdf, 5x8.bdf) are defined
|
||||||
|
at 75dpi, where the two values genuinely differ."""
|
||||||
|
size_line_value = None
|
||||||
|
try:
|
||||||
|
with open(bdf_path, "r", encoding="ascii", errors="ignore") as f:
|
||||||
|
for line in f:
|
||||||
|
if line.startswith("PIXEL_SIZE"):
|
||||||
|
parts = line.split()
|
||||||
|
if len(parts) >= 2:
|
||||||
|
return int(float(parts[1]))
|
||||||
|
elif line.startswith("SIZE") and size_line_value is None:
|
||||||
|
# Format: "SIZE <point_size> <xres> <yres>"
|
||||||
|
parts = line.split()
|
||||||
|
if len(parts) >= 2:
|
||||||
|
size_line_value = int(float(parts[1]))
|
||||||
|
elif line.startswith("STARTCHAR"):
|
||||||
|
break
|
||||||
|
except (OSError, ValueError):
|
||||||
|
return None
|
||||||
|
return size_line_value
|
||||||
|
|
||||||
def _get_fallback_font(self) -> ImageFont.ImageFont:
|
def _get_fallback_font(self) -> ImageFont.ImageFont:
|
||||||
"""Get a fallback font when loading fails."""
|
"""Get a fallback font when loading fails."""
|
||||||
return ImageFont.load_default()
|
return ImageFont.load_default()
|
||||||
|
|||||||
@@ -15,6 +15,19 @@ import logging
|
|||||||
from src.logging_config import get_logger
|
from src.logging_config import get_logger
|
||||||
|
|
||||||
|
|
||||||
|
_shared_fallback_font_manager: Optional[Any] = None
|
||||||
|
|
||||||
|
|
||||||
|
def _fallback_font_manager() -> Any:
|
||||||
|
"""Shared FontManager for environments (unit tests, mocks) where the
|
||||||
|
plugin manager doesn't carry one. Scans assets/fonts like the real one."""
|
||||||
|
global _shared_fallback_font_manager
|
||||||
|
if _shared_fallback_font_manager is None:
|
||||||
|
from src.font_manager import FontManager
|
||||||
|
_shared_fallback_font_manager = FontManager({})
|
||||||
|
return _shared_fallback_font_manager
|
||||||
|
|
||||||
|
|
||||||
class VegasDisplayMode(Enum):
|
class VegasDisplayMode(Enum):
|
||||||
"""
|
"""
|
||||||
Display mode for Vegas scroll integration.
|
Display mode for Vegas scroll integration.
|
||||||
@@ -130,6 +143,97 @@ class BasePlugin(ABC):
|
|||||||
"""
|
"""
|
||||||
raise NotImplementedError("Plugins must implement display()")
|
raise NotImplementedError("Plugins must implement display()")
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
# Adaptive layout support (opt-in)
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
@property
|
||||||
|
def layout(self) -> Any:
|
||||||
|
"""
|
||||||
|
LayoutContext for the current logical display size.
|
||||||
|
|
||||||
|
Lazily built and rebuilt automatically when the display size changes
|
||||||
|
(e.g. Vegas segment widths, double-sided logical screens). Provides
|
||||||
|
Region carving (self.layout.bounds), breakpoint tiers, a geometry
|
||||||
|
scale factor vs. the manifest's display.design_size, and fit-text
|
||||||
|
queries against font ladders. See src/adaptive_layout.py.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
rows = self.layout.bounds.inset(1).split_v(3, 1, gap=1)
|
||||||
|
self.draw_fit(big_text, rows[0], ladder=LADDER_ARCADE)
|
||||||
|
self.draw_fit(small_text, rows[1])
|
||||||
|
"""
|
||||||
|
from src.adaptive_layout import LayoutContext
|
||||||
|
|
||||||
|
width = getattr(self.display_manager, "width", None)
|
||||||
|
height = getattr(self.display_manager, "height", None)
|
||||||
|
if not width or not height:
|
||||||
|
matrix = getattr(self.display_manager, "matrix", None)
|
||||||
|
width = getattr(matrix, "width", 128)
|
||||||
|
height = getattr(matrix, "height", 32)
|
||||||
|
|
||||||
|
font_manager = self._get_font_manager()
|
||||||
|
generation = getattr(font_manager, "cache_generation", 0)
|
||||||
|
cached = getattr(self, "_layout_context", None)
|
||||||
|
if (cached is not None
|
||||||
|
and (cached.width, cached.height) == (width, height)
|
||||||
|
and getattr(self, "_layout_font_generation", None) == generation):
|
||||||
|
return cached
|
||||||
|
|
||||||
|
context = LayoutContext(
|
||||||
|
width, height, font_manager,
|
||||||
|
design_size=self._get_design_size(),
|
||||||
|
)
|
||||||
|
self._layout_context = context
|
||||||
|
self._layout_font_generation = generation
|
||||||
|
return context
|
||||||
|
|
||||||
|
def draw_fit(self, text: str, box: Any,
|
||||||
|
color: tuple = (255, 255, 255),
|
||||||
|
ladder: Optional[Any] = None,
|
||||||
|
align: str = "center", valign: str = "center") -> Any:
|
||||||
|
"""
|
||||||
|
Fit text to a Region with the largest crisp font that fits, then draw
|
||||||
|
it aligned within that region via the display manager.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: Text to display (ellipsized if even the smallest rung is too wide)
|
||||||
|
box: Region (or (w, h) tuple anchored at 0,0) to fit and align within
|
||||||
|
color: RGB color tuple
|
||||||
|
ladder: FontLadder to walk (default LADDER_GRID; use LADDER_ARCADE
|
||||||
|
for headline text like clocks and scores)
|
||||||
|
align/valign: alignment of the text ink within the box
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
FitResult (font, family, size_px, text, ink metrics, fits flag)
|
||||||
|
"""
|
||||||
|
from src.adaptive_layout import LADDER_DEFAULT, draw_fitted_text
|
||||||
|
|
||||||
|
fit = self.layout.fit_text(text, box, ladder=ladder or LADDER_DEFAULT)
|
||||||
|
draw_fitted_text(self.display_manager, fit, box,
|
||||||
|
color=color, align=align, valign=valign)
|
||||||
|
return fit
|
||||||
|
|
||||||
|
def _get_font_manager(self) -> Any:
|
||||||
|
"""The shared FontManager, or a module-level fallback when running
|
||||||
|
under mocks/harnesses that don't provide one."""
|
||||||
|
font_manager = getattr(self.plugin_manager, "font_manager", None)
|
||||||
|
if font_manager is not None and hasattr(font_manager, "get_font"):
|
||||||
|
return font_manager
|
||||||
|
return _fallback_font_manager()
|
||||||
|
|
||||||
|
def _get_design_size(self) -> tuple:
|
||||||
|
"""Panel size this plugin's layout was authored against, from the
|
||||||
|
manifest's optional display.design_size (defaults to 128x32)."""
|
||||||
|
from src.adaptive_layout import DEFAULT_DESIGN_SIZE
|
||||||
|
|
||||||
|
if self.plugin_manager and hasattr(self.plugin_manager, "plugin_manifests"):
|
||||||
|
manifest = self.plugin_manager.plugin_manifests.get(self.plugin_id, {})
|
||||||
|
declared = manifest.get("display", {}).get("design_size", {})
|
||||||
|
width, height = declared.get("width"), declared.get("height")
|
||||||
|
if width and height:
|
||||||
|
return (int(width), int(height))
|
||||||
|
return DEFAULT_DESIGN_SIZE
|
||||||
|
|
||||||
def get_display_duration(self) -> float:
|
def get_display_duration(self) -> float:
|
||||||
"""
|
"""
|
||||||
Get the display duration for this plugin instance.
|
Get the display duration for this plugin instance.
|
||||||
|
|||||||
@@ -10,8 +10,11 @@ that don't scale down to a smaller panel.
|
|||||||
|
|
||||||
Limitations (documented on purpose):
|
Limitations (documented on purpose):
|
||||||
- Overflow past the LEFT or TOP edge (negative coordinates) is still clipped by
|
- Overflow past the LEFT or TOP edge (negative coordinates) is still clipped by
|
||||||
PIL and not detected here. The dominant real-world breakage is content that is
|
PIL and not detected pixel-wise here. The dominant real-world breakage is
|
||||||
too wide/tall for a smaller panel, which this catches.
|
content that is too wide/tall for a smaller panel, which this catches.
|
||||||
|
As a partial net, draw_text/draw_image calls made with negative coordinates
|
||||||
|
through this manager are recorded in `negative_coordinate_calls` — but draws
|
||||||
|
made directly on the raw PIL canvas remain uncovered.
|
||||||
- BDF text is clipped to the declared bounds by the parent's bitmap drawer, so
|
- BDF text is clipped to the declared bounds by the parent's bitmap drawer, so
|
||||||
BDF overflow is not flagged. Golden-image regression covers those plugins.
|
BDF overflow is not flagged. Golden-image regression covers those plugins.
|
||||||
- If a plugin replaces the canvas with its own image (display_manager.image = ...),
|
- If a plugin replaces the canvas with its own image (display_manager.image = ...),
|
||||||
@@ -56,6 +59,21 @@ class BoundsCheckingDisplayManager(VisualTestDisplayManager):
|
|||||||
super().__init__(self._canvas_width, self._canvas_height)
|
super().__init__(self._canvas_width, self._canvas_height)
|
||||||
# Plugins must see the DECLARED size, not the padded canvas size.
|
# Plugins must see the DECLARED size, not the padded canvas size.
|
||||||
self.matrix = _MatrixProxy(self._declared_width, self._declared_height)
|
self.matrix = _MatrixProxy(self._declared_width, self._declared_height)
|
||||||
|
# (text-or-'image', x, y) for every mediated draw call given a
|
||||||
|
# negative coordinate — PIL clips these silently, so record them.
|
||||||
|
self.negative_coordinate_calls: list = []
|
||||||
|
|
||||||
|
# -- negative-coordinate (left/top overflow) recording --
|
||||||
|
|
||||||
|
def draw_text(self, text, x=None, y=None, *args, **kwargs):
|
||||||
|
if (x is not None and x < 0) or (y is not None and y < 0):
|
||||||
|
self.negative_coordinate_calls.append((text, x, y))
|
||||||
|
return super().draw_text(text, x, y, *args, **kwargs)
|
||||||
|
|
||||||
|
def draw_image(self, image, x, y, *args, **kwargs):
|
||||||
|
if x < 0 or y < 0:
|
||||||
|
self.negative_coordinate_calls.append(('image', x, y))
|
||||||
|
return super().draw_image(image, x, y, *args, **kwargs)
|
||||||
|
|
||||||
# -- declared dimensions (override parent's image-derived properties) --
|
# -- declared dimensions (override parent's image-derived properties) --
|
||||||
|
|
||||||
|
|||||||
@@ -161,6 +161,13 @@ class MockPluginManager:
|
|||||||
self.plugin_manifests: Dict[str, Dict] = {}
|
self.plugin_manifests: Dict[str, Dict] = {}
|
||||||
self.get_plugin_calls = []
|
self.get_plugin_calls = []
|
||||||
self.get_all_plugins_calls = []
|
self.get_all_plugins_calls = []
|
||||||
|
# Real FontManager so BasePlugin.layout / draw_fit behave identically
|
||||||
|
# under the harness (it only needs assets/fonts on disk).
|
||||||
|
try:
|
||||||
|
from src.font_manager import FontManager
|
||||||
|
self.font_manager: Optional[Any] = FontManager({})
|
||||||
|
except Exception:
|
||||||
|
self.font_manager = None
|
||||||
|
|
||||||
def get_plugin(self, plugin_id: str) -> Optional[Any]:
|
def get_plugin(self, plugin_id: str) -> Optional[Any]:
|
||||||
"""Get a plugin instance."""
|
"""Get a plugin instance."""
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ DEFAULT_TEST_SIZES: List[Tuple[int, int]] = [
|
|||||||
(64, 32), # 1x1 — single panel, the tightest common rectangle
|
(64, 32), # 1x1 — single panel, the tightest common rectangle
|
||||||
(128, 32), # 2x1 — the baseline most plugins are tuned for
|
(128, 32), # 2x1 — the baseline most plugins are tuned for
|
||||||
(64, 64), # 1x2 — stacked, exercises tall-narrow centering
|
(64, 64), # 1x2 — stacked, exercises tall-narrow centering
|
||||||
|
(96, 48), # non-64x32-module panel (e.g. Waveshare), off-grid dims
|
||||||
(128, 64), # 2x2 — block, icon scaling / vertical centering
|
(128, 64), # 2x2 — block, icon scaling / vertical centering
|
||||||
(256, 32), # 4x1 — long strip, wide horizontal layout
|
(256, 32), # 4x1 — long strip, wide horizontal layout
|
||||||
(128, 96), # 2x3 — tall, exercises vertical overflow
|
(128, 96), # 2x3 — tall, exercises vertical overflow
|
||||||
|
|||||||
@@ -0,0 +1,255 @@
|
|||||||
|
"""Tests for the adaptive layout system (src/adaptive_layout.py)."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.adaptive_layout import (
|
||||||
|
DEFAULT_DESIGN_SIZE,
|
||||||
|
LADDER_ARCADE,
|
||||||
|
LADDER_GRID,
|
||||||
|
LayoutContext,
|
||||||
|
Region,
|
||||||
|
draw_fitted_text,
|
||||||
|
measure_ink,
|
||||||
|
)
|
||||||
|
from src.font_manager import FontManager
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def font_manager():
|
||||||
|
"""Real FontManager over assets/fonts — the ladders depend on it."""
|
||||||
|
return FontManager({})
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def ctx(font_manager):
|
||||||
|
return LayoutContext(128, 32, font_manager)
|
||||||
|
|
||||||
|
|
||||||
|
class TestRegion:
|
||||||
|
"""Pure integer rect algebra."""
|
||||||
|
|
||||||
|
def test_bands_partition_without_overlap(self):
|
||||||
|
r = Region(0, 0, 128, 32)
|
||||||
|
top = r.top_band(7)
|
||||||
|
bottom = r.bottom_band(7)
|
||||||
|
middle = r.middle(7, 7)
|
||||||
|
assert top.bottom == middle.y
|
||||||
|
assert middle.bottom == bottom.y
|
||||||
|
assert top.h + middle.h + bottom.h == r.h
|
||||||
|
|
||||||
|
def test_bands_clamp_on_short_panel(self):
|
||||||
|
# The classic failure: y=1 top band and y=height-7 bottom band
|
||||||
|
# overlapping on a short panel. Bands can't exceed the region.
|
||||||
|
r = Region(0, 0, 32, 8)
|
||||||
|
assert r.top_band(16).h == 8
|
||||||
|
assert r.bottom_band(16).h == 8
|
||||||
|
assert r.middle(8, 8).h == 0 # degenerate, never negative
|
||||||
|
|
||||||
|
def test_split_v_weights_sum_to_height(self):
|
||||||
|
r = Region(0, 0, 64, 33)
|
||||||
|
rows = r.split_v(3, 1, 1, gap=1)
|
||||||
|
assert len(rows) == 3
|
||||||
|
assert sum(row.h for row in rows) == 33 - 2 # two 1px gaps
|
||||||
|
assert rows[0].h > rows[1].h
|
||||||
|
assert rows[-1].bottom == r.bottom
|
||||||
|
|
||||||
|
def test_split_h_columns_advance(self):
|
||||||
|
r = Region(0, 0, 100, 32)
|
||||||
|
cols = r.split_h(1, 1, gap=2)
|
||||||
|
assert cols[0].right + 2 == cols[1].x
|
||||||
|
assert cols[1].right == r.right
|
||||||
|
|
||||||
|
def test_degenerate_sizes_never_negative(self):
|
||||||
|
for w, h in [(8, 8), (32, 16), (1, 1)]:
|
||||||
|
r = Region(0, 0, w, h).inset(4)
|
||||||
|
assert r.w >= 0 and r.h >= 0
|
||||||
|
for sub in r.split_v(1, 1) + r.split_h(1, 1, gap=3):
|
||||||
|
assert sub.w >= 0 and sub.h >= 0
|
||||||
|
|
||||||
|
def test_align_xy(self):
|
||||||
|
r = Region(10, 10, 100, 20)
|
||||||
|
assert r.align_xy(20, 10, "left", "top") == (10, 10)
|
||||||
|
assert r.align_xy(20, 10, "right", "bottom") == (90, 20)
|
||||||
|
assert r.align_xy(20, 10) == (50, 15)
|
||||||
|
|
||||||
|
def test_left_right_cols(self):
|
||||||
|
r = Region(0, 0, 128, 32)
|
||||||
|
assert r.left_col(32) == Region(0, 0, 32, 32)
|
||||||
|
assert r.right_col(32) == Region(96, 0, 32, 32)
|
||||||
|
|
||||||
|
|
||||||
|
class TestLayoutContext:
|
||||||
|
def test_tiers(self, font_manager):
|
||||||
|
assert LayoutContext(128, 32, font_manager).tier == "sm"
|
||||||
|
assert LayoutContext(96, 48, font_manager).tier == "md"
|
||||||
|
assert LayoutContext(128, 64, font_manager).tier == "lg"
|
||||||
|
assert LayoutContext(64, 16, font_manager).tier == "xs"
|
||||||
|
assert LayoutContext(256, 128, font_manager).tier == "xl"
|
||||||
|
|
||||||
|
def test_wide_short_flag(self, font_manager):
|
||||||
|
assert LayoutContext(128, 32, font_manager).is_wide_short
|
||||||
|
assert not LayoutContext(128, 64, font_manager).is_wide_short
|
||||||
|
|
||||||
|
def test_scale_against_design_size(self, font_manager):
|
||||||
|
assert LayoutContext(128, 32, font_manager).scale == 1.0
|
||||||
|
assert LayoutContext(256, 64, font_manager).scale == 2.0
|
||||||
|
# min() of the two axes: don't overscale the constrained one
|
||||||
|
assert LayoutContext(256, 32, font_manager).scale == 1.0
|
||||||
|
assert DEFAULT_DESIGN_SIZE == (128, 32)
|
||||||
|
|
||||||
|
def test_px_scales_and_clamps(self, font_manager):
|
||||||
|
big = LayoutContext(256, 64, font_manager)
|
||||||
|
assert big.px(4) == 8
|
||||||
|
assert big.px(4, maximum=6) == 6
|
||||||
|
tiny = LayoutContext(32, 16, font_manager)
|
||||||
|
assert tiny.px(4, minimum=2) == 2
|
||||||
|
|
||||||
|
def test_by_tier_nearest_at_or_below(self, font_manager):
|
||||||
|
mapping = {"sm": 10, "lg": 18}
|
||||||
|
assert LayoutContext(128, 32, font_manager).by_tier(mapping) == 10
|
||||||
|
assert LayoutContext(96, 48, font_manager).by_tier(mapping) == 10 # md -> sm
|
||||||
|
assert LayoutContext(128, 64, font_manager).by_tier(mapping) == 18
|
||||||
|
assert LayoutContext(256, 128, font_manager).by_tier(mapping) == 18 # xl -> lg
|
||||||
|
# nothing at-or-below: fall forward to smallest defined above
|
||||||
|
assert LayoutContext(64, 16, font_manager).by_tier(mapping) == 10
|
||||||
|
|
||||||
|
|
||||||
|
class TestFontFitting:
|
||||||
|
def test_ladder_monotonic(self, font_manager):
|
||||||
|
"""Each ladder rung must render no taller than the one before it."""
|
||||||
|
for ladder in (LADDER_GRID, LADDER_ARCADE):
|
||||||
|
heights = []
|
||||||
|
for step in ladder:
|
||||||
|
font = font_manager.get_font(step.family, step.size_px)
|
||||||
|
heights.append(measure_ink("Ay0", font)[1])
|
||||||
|
assert heights == sorted(heights, reverse=True), (
|
||||||
|
f"ladder not monotonically shrinking: {heights}")
|
||||||
|
|
||||||
|
def test_fit_text_grows_on_taller_panel(self, font_manager):
|
||||||
|
small = LayoutContext(64, 32, font_manager)
|
||||||
|
large = LayoutContext(128, 64, font_manager)
|
||||||
|
text = "12:34"
|
||||||
|
fit_small = small.fit_text(text, small.bounds, ladder=LADDER_ARCADE)
|
||||||
|
fit_large = large.fit_text(text, large.bounds, ladder=LADDER_ARCADE)
|
||||||
|
assert fit_small.fits and fit_large.fits
|
||||||
|
assert fit_large.size_px > fit_small.size_px
|
||||||
|
|
||||||
|
def test_fit_text_fits_the_box(self, ctx):
|
||||||
|
box = ctx.bounds.inset(1)
|
||||||
|
fit = ctx.fit_text("HELLO WORLD", box)
|
||||||
|
assert fit.fits
|
||||||
|
assert fit.width <= box.w and fit.height <= box.h
|
||||||
|
|
||||||
|
def test_fit_text_ellipsizes_overlong_text(self, font_manager):
|
||||||
|
tiny = LayoutContext(32, 16, font_manager)
|
||||||
|
fit = tiny.fit_text("SUPERCALIFRAGILISTIC", tiny.bounds)
|
||||||
|
assert fit.text != "SUPERCALIFRAGILISTIC"
|
||||||
|
assert fit.text.endswith("…")
|
||||||
|
assert fit.width <= tiny.bounds.w
|
||||||
|
|
||||||
|
def test_fit_text_cached(self, ctx):
|
||||||
|
first = ctx.fit_text("CACHED", ctx.bounds)
|
||||||
|
second = ctx.fit_text("CACHED", ctx.bounds)
|
||||||
|
assert first is second
|
||||||
|
ctx.clear_cache()
|
||||||
|
assert ctx.fit_text("CACHED", ctx.bounds) is not first
|
||||||
|
|
||||||
|
def test_fit_lines_stacks_within_height(self, ctx):
|
||||||
|
box = ctx.bounds
|
||||||
|
lines = ["LINE ONE", "LINE TWO", "LINE THREE"]
|
||||||
|
fit = ctx.fit_lines(lines, box, spacing=1)
|
||||||
|
assert fit.fits
|
||||||
|
assert 3 * fit.line_height + 2 <= box.h
|
||||||
|
|
||||||
|
def test_font_for_rows(self, ctx):
|
||||||
|
fit = ctx.font_for_rows(4, 32)
|
||||||
|
assert fit.fits
|
||||||
|
assert 4 * fit.line_height <= 32
|
||||||
|
|
||||||
|
def test_ellipsize_returns_original_when_it_fits(self, ctx):
|
||||||
|
font = ctx.font_manager.get_font("4x6", 6)
|
||||||
|
assert ctx.ellipsize("HI", font, 1000) == "HI"
|
||||||
|
|
||||||
|
|
||||||
|
class TestDrawFittedText:
|
||||||
|
def test_draws_within_region(self, ctx):
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
class _DM:
|
||||||
|
def draw_text(self, text, x=None, y=None, color=None, font=None):
|
||||||
|
calls.append((text, x, y))
|
||||||
|
|
||||||
|
box = Region(10, 4, 100, 24)
|
||||||
|
fit = ctx.fit_text("SCORE", box)
|
||||||
|
draw_fitted_text(_DM(), fit, box)
|
||||||
|
text, x, y = calls[0]
|
||||||
|
assert text == "SCORE"
|
||||||
|
assert box.x <= x <= box.right - fit.width
|
||||||
|
# the ink (y + y_offset .. + height) must land inside the box
|
||||||
|
assert box.y <= y + fit.y_offset
|
||||||
|
assert y + fit.y_offset + fit.height <= box.bottom
|
||||||
|
|
||||||
|
|
||||||
|
class TestBasePluginIntegration:
|
||||||
|
def test_layout_property_and_draw_fit(self):
|
||||||
|
from src.plugin_system.base_plugin import BasePlugin
|
||||||
|
from src.plugin_system.testing.mocks import (
|
||||||
|
MockCacheManager, MockDisplayManager, MockPluginManager,
|
||||||
|
)
|
||||||
|
|
||||||
|
class _Plugin(BasePlugin):
|
||||||
|
def update(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def display(self, force_clear=False):
|
||||||
|
pass
|
||||||
|
|
||||||
|
plugin = _Plugin("test-plugin", {}, MockDisplayManager(96, 48),
|
||||||
|
MockCacheManager(), MockPluginManager())
|
||||||
|
assert (plugin.layout.width, plugin.layout.height) == (96, 48)
|
||||||
|
assert plugin.layout is plugin.layout # cached
|
||||||
|
fit = plugin.draw_fit("HELLO", plugin.layout.bounds.inset(1))
|
||||||
|
assert fit.fits
|
||||||
|
assert plugin.display_manager.draw_calls # actually drew
|
||||||
|
|
||||||
|
def test_layout_rebuilds_on_size_change(self):
|
||||||
|
from src.plugin_system.base_plugin import BasePlugin
|
||||||
|
from src.plugin_system.testing.mocks import (
|
||||||
|
MockCacheManager, MockDisplayManager, MockPluginManager,
|
||||||
|
)
|
||||||
|
|
||||||
|
class _Plugin(BasePlugin):
|
||||||
|
def update(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def display(self, force_clear=False):
|
||||||
|
pass
|
||||||
|
|
||||||
|
dm = MockDisplayManager(128, 32)
|
||||||
|
plugin = _Plugin("test-plugin", {}, dm,
|
||||||
|
MockCacheManager(), MockPluginManager())
|
||||||
|
assert plugin.layout.tier == "sm"
|
||||||
|
dm.width, dm.height = 128, 64
|
||||||
|
assert plugin.layout.tier == "lg"
|
||||||
|
|
||||||
|
def test_design_size_from_manifest(self):
|
||||||
|
from src.plugin_system.base_plugin import BasePlugin
|
||||||
|
from src.plugin_system.testing.mocks import (
|
||||||
|
MockCacheManager, MockDisplayManager, MockPluginManager,
|
||||||
|
)
|
||||||
|
|
||||||
|
class _Plugin(BasePlugin):
|
||||||
|
def update(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def display(self, force_clear=False):
|
||||||
|
pass
|
||||||
|
|
||||||
|
pm = MockPluginManager()
|
||||||
|
pm.plugin_manifests["test-plugin"] = {
|
||||||
|
"display": {"design_size": {"width": 64, "height": 32}}
|
||||||
|
}
|
||||||
|
plugin = _Plugin("test-plugin", {}, MockDisplayManager(128, 64),
|
||||||
|
MockCacheManager(), pm)
|
||||||
|
assert plugin.layout.design_size == (64, 32)
|
||||||
|
assert plugin.layout.scale == 2.0
|
||||||
Reference in New Issue
Block a user