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": {
|
||||
"type": "integer",
|
||||
"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": {
|
||||
"type": "string",
|
||||
"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
|
||||
|
||||
# Shared throwaway draw surface for measuring text without a target canvas.
|
||||
_measure_draw = ImageDraw.Draw(Image.new("RGB", (1, 1)))
|
||||
|
||||
|
||||
class TextHelper:
|
||||
"""
|
||||
@@ -112,10 +115,10 @@ class TextHelper:
|
||||
Width in pixels
|
||||
"""
|
||||
try:
|
||||
return draw.textlength(text, font=font)
|
||||
return int(_measure_draw.textlength(text, font=font))
|
||||
except AttributeError:
|
||||
# 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]
|
||||
|
||||
def get_text_height(self, text: str, font: ImageFont.ImageFont) -> int:
|
||||
@@ -129,13 +132,8 @@ class TextHelper:
|
||||
Returns:
|
||||
Height in pixels
|
||||
"""
|
||||
try:
|
||||
bbox = draw.textbbox((0, 0), text, font=font)
|
||||
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]
|
||||
bbox = _measure_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]:
|
||||
"""
|
||||
|
||||
@@ -103,6 +103,10 @@ class FontManager:
|
||||
# Font overrides storage (for manual overrides)
|
||||
self.font_overrides_file = "config/font_overrides.json"
|
||||
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()
|
||||
|
||||
@@ -112,6 +116,7 @@ class FontManager:
|
||||
self.fonts_config = new_config.get("fonts", {})
|
||||
self.font_cache.clear() # Clear cache to force reload
|
||||
self.metrics_cache.clear() # Clear metrics cache
|
||||
self.cache_generation += 1
|
||||
self._initialize_fonts()
|
||||
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:
|
||||
"""Load a BDF font using FreeType."""
|
||||
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)
|
||||
# Set character size (width, height) in 1/64th of points
|
||||
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}")
|
||||
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:
|
||||
"""Get a fallback font when loading fails."""
|
||||
return ImageFont.load_default()
|
||||
|
||||
@@ -15,6 +15,19 @@ import logging
|
||||
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):
|
||||
"""
|
||||
Display mode for Vegas scroll integration.
|
||||
@@ -130,6 +143,97 @@ class BasePlugin(ABC):
|
||||
"""
|
||||
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:
|
||||
"""
|
||||
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):
|
||||
- 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
|
||||
too wide/tall for a smaller panel, which this catches.
|
||||
PIL and not detected pixel-wise here. The dominant real-world breakage is
|
||||
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 overflow is not flagged. Golden-image regression covers those plugins.
|
||||
- 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)
|
||||
# Plugins must see the DECLARED size, not the padded canvas size.
|
||||
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) --
|
||||
|
||||
|
||||
@@ -161,6 +161,13 @@ class MockPluginManager:
|
||||
self.plugin_manifests: Dict[str, Dict] = {}
|
||||
self.get_plugin_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]:
|
||||
"""Get a plugin instance."""
|
||||
|
||||
@@ -28,6 +28,7 @@ DEFAULT_TEST_SIZES: List[Tuple[int, int]] = [
|
||||
(64, 32), # 1x1 — single panel, the tightest common rectangle
|
||||
(128, 32), # 2x1 — the baseline most plugins are tuned for
|
||||
(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
|
||||
(256, 32), # 4x1 — long strip, wide horizontal layout
|
||||
(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