From f16c7251a51f424e31bd5feeae74d455b7dc27de Mon Sep 17 00:00:00 2001 From: Chuck <33324927+ChuckBuilds@users.noreply.github.com> Date: Sat, 11 Jul 2026 18:20:56 -0400 Subject: [PATCH 1/4] feat(plugins): adaptive layout, element style, and dev preview tooling Upstreams a substantial body of local work found on this device that had never been committed or pushed. Verified functionally sound (see test plan) before packaging; not authored this session. ## Adaptive layout (src/adaptive_layout.py, src/adaptive_images.py) Region/LayoutContext geometry system so plugins can carve their panel into regions and fit text/images to them instead of hardcoding coordinates per panel size. Wired into BasePlugin as self.layout (lazily built, cached, and invalidated on display-size or font-cache-generation change) and draw_fit(text, box, ladder=...) for size-fitted text. src/image_utils.py (the old fit-image helper) is marked deprecated in favor of adaptive_images.fit_image. ## Element style (src/element_style.py) Universal per-element style resolution for plugin customization (config['customization']), replacing four divergent hand-rolled implementations across the sports/music plugins. Centralizes font loading, x/y-offset reading, and the did the user actually override this? check -- subtle enough that it previously shipped broken twice, since a key being present in config never means the user set it (schema_manager. merge_with_defaults and the plugin manager both write full schema defaults into config before a plugin ever sees it). BasePlugin.element_style() wires this to a plugin's own config_schema.json defaults via SchemaManager. schema/manifest_schema.json and schema_manager.py gained the declarative generated element expansion (a customization block auto-populated with font/color/offset sub-keys from a short declaration) backing this. ## Testing harness - bounds_display_manager.py: records negative-coordinate (left/top overflow) draw calls -- previously undetectable, since PIL clips them silently. - harness.py/sizes.py: a design_size-driven fill check (a panel >= 2x a plugin's declared design size must not be left mostly empty -- the scale-up counterpart to the existing overflow check), a new 96x48 (non-64x32-grid) test size, and support for check_plugin.py variants (e.g. testing a plugin with adaptive-layout mode enabled alongside its classic default, each against its own golden dir). - render_service.py: shared single-plugin-render logic factored out for reuse by the new web UI preview endpoint below. ## Web UI live preview (web_interface/blueprints/api_v3.py, pages_v3.py, plugin_config.html; scripts/dev_server.py, templates/dev_preview.html) New POST /api/v3/plugins//preview endpoint (and dev_server.py's equivalent standalone route) renders a plugin's current in-progress config at a given panel size without saving it, so the Plugin Manager UI's config form can show a live preview. dev_preview.html gained a size-preset dropdown plus an All Sizes button that renders the current config across every harness size in a side-by-side gallery. api_v3.py's config-form parsing helpers (fix_array_structures, ensure_array_defaults) were promoted from nested closures to module-level functions so the new preview endpoint can reuse the exact same parsing the save endpoint uses. ## Test plan - Ran every new/touched test file on this device (which has the real RGBMatrixEmulator/plugin-repos environment, not a scratch subset): 187 passed across test_adaptive_layout.py, test_adaptive_images.py, test_element_style.py, test_schema_style_expansion.py, test_harness_fill.py, and test/web_interface/test_plugin_preview.py. - Ran the full existing test/ suite (1024+ tests) to check for regressions: no new failures. The failures present (test_circuit_breaker, test_save_double_sided_settings, test_save_double_sided_unchecked_disables, and two test_state_reconciliation.py cases) were confirmed to reproduce identically against a clean origin/main checkout in an isolated worktree, so they predate and are unrelated to this change. - Did not exhaustively line-by-line review all ~5000 lines as a fresh design review; relied on the above test evidence plus a structural read of each file's stated purpose. --- docs/ADAPTIVE_LAYOUT.md | 234 +++++ docs/ADVANCED_PLUGIN_DEVELOPMENT.md | 6 + docs/DEVELOPER_QUICK_REFERENCE.md | 6 + docs/DEV_PREVIEW.md | 6 + docs/FONT_MANAGER.md | 5 + docs/PLUGIN_API_REFERENCE.md | 5 + docs/PLUGIN_DEVELOPMENT_GUIDE.md | 5 + schema/manifest_schema.json | 29 + scripts/check_plugin.py | 76 +- scripts/dev_server.py | 152 +-- scripts/templates/dev_preview.html | 126 ++- src/adaptive_images.py | 166 ++++ src/adaptive_layout.py | 731 ++++++++++++++ src/common/README.md | 22 + src/common/__init__.py | 43 + src/common/logo_helper.py | 15 +- src/common/text_helper.py | 16 +- src/element_style.py | 533 +++++++++++ src/font_manager.py | 48 + src/image_utils.py | 6 + src/plugin_system/base_plugin.py | 201 ++++ src/plugin_system/schema_manager.py | 42 +- .../testing/bounds_display_manager.py | 22 +- src/plugin_system/testing/harness.py | 74 ++ src/plugin_system/testing/loading.py | 10 +- src/plugin_system/testing/mocks.py | 7 + src/plugin_system/testing/render_service.py | 96 ++ src/plugin_system/testing/sizes.py | 1 + test/test_adaptive_images.py | 196 ++++ test/test_adaptive_layout.py | 438 +++++++++ test/test_element_style.py | 509 ++++++++++ test/test_harness_fill.py | 98 ++ test/test_schema_style_expansion.py | 273 ++++++ test/web_interface/test_plugin_preview.py | 189 ++++ web_interface/blueprints/api_v3.py | 898 +++++++++++------- web_interface/blueprints/pages_v3.py | 6 + .../templates/v3/partials/plugin_config.html | 45 +- 37 files changed, 4877 insertions(+), 458 deletions(-) create mode 100644 docs/ADAPTIVE_LAYOUT.md create mode 100644 src/adaptive_images.py create mode 100644 src/adaptive_layout.py create mode 100644 src/element_style.py create mode 100644 src/plugin_system/testing/render_service.py create mode 100644 test/test_adaptive_images.py create mode 100644 test/test_adaptive_layout.py create mode 100644 test/test_element_style.py create mode 100644 test/test_harness_fill.py create mode 100644 test/test_schema_style_expansion.py create mode 100644 test/web_interface/test_plugin_preview.py diff --git a/docs/ADAPTIVE_LAYOUT.md b/docs/ADAPTIVE_LAYOUT.md new file mode 100644 index 000000000..7cb3577fc --- /dev/null +++ b/docs/ADAPTIVE_LAYOUT.md @@ -0,0 +1,234 @@ +# 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_text_proportional(text, box, base_size_px, ladder, ellipsis=True, scale=None)` — + rung closest to (not exceeding) `base_size_px * scale`, still capped to + what fits the box. Use this instead of `fit_text` when several + independently-fitted elements need to stay visually harmonious as the + panel grows — `fit_text` maximizes *each one* within its own region, + which can make one element (e.g. a score with a generous box) balloon + out of proportion to a neighbor that scales by geometry (e.g. logos + sized via `px()`), even though each individual pick is "correct" in + isolation. `base_size_px` is normally the element's existing classic/ + fixed font size. `scale` defaults to `self.scale` (the conservative + min-of-both-axes factor `px()` uses); pass an axis-specific value when + the surrounding composition already scales that way — e.g. a scoreboard + whose logo slots track height alone (`min(height, width // 2)`) should + size its text by `height / design_height` too, or the text reads as + under-scaled next to bigger logos on a panel that only grew taller. +- `fit_lines(lines, box, ladder, spacing)` — every line fits the width and + the stack fits the height (measures the actual strings). +- `font_for_rows(rows, box_h, ladder)` — largest rung whose line height + fits `rows` rows. + +`FitResult` carries the ready-to-use `font` (drops straight into +`display_manager.draw_text(font=...)`), the possibly-ellipsized `text`, +ink `width`/`height`, `baseline`, `y_offset`, `line_height`, and `fits`. + +## Adaptive images + +`src/adaptive_images.py` is the image counterpart to `fit_text`, exposed as +`self.layout.fit_image(...)` (cached per panel size) and the one-liner +`self.draw_image(...)`: + +```python +# Team logo: trim its transparent padding, fill the slot height (the +# football/hockey pattern), cached across frames by a stable key +self.draw_image(logo, regs.away_slot, mode="fill_height", + crop_to_ink=True, cache_key=f"logo:{abbr}") + +# Album art: cover-crop a square, faces kept by the top anchor +self.draw_image(art, row.art, mode="cover", anchor="top") + +# Pixel flags / sprite icons: NEAREST keeps hard edges +from src.adaptive_images import RESAMPLE_NEAREST +self.draw_image(flag, box, resample=RESAMPLE_NEAREST) +``` + +Modes: `contain` (letterbox, default), `cover` (crop-to-fill), +`fill_height` (logo-style), `stretch`. Unlike PIL's `thumbnail()` +(downscale-only — why imagery stays tiny on big panels) fitting **upscales +by default**; pass `upscale=False` for the legacy behavior. Results are +cached per (image, box size, options) with a bounded LRU — always pass a +stable `cache_key` (e.g. `"logo:KC"`) for images you reload. The module +also exports the Pillow-compat `RESAMPLE_LANCZOS`/`RESAMPLE_NEAREST` +constants so plugins can drop their local shims. + +## Composite layouts + +Pre-carved Region arrangements for the layouts plugins keep rebuilding: + +```python +from src.adaptive_layout import scoreboard_regions, media_row + +regs = scoreboard_regions(self.layout.bounds, ctx=self.layout) +# regs.away_slot / home_slot — logo slots (logo_slot = min(H, W // 2), +# capped so a center reserve always exists — +# see below) +# regs.status_band — top band (replaces the magic y = 1) +# regs.score_area — center gap, plus a controlled bleed into +# each logo slot (replaces y = H//2 - 3) +# regs.detail_band — bottom band (replaces y = H - 7) +# regs.bottom_left / bottom_right — record/timeout corners + +row = media_row(self.layout.bounds, ctx=self.layout) # art left, text right +``` + +Both work on the full panel or on a scroll-mode card Region. They return +Regions and never draw — compose them with `draw_fit`/`draw_image`. + +**`scoreboard_regions`'s center reserve.** The raw `logo_slot = min(H, W//2)` +formula has a blind spot: at exactly 2:1 aspect ratio (width = 2×height — +two, four, or more square modules stacked into a taller panel, e.g. +96x48, 128x64, 256x128) the two logo slots mathematically claim the +*entire* width, leaving zero pixels for a center column no matter how +big the panel gets. Wide panels (the 128x32 design baseline, 192x48, +256x32) never hit this, since height is already the tighter constraint +there. Two parameters fix it without any plugin-side code: +`min_center_fraction`/`min_center_design_px` guarantee a real minimum +center reserve at any aspect ratio, and `score_bleed_fraction` lets the +score's *fit box* extend a controlled amount into each logo slot — the +same way a real broadcast scoreboard's numbers cross slightly into the +team marks flanking them — so a short score string never has to truncate +even on the tightest aspect ratios. All three have sane defaults; override +them per call if a plugin's card proportions genuinely differ. + +## Preserving user customization + +Adaptive layout supplies *defaults*; explicit user configuration wins: + +- **User-set fonts win.** If the plugin's config has an explicit + `font`/`font_size` for an element, load it as before and skip the ladder — + fit only when the user hasn't overridden (see the football-scoreboard + `_resolve_element_fit` pattern). +- **Offsets apply on top.** `customization.layout..{x_offset,y_offset}` + style knobs translate the *computed* region as a final step: + `region.offset(user_dx, user_dy)`. `draw_image(..., offset=(dx, dy))` + does the same for images. +- **Colors pass through.** `draw_fit`/`draw_fitted_text` take explicit + `color=` params; adaptive mode never repaints semantic or user-chosen + colors. + +## Manifest declaration + +Declare the size your layout was authored against so `ctx.scale` means +something: + +```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 --sizes 64x32,128x32,96x48,128x64,256x64 +python scripts/render_plugin.py --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"`. diff --git a/docs/ADVANCED_PLUGIN_DEVELOPMENT.md b/docs/ADVANCED_PLUGIN_DEVELOPMENT.md index a0a33cd93..eddeb388a 100644 --- a/docs/ADVANCED_PLUGIN_DEVELOPMENT.md +++ b/docs/ADVANCED_PLUGIN_DEVELOPMENT.md @@ -2,6 +2,12 @@ Advanced patterns, examples, and best practices for developing LEDMatrix plugins. +> **Adaptive layout:** for plugins that should render legibly on any panel +> size (fonts that grow on big panels, layouts that degrade gracefully on +> small ones), use the adaptive layout system — `self.layout`, `draw_fit`, +> `draw_image`, `scoreboard_regions` — documented in +> [ADAPTIVE_LAYOUT.md](ADAPTIVE_LAYOUT.md). + ## Table of Contents - [Using Weather Icons](#using-weather-icons) diff --git a/docs/DEVELOPER_QUICK_REFERENCE.md b/docs/DEVELOPER_QUICK_REFERENCE.md index 979bc6b0e..93ca8e16c 100644 --- a/docs/DEVELOPER_QUICK_REFERENCE.md +++ b/docs/DEVELOPER_QUICK_REFERENCE.md @@ -48,6 +48,12 @@ display_manager.draw_text("Centered", centered=True) # Auto-center width = display_manager.get_text_width("Text", font) height = display_manager.get_font_height(font) +# Adaptive layout (recommended for multi-size support — text and images +# that scale to any panel; see docs/ADAPTIVE_LAYOUT.md) +rows = self.layout.bounds.inset(1).split_v(3, 1, gap=1) +self.draw_fit("12:34", rows[0]) # largest crisp font that fits +self.draw_image(logo, rows[1], mode="fill_height", crop_to_ink=True) + # Weather icons display_manager.draw_weather_icon("rain", x=10, y=10, size=16) diff --git a/docs/DEV_PREVIEW.md b/docs/DEV_PREVIEW.md index 9338f97e0..afb3aa1d9 100644 --- a/docs/DEV_PREVIEW.md +++ b/docs/DEV_PREVIEW.md @@ -6,6 +6,12 @@ Tools for rapid plugin development without deploying to the RPi. Interactive web UI for tweaking plugin configs and seeing the rendered display in real time. +The size inputs have a preset dropdown with the harness's standard panel +sizes, and the **All Sizes** button renders the current config at every +harness size in a side-by-side gallery (`POST /api/render-matrix`) — the +quickest way to eyeball adaptive-layout behavior across panels +(see [ADAPTIVE_LAYOUT.md](ADAPTIVE_LAYOUT.md)). + ### Quick Start ```bash diff --git a/docs/FONT_MANAGER.md b/docs/FONT_MANAGER.md index 3ca551a2d..74fab2555 100644 --- a/docs/FONT_MANAGER.md +++ b/docs/FONT_MANAGER.md @@ -1,5 +1,10 @@ # FontManager Usage Guide +> **Picking a size automatically:** if you want the *largest font that fits +> a given area* rather than a fixed size, use the adaptive layout system's +> font ladders (`self.layout.fit_text(...)`) which resolve through this +> FontManager — see [ADAPTIVE_LAYOUT.md](ADAPTIVE_LAYOUT.md). + ## Overview The enhanced FontManager provides comprehensive font management for the LEDMatrix application with support for: diff --git a/docs/PLUGIN_API_REFERENCE.md b/docs/PLUGIN_API_REFERENCE.md index 6e3b4ca30..751d5609e 100644 --- a/docs/PLUGIN_API_REFERENCE.md +++ b/docs/PLUGIN_API_REFERENCE.md @@ -2,6 +2,11 @@ Complete API reference for plugin developers. This document describes all methods and properties available to plugins through the Display Manager, Cache Manager, and Plugin Manager. +> **Adaptive layout:** every `BasePlugin` also exposes `self.layout`, +> `self.draw_fit(text, region)` and `self.draw_image(img, region, ...)` — +> the recommended way to render text and images that scale to any panel +> size. See [ADAPTIVE_LAYOUT.md](ADAPTIVE_LAYOUT.md). + ## Table of Contents - [BasePlugin](#baseplugin) diff --git a/docs/PLUGIN_DEVELOPMENT_GUIDE.md b/docs/PLUGIN_DEVELOPMENT_GUIDE.md index acd7b00a7..bcb637a34 100644 --- a/docs/PLUGIN_DEVELOPMENT_GUIDE.md +++ b/docs/PLUGIN_DEVELOPMENT_GUIDE.md @@ -2,6 +2,11 @@ This guide explains how to set up a development workflow for plugins that are maintained in separate Git repositories while still being able to test them within the LEDMatrix project. +> **Rendering guidance:** plugins are expected to read the display size +> dynamically and lay themselves out for any panel. The adaptive layout +> system ([ADAPTIVE_LAYOUT.md](ADAPTIVE_LAYOUT.md)) provides the shared +> helpers for that — fonts, images, and composite layouts that scale. + ## Overview When developing plugins in separate repositories, you need a way to: diff --git a/schema/manifest_schema.json b/schema/manifest_schema.json index cfb3f6ff3..dce337c58 100644 --- a/schema/manifest_schema.json +++ b/schema/manifest_schema.json @@ -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" diff --git a/scripts/check_plugin.py b/scripts/check_plugin.py index 63fbb7e96..0960e65a2 100644 --- a/scripts/check_plugin.py +++ b/scripts/check_plugin.py @@ -37,10 +37,11 @@ from src.logging_config import get_logger # noqa: E402 from src.plugin_system.testing.loading import ( # noqa: E402 - find_plugin_dir, load_config_defaults, load_harness_spec, + find_plugin_dir, load_config_defaults, load_harness_spec, load_manifest, ) from src.plugin_system.testing.harness import ( # noqa: E402 RenderResult, render_plugin_matrix, compare_to_goldens, write_goldens, + check_scale_up, ) from src.plugin_system.testing.sizes import ( # noqa: E402 parse_size_token, resolve_test_sizes, safe_mode_filename, size_label, @@ -110,28 +111,55 @@ def check_one(plugin_id: str, search_dirs: List[str], sizes, mock_data: Dict, effective_freeze = freeze_time or spec.get("freeze_time") effective_run_update = run_update and not spec.get("skip_update", False) - results = render_plugin_matrix( - plugin_id=plugin_id, plugin_dir=plugin_dir, config=full_config, - mock_data=effective_mock_data, sizes=effective_sizes, - run_update=effective_run_update, freeze_time=effective_freeze, - ) + # The plugin's declared design size drives the scale-up fill check + # (panels >= 2x the design size must not be left mostly empty). + declared = load_manifest(plugin_dir).get("display", {}).get("design_size", {}) + design_size = (int(declared.get("width", 128)), int(declared.get("height", 32))) + fill_strict = spec.get("fill_check") == "strict" + + # Every run: the base config, plus one per harness.json "variant" — + # a config overlay with its own golden dir (e.g. adaptive layout mode + # tested alongside the classic default). + runs = [(None, {}, golden_dir_override or (plugin_dir / 'test' / 'golden'))] + for variant in spec.get("variants", []): + name = variant.get("name") or "variant" + vdir = plugin_dir / variant.get("golden_dir", f"test/golden-{name}") + runs.append((name, variant.get("config", {}), vdir)) + + all_run_results: List[RenderResult] = [] + for variant_name, overlay, golden_dir in runs: + run_config = {**full_config, **overlay} + results = render_plugin_matrix( + plugin_id=plugin_id, plugin_dir=plugin_dir, config=run_config, + mock_data=effective_mock_data, sizes=effective_sizes, + run_update=effective_run_update, freeze_time=effective_freeze, + ) - golden_dir = golden_dir_override or (plugin_dir / 'test' / 'golden') - if update_golden: - written = write_goldens(results, golden_dir) - logger.info("Wrote %d golden image(s) for %s to %s", written, plugin_id, golden_dir) - else: - compare_to_goldens(results, golden_dir) + if update_golden: + written = write_goldens(results, golden_dir) + logger.info("Wrote %d golden image(s) for %s%s to %s", written, plugin_id, + f" [{variant_name}]" if variant_name else "", golden_dir) + else: + compare_to_goldens(results, golden_dir) - if out_dir: - for r in results: - if r.image is None: - continue - dest = out_dir / plugin_id / size_label(r.width, r.height) - dest.mkdir(parents=True, exist_ok=True) - r.image.save(dest / f"{safe_mode_filename(r.mode)}.png", format="PNG") + check_scale_up(results, design_size=design_size, strict=fill_strict) + + # Tag variant runs so the report and PNG dumps stay distinguishable. + if variant_name: + for r in results: + r.mode = f"{r.mode}@{variant_name}" - return results + if out_dir: + for r in results: + if r.image is None: + continue + dest = out_dir / plugin_id / size_label(r.width, r.height) + dest.mkdir(parents=True, exist_ok=True) + r.image.save(dest / f"{safe_mode_filename(r.mode)}.png", format="PNG") + + all_run_results.extend(results) + + return all_run_results def print_report(all_results: Dict[str, List[RenderResult]]) -> bool: @@ -147,6 +175,10 @@ def print_report(all_results: Dict[str, List[RenderResult]]) -> bool: detail = " (golden ✓)" if r.update_error is not None: detail += f" (update warn: {r.update_error})" + if r.fill_checked and r.fill_ok is None and r.fill_extent: + # warn-only underfill: big panel left mostly empty + ex, ey = r.fill_extent + detail += f" (fill warn: extent {ex:.0%}x{ey:.0%})" else: everything_ok = False if r.error is not None: @@ -156,6 +188,10 @@ def print_report(all_results: Dict[str, List[RenderResult]]) -> bool: elif r.golden_ok is False: status = "FAIL" detail = f" golden drift: {r.golden_diff_pixels}px (max Δ={r.golden_max_delta})" + elif r.fill_ok is False: + ex, ey = r.fill_extent or (0.0, 0.0) + status = "FAIL" + detail = f" fill: extent {ex:.0%}x{ey:.0%} below required coverage" else: status, detail = "FAIL", "" print(f" [{status}] {r.size_label:>7} {r.mode}{detail}") diff --git a/scripts/dev_server.py b/scripts/dev_server.py index 183744053..0f18eb850 100644 --- a/scripts/dev_server.py +++ b/scripts/dev_server.py @@ -176,6 +176,42 @@ def api_plugin_defaults(plugin_id): return jsonify({'defaults': defaults}) +def _render_once(plugin_id, plugin_dir, manifest, config, mock_data, width, height, + skip_update): + """Render one plugin at one size. Returns the /api/render response dict. + + Thin wrapper over the shared render service (also used by the web UI's + config-page preview); a fresh plugin instance per call, mirroring the + safety harness, so sizes never share state. + """ + from src.plugin_system.testing.render_service import render_plugin_once + + return render_plugin_once( + plugin_id, plugin_dir, manifest=manifest, config=config, + mock_data=mock_data, width=width, height=height, + skip_update=skip_update) + + +def _parse_render_request(data): + """Shared /api/render* request prep. Returns (plugin_dir, manifest, config, + mock_data, skip_update) or raises ValueError with a client message.""" + plugin_id = data['plugin_id'] + plugin_dir = find_plugin_dir(plugin_id) + if not plugin_dir: + raise LookupError(f'Plugin not found: {plugin_id}') + + manifest_path = plugin_dir / 'manifest.json' + with open(manifest_path, 'r') as f: + manifest = json.load(f) + + # Build config: schema defaults + user overrides + config = {'enabled': True} + config.update(load_config_defaults(plugin_dir)) + config.update(data.get('config', {})) + + return plugin_dir, manifest, config, data.get('mock_data', {}), data.get('skip_update', False) + + @app.route('/api/render', methods=['POST']) def api_render(): """Render a plugin and return the display as base64 PNG.""" @@ -183,11 +219,6 @@ def api_render(): if not data or 'plugin_id' not in data: return jsonify({'error': 'plugin_id is required'}), 400 - plugin_id = data['plugin_id'] - user_config = data.get('config', {}) - mock_data = data.get('mock_data', {}) - skip_update = data.get('skip_update', False) - try: width = int(data.get('width', 128)) height = int(data.get('height', 32)) @@ -199,78 +230,67 @@ def api_render(): if not (MIN_HEIGHT <= height <= MAX_HEIGHT): return jsonify({'error': f'height must be between {MIN_HEIGHT} and {MAX_HEIGHT}'}), 400 - # Find plugin - plugin_dir = find_plugin_dir(plugin_id) - if not plugin_dir: - return jsonify({'error': f'Plugin not found: {plugin_id}'}), 404 - - # Load manifest - manifest_path = plugin_dir / 'manifest.json' - with open(manifest_path, 'r') as f: - manifest = json.load(f) + try: + plugin_dir, manifest, config, mock_data, skip_update = _parse_render_request(data) + except LookupError as e: + return jsonify({'error': str(e)}), 404 - # Build config: schema defaults + user overrides - config_defaults = load_config_defaults(plugin_dir) - config = {'enabled': True} - config.update(config_defaults) - config.update(user_config) + try: + result = _render_once(data['plugin_id'], plugin_dir, manifest, config, + mock_data, width, height, skip_update) + except Exception as e: + return jsonify({'error': f'Failed to load plugin: {e}'}), 500 + return jsonify(result) - # Create display manager and mocks - from src.plugin_system.testing import VisualTestDisplayManager, MockCacheManager, MockPluginManager - from src.plugin_system.plugin_loader import PluginLoader - display_manager = VisualTestDisplayManager(width=width, height=height) - cache_manager = MockCacheManager() - plugin_manager = MockPluginManager() +@app.route('/api/sizes') +def api_sizes(): + """The representative panel-size sample the safety harness renders at.""" + from src.plugin_system.testing.sizes import DEFAULT_TEST_SIZES + return jsonify({'sizes': [list(s) for s in DEFAULT_TEST_SIZES]}) - # Pre-populate cache with mock data - for key, value in mock_data.items(): - cache_manager.set(key, value) - # Load plugin - loader = PluginLoader() - errors = [] - warnings = [] +MAX_MATRIX_SIZES = 12 - try: - plugin_instance, module = loader.load_plugin( - plugin_id=plugin_id, - manifest=manifest, - plugin_dir=plugin_dir, - config=config, - display_manager=display_manager, - cache_manager=cache_manager, - plugin_manager=plugin_manager, - install_deps=False, - ) - except Exception as e: - return jsonify({'error': f'Failed to load plugin: {e}'}), 500 - start_time = time.time() +@app.route('/api/render-matrix', methods=['POST']) +def api_render_matrix(): + """Render a plugin at a list of sizes (default: the harness sample) so the + UI can show a side-by-side multi-resolution gallery.""" + data = request.get_json() + if not data or 'plugin_id' not in data: + return jsonify({'error': 'plugin_id is required'}), 400 - # Run update() - if not skip_update: + from src.plugin_system.testing.sizes import DEFAULT_TEST_SIZES + sizes = data.get('sizes') or [list(s) for s in DEFAULT_TEST_SIZES] + if len(sizes) > MAX_MATRIX_SIZES: + return jsonify({'error': f'at most {MAX_MATRIX_SIZES} sizes per request'}), 400 + parsed_sizes = [] + for pair in sizes: try: - plugin_instance.update() - except Exception as e: - warnings.append(f"update() raised: {e}") + w, h = int(pair[0]), int(pair[1]) + except (TypeError, ValueError, IndexError): + return jsonify({'error': f'invalid size entry {pair!r} (expected [w, h])'}), 400 + if not (MIN_WIDTH <= w <= MAX_WIDTH and MIN_HEIGHT <= h <= MAX_HEIGHT): + return jsonify({'error': f'size {w}x{h} out of bounds'}), 400 + parsed_sizes.append((w, h)) - # Run display() try: - plugin_instance.display(force_clear=True) - except Exception as e: - errors.append(f"display() raised: {e}") - - render_time_ms = round((time.time() - start_time) * 1000, 1) - - return jsonify({ - 'image': f'data:image/png;base64,{display_manager.get_image_base64()}', - 'width': width, - 'height': height, - 'render_time_ms': render_time_ms, - 'errors': errors, - 'warnings': warnings, - }) + plugin_dir, manifest, config, mock_data, skip_update = _parse_render_request(data) + except LookupError as e: + return jsonify({'error': str(e)}), 404 + + results = [] + for w, h in parsed_sizes: + try: + results.append(_render_once(data['plugin_id'], plugin_dir, manifest, + config, mock_data, w, h, skip_update)) + except Exception as e: + results.append({'image': None, 'width': w, 'height': h, + 'render_time_ms': 0, + 'errors': [f'Failed to load plugin: {e}'], + 'warnings': []}) + return jsonify({'results': results}) # -------------------------------------------------------------------------- diff --git a/scripts/templates/dev_preview.html b/scripts/templates/dev_preview.html index 84756a66d..4e308013f 100644 --- a/scripts/templates/dev_preview.html +++ b/scripts/templates/dev_preview.html @@ -209,6 +209,11 @@

LEDMatrix onchange="onConfigChange()"> px + @@ -242,13 +247,18 @@

LEDMatrix - +
+
@@ -311,6 +321,15 @@

LEDMatrix + + + @@ -340,8 +359,30 @@

LEDMatrix opt.textContent = `${p.name} (${p.id})`; select.appendChild(opt); }); + + // Load harness size presets + try { + const sizesRes = await fetch('/api/sizes'); + const sizesData = await sizesRes.json(); + const preset = document.getElementById('sizePreset'); + (sizesData.sizes || []).forEach(([w, h]) => { + const opt = document.createElement('option'); + opt.value = `${w}x${h}`; + opt.textContent = `${w} x ${h}`; + preset.appendChild(opt); + }); + } catch (e) { /* presets are a convenience; ignore */ } }); + function applySizePreset() { + const value = document.getElementById('sizePreset').value; + if (!value) return; + const [w, h] = value.split('x'); + document.getElementById('displayWidth').value = w; + document.getElementById('displayHeight').value = h; + onConfigChange(); + } + // ---------- Plugin selection ---------- async function onPluginChange() { const pluginId = document.getElementById('pluginSelect').value; @@ -485,6 +526,89 @@

LEDMatrix } } + // ---------- Multi-size gallery ---------- + async function renderAllSizes() { + if (!currentPluginId) return; + + const btn = document.getElementById('renderAllBtn'); + const panel = document.getElementById('galleryPanel'); + const grid = document.getElementById('galleryGrid'); + const status = document.getElementById('galleryStatus'); + btn.disabled = true; + btn.textContent = 'Rendering…'; + panel.classList.remove('hidden'); + grid.innerHTML = ''; + status.textContent = 'Rendering at all harness sizes…'; + + const config = jsonEditor ? jsonEditor.getValue() : {}; + config.enabled = true; + let mockData = {}; + const mockInput = document.getElementById('mockDataInput').value.trim(); + if (mockInput) { + try { mockData = JSON.parse(mockInput); } + catch (e) { showMessages([], [`Mock data JSON error: ${e.message}`]); } + } + + try { + const res = await fetch('/api/render-matrix', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + plugin_id: currentPluginId, + config: config, + mock_data: mockData, + }), + }); + const data = await res.json(); + if (data.error) { + status.textContent = data.error; + return; + } + + let failures = 0; + (data.results || []).forEach(r => { + const cell = document.createElement('div'); + cell.style.cssText = 'display:flex;flex-direction:column;gap:4px;'; + const failed = (r.errors || []).length > 0 || !r.image; + if (failed) failures++; + + const label = document.createElement('span'); + label.className = 'text-xs font-mono'; + label.style.color = failed ? '#f87171' : 'var(--text-secondary)'; + label.textContent = `${r.width}x${r.height} · ${r.render_time_ms}ms`; + cell.appendChild(label); + + if (r.image) { + const img = document.createElement('img'); + img.src = r.image; + // Small panels get 2x zoom so they stay legible in the grid + const zoom = r.height >= 128 ? 1 : 2; + img.style.cssText = + `image-rendering: pixelated; width:${r.width * zoom}px; ` + + `height:${r.height * zoom}px; ` + + `border:1px solid ${failed ? '#f87171' : 'var(--border-color)'};`; + cell.appendChild(img); + } + if (failed) { + const err = document.createElement('span'); + err.className = 'text-xs font-mono'; + err.style.color = '#f87171'; + err.textContent = (r.errors || ['render failed']).join('; '); + cell.appendChild(err); + } + grid.appendChild(cell); + }); + status.textContent = failures + ? `${failures} size(s) failed` + : `${(data.results || []).length} sizes rendered`; + } catch (e) { + status.textContent = `Network error: ${e.message}`; + } finally { + btn.disabled = false; + btn.textContent = 'All Sizes'; + } + } + // ---------- Zoom ---------- function updateZoom() { const zoom = parseInt(document.getElementById('zoomSlider').value); diff --git a/src/adaptive_images.py b/src/adaptive_images.py new file mode 100644 index 000000000..1887d4e1a --- /dev/null +++ b/src/adaptive_images.py @@ -0,0 +1,166 @@ +""" +Adaptive image fitting for plugins — the image counterpart to +src/adaptive_layout.py's text fitting. + +Promotes the proven in-field image patterns into one shared helper so +plugins stop hand-copying resize/cache code: + +- "crop transparent padding, then fill the row height" (football/hockey + logo pattern) -> ``crop_to_ink=True, mode="fill_height"`` +- "crop-to-fill with a top anchor for faces" (masters-tournament headshot + pattern) -> ``mode="cover", anchor="top"`` +- "letterbox to fit, centered on a background" (static-image pattern) + -> ``mode="contain"`` +- NEAREST for pixel art/flags vs LANCZOS for photos (masters flag pattern) + -> ``resample=RESAMPLE_NEAREST`` + +Unlike PIL's ``thumbnail()`` (downscale-only — the reason plugin imagery +stays tiny on big panels), ``fit_image`` upscales by default so content +genuinely adapts to larger displays; pass ``upscale=False`` for the old +behavior. + +Use via ``LayoutContext.fit_image(...)`` (cached per panel size) or +``BasePlugin.draw_image(...)``; the module-level functions are the +uncached primitives. +""" + +from dataclasses import dataclass +from typing import Any, Optional, Tuple, Union + +from PIL import Image + +# The one Pillow >= 9.1 compat shim (replaces the per-plugin copies). +try: + RESAMPLE_LANCZOS = Image.Resampling.LANCZOS + RESAMPLE_NEAREST = Image.Resampling.NEAREST +except AttributeError: # Pillow < 9.1 + RESAMPLE_LANCZOS = Image.LANCZOS + RESAMPLE_NEAREST = Image.NEAREST + +FIT_MODES = ("contain", "cover", "fill_height", "stretch") + + +@dataclass(frozen=True) +class ImageFitResult: + """A processed RGBA copy of a source image, sized for a target box.""" + image: Image.Image + width: int + height: int + scale: float # scale applied vs the (possibly ink-cropped) source + mode: str + source_size: Tuple[int, int] + + @property + def is_empty(self) -> bool: + return self.width <= 0 or self.height <= 0 + + +_EMPTY_IMAGE = Image.new("RGBA", (1, 1), (0, 0, 0, 0)) + + +def _empty_result(mode: str, source_size: Tuple[int, int]) -> ImageFitResult: + return ImageFitResult(_EMPTY_IMAGE, 0, 0, 0.0, mode, source_size) + + +def _box_dims(box: Any) -> Tuple[int, int]: + """Accept a Region (duck-typed .w/.h) or a (w, h) tuple.""" + if hasattr(box, "w") and hasattr(box, "h"): + return (int(box.w), int(box.h)) + w, h = box + return (int(w), int(h)) + + +def fit_image(img: Image.Image, box: Any, *, mode: str = "contain", + crop_to_ink: bool = False, anchor: str = "center", + resample: Any = None, upscale: bool = True) -> ImageFitResult: + """Fit an image into a box, preserving crispness policy per content type. + + Args: + img: Source PIL image (any mode; output is always RGBA). + box: Region or (w, h) target box. + mode: "contain" (letterbox), "cover" (crop-to-fill), + "fill_height" (height == box height, contain-capped by width), + "stretch" (exact resize). + crop_to_ink: Trim fully-transparent padding (getbbox) before fitting — + logos shipped with generous padding otherwise render small. + anchor: For "cover" crops: "center" or "top" (keeps faces/tops). + resample: PIL resampling filter; defaults to RESAMPLE_LANCZOS. + Use RESAMPLE_NEAREST for pixel art, flags, and sprite icons. + upscale: Allow scaling above source size (default True — the adaptive + point). False mimics the legacy thumbnail() behavior. + """ + if mode not in FIT_MODES: + raise ValueError(f"Unknown fit mode '{mode}' (expected one of {FIT_MODES})") + box_w, box_h = _box_dims(box) + if box_w <= 0 or box_h <= 0 or img.width <= 0 or img.height <= 0: + return _empty_result(mode, img.size) + + resample = RESAMPLE_LANCZOS if resample is None else resample + + work = img if img.mode == "RGBA" else img.convert("RGBA") + if crop_to_ink: + bbox = work.getbbox() + if bbox is None: # fully transparent + return _empty_result(mode, img.size) + work = work.crop(bbox) + + src_w, src_h = work.size + + if mode == "stretch": + out = work.resize((box_w, box_h), resample) + return ImageFitResult(out, box_w, box_h, box_w / src_w, mode, (src_w, src_h)) + + if mode == "cover": + scale = max(box_w / src_w, box_h / src_h) + if not upscale: + scale = min(scale, 1.0) + scaled_w = max(1, round(src_w * scale)) + scaled_h = max(1, round(src_h * scale)) + out = work.resize((scaled_w, scaled_h), resample) + # Crop the overhang down to the box (only when the scaled image is + # larger; with upscale=False it may be smaller and is left as-is). + crop_w, crop_h = min(box_w, scaled_w), min(box_h, scaled_h) + left = (scaled_w - crop_w) // 2 + top = 0 if anchor == "top" else (scaled_h - crop_h) // 2 + out = out.crop((left, top, left + crop_w, top + crop_h)) + return ImageFitResult(out, out.width, out.height, scale, mode, (src_w, src_h)) + + # contain / fill_height share the "preserve aspect, no crop" path + if mode == "fill_height": + scale = box_h / src_h + # contain-cap: never exceed the box width (football's logo_slot rule) + scale = min(scale, box_w / src_w) + else: # contain + scale = min(box_w / src_w, box_h / src_h) + if not upscale: + scale = min(scale, 1.0) + out_w = max(1, round(src_w * scale)) + out_h = max(1, round(src_h * scale)) + out = work if (out_w, out_h) == (src_w, src_h) else work.resize((out_w, out_h), resample) + return ImageFitResult(out, out_w, out_h, scale, mode, (src_w, src_h)) + + +def draw_fitted_image(display_manager: Any, ifit: ImageFitResult, box: Any, *, + align: str = "center", valign: str = "center", + offset: Tuple[int, int] = (0, 0)) -> Optional[Tuple[int, int]]: + """Paste a fitted image aligned within a Region onto the display canvas. + + Pastes with the image's own alpha mask. Returns the (x, y) actually used + so callers can position adjacent decorations, or None when nothing was + drawn (empty fit / no canvas). + """ + if ifit is None or ifit.is_empty: + return None + image = getattr(display_manager, "image", None) + if image is None: + return None + if hasattr(box, "align_xy"): + x, y = box.align_xy(ifit.width, ifit.height, align, valign) + else: + box_w, box_h = _box_dims(box) + x = (box_w - ifit.width) // 2 + y = (box_h - ifit.height) // 2 + x += int(offset[0]) + y += int(offset[1]) + image.paste(ifit.image, (x, y), ifit.image) + return (x, y) diff --git a/src/adaptive_layout.py b/src/adaptive_layout.py new file mode 100644 index 000000000..1b316f2c8 --- /dev/null +++ b/src/adaptive_layout.py @@ -0,0 +1,731 @@ +""" +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 collections import OrderedDict +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 offset(self, dx: int, dy: int) -> "Region": + """Translate without resizing — the hook for user x/y-offset + customization: compute regions first, then apply the user's + configured offsets as a final translation.""" + return Region(self.x + dx, self.y + dy, self.w, self.h) + + 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 + + +def measure_font_crispness(font: Any, sample_text: str = "Ay0", + canvas_size: Tuple[int, int] = (250, 60)) -> float: + """Fraction of the rendered sample's ink-bbox pixels that are neither + pure black nor pure white — i.e. antialiased. + + BDF (freetype.Face) glyphs are true bitmaps and always render at 0.0. + "Pixel-style" TTFs (PressStart2P, and similar fonts bundled for + plugins that draw through ImageDraw.text() and so can't take a BDF + face) are NOT automatically crisp at arbitrary sizes — PIL antialiases + TTF outlines by default, and a pixel-grid font only lands on whole + pixels at specific sizes (for PressStart2P: exact multiples of 8). + Requesting an unverified size silently produces soft/blurry glyphs on + an LED panel, which reads as fuzzy compared to a true BDF rung. + + Use this to vet any custom FontLadder rung that mixes TTF fonts before + shipping it — see test_adaptive_layout.py::test_ladder_is_crisp for the + pattern. A rung should score 0.0 (or very close, to allow for the odd + diagonal stroke) before it belongs in a "crisp" ladder. + """ + if isinstance(font, freetype.Face): + return 0.0 + from PIL import Image, ImageDraw + img = Image.new("L", canvas_size, 0) + ImageDraw.Draw(img).text((2, 2), sample_text, font=font, fill=255) + bbox = img.getbbox() + if bbox is None: + return 0.0 + pixels = img.crop(bbox).tobytes() + pure = sum(1 for p in pixels if p == 0 or p == 255) + return (len(pixels) - pure) / len(pixels) + + +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] = {} + # LRU-bounded (images are big, unlike text fits). Entries hold a + # strong reference to the source image when keyed by id() so the id + # can't be recycled out from under the cache. + self._image_cache: "OrderedDict[Any, Tuple[Any, Any]]" = OrderedDict() + + _IMAGE_CACHE_MAX = 64 + + # ---- 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 = self._walk_ladder(text, ladder, box_w, box_h, ellipsis) + self._fit_cache[key] = result + return result + + def fit_text_proportional(self, text: str, box: Union[Region, Tuple[int, int]], + base_size_px: int, ladder: FontLadder = LADDER_DEFAULT, + ellipsis: bool = True, + scale: Optional[float] = None) -> FitResult: + """Ladder rung closest to (but not exceeding) ``base_size_px * scale`` + that still fits the box — proportional sizing instead of ``fit_text``'s + "always maximize" behavior. + + Use this when several independently-fitted elements need to stay + visually harmonious as the panel grows (e.g. a scoreboard's score, + status, and detail text) — ``fit_text`` maximizes each one within + its own region, which can make one element balloon out of + proportion to its neighbors (a huge score overlapping logos it fit + fine at the design size) even though every individual pick is + independently "correct". ``base_size_px`` is the size that element + renders at on the design size (``design_size``, typically 128x32) + — commonly a plugin's existing classic/fixed font size for that + element. + + ``scale`` defaults to ``self.scale`` (the same conservative + min(width_ratio, height_ratio) factor ``px()`` uses — safe for + content whose aspect ratio matters). Pass an explicit axis-specific + value when the surrounding composition already scales that way — + e.g. a scoreboard whose logos scale with height alone + (``logo_slot = min(height, width // 2)``) should size its score + text by ``height / design_height`` too, or its text will look + under-scaled next to bigger logos on a panel that only grew taller. + + Falls back to the smallest rung when even that exceeds the target + (a tiny scale factor), and to fit_text's ordinary smaller-rung + fallback when the closest-to-target rung doesn't actually fit the + box. + """ + box_w, box_h = _box_dims(box) + effective_scale = self.scale if scale is None else scale + key = ("text_prop", text, box_w, box_h, ladder, base_size_px, ellipsis, effective_scale) + cached = self._fit_cache.get(key) + if cached is not None: + return cached + target = base_size_px * effective_scale + eligible = [step for step in ladder if step.size_px <= target] + candidates = eligible if eligible else (min(ladder, key=lambda s: s.size_px),) + result = self._walk_ladder(text, candidates, box_w, box_h, ellipsis) + self._fit_cache[key] = result + return result + + def _walk_ladder(self, text: str, ladder: Sequence[FontStep], + box_w: int, box_h: int, ellipsis: bool) -> FitResult: + """Shared by fit_text/fit_text_proportional: first ladder entry (in + the order given) whose rendered text fits, ellipsizing the last one + tried if none do.""" + 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) + 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 + + # ---- images --------------------------------------------------------- + + def fit_image(self, img: Any, box: Union[Region, Tuple[int, int]], *, + mode: str = "contain", crop_to_ink: bool = False, + anchor: str = "center", resample: Any = None, + upscale: bool = True, cache_key: Any = None) -> Any: + """Fit an image into a box (see src/adaptive_images.py for modes), + cached per (image, box size, options) for this panel size. + + Prefer a stable ``cache_key`` (e.g. "logo:KC") for images that get + reloaded — the default id()-based key is safe (the entry pins the + source image) but misses across reloads of the same content. + """ + from src.adaptive_images import fit_image as _fit_image + + box_w, box_h = _box_dims(box) + resample_name = getattr(resample, "name", repr(resample)) if resample is not None else "default" + identity = cache_key if cache_key is not None else ("id", id(img)) + key = ("image", identity, img.size, box_w, box_h, mode, + crop_to_ink, anchor, resample_name, upscale) + + cached = self._image_cache.get(key) + if cached is not None: + self._image_cache.move_to_end(key) + return cached[0] + + result = _fit_image(img, (box_w, box_h), mode=mode, + crop_to_ink=crop_to_ink, anchor=anchor, + resample=resample, upscale=upscale) + # Pin the source only for id()-keyed entries (see docstring). + self._image_cache[key] = (result, img if cache_key is None else None) + while len(self._image_cache) > self._IMAGE_CACHE_MAX: + self._image_cache.popitem(last=False) + 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() + self._image_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) + + +# --------------------------------------------------------------------------- +# Composite layouts — the region arrangements repeated across plugins, +# expressed as Region math so migrated plugins stop hand-copying coordinate +# formulas. Deliberately tiny: these return Regions, they don't draw. +# --------------------------------------------------------------------------- + +@dataclass(frozen=True) +class ScoreboardRegions: + """The two-logos-plus-center-score card shared by the sports plugins.""" + bounds: Region + logo_slot: int # width of each logo slot: min(H, W // 2), center-reserved + away_slot: Region # left logo slot + home_slot: Region # right logo slot + center_col: Region # column between the slots (>= min_center_fraction of width) + status_band: Region # top band (replaces the magic y = 1) + score_area: Region # center_col's true width, between the bands (replaces y = H//2 - 3) + detail_band: Region # bottom band (replaces the magic y = H - 7) + bottom_left: Region # bottom corner: away records / timeouts + bottom_right: Region # bottom corner: home records / timeouts + + +def scoreboard_regions(bounds: Region, *, ctx: Optional["LayoutContext"] = None, + status_h: Optional[int] = None, + detail_h: Optional[int] = None, + min_center_fraction: float = 0.15, + min_center_design_px: int = 40, + score_bleed_fraction: float = 0.5) -> ScoreboardRegions: + """Carve a game-card Region into the standard scoreboard arrangement. + + Encodes the invariant duplicated across the sports plugins: + ``logo_slot = min(height, width // 2)`` (capped at half the card so the + home slot never collapses), away logo centered in the left slot, home in + the right. + + That formula alone has a blind spot: at exactly 2:1 aspect ratio + (width == 2 * height — a very common shape, e.g. two, four, or more + square modules stacked into a taller panel) ``width // 2`` and + ``height`` are equal, so the two logo slots claim the *entire* width + and leave zero pixels for a center column, no matter how large the + panel gets. It isn't a "small panel" problem: 96x48, 128x64, and + 256x128 (all exactly 2:1) hit it identically, while wide panels like + the 128x32 design baseline or a 192x48/256x32 panel never do, because + height is already the tighter constraint there. + + Two knobs fix it, both defaulted to values verified against the full + harness size spread (see test_adaptive_layout.py::TestScoreboardRegions): + + - ``min_center_fraction`` / ``min_center_design_px`` reserve at least + ``max(width * min_center_fraction, min_center_design_px * ctx.scale)`` + for the center column, capping ``logo_slot`` further when needed. The + design-px term (scaled by the context's geometry factor, so it grows + on bigger panels like everything else in ``px()``) matters most on + small panels where a flat fraction alone reserves too little absolute + space for even a short score string. On wide panels the height + constraint already leaves more room than either reserves, so both are + a no-op there — 128x32/192x48-style layouts are unaffected. + - ``score_bleed_fraction`` extends the score's own *fit box* (not the + logo slots themselves) an extra ``logo_slot * score_bleed_fraction`` + into each side — controlled, intentional overlap with the logo art, + the same way real broadcast scoreboards let a big score number's + edges cross into the team marks flanking it. Without this, on a + square-ish panel the center reserve alone can be too narrow for even + a modest score to render without truncating (`"17-21"` -> `"17-2…"`), + which is worse than a little overlap. + + status_band and detail_band span the FULL card width and overlay the + logo slots — matching the classic layouts, where short outlined status/ + date text is drawn over the logos without issue; only score_area (the + one element whose size actively grows with the panel) uses the + narrower, bleed-adjusted box. Band heights default to the classic + 128x32 values, scaled by the context's geometry factor when one is + provided. Works on a full panel or on a scroll-mode card Region. + """ + if status_h is None: + status_h = ctx.px(9, minimum=7) if ctx else 9 + if detail_h is None: + detail_h = ctx.px(8, minimum=7) if ctx else 8 + + logo_slot = min(bounds.h, bounds.w // 2) + design_reserve = int(min_center_design_px * (ctx.scale if ctx else 1.0)) + min_center_w = max(1, int(bounds.w * min_center_fraction), design_reserve) + max_logo_slot_by_center = max(1, (bounds.w - min_center_w) // 2) + logo_slot = min(logo_slot, max_logo_slot_by_center) + away_slot = bounds.left_col(logo_slot) + home_slot = bounds.right_col(logo_slot) + center_col = Region(bounds.x + logo_slot, bounds.y, + bounds.w - 2 * logo_slot, bounds.h) + status_band = bounds.top_band(status_h) + detail_band = bounds.bottom_band(detail_h) + middle = bounds.middle(status_band.h, detail_band.h) + # score_area is the true center gap's width plus a controlled bleed + # into each logo slot (see score_bleed_fraction above) -- narrower than + # the full card width status/detail get, since it's the one element + # whose size actively grows with the panel and needs its *fit box* to + # reflect real available space, but generous enough that a short score + # string never has to truncate on a square-ish panel. + bleed = int(logo_slot * score_bleed_fraction) + score_area = Region(center_col.x - bleed, middle.y, + center_col.w + 2 * bleed, middle.h) + bottom = bounds.bottom_band(detail_h) + return ScoreboardRegions( + bounds=bounds, logo_slot=logo_slot, + away_slot=away_slot, home_slot=home_slot, center_col=center_col, + status_band=status_band, score_area=score_area, detail_band=detail_band, + bottom_left=bottom.left_col(logo_slot), + bottom_right=bottom.right_col(logo_slot), + ) + + +@dataclass(frozen=True) +class MediaRow: + """Art/icon on the left, text column on the right (music's idiom).""" + art: Region + body: Region + + +def media_row(bounds: Region, *, ctx: Optional["LayoutContext"] = None, + square: bool = True, gap: Optional[int] = None) -> MediaRow: + """Split a Region into an art slot and a body column. + + With ``square=True`` the art slot is bounds.h wide (album-art style); + otherwise it takes the left half. The gap defaults to 2px scaled by the + context's geometry factor. + """ + if gap is None: + gap = ctx.px(2, minimum=1) if ctx else 2 + art_w = bounds.h if square else bounds.w // 2 + art_w = min(art_w, bounds.w) + art = bounds.left_col(art_w) + body = Region(bounds.x + art_w + gap, bounds.y, + bounds.w - art_w - gap, bounds.h) + return MediaRow(art=art, body=body) diff --git a/src/common/README.md b/src/common/README.md index 4f9b0b6b7..cccaa40bc 100644 --- a/src/common/README.md +++ b/src/common/README.md @@ -2,6 +2,28 @@ This directory contains reusable utilities and helpers for LEDMatrix plugins and core modules. +## Adaptive Layout & Images (`src/adaptive_layout.py`, `src/adaptive_images.py`) + +The recommended way to lay out plugins that render legibly on **any** panel +size (64x32 through 256x128+) without hand-tuned coordinates. Re-exported +from `src.common` for convenience; canonical import paths are +`src.adaptive_layout` / `src.adaptive_images`. + +```python +# Every BasePlugin already has self.layout and the draw helpers: +regs = scoreboard_regions(self.layout.bounds, ctx=self.layout) +self.draw_image(away_logo, regs.away_slot, mode="fill_height", + crop_to_ink=True, cache_key=f"logo:{abbr}") +self.draw_fit(score_text, regs.score_area) # largest crisp font that fits +self.draw_fit(status, regs.status_band) +``` + +Key pieces: `Region` (rect algebra: bands/columns/splits/offset), +font ladders (`LADDER_GRID`, `LADDER_ARCADE` — discrete crisp sizes, never +fractional scaling), `LayoutContext` (`fit_text`, `fit_image`, `by_tier`, +`px`), and composite carvers `scoreboard_regions()` / `media_row()`. +Full guide: [docs/ADAPTIVE_LAYOUT.md](../../docs/ADAPTIVE_LAYOUT.md). + ## Error Handling (`error_handler.py`) Common error handling patterns and utilities: diff --git a/src/common/__init__.py b/src/common/__init__.py index 9ea51bed4..4b6cb3833 100644 --- a/src/common/__init__.py +++ b/src/common/__init__.py @@ -26,6 +26,31 @@ from src.common.logo_helper import LogoHelper from src.common.text_helper import TextHelper +# Adaptive layout & images (canonical homes: src.adaptive_layout / +# src.adaptive_images — re-exported here so plugin authors find them in the +# blessed-helpers package). See docs/ADAPTIVE_LAYOUT.md. +from src.adaptive_layout import ( + Region, + LayoutContext, + FontStep, + FontLadder, + LADDER_GRID, + LADDER_ARCADE, + FitResult, + draw_fitted_text, + ScoreboardRegions, + scoreboard_regions, + MediaRow, + media_row, +) +from src.adaptive_images import ( + ImageFitResult, + fit_image, + draw_fitted_image, + RESAMPLE_LANCZOS, + RESAMPLE_NEAREST, +) + __all__ = [ 'handle_file_operation', 'handle_json_operation', @@ -37,4 +62,22 @@ 'ScrollHelper', 'LogoHelper', 'TextHelper', + # adaptive layout & images + 'Region', + 'LayoutContext', + 'FontStep', + 'FontLadder', + 'LADDER_GRID', + 'LADDER_ARCADE', + 'FitResult', + 'draw_fitted_text', + 'ScoreboardRegions', + 'scoreboard_regions', + 'MediaRow', + 'media_row', + 'ImageFitResult', + 'fit_image', + 'draw_fitted_image', + 'RESAMPLE_LANCZOS', + 'RESAMPLE_NEAREST', ] diff --git a/src/common/logo_helper.py b/src/common/logo_helper.py index ee73c3395..7d0dc4df5 100644 --- a/src/common/logo_helper.py +++ b/src/common/logo_helper.py @@ -72,9 +72,20 @@ def load_logo(self, team_abbr: str, logo_path: Union[str, Path], Returns: PIL Image object or None if loading fails + + Note: for new adaptive-layout code prefer ``BasePlugin.draw_image`` + / ``LayoutContext.fit_image`` (src/adaptive_images.py) for the + fitting step — LogoHelper remains useful for its download and + placeholder logic. """ - # Check cache first - cache_key = f"{team_abbr}_{logo_path}" + # Resolve the effective target size BEFORE the cache lookup so the + # key is size-qualified — a panel-size change must not return a + # logo resized for the old dimensions. + if max_width is None: + max_width = int(self.display_width * 1.5) + if max_height is None: + max_height = int(self.display_height * 1.5) + cache_key = f"{team_abbr}_{logo_path}_{max_width}x{max_height}" if cache_key in self._logo_cache: self.logger.debug(f"Using cached logo for {team_abbr}") # Update LRU order (move to end) diff --git a/src/common/text_helper.py b/src/common/text_helper.py index 5cbce429d..b6a8df3e6 100644 --- a/src/common/text_helper.py +++ b/src/common/text_helper.py @@ -11,6 +11,9 @@ 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 @@ def get_text_width(self, text: str, font: ImageFont.ImageFont) -> int: 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 @@ def get_text_height(self, text: str, font: ImageFont.ImageFont) -> int: 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]: """ diff --git a/src/element_style.py b/src/element_style.py new file mode 100644 index 000000000..da5b336fa --- /dev/null +++ b/src/element_style.py @@ -0,0 +1,533 @@ +"""Universal per-element style resolution for plugin customization. + +Plugins expose per-element user customization under ``config['customization']``: + + "customization": { + "score_text": {"font": "PressStart2P-Regular.ttf", "font_size": 10, + "text_color": [255, 255, 255]}, + "layout": {"score": {"x_offset": 2, "y_offset": 0}} + } + +Before this module, every plugin re-implemented the same three pieces — +a font loader, an x/y-offset reader, and (for adaptive layout mode) a +"did the user actually override this?" check. The loaders diverged four +ways across the sports plugins and music, the offset reader was copied +twice, and the override check is subtle enough that it shipped broken +twice: the web UI's save flow (schema_manager.merge_with_defaults) writes +the FULL schema default object into config.json on every save, and the +plugin manager merges defaults into ``config`` again before instantiation, +so a key being *present* never means the user set it. The only correct +test is "present AND different from the schema default", which requires +knowing the schema defaults — previously a hand-maintained dict per plugin. + +This module is that logic, once: + + resolver = ElementStyleResolver(config, schema_defaults) + style = resolver.style('score_text', classic_font='PressStart2P-Regular.ttf', + classic_size=10) + style.font # loaded PIL font, ready for draw.text + style.user_forced # True only for a genuine user override + dx, dy = resolver.offset('score') + +``BasePlugin.element_style()`` wires this up automatically (schema defaults +come from the plugin's own config_schema.json via the schema manager). +Standalone helper classes (e.g. a plugin's GameRenderer) should receive a +resolver from their owning plugin rather than build one themselves. + +Deliberately pure PIL + stdlib: no imports from the plugin system or web +layer, so it is usable from any renderer and trivially testable. +""" + +import json +import logging +import os +from dataclasses import dataclass +from typing import Any, Dict, Optional, Tuple, Union + +from PIL import ImageFont + +logger = logging.getLogger(__name__) + + +# Font-family aliases accepted in customization configs. Filenames pass +# through unchanged. (Supersedes the per-plugin copies in the baseball +# plugin; keep names in sync with the web UI's /fonts/catalog so the +# font-selector widget and this loader agree.) +FONT_ALIASES: Dict[str, str] = { + "press_start": "PressStart2P-Regular.ttf", + "four_by_six": "4x6-font.ttf", + "five_by_seven": "5x7.bdf", +} + +DEFAULT_FONTS_DIR = os.path.join("assets", "fonts") +DEFAULT_FALLBACK_FONT = "PressStart2P-Regular.ttf" + +PILFont = Union[ImageFont.FreeTypeFont, ImageFont.ImageFont] + + +def resolve_font_name(font_name: str) -> str: + """Resolve a font family alias to its filename, leaving filenames as-is.""" + return FONT_ALIASES.get(font_name, font_name) + + +def extract_schema_defaults(schema: Dict[str, Any]) -> Dict[str, Any]: + """Nested defaults dict from a JSON Schema (mirrors + SchemaManager.extract_defaults_from_schema, kept here so this module + stays importable without the plugin system). + + An object property carrying its own ``default`` short-circuits recursion, + matching the schema manager's behavior. + """ + defaults: Dict[str, Any] = {} + for key, prop in (schema.get("properties") or {}).items(): + if not isinstance(prop, dict): + continue + if "default" in prop: + defaults[key] = prop["default"] + elif prop.get("type") == "object" and "properties" in prop: + nested = extract_schema_defaults(prop) + if nested: + defaults[key] = nested + return defaults + + +def defaults_from_schema_file(schema_path: str) -> Dict[str, Any]: + """Schema defaults straight from a plugin's own config_schema.json. + + Plugins that hand a resolver to standalone helper classes should build + it with this, pointed at their own schema file — it works identically + in production, the test harness, and the dev server, unlike the plugin + manager's schema manager (absent under mocks). x-style-elements + declarations are expanded first, so declared elements' defaults are + included exactly as the web UI's schema manager sees them. Returns {} + on any error. + """ + try: + with open(schema_path, "r", encoding="utf-8") as f: + schema = json.load(f) + if isinstance(schema, dict): + return extract_schema_defaults(expand_style_elements(schema)) + except Exception as e: + logger.debug("Could not load schema defaults from %s: %s", + schema_path, e) + return {} + + +# --------------------------------------------------------------------------- +# x-style-elements schema expansion +# --------------------------------------------------------------------------- +# +# A plugin declares its styleable display elements ONCE, compactly, on its +# customization object instead of hand-copying ~50-line property blocks: +# +# "customization": { +# "type": "object", +# "x-style-elements": { +# "score_text": { +# "title": "Game Score", +# "font": {"default": "PressStart2P-Regular.ttf"}, +# "size": {"default": 10, "min": 4, "max": 16}, +# "color": true, # or {"default": [r,g,b]} +# "offsets": true +# } +# } +# } +# +# expand_style_elements() turns each declaration into full font/font_size/ +# text_color/layout-offset property blocks (marked "x-style-managed": true) +# using widgets the web config form already renders. The declaration stays +# in the schema — it doubles as the element registry for tooling. Expansion +# is idempotent, and a hand-written property block for the same element +# always wins over the generated one. +# +# SchemaManager.load_schema() applies this at serve time (so the web form, +# save path, validation, and defaults generation all see the expanded +# shape), and defaults_from_schema_file() applies it when plugins read +# their own schema — one implementation, no drift. + +def get_style_elements(schema: Dict[str, Any]) -> Dict[str, Any]: + """The x-style-elements declaration from a schema ({} if none).""" + try: + decl = schema.get("properties", {}).get("customization", {}).get("x-style-elements") + return decl if isinstance(decl, dict) else {} + except AttributeError: + return {} + + +def expand_style_elements(schema: Dict[str, Any]) -> Dict[str, Any]: + """Expand x-style-elements into full customization property blocks. + + Returns the schema unchanged (same object) when there is nothing to + expand; otherwise returns an expanded DEEP COPY, leaving the input + untouched. Never raises — on any error the original schema is returned + so a malformed declaration can't take a plugin down. + """ + import copy + + try: + declarations = get_style_elements(schema) + if not declarations: + return schema + + schema = copy.deepcopy(schema) + customization = schema["properties"]["customization"] + properties = customization.setdefault("properties", {}) + order = customization.get("x-propertyOrder") + + offset_elements = [] + for element_key, declaration in declarations.items(): + if not isinstance(declaration, dict): + continue + if declaration.get("offsets") is True: + offset_elements.append((element_key, declaration)) + if element_key in properties: + # Hand-written (or previously expanded) block wins. + continue + properties[element_key] = _style_element_block(element_key, declaration) + if isinstance(order, list) and element_key not in order: + # Keep generated elements ahead of the layout section. + insert_at = order.index("layout") if "layout" in order else len(order) + order.insert(insert_at, element_key) + + if offset_elements: + _expand_offset_blocks(properties, order, offset_elements) + + return schema + except Exception as e: + logger.error("x-style-elements expansion failed: %s", e) + return schema + + +def _style_element_block(element_key: str, declaration: Dict[str, Any]) -> Dict[str, Any]: + """One generated customization. property block.""" + title = declaration.get("title") or element_key.replace("_", " ").title() + font_decl = declaration.get("font") if isinstance(declaration.get("font"), dict) else {} + size_decl = declaration.get("size") if isinstance(declaration.get("size"), dict) else {} + + block_properties: Dict[str, Any] = { + "font": { + "type": "string", + "title": "Font Family", + "description": "Select the font to use", + "x-widget": "font-selector", + "default": font_decl.get("default", DEFAULT_FALLBACK_FONT), + }, + "font_size": { + "type": "integer", + "title": "Font Size", + "description": ("Font size in pixels (BDF fonts are fixed-size " + "and ignore this)"), + "minimum": size_decl.get("min", 4), + "maximum": size_decl.get("max", 32), + "default": size_decl.get("default", 8), + }, + } + block_order = ["font", "font_size"] + + color_decl = declaration.get("color") + if color_decl: + default_color = [255, 255, 255] + if isinstance(color_decl, dict) and isinstance(color_decl.get("default"), list): + default_color = color_decl["default"] + # The default doubles as the "untouched" sentinel: the resolver only + # honors a color that DIFFERS from it, so untouched saves (the web + # form always posts the RGB inputs) can't clobber a plugin's + # semantic/state-dependent colors. + block_properties["text_color"] = { + "type": "array", + "title": "Text Color", + "description": "RGB color as [red, green, blue] (0-255 each)", + "items": {"type": "integer", "minimum": 0, "maximum": 255}, + "minItems": 3, + "maxItems": 3, + "x-widget": "color-picker", + "default": default_color, + } + block_order.append("text_color") + + return { + "type": "object", + "title": title, + "description": f"Style settings for {title}", + "x-style-managed": True, + "properties": block_properties, + "x-propertyOrder": block_order, + "additionalProperties": False, + } + + +def _expand_offset_blocks(properties: Dict[str, Any], order, + offset_elements) -> None: + """Generate customization.layout. x/y offset blocks.""" + layout = properties.get("layout") + if not isinstance(layout, dict): + layout = { + "type": "object", + "title": "Layout Positioning", + "description": ("Adjust X,Y coordinate offsets for elements. " + "Values are relative to default positions; " + "negative moves left/up, positive right/down."), + "x-style-managed": True, + "properties": {}, + "additionalProperties": False, + } + properties["layout"] = layout + if isinstance(order, list) and "layout" not in order: + order.append("layout") + + layout_properties = layout.setdefault("properties", {}) + layout_order = layout.get("x-propertyOrder") + for element_key, declaration in offset_elements: + if element_key in layout_properties: + continue # hand-written layout entry wins + title = declaration.get("title") or element_key.replace("_", " ").title() + layout_properties[element_key] = { + "type": "object", + "title": title, + "x-style-managed": True, + "properties": { + "x_offset": { + "type": "integer", + "title": "X Offset", + "description": "Horizontal offset in pixels (default: 0)", + "default": 0, + }, + "y_offset": { + "type": "integer", + "title": "Y Offset", + "description": "Vertical offset in pixels (default: 0)", + "default": 0, + }, + }, + "additionalProperties": False, + } + if isinstance(layout_order, list) and element_key not in layout_order: + layout_order.append(element_key) + + +def load_font(font_name: str, size: int, *, + fonts_dir: str = DEFAULT_FONTS_DIR, + fallback_font: str = DEFAULT_FALLBACK_FONT) -> PILFont: + """Load a font by name at a pixel size, never raising. + + Resolution order: + 1. alias -> filename (``FONT_ALIASES``) + 2. ``ImageFont.truetype`` — handles .ttf/.otf, and .bdf too (FreeType + loads BDF strikes at their native size; a non-native size raises + "invalid pixel size" and falls through) + 3. for .bdf: a pre-converted ``.pil`` sidecar via ``ImageFont.load`` + 4. ``fallback_font`` at the requested size + 5. ``ImageFont.load_default()`` + """ + font_name = resolve_font_name(font_name or "") + font_path = os.path.join(fonts_dir, font_name) + lower = font_name.lower() + + if os.path.exists(font_path): + try: + return ImageFont.truetype(font_path, size) + except Exception as e: + logger.debug("truetype failed for %s@%s: %s", font_name, size, e) + if lower.endswith(".bdf"): + pil_path = font_path.rsplit(".", 1)[0] + ".pil" + if os.path.exists(pil_path): + try: + return ImageFont.load(pil_path) + except Exception as e: + logger.debug("PIL sidecar failed for %s: %s", pil_path, e) + logger.warning( + "BDF font %s could not be loaded at size %s (BDF fonts are " + "fixed-size; font_size must match the native size). Falling " + "back to %s.", font_name, size, fallback_font) + else: + logger.warning("Font file not found: %s, falling back to %s", + font_path, fallback_font) + + fallback_path = os.path.join(fonts_dir, resolve_font_name(fallback_font)) + try: + return ImageFont.truetype(fallback_path, size) + except Exception as e: + logger.warning("Fallback font %s failed (%s); using PIL default", + fallback_font, e) + return ImageFont.load_default() + + +@dataclass(frozen=True) +class ElementStyle: + """Resolved style for one display element.""" + font: PILFont + font_name: str + font_size: int + #: The user's color when they genuinely changed it, else the plugin's + #: classic color (which may be state-dependent — e.g. a score that turns + #: gold on a touchdown — so an untouched schema default must never + #: clobber it; the web form always posts the color inputs). + color: Optional[Tuple[int, int, int]] + #: Additive (dx, dy) translation from customization.layout offsets. + offset: Tuple[int, int] + #: True when the configured value genuinely differs from the schema + #: default (NOT merely present — saved configs always contain defaults). + user_forced_font: bool + user_forced_size: bool + user_forced_color: bool = False + + @property + def user_forced(self) -> bool: + """True when the user pinned this element's font or size; adaptive + layouts must use the font as-is instead of ladder-fitting. (Color is + deliberately excluded — it never affects sizing.)""" + return self.user_forced_font or self.user_forced_size + + +def _as_int(value: Any, default: int) -> int: + """Int coercion tolerant of floats and numeric strings from configs.""" + if value is None: + return default + if isinstance(value, bool): + return default + if isinstance(value, (int, float)): + return int(value) + try: + return int(float(value)) + except (TypeError, ValueError): + return default + + +def _as_color(value: Any) -> Optional[Tuple[int, int, int]]: + """[r, g, b] list/tuple -> tuple; anything else -> None.""" + if isinstance(value, (list, tuple)) and len(value) == 3: + try: + return tuple(max(0, min(255, int(c))) for c in value) + except (TypeError, ValueError): + return None + return None + + +class ElementStyleResolver: + """Resolves per-element fonts, colors and offsets from a plugin config. + + ``schema_defaults`` is the nested defaults dict extracted from the + plugin's config_schema.json (SchemaManager.extract_defaults_from_schema). + It is the reference for the user-override check: a configured value + equal to its schema default is treated as untouched, because the save + flow persists all defaults. When ``schema_defaults`` is empty (older + cores, unit tests), the check degrades to comparing against the + ``classic_*`` values the caller supplies. + """ + + def __init__(self, config: Optional[Dict[str, Any]], + schema_defaults: Optional[Dict[str, Any]] = None, *, + fonts_dir: str = DEFAULT_FONTS_DIR, + fallback_font: str = DEFAULT_FALLBACK_FONT): + self._config = config if isinstance(config, dict) else {} + self._defaults = schema_defaults if isinstance(schema_defaults, dict) else {} + self._fonts_dir = fonts_dir + self._fallback_font = fallback_font + self._cache: Dict[Any, ElementStyle] = {} + + # -- internals ---------------------------------------------------- + + def _element_config(self, element_key: str) -> Dict[str, Any]: + cust = self._config.get("customization") + if not isinstance(cust, dict): + return {} + element = cust.get(element_key) + return element if isinstance(element, dict) else {} + + def _element_defaults(self, element_key: str) -> Dict[str, Any]: + cust = self._defaults.get("customization") + if not isinstance(cust, dict): + return {} + element = cust.get(element_key) + return element if isinstance(element, dict) else {} + + # -- public API --------------------------------------------------- + + def style(self, element_key: str, *, classic_font: str, classic_size: int, + classic_color: Optional[Tuple[int, int, int]] = None) -> ElementStyle: + """Resolve the style for one element. + + ``classic_font``/``classic_size``/``classic_color`` are the plugin's + hardcoded defaults for this element — used when the config has no + value, and as the override reference when schema defaults are + unavailable. + """ + cache_key = (element_key, classic_font, classic_size, classic_color) + cached = self._cache.get(cache_key) + if cached is not None: + return cached + + element_cfg = self._element_config(element_key) + element_defaults = self._element_defaults(element_key) + + configured_font = element_cfg.get("font") + configured_size = element_cfg.get("font_size") + + # Reference for "did the user change it": schema default when known, + # else the plugin's classic default. + reference_font = element_defaults.get("font", classic_font) + reference_size = _as_int(element_defaults.get("font_size"), classic_size) + + user_forced_font = (configured_font is not None + and configured_font != reference_font) + user_forced_size = (configured_size is not None + and _as_int(configured_size, reference_size) != reference_size) + + font_name = configured_font if configured_font is not None else classic_font + font_size = _as_int(configured_size, classic_size) + font = load_font(font_name, font_size, fonts_dir=self._fonts_dir, + fallback_font=self._fallback_font) + + # Color follows the same provenance rule as fonts: the web form + # always posts the RGB inputs, so a saved config carries the schema + # default whether or not the user touched it — only a value that + # DIFFERS from the schema default is a real override. Otherwise keep + # classic_color, which may be state-dependent (semantic colors like + # a gold touchdown score) and must not be clobbered by a default. + configured_color = _as_color(element_cfg.get("text_color")) + default_color = _as_color(element_defaults.get("text_color")) + if configured_color is None: + user_forced_color = False + elif default_color is None: + # no schema default to compare against — presence is intent + user_forced_color = True + else: + user_forced_color = configured_color != default_color + color = configured_color if user_forced_color else classic_color + + resolved = ElementStyle( + font=font, font_name=font_name, font_size=font_size, + color=color, offset=self.offset(element_key), + user_forced_font=user_forced_font, user_forced_size=user_forced_size, + user_forced_color=user_forced_color, + ) + self._cache[cache_key] = resolved + return resolved + + def offset_value(self, element_key: str, axis: str, default: int = 0) -> int: + """One offset axis for an element (e.g. 'x_offset', 'away_x_offset'). + + Reads ``customization.layout.`` first (the deployed sports + convention), falling back to ``customization.`` for plugins + that keep offsets on the element itself. + """ + cust = self._config.get("customization") + if not isinstance(cust, dict): + return default + layout = cust.get("layout") + if isinstance(layout, dict): + element = layout.get(element_key) + if isinstance(element, dict) and axis in element: + return _as_int(element.get(axis), default) + element = cust.get(element_key) + if isinstance(element, dict) and axis in element: + return _as_int(element.get(axis), default) + return default + + def offset(self, element_key: str) -> Tuple[int, int]: + """(dx, dy) additive translation for an element; (0, 0) when unset.""" + return (self.offset_value(element_key, "x_offset"), + self.offset_value(element_key, "y_offset")) + + def clear_cache(self) -> None: + self._cache.clear() diff --git a/src/font_manager.py b/src/font_manager.py index 89d82b503..41306c53b 100644 --- a/src/font_manager.py +++ b/src/font_manager.py @@ -103,6 +103,10 @@ def __init__(self, config: Dict[str, Any]): # 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 @@ def reload_config(self, new_config: Dict[str, Any]): 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 @@ def get_font(self, family: str, size_px: int) -> Union[ImageFont.FreeTypeFont, f 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 @@ def _load_bdf_font(self, font_path: str, size_px: int) -> freetype.Face: 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 " + 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() diff --git a/src/image_utils.py b/src/image_utils.py index dc6e6703c..6179f9bf7 100644 --- a/src/image_utils.py +++ b/src/image_utils.py @@ -1,3 +1,9 @@ +"""Deprecated: use src/adaptive_images.py (fit_image) instead. + +This module predates the adaptive image system and has no known callers. +It is kept only so any out-of-tree code importing it keeps working. +""" + import logging from PIL import Image diff --git a/src/plugin_system/base_plugin.py b/src/plugin_system/base_plugin.py index f201d361d..b8285d688 100644 --- a/src/plugin_system/base_plugin.py +++ b/src/plugin_system/base_plugin.py @@ -15,6 +15,19 @@ 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,191 @@ def display(self, force_clear=False): """ 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 draw_image(self, img: Any, box: Any, *, + mode: str = "contain", align: str = "center", + valign: str = "center", crop_to_ink: bool = False, + anchor: str = "center", resample: Optional[Any] = None, + cache_key: Optional[Any] = None, + offset: tuple = (0, 0)) -> Any: + """ + Fit an image into a Region and paste it aligned within that region + onto the display canvas — the image counterpart to draw_fit(). + + Args: + img: Source PIL image (logos, art, icons) + box: Region (or (w, h) tuple) to fit and align within + mode: "contain" (letterbox), "cover" (crop-to-fill), + "fill_height" (logo-style), "stretch" + crop_to_ink: Trim transparent padding before fitting + anchor: "center" or "top" for cover crops + resample: PIL filter; default LANCZOS. Use RESAMPLE_NEAREST + (from src.adaptive_images) for pixel art/flags + cache_key: Stable identity (e.g. "logo:KC") for cross-reload + caching; defaults to the image object's identity + offset: Final (dx, dy) translation — the hook for user + x/y-offset customization + + Returns: + ImageFitResult (processed image + dimensions + scale) + """ + from src.adaptive_images import draw_fitted_image + + ifit = self.layout.fit_image(img, box, mode=mode, + crop_to_ink=crop_to_ink, anchor=anchor, + resample=resample, cache_key=cache_key) + draw_fitted_image(self.display_manager, ifit, box, + align=align, valign=valign, offset=offset) + return ifit + + 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 + + # ------------------------------------------------------------------------- + # Element style resolution (per-element user customization) + # ------------------------------------------------------------------------- + @property + def style_resolver(self) -> Any: + """ + ElementStyleResolver for this plugin's customization config. + + Lazily built with the schema defaults from this plugin's own + config_schema.json (via the plugin manager's schema manager), so + "did the user override this font?" is answered correctly even though + saved configs always contain the schema defaults. Rebuilt when the + config changes (see on_config_change). Pass it into standalone helper + classes (game renderers etc.) instead of letting them build their own. + + See src/element_style.py. + """ + from src.element_style import ElementStyleResolver + + cached = getattr(self, "_style_resolver", None) + if cached is not None: + return cached + + schema_defaults: Dict[str, Any] = {} + schema_manager = getattr(self.plugin_manager, "schema_manager", None) + if schema_manager is not None: + try: + schema = schema_manager.load_schema(self.plugin_id) + if schema: + schema_defaults = schema_manager.extract_defaults_from_schema(schema) + except Exception as e: + self.logger.debug("Schema defaults unavailable for %s: %s", + self.plugin_id, e) + + resolver = ElementStyleResolver(self.config, schema_defaults) + self._style_resolver = resolver + return resolver + + def element_style(self, element_key: str, *, classic_font: str, + classic_size: int, + classic_color: Optional[tuple] = None) -> Any: + """ + Resolved font/color/offset for one display element, honoring the + user's customization. config. classic_* are this plugin's + hardcoded defaults for the element. + + Example: + style = self.element_style('title_text', + classic_font='PressStart2P-Regular.ttf', + classic_size=8) + draw.text((x + style.offset[0], y + style.offset[1]), + title, font=style.font, + fill=style.color or (255, 255, 255)) + """ + return self.style_resolver.style(element_key, classic_font=classic_font, + classic_size=classic_size, + classic_color=classic_color) + def get_display_duration(self) -> float: """ Get the display duration for this plugin instance. @@ -592,6 +790,9 @@ def on_config_change(self, new_config: Dict[str, Any]) -> None: # Update simple flags self.enabled = self.config.get("enabled", self.enabled) + # Invalidate the cached style resolver — it captured the old config + self._style_resolver = None + def get_info(self) -> Dict[str, Any]: """ Return plugin info for display in web UI. diff --git a/src/plugin_system/schema_manager.py b/src/plugin_system/schema_manager.py index 385b53fd7..779338772 100644 --- a/src/plugin_system/schema_manager.py +++ b/src/plugin_system/schema_manager.py @@ -110,12 +110,17 @@ def load_schema(self, plugin_id: str, use_cache: bool = True) -> Optional[Dict[s try: with open(schema_path, 'r', encoding='utf-8') as f: schema = json.load(f) - + # Validate schema structure (basic check) if not isinstance(schema, dict): self.logger.error(f"Invalid schema format for {plugin_id}: not a dictionary") return None - + + # Expand x-style-elements declarations BEFORE caching, so every + # consumer (config form GET, save path, validation, defaults + # generation) sees the identical expanded shape. + schema = self._expand_style_elements(schema) + # Cache the schema self._schema_cache[plugin_id] = schema @@ -132,10 +137,41 @@ def load_schema(self, plugin_id: str, use_cache: bool = True) -> Optional[Dict[s self.logger.error(f"Error loading schema for {plugin_id}: {e}") return None + # ------------------------------------------------------------------ + # x-style-elements expansion + # ------------------------------------------------------------------ + # Plugins declare styleable display elements compactly via an + # "x-style-elements" object on their customization schema; load_schema + # expands each declaration into full font/font_size/text_color/offset + # property blocks before caching, so the config form, save path, + # validation, and defaults generation all see the same expanded shape. + # The single implementation lives in src.element_style (pure, also used + # by plugins reading their own schema file) — see that module for the + # declaration format. + + @staticmethod + def get_style_elements(schema: Dict[str, Any]) -> Dict[str, Any]: + """The x-style-elements declaration from a schema ({} if none).""" + from src.element_style import get_style_elements + return get_style_elements(schema) + + def _expand_style_elements(self, schema: Dict[str, Any]) -> Dict[str, Any]: + """Expand x-style-elements declarations (no-op without any). + + Never raises; on any failure the original schema is returned so a + malformed declaration can't take a plugin down. + """ + try: + from src.element_style import expand_style_elements + return expand_style_elements(schema) + except Exception as e: + self.logger.error(f"x-style-elements expansion failed: {e}") + return schema + def invalidate_cache(self, plugin_id: Optional[str] = None) -> None: """ Invalidate schema cache for a plugin or all plugins. - + Args: plugin_id: Plugin identifier to invalidate, or None to clear all """ diff --git a/src/plugin_system/testing/bounds_display_manager.py b/src/plugin_system/testing/bounds_display_manager.py index ccbf393fb..0f764ebc1 100644 --- a/src/plugin_system/testing/bounds_display_manager.py +++ b/src/plugin_system/testing/bounds_display_manager.py @@ -10,8 +10,11 @@ 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 @@ def __init__(self, width: int = 128, height: int = 32, 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) -- diff --git a/src/plugin_system/testing/harness.py b/src/plugin_system/testing/harness.py index 21c442351..ee4d66136 100644 --- a/src/plugin_system/testing/harness.py +++ b/src/plugin_system/testing/harness.py @@ -73,6 +73,10 @@ class RenderResult: golden_ok: Optional[bool] = None golden_diff_pixels: int = 0 golden_max_delta: int = 0 + # fill / scale-up check (populated only for sizes >= 2x the design size) + fill_checked: bool = False + fill_ok: Optional[bool] = None # False only in strict mode + fill_extent: Optional[Tuple[float, float]] = None # (extent_x, extent_y) @property def size_label(self) -> str: @@ -86,6 +90,8 @@ def ok(self) -> bool: return False if self.golden_checked and self.golden_ok is False: return False + if self.fill_ok is False: + return False return True @@ -301,6 +307,74 @@ def compare_to_goldens(results: List[RenderResult], golden_dir: Path, return results +# --------------------------------------------------------------------------- +# Fill / scale-up check +# --------------------------------------------------------------------------- +# +# Overflow catches content that is too BIG for a panel; nothing catches +# content that stays tiny on a panel much larger than the plugin's design +# size (e.g. 128x32 content in the corner of a 256x128 renders "green"). +# These helpers measure how much of the panel the lit content spans so the +# harness can flag plugins that don't scale up. + +# A pixel counts as "lit" above this luminance — low enough to catch dim +# content, high enough to ignore near-black noise. +_LIT_THRESHOLD = 16 +# Content must span at least this fraction of an axis that is >= 2x the +# design size. Lenient on purpose: margins are fine, a tiny corner is not. +_MIN_FILL_EXTENT = 0.5 + + +def fill_metrics(image: Image.Image) -> Tuple[float, float, float]: + """Measure lit-content coverage: (extent_x, extent_y, ink_ratio). + + extent_* are the lit bounding box's spans as fractions of the panel; + ink_ratio is the fraction of pixels lit (reporting only — sparse pixel + fonts legitimately have low ink ratios).""" + lit = image.convert("L").point(lambda p: 255 if p > _LIT_THRESHOLD else 0) + bbox = lit.getbbox() + if bbox is None: + return (0.0, 0.0, 0.0) + extent_x = (bbox[2] - bbox[0]) / image.width + extent_y = (bbox[3] - bbox[1]) / image.height + ink = sum(1 for p in lit.getdata() if p) / (image.width * image.height) + return (extent_x, extent_y, ink) + + +def check_scale_up(results: List[RenderResult], + design_size: Tuple[int, int] = (128, 32), + min_extent: float = _MIN_FILL_EXTENT, + strict: bool = False) -> List[RenderResult]: + """Flag renders that leave a big panel mostly empty. + + For each result whose panel is at least 2x the design size on an axis, + require the lit content to span >= min_extent of that axis. Mutates the + results' fill_* fields. In the default warn-only mode fill_ok is left + None (reported, never failing); strict=True sets fill_ok=False, which + fails RenderResult.ok — opt in per plugin via harness.json + {"fill_check": "strict"} once its adaptive layout is in place. + """ + design_w, design_h = design_size + for r in results: + if r.image is None or r.error is not None: + continue + check_x = r.width >= 2 * design_w + check_y = r.height >= 2 * design_h + if not (check_x or check_y): + continue + extent_x, extent_y, _ink = fill_metrics(r.image) + r.fill_checked = True + r.fill_extent = (round(extent_x, 3), round(extent_y, 3)) + underfilled = ((check_x and extent_x < min_extent) + or (check_y and extent_y < min_extent)) + if underfilled and strict: + r.fill_ok = False + elif not underfilled: + r.fill_ok = True + # warn-only underfill: fill_ok stays None; fill_extent tells the story + return results + + def write_goldens(results: List[RenderResult], golden_dir: Path) -> int: """Write each successfully-rendered result to its golden path. Returns count.""" written = 0 diff --git a/src/plugin_system/testing/loading.py b/src/plugin_system/testing/loading.py index ed692d5f6..f4971ec3a 100644 --- a/src/plugin_system/testing/loading.py +++ b/src/plugin_system/testing/loading.py @@ -56,7 +56,15 @@ def load_harness_spec(plugin_dir: Union[str, Path]) -> Dict[str, Any]: "config": {...}, # config overrides "mock_data": "fixtures/mock.json", # path (relative to plugin dir) to cache fixtures "freeze_time": "2025-08-01 15:25:00", - "skip_update": false + "skip_update": false, + "fill_check": "warn", # or "strict": underfilled big panels FAIL + "variants": [ # extra runs with config overlays and + { # their own golden dirs — e.g. an + "name": "adaptive", # opt-in adaptive mode tested beside + "config": {"layout_mode": "adaptive"}, # the classic default + "golden_dir": "test/golden-adaptive" + } + ] } Returns {} when no harness.json exists. """ diff --git a/src/plugin_system/testing/mocks.py b/src/plugin_system/testing/mocks.py index df436acd4..2bbc9bcca 100644 --- a/src/plugin_system/testing/mocks.py +++ b/src/plugin_system/testing/mocks.py @@ -161,6 +161,13 @@ def __init__(self): 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.""" diff --git a/src/plugin_system/testing/render_service.py b/src/plugin_system/testing/render_service.py new file mode 100644 index 000000000..5a1b8c7dd --- /dev/null +++ b/src/plugin_system/testing/render_service.py @@ -0,0 +1,96 @@ +"""Headless single-render service for plugins. + +Renders one plugin instance at one panel size to an in-memory PIL image — +no hardware, no singletons, no pip (install_deps is always False). Shared +by the dev server's /api/render endpoints and the production web UI's +config-page live preview. + +A fresh plugin instance is created per call (mirroring the safety +harness), so repeated renders never share instance state. The plugin's +module does stay imported in the process — module-level globals persist +across calls, which is fine for previewing but worth knowing. +""" + +import json +import time +from pathlib import Path +from typing import Any, Dict, Optional + + +def render_plugin_once(plugin_id: str, plugin_dir: Path, + manifest: Optional[Dict[str, Any]] = None, + config: Optional[Dict[str, Any]] = None, + mock_data: Optional[Dict[str, Any]] = None, + width: int = 128, height: int = 32, + skip_update: bool = True) -> Dict[str, Any]: + """Render one plugin at one size. Returns a response-shaped dict: + + {'image': 'data:image/png;base64,...', 'width', 'height', + 'render_time_ms', 'errors': [...], 'warnings': [...]} + + ``skip_update`` defaults to True: update() may block on live network + (sports APIs, Spotify) — callers that want real data should prime + ``mock_data`` (e.g. from the plugin's test/harness.json fixture, see + ``load_harness_spec``) or explicitly pass skip_update=False. + + Raises on plugin load failure; update()/display() exceptions are + captured into warnings/errors instead so a broken render still shows + whatever was drawn. + """ + from src.plugin_system.plugin_loader import PluginLoader + from src.plugin_system.testing import ( + MockCacheManager, MockPluginManager, VisualTestDisplayManager) + + plugin_dir = Path(plugin_dir) + if manifest is None: + with open(plugin_dir / 'manifest.json', 'r', encoding='utf-8') as f: + manifest = json.load(f) + config = config or {'enabled': True} + mock_data = mock_data or {} + + display_manager = VisualTestDisplayManager(width=width, height=height) + cache_manager = MockCacheManager() + plugin_manager = MockPluginManager() + + # Pre-populate cache with mock data + for key, value in mock_data.items(): + cache_manager.set(key, value) + + loader = PluginLoader() + errors = [] + warnings = [] + + plugin_instance, _module = loader.load_plugin( + plugin_id=plugin_id, + manifest=manifest, + plugin_dir=plugin_dir, + config=config, + display_manager=display_manager, + cache_manager=cache_manager, + plugin_manager=plugin_manager, + install_deps=False, + ) + + start_time = time.time() + + if not skip_update: + try: + plugin_instance.update() + except Exception as e: + warnings.append(f"update() raised: {e}") + + try: + plugin_instance.display(force_clear=True) + except Exception as e: + errors.append(f"display() raised: {e}") + + render_time_ms = round((time.time() - start_time) * 1000, 1) + + return { + 'image': f'data:image/png;base64,{display_manager.get_image_base64()}', + 'width': width, + 'height': height, + 'render_time_ms': render_time_ms, + 'errors': errors, + 'warnings': warnings, + } diff --git a/src/plugin_system/testing/sizes.py b/src/plugin_system/testing/sizes.py index 793dcbc3b..512e88b06 100644 --- a/src/plugin_system/testing/sizes.py +++ b/src/plugin_system/testing/sizes.py @@ -28,6 +28,7 @@ (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 diff --git a/test/test_adaptive_images.py b/test/test_adaptive_images.py new file mode 100644 index 000000000..4ccd48893 --- /dev/null +++ b/test/test_adaptive_images.py @@ -0,0 +1,196 @@ +"""Tests for adaptive image fitting (src/adaptive_images.py) and the +LayoutContext image cache.""" + +import pytest +from PIL import Image + +from src.adaptive_images import ( + RESAMPLE_LANCZOS, + RESAMPLE_NEAREST, + ImageFitResult, + draw_fitted_image, + fit_image, +) +from src.adaptive_layout import LayoutContext, Region +from src.font_manager import FontManager + + +@pytest.fixture(scope="module") +def font_manager(): + return FontManager({}) + + +@pytest.fixture +def ctx(font_manager): + return LayoutContext(128, 32, font_manager) + + +def _solid(w, h, color=(255, 0, 0, 255)): + return Image.new("RGBA", (w, h), color) + + +def _padded_logo(ink_w=10, ink_h=10, pad=10): + """Transparent canvas with a solid ink block in the middle — models a + logo shipped with generous transparent padding.""" + img = Image.new("RGBA", (ink_w + 2 * pad, ink_h + 2 * pad), (0, 0, 0, 0)) + img.paste(_solid(ink_w, ink_h), (pad, pad)) + return img + + +class TestFitModes: + def test_contain_letterboxes_and_upscales(self): + fit = fit_image(_solid(10, 5), (40, 40)) + assert (fit.width, fit.height) == (40, 20) # aspect preserved + assert fit.scale == 4.0 + + def test_contain_no_upscale(self): + fit = fit_image(_solid(10, 5), (40, 40), upscale=False) + assert (fit.width, fit.height) == (10, 5) + assert fit.scale == 1.0 + + def test_cover_fills_and_crops(self): + fit = fit_image(_solid(10, 20), (40, 40), mode="cover") + assert (fit.width, fit.height) == (40, 40) + + def test_cover_top_anchor(self): + # top half red, bottom half blue; cover-crop a wide box with top anchor + img = Image.new("RGBA", (20, 40), (0, 0, 255, 255)) + img.paste(_solid(20, 20, (255, 0, 0, 255)), (0, 0)) + fit = fit_image(img, (20, 20), mode="cover", anchor="top") + assert fit.image.getpixel((10, 5))[:3] == (255, 0, 0) # kept the top + + def test_fill_height_matches_box_height(self): + fit = fit_image(_solid(10, 10), (64, 32), mode="fill_height") + assert fit.height == 32 and fit.width == 32 + + def test_fill_height_capped_by_width(self): + # very wide source: height-fill would overflow the box width + fit = fit_image(_solid(100, 10), (40, 32), mode="fill_height") + assert fit.width <= 40 + + def test_stretch_exact(self): + fit = fit_image(_solid(3, 7), (25, 13), mode="stretch") + assert (fit.width, fit.height) == (25, 13) + + def test_crop_to_ink(self): + fit = fit_image(_padded_logo(), (30, 30), crop_to_ink=True) + # 10x10 ink upscaled to fill 30x30 (padding would have kept it small) + assert (fit.width, fit.height) == (30, 30) + no_crop = fit_image(_padded_logo(), (30, 30), crop_to_ink=False) + assert no_crop.width == 30 # whole padded canvas scaled instead + + def test_fully_transparent_source(self): + img = Image.new("RGBA", (10, 10), (0, 0, 0, 0)) + fit = fit_image(img, (20, 20), crop_to_ink=True) + assert fit.is_empty + + def test_degenerate_box(self): + assert fit_image(_solid(10, 10), (0, 20)).is_empty + assert fit_image(_solid(10, 10), Region(0, 0, 20, 0)).is_empty + + def test_output_always_rgba(self): + rgb = Image.new("RGB", (10, 10), (1, 2, 3)) + assert fit_image(rgb, (20, 20)).image.mode == "RGBA" + + def test_nearest_keeps_hard_edges(self): + # 2x2 checker scaled 8x: NEAREST keeps pure colors, LANCZOS blends + img = Image.new("RGBA", (2, 2), (0, 0, 0, 255)) + img.putpixel((0, 0), (255, 255, 255, 255)) + near = fit_image(img, (16, 16), mode="stretch", resample=RESAMPLE_NEAREST) + colors = {near.image.getpixel((x, y))[:3] for x in range(16) for y in range(16)} + assert colors == {(255, 255, 255), (0, 0, 0)} + + def test_unknown_mode_raises(self): + with pytest.raises(ValueError): + fit_image(_solid(4, 4), (8, 8), mode="tile") + + +class TestDrawFittedImage: + class _DM: + def __init__(self, w=64, h=32): + self.image = Image.new("RGB", (w, h), (0, 0, 0)) + + def test_pastes_aligned_in_region(self): + dm = self._DM() + box = Region(10, 4, 20, 20) + fit = fit_image(_solid(10, 10), box) + xy = draw_fitted_image(dm, fit, box) + assert xy == box.align_xy(fit.width, fit.height) + assert dm.image.getpixel((xy[0] + 1, xy[1] + 1)) == (255, 0, 0) + + def test_offset_translates(self): + dm = self._DM() + box = Region(0, 0, 20, 20) + fit = fit_image(_solid(10, 10), box) + x, y = draw_fitted_image(dm, fit, box, align="left", valign="top", + offset=(3, 5)) + assert (x, y) == (3, 5) + + def test_empty_fit_noops(self): + dm = self._DM() + fit = fit_image(_solid(10, 10), (0, 0)) + assert draw_fitted_image(dm, fit, Region(0, 0, 10, 10)) is None + + +class TestContextImageCache: + def test_size_keyed_hit_and_miss(self, ctx): + img = _solid(10, 10) + a = ctx.fit_image(img, (20, 20), cache_key="logo:A") + assert ctx.fit_image(img, (20, 20), cache_key="logo:A") is a + b = ctx.fit_image(img, (30, 30), cache_key="logo:A") + assert b is not a and b.width == 30 # different box size = new entry + + def test_id_keyed_default(self, ctx): + img = _solid(10, 10) + a = ctx.fit_image(img, (20, 20)) + assert ctx.fit_image(img, (20, 20)) is a + + def test_id_safety_pins_source(self, ctx): + # id()-keyed entries must pin the source image so a recycled id + # can't alias a dead image's cache entry. + img = _solid(10, 10) + ctx.fit_image(img, (20, 20)) + pinned = [entry[1] for entry in ctx._image_cache.values()] + assert img in pinned + + def test_cache_key_entries_do_not_pin(self, ctx): + img = _solid(10, 10) + ctx.fit_image(img, (20, 20), cache_key="logo:X") + key = next(k for k in ctx._image_cache if k[1] == "logo:X") + assert ctx._image_cache[key][1] is None + + def test_lru_eviction(self, ctx): + for i in range(ctx._IMAGE_CACHE_MAX + 5): + ctx.fit_image(_solid(4, 4), (8, 8), cache_key=f"k{i}") + assert len(ctx._image_cache) == ctx._IMAGE_CACHE_MAX + assert not any(k[1] == "k0" for k in ctx._image_cache) # oldest evicted + + def test_clear_cache_clears_images(self, ctx): + ctx.fit_image(_solid(4, 4), (8, 8), cache_key="k") + ctx.clear_cache() + assert len(ctx._image_cache) == 0 + + +class TestBasePluginDrawImage: + def test_draw_image_end_to_end(self): + from src.plugin_system.base_plugin import BasePlugin + from src.plugin_system.testing.mocks import ( + MockCacheManager, MockDisplayManager, MockPluginManager, + ) + + class _P(BasePlugin): + def update(self): + pass + + def display(self, force_clear=False): + pass + + plugin = _P("t", {}, MockDisplayManager(64, 32), + MockCacheManager(), MockPluginManager()) + logo = _padded_logo() + box = plugin.layout.bounds.left_col(32) + ifit = plugin.draw_image(logo, box, mode="fill_height", + crop_to_ink=True, cache_key="logo:T") + assert ifit.height == 32 + # pasted onto the mock's canvas + assert plugin.display_manager.image.getpixel((16, 16)) != (0, 0, 0) diff --git a/test/test_adaptive_layout.py b/test/test_adaptive_layout.py new file mode 100644 index 000000000..678c0be04 --- /dev/null +++ b/test/test_adaptive_layout.py @@ -0,0 +1,438 @@ +"""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_font_crispness, + measure_ink, + media_row, + scoreboard_regions, +) +from src.plugin_system.testing.sizes import DEFAULT_TEST_SIZES +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) + + def test_offset_translates_without_resizing(self): + r = Region(5, 5, 20, 10).offset(3, -2) + assert r == Region(8, 3, 20, 10) + + +class TestScoreboardRegions: + @pytest.mark.parametrize("w,h", DEFAULT_TEST_SIZES + [(8, 8)]) + def test_invariants_at_all_sizes(self, w, h): + regs = scoreboard_regions(Region(0, 0, w, h)) + assert regs.logo_slot <= min(h, w // 2) + # slots hug the edges and never overlap the center column + assert regs.away_slot.x == 0 and regs.home_slot.right == w + assert regs.away_slot.right <= regs.center_col.x or regs.center_col.w == 0 + assert regs.center_col.right <= regs.home_slot.x or regs.center_col.w == 0 + # bands stack inside the center column without overlap + assert regs.status_band.bottom <= regs.score_area.y or regs.score_area.h == 0 + assert regs.score_area.bottom <= regs.detail_band.y or regs.score_area.h == 0 + # everything within bounds, nothing negative + for reg in (regs.away_slot, regs.home_slot, regs.center_col, + regs.status_band, regs.score_area, regs.detail_band, + regs.bottom_left, regs.bottom_right): + assert reg.w >= 0 and reg.h >= 0 + assert reg.x >= 0 and reg.y >= 0 + assert reg.right <= w and reg.bottom <= h + + @pytest.mark.parametrize("w,h", [(96, 48), (128, 64), (256, 128), (64, 32), (128, 96)]) + def test_2to1_aspect_gets_a_center_reserve(self, w, h): + """These sizes are all <= 2:1 aspect, where the raw min(h, w//2) + formula claims the entire width for logos and leaves zero pixels + for a center column — the bug this reserve exists to fix.""" + regs = scoreboard_regions(Region(0, 0, w, h)) + assert regs.center_col.w >= int(w * 0.15) - 1 # -1 for int() rounding + + @pytest.mark.parametrize("w,h", [(128, 32), (192, 48), (256, 32)]) + def test_wide_panels_unaffected_by_center_reserve(self, w, h): + """Wide (>= ~4:1) panels already have height as the tighter + constraint, so the center reserve must be a no-op there — the + design-size baseline's proportions shouldn't shift.""" + regs = scoreboard_regions(Region(0, 0, w, h)) + assert regs.logo_slot == min(h, w // 2) + + def test_center_reserve_fraction_is_configurable(self): + # min_center_design_px=0 isolates the fraction term (otherwise the + # scaled absolute floor can dominate and mask a fraction change). + regs_default = scoreboard_regions(Region(0, 0, 128, 64), min_center_design_px=0) + regs_wider = scoreboard_regions(Region(0, 0, 128, 64), min_center_fraction=0.5, + min_center_design_px=0) + assert regs_wider.center_col.w > regs_default.center_col.w + assert regs_wider.logo_slot < regs_default.logo_slot + + def test_score_bleed_extends_past_center_col(self): + regs = scoreboard_regions(Region(0, 0, 128, 64), score_bleed_fraction=0.5) + assert regs.score_area.w > regs.center_col.w + assert regs.score_area.x < regs.center_col.x + assert regs.score_area.right > regs.center_col.right + + def test_score_bleed_zero_matches_center_col(self): + regs = scoreboard_regions(Region(0, 0, 128, 64), score_bleed_fraction=0.0) + assert regs.score_area.w == regs.center_col.w + assert regs.score_area.x == regs.center_col.x + + @pytest.mark.parametrize("w,h", [(64, 32), (96, 48), (128, 64), (256, 128), (128, 96)]) + def test_score_never_needs_ellipsis_for_a_short_score(self, w, h, font_manager): + """The concrete regression this whole reserve/bleed system exists to + prevent: a real game score like '17-21' must always render in full, + never truncated, at every 2:1-or-tighter aspect ratio in the sample.""" + ctx = LayoutContext(w, h, font_manager) + regs = scoreboard_regions(Region(0, 0, w, h), ctx=ctx) + height_scale = h / 32.0 + fit = ctx.fit_text_proportional("17-21", regs.score_area, base_size_px=10, + ladder=LADDER_ARCADE, scale=height_scale) + assert fit.text == "17-21" + assert fit.fits + + def test_ctx_scales_band_heights(self, font_manager): + small = scoreboard_regions(Region(0, 0, 128, 32), + ctx=LayoutContext(128, 32, font_manager)) + big = scoreboard_regions(Region(0, 0, 256, 64), + ctx=LayoutContext(256, 64, font_manager)) + assert big.status_band.h > small.status_band.h + + def test_works_on_offset_card_region(self): + card = Region(10, 4, 100, 24) + regs = scoreboard_regions(card) + assert regs.away_slot.x == 10 + assert regs.home_slot.right == card.right + + +class TestMediaRow: + def test_square_art_plus_body(self): + row = media_row(Region(0, 0, 128, 32)) + assert row.art == Region(0, 0, 32, 32) + assert row.body.x == 32 + 2 and row.body.right == 128 + + def test_non_square(self): + row = media_row(Region(0, 0, 100, 20), square=False, gap=4) + assert row.art.w == 50 + assert row.body.x == 54 + + def test_narrow_panel_clamps(self): + row = media_row(Region(0, 0, 16, 32)) + assert row.art.w == 16 and row.body.w == 0 + + +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_ladder_grid_is_crisp(self, font_manager): + """LADDER_GRID's BDF fonts are real bitmaps — always 0% antialiased.""" + for step in LADDER_GRID: + font = font_manager.get_font(step.family, step.size_px) + assert measure_font_crispness(font, "Ay0") == 0.0 + + def test_ladder_arcade_is_crisp(self, font_manager): + """PressStart2P only rasterizes without antialiasing at exact + multiples of its 8px design grid — every LADDER_ARCADE rung must + land on one.""" + for step in LADDER_ARCADE: + assert step.size_px % 8 == 0, f"{step} is not a multiple of 8" + font = font_manager.get_font(step.family, step.size_px) + assert measure_font_crispness(font, "17-21") == 0.0 + + def test_crispness_catches_a_bad_size(self, font_manager): + """Sanity check the measurement itself: a known-bad size for a + pixel-grid font must NOT read as crisp.""" + font = font_manager.get_font("press_start", 10) # not a multiple of 8 + assert measure_font_crispness(font, "17-21") > 0.1 + + 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_text_proportional_tracks_design_scale(self, font_manager): + # design size 128x32, base_size_px=10 (a typical classic score size): + # at 2x scale the target is 20px -> nearest LADDER_ARCADE rung <= 20 + # is 16px, not the largest that merely fits the box (32). + ctx = LayoutContext(256, 64, font_manager) # scale = min(2,2) = 2 + fit = ctx.fit_text_proportional("17-21", ctx.bounds, base_size_px=10, + ladder=LADDER_ARCADE) + assert fit.size_px == 16 + + def test_fit_text_proportional_does_not_exceed_max_fit(self, ctx): + # at scale=1 (128x32, the design size itself) the target equals + # base_size_px, so proportional should never pick something LARGER + # than plain fit_text would for the same box. + prop = ctx.fit_text_proportional("17-21", ctx.bounds, base_size_px=10, + ladder=LADDER_ARCADE) + maxed = ctx.fit_text("17-21", ctx.bounds, ladder=LADDER_ARCADE) + assert prop.size_px <= maxed.size_px + + def test_fit_text_proportional_floors_at_smallest_rung(self, font_manager): + # scale so small the target is below every rung -> use the smallest + # rung as a floor rather than refusing to render anything. + ctx = LayoutContext(32, 8, font_manager) # scale = min(32/128, 8/32) = 0.25 + fit = ctx.fit_text_proportional("HI", ctx.bounds, base_size_px=10, + ladder=LADDER_ARCADE) + assert fit.size_px == min(s.size_px for s in LADDER_ARCADE) + + def test_fit_text_proportional_falls_through_when_target_rung_overflows(self, font_manager): + # a long string at the target rung might not fit a narrow box even + # though the target size is "correct" -- must fall through to a + # smaller rung exactly like fit_text does, not just refuse to fit. + ctx = LayoutContext(256, 64, font_manager) + narrow_box = Region(0, 0, 40, 64) + fit = ctx.fit_text_proportional("A REALLY LONG STRING HERE", narrow_box, + base_size_px=10, ladder=LADDER_ARCADE) + assert fit.fits or fit.text.endswith("…") + + def test_fit_text_proportional_cached(self, ctx): + first = ctx.fit_text_proportional("X", ctx.bounds, base_size_px=10) + second = ctx.fit_text_proportional("X", ctx.bounds, base_size_px=10) + assert first is second + + def test_fit_text_proportional_scale_override(self, font_manager): + # 128x64 vs design 128x32: self.scale (min of both axes) is 1.0 + # since width didn't grow, but a caller whose composition scales by + # HEIGHT alone (e.g. logo_slot = min(h, w//2)) should be able to + # override the reference scale so text grows with it too. + ctx = LayoutContext(128, 64, font_manager) + assert ctx.scale == 1.0 + default_fit = ctx.fit_text_proportional("17-21", ctx.bounds, base_size_px=10, + ladder=LADDER_ARCADE) + height_scale = 64 / 32 # matches design height + scaled_fit = ctx.fit_text_proportional("17-21", ctx.bounds, base_size_px=10, + ladder=LADDER_ARCADE, scale=height_scale) + assert scaled_fit.size_px > default_fit.size_px + + 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 diff --git a/test/test_element_style.py b/test/test_element_style.py new file mode 100644 index 000000000..a42912302 --- /dev/null +++ b/test/test_element_style.py @@ -0,0 +1,509 @@ +"""Tests for src/element_style.py — universal per-element style resolution. + +The load-bearing behavior is the user-override check: saved configs ALWAYS +contain the schema defaults (merge_with_defaults runs at save time and again +before plugin instantiation), so "key present" must never be read as "user +set it". Only "present and different from the schema default" counts. +""" + +import json +import os +import sys + +import pytest +from PIL import ImageFont + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +from src.element_style import ( # noqa: E402 + ElementStyle, + ElementStyleResolver, + FONT_ALIASES, + defaults_from_schema_file, + extract_schema_defaults, + load_font, + resolve_font_name, +) + +PRESS_START = "PressStart2P-Regular.ttf" +FOUR_BY_SIX = "4x6-font.ttf" +FIVE_BY_SEVEN_BDF = "5x7.bdf" + + +# --------------------------------------------------------------------------- +# load_font +# --------------------------------------------------------------------------- + +class TestLoadFont: + def test_ttf(self): + font = load_font(PRESS_START, 8) + assert isinstance(font, ImageFont.FreeTypeFont) + assert font.size == 8 + + def test_bdf_at_native_size(self): + """FreeType loads BDF strikes directly at their native size.""" + font = load_font(FIVE_BY_SEVEN_BDF, 7) + assert isinstance(font, ImageFont.FreeTypeFont) + + def test_bdf_at_wrong_size_falls_back(self): + """BDF fonts are fixed-size; a non-native size falls back to the + fallback font at the requested size rather than raising.""" + font = load_font(FIVE_BY_SEVEN_BDF, 14) + assert isinstance(font, ImageFont.FreeTypeFont) + assert font.size == 14 # fallback font honored the requested size + + def test_alias_resolves(self): + assert resolve_font_name("press_start") == PRESS_START + font = load_font("press_start", 16) + assert isinstance(font, ImageFont.FreeTypeFont) + assert font.size == 16 + + def test_filename_passes_through_alias(self): + assert resolve_font_name(FOUR_BY_SIX) == FOUR_BY_SIX + + def test_missing_file_falls_back(self): + font = load_font("no-such-font.ttf", 10) + assert isinstance(font, ImageFont.FreeTypeFont) + assert font.size == 10 + + @pytest.mark.parametrize("garbage", ["", None, "../../etc/passwd", "x" * 300]) + def test_garbage_never_raises(self, garbage): + font = load_font(garbage, 8) + assert font is not None + + def test_everything_missing_uses_pil_default(self): + font = load_font("nope.ttf", 8, fonts_dir="/nonexistent", + fallback_font="also-nope.ttf") + assert font is not None # ImageFont.load_default() + + def test_aliases_cover_the_baseball_set(self): + """The centralized map must be a superset of the per-plugin copies + it replaces (baseball game_renderer.py + sports.py).""" + assert FONT_ALIASES["press_start"] == PRESS_START + assert FONT_ALIASES["four_by_six"] == FOUR_BY_SIX + assert FONT_ALIASES["five_by_seven"] == FIVE_BY_SEVEN_BDF + + +# --------------------------------------------------------------------------- +# user_forced provenance +# --------------------------------------------------------------------------- + +SCHEMA_DEFAULTS = { + "customization": { + "score_text": {"font": PRESS_START, "font_size": 10}, + } +} + + +def _style(config, defaults=SCHEMA_DEFAULTS): + return ElementStyleResolver(config, defaults).style( + "score_text", classic_font=PRESS_START, classic_size=10) + + +class TestUserForced: + def test_absent_is_not_forced(self): + style = _style({}) + assert not style.user_forced + assert style.font_name == PRESS_START + assert style.font_size == 10 + + def test_schema_default_present_is_not_forced(self): + """THE bug this module exists to fix: the save flow writes schema + defaults into every saved config, so their presence means nothing.""" + config = {"customization": {"score_text": { + "font": PRESS_START, "font_size": 10}}} + style = _style(config) + assert not style.user_forced + + def test_different_font_is_forced(self): + config = {"customization": {"score_text": { + "font": FOUR_BY_SIX, "font_size": 10}}} + style = _style(config) + assert style.user_forced_font + assert not style.user_forced_size + assert style.user_forced + + def test_different_size_is_forced(self): + config = {"customization": {"score_text": { + "font": PRESS_START, "font_size": 14}}} + style = _style(config) + assert style.user_forced_size + assert not style.user_forced_font + assert style.font_size == 14 + + def test_string_size_equal_to_default_is_not_forced(self): + config = {"customization": {"score_text": {"font_size": "10"}}} + assert not _style(config).user_forced + + def test_without_schema_defaults_compares_against_classic(self): + """Degraded mode (old cores, tests): classic_* is the reference.""" + config = {"customization": {"score_text": { + "font": PRESS_START, "font_size": 10}}} + style = _style(config, defaults={}) + assert not style.user_forced + forced = _style({"customization": {"score_text": {"font_size": 12}}}, + defaults={}) + assert forced.user_forced + + def test_schema_default_differing_from_classic_wins_as_reference(self): + """When the schema declares a different default than the classic_* + args, the schema is the reference — a config equal to the schema + default is untouched.""" + defaults = {"customization": {"score_text": { + "font": FOUR_BY_SIX, "font_size": 6}}} + config = {"customization": {"score_text": { + "font": FOUR_BY_SIX, "font_size": 6}}} + style = ElementStyleResolver(config, defaults).style( + "score_text", classic_font=PRESS_START, classic_size=10) + assert not style.user_forced + + def test_unknown_element_uses_classic(self): + style = ElementStyleResolver({}, SCHEMA_DEFAULTS).style( + "no_such_element", classic_font=FOUR_BY_SIX, classic_size=6) + assert not style.user_forced + assert style.font_name == FOUR_BY_SIX + assert style.font_size == 6 + + def test_malformed_customization_is_tolerated(self): + for bad in [{"customization": "oops"}, + {"customization": {"score_text": "oops"}}, + {"customization": {"score_text": {"font_size": "huge"}}}, + None]: + style = _style(bad) + assert not style.user_forced + assert style.font_size == 10 + + +# --------------------------------------------------------------------------- +# color +# --------------------------------------------------------------------------- + +class TestColor: + """Color provenance mirrors fonts: the web form ALWAYS posts the RGB + inputs, so a saved config carries the schema-default color whether or + not the user touched it. Only a value differing from the schema default + is an override; otherwise the plugin's classic color survives — critical + for state-dependent colors (a score that turns gold on a touchdown).""" + + COLOR_DEFAULTS = {"customization": {"score_text": { + "font": PRESS_START, "font_size": 10, "text_color": [255, 255, 255]}}} + + def _color_style(self, config, defaults=None, classic_color=(255, 215, 0)): + return ElementStyleResolver(config, defaults or self.COLOR_DEFAULTS).style( + "score_text", classic_font=PRESS_START, classic_size=10, + classic_color=classic_color) + + def test_absent_returns_classic_color(self): + style = self._color_style({}) + assert style.color == (255, 215, 0) + assert not style.user_forced_color + + def test_absent_with_no_classic_is_none(self): + assert _style({}).color is None + + def test_schema_default_present_keeps_classic_color(self): + """A saved config always contains the default — it must not clobber + the plugin's (possibly semantic) classic color.""" + config = {"customization": {"score_text": {"text_color": [255, 255, 255]}}} + style = self._color_style(config) + assert style.color == (255, 215, 0) + assert not style.user_forced_color + + def test_changed_color_is_an_override(self): + config = {"customization": {"score_text": {"text_color": [0, 128, 255]}}} + style = self._color_style(config) + assert style.color == (0, 128, 255) + assert style.user_forced_color + + def test_present_without_schema_default_is_an_override(self): + """Hand-written schemas without a text_color default: presence is + intent (there is nothing to compare against).""" + config = {"customization": {"score_text": {"text_color": [0, 128, 255]}}} + style = self._color_style(config, defaults=SCHEMA_DEFAULTS) + assert style.color == (0, 128, 255) + assert style.user_forced_color + + def test_values_clamped(self): + config = {"customization": {"score_text": {"text_color": [300, -5, 128]}}} + assert self._color_style(config).color == (255, 0, 128) + + def test_color_never_affects_user_forced_sizing(self): + config = {"customization": {"score_text": {"text_color": [0, 128, 255]}}} + style = self._color_style(config) + assert style.user_forced_color + assert not style.user_forced + + @pytest.mark.parametrize("bad", [[1, 2], [1, 2, 3, 4], "red", + ["a", "b", "c"], 255, None]) + def test_malformed_falls_back_to_classic(self, bad): + config = {"customization": {"score_text": {"text_color": bad}}} + style = self._color_style(config, classic_color=(1, 2, 3)) + assert style.color == (1, 2, 3) + assert not style.user_forced_color + + +# --------------------------------------------------------------------------- +# offsets +# --------------------------------------------------------------------------- + +class TestOffsets: + def test_unset_is_zero(self): + assert ElementStyleResolver({}).offset("score") == (0, 0) + + def test_layout_section(self): + """The deployed sports convention: customization.layout..""" + config = {"customization": {"layout": {"score": { + "x_offset": 3, "y_offset": -2}}}} + assert ElementStyleResolver(config).offset("score") == (3, -2) + + def test_element_section_fallback(self): + config = {"customization": {"score": {"x_offset": 5}}} + assert ElementStyleResolver(config).offset("score") == (5, 0) + + def test_layout_section_wins_over_element_section(self): + config = {"customization": { + "layout": {"score": {"x_offset": 1}}, + "score": {"x_offset": 9, "y_offset": 9}, + }} + resolver = ElementStyleResolver(config) + assert resolver.offset_value("score", "x_offset") == 1 + # y_offset absent from layout section -> element section supplies it + assert resolver.offset_value("score", "y_offset") == 9 + + @pytest.mark.parametrize("raw,expected", [ + (2, 2), (2.7, 2), ("3", 3), ("2.0", 2), ("-4", -4), + (None, 0), ("junk", 0), ([], 0), (True, 0), + ]) + def test_coercion_matches_sports_helper(self, raw, expected): + """Same tolerance as the sports.py/_get_layout_offset copies this + replaces: int/float/numeric-string pass, anything else -> default.""" + config = {"customization": {"layout": {"e": {"x_offset": raw}}}} + assert ElementStyleResolver(config).offset_value("e", "x_offset") == expected + + def test_custom_axis_names(self): + """Football's records use away_x_offset/home_x_offset.""" + config = {"customization": {"layout": {"records": { + "away_x_offset": 4, "home_x_offset": -4}}}} + resolver = ElementStyleResolver(config) + assert resolver.offset_value("records", "away_x_offset") == 4 + assert resolver.offset_value("records", "home_x_offset") == -4 + + def test_style_carries_offset(self): + config = {"customization": {"layout": {"score_text": { + "x_offset": 2, "y_offset": 1}}}} + assert _style(config).offset == (2, 1) + + +# --------------------------------------------------------------------------- +# caching +# --------------------------------------------------------------------------- + +class TestCaching: + def test_same_call_is_cached(self): + resolver = ElementStyleResolver({}, SCHEMA_DEFAULTS) + a = resolver.style("score_text", classic_font=PRESS_START, classic_size=10) + b = resolver.style("score_text", classic_font=PRESS_START, classic_size=10) + assert a is b + + def test_clear_cache(self): + resolver = ElementStyleResolver({}, SCHEMA_DEFAULTS) + a = resolver.style("score_text", classic_font=PRESS_START, classic_size=10) + resolver.clear_cache() + b = resolver.style("score_text", classic_font=PRESS_START, classic_size=10) + assert a is not b + # PIL fonts compare by identity; compare the value fields + assert (a.font_name, a.font_size, a.color, a.offset, a.user_forced) == \ + (b.font_name, b.font_size, b.color, b.offset, b.user_forced) + + +# --------------------------------------------------------------------------- +# schema default extraction +# --------------------------------------------------------------------------- + +class TestSchemaDefaults: + def test_matches_schema_manager_extraction(self): + """The pure helper must agree with SchemaManager.extract_defaults_from_schema + on a real plugin-style schema — it exists so plugins get the same + answer in harness contexts where the schema manager is absent.""" + from src.plugin_system.schema_manager import SchemaManager + schema = { + "type": "object", + "properties": { + "enabled": {"type": "boolean", "default": True}, + "customization": { + "type": "object", + "properties": { + "score_text": { + "type": "object", + "properties": { + "font": {"type": "string", "default": PRESS_START}, + "font_size": {"type": "integer", "default": 10}, + "y_percent": {"type": "number"}, + }, + }, + "layout": { + "type": "object", + "properties": { + "score": { + "type": "object", + "properties": { + "x_offset": {"type": "integer", "default": 0}, + }, + }, + }, + }, + }, + }, + "opaque_with_default": {"type": "object", "default": {}, + "properties": {"x": {"default": 1}}}, + }, + } + pure = extract_schema_defaults(schema) + managed = SchemaManager().extract_defaults_from_schema(schema) + assert pure == managed + assert pure["customization"]["score_text"]["font"] == PRESS_START + # object-level default short-circuits recursion (both must agree) + assert pure["opaque_with_default"] == {} + + def test_defaults_from_schema_file(self, tmp_path): + schema_path = tmp_path / "config_schema.json" + schema_path.write_text(json.dumps({ + "type": "object", + "properties": { + "customization": { + "type": "object", + "properties": { + "title_text": { + "type": "object", + "properties": { + "font": {"type": "string", "default": PRESS_START}, + }, + }, + }, + }, + }, + })) + defaults = defaults_from_schema_file(str(schema_path)) + assert defaults["customization"]["title_text"]["font"] == PRESS_START + + def test_defaults_from_missing_or_bad_file(self, tmp_path): + assert defaults_from_schema_file("/nonexistent/schema.json") == {} + bad = tmp_path / "bad.json" + bad.write_text("{not json") + assert defaults_from_schema_file(str(bad)) == {} + + +# --------------------------------------------------------------------------- +# BasePlugin integration +# --------------------------------------------------------------------------- + +class _StubSchemaManager: + """Schema manager double exposing the two methods the resolver path uses.""" + + def __init__(self, schema): + self._schema = schema + + def load_schema(self, plugin_id, use_cache=True): + return self._schema + + def extract_defaults_from_schema(self, schema, prefix=""): + # Mirror the real nested-dict extraction for this simple shape + def walk(props): + out = {} + for key, spec in props.get("properties", {}).items(): + if "default" in spec: + out[key] = spec["default"] + elif spec.get("type") == "object" and "properties" in spec: + nested = walk(spec) + if nested: + out[key] = nested + return out + return walk(schema) + + +def _make_plugin(config, schema=None): + from src.plugin_system.base_plugin import BasePlugin + from src.plugin_system.testing.mocks import ( + MockCacheManager, MockPluginManager) + from src.plugin_system.testing.visual_display_manager import ( + VisualTestDisplayManager) + + class _Plugin(BasePlugin): + def update(self): + return True + + def display(self, force_clear=False): + return None + + plugin_manager = MockPluginManager() + if schema is not None: + plugin_manager.schema_manager = _StubSchemaManager(schema) + return _Plugin("test-plugin", config, + VisualTestDisplayManager(64, 32), + MockCacheManager(), plugin_manager) + + +TEST_SCHEMA = { + "type": "object", + "properties": { + "customization": { + "type": "object", + "properties": { + "score_text": { + "type": "object", + "properties": { + "font": {"type": "string", "default": PRESS_START}, + "font_size": {"type": "integer", "default": 10}, + }, + }, + }, + }, + }, +} + + +class TestBasePluginIntegration: + def test_element_style_with_schema_defaults(self): + """The full path: saved config carries schema defaults, plugin's + element_style still reports not-forced.""" + config = {"enabled": True, "customization": {"score_text": { + "font": PRESS_START, "font_size": 10}}} + plugin = _make_plugin(config, schema=TEST_SCHEMA) + style = plugin.element_style("score_text", classic_font=PRESS_START, + classic_size=10) + assert not style.user_forced + + def test_element_style_detects_real_override(self): + config = {"enabled": True, "customization": {"score_text": { + "font": PRESS_START, "font_size": 14}}} + plugin = _make_plugin(config, schema=TEST_SCHEMA) + style = plugin.element_style("score_text", classic_font=PRESS_START, + classic_size=10) + assert style.user_forced_size + assert style.font_size == 14 + + def test_works_without_schema_manager(self): + """MockPluginManager has no schema_manager attribute by default — + the resolver degrades to classic-default comparison, no crash.""" + config = {"enabled": True, "customization": {"score_text": { + "font_size": 12}}} + plugin = _make_plugin(config, schema=None) + style = plugin.element_style("score_text", classic_font=PRESS_START, + classic_size=10) + assert style.user_forced_size # 12 != classic 10 + + def test_resolver_is_cached_and_invalidated_on_config_change(self): + plugin = _make_plugin({"enabled": True}, schema=TEST_SCHEMA) + first = plugin.style_resolver + assert plugin.style_resolver is first + plugin.on_config_change({"enabled": True, "customization": { + "score_text": {"font_size": 14}}}) + second = plugin.style_resolver + assert second is not first + style = plugin.element_style("score_text", classic_font=PRESS_START, + classic_size=10) + assert style.user_forced_size + + +if __name__ == "__main__": + sys.exit(pytest.main([__file__, "-v"])) diff --git a/test/test_harness_fill.py b/test/test_harness_fill.py new file mode 100644 index 000000000..909a1556e --- /dev/null +++ b/test/test_harness_fill.py @@ -0,0 +1,98 @@ +"""Tests for the harness fill / scale-up check (src/plugin_system/testing/harness.py).""" + +from PIL import Image + +from src.plugin_system.testing.harness import ( + RenderResult, + check_scale_up, + fill_metrics, +) + + +def _canvas(w, h): + return Image.new("RGB", (w, h), (0, 0, 0)) + + +def _with_block(w, h, bx, by, bw, bh, color=(255, 255, 255)): + img = _canvas(w, h) + img.paste(Image.new("RGB", (bw, bh), color), (bx, by)) + return img + + +def _result(w, h, image): + return RenderResult("p", w, h, "mode", image=image) + + +class TestFillMetrics: + def test_full_white(self): + ex, ey, ink = fill_metrics(Image.new("RGB", (64, 32), (255, 255, 255))) + assert (ex, ey, ink) == (1.0, 1.0, 1.0) + + def test_black_is_empty(self): + assert fill_metrics(_canvas(64, 32)) == (0.0, 0.0, 0.0) + + def test_corner_dot(self): + ex, ey, ink = fill_metrics(_with_block(100, 100, 0, 0, 10, 10)) + assert ex == 0.1 and ey == 0.1 + assert ink == 0.01 + + def test_centered_half(self): + ex, ey, _ = fill_metrics(_with_block(100, 100, 25, 25, 50, 50)) + assert ex == 0.5 and ey == 0.5 + + def test_dim_pixels_ignored(self): + img = _canvas(10, 10) + img.putpixel((5, 5), (10, 10, 10)) # below the lit threshold + assert fill_metrics(img) == (0.0, 0.0, 0.0) + + +class TestCheckScaleUp: + def test_not_checked_below_2x(self): + # 128x64 vs design 128x32: only height is 2x -> checked on y only; + # 128x32 itself: not checked at all + r = _result(128, 32, _with_block(128, 32, 0, 0, 10, 10)) + check_scale_up([r], design_size=(128, 32)) + assert not r.fill_checked + + def test_warn_mode_records_but_passes(self): + # tiny corner content on a 256x128 (2x both axes) + r = _result(256, 128, _with_block(256, 128, 0, 0, 20, 20)) + check_scale_up([r], design_size=(128, 32), strict=False) + assert r.fill_checked + assert r.fill_ok is None # warn-only: not a failure + assert r.ok # still passes + assert r.fill_extent[0] < 0.5 + + def test_strict_mode_fails_underfill(self): + r = _result(256, 128, _with_block(256, 128, 0, 0, 20, 20)) + check_scale_up([r], design_size=(128, 32), strict=True) + assert r.fill_ok is False + assert not r.ok + + def test_well_filled_passes_strict(self): + r = _result(256, 128, _with_block(256, 128, 10, 10, 200, 100)) + check_scale_up([r], design_size=(128, 32), strict=True) + assert r.fill_ok is True and r.ok + + def test_axis_selection_wide_only(self): + # 256x32 vs design 128x32: width is 2x, height is not -> only the + # x-extent matters; content spanning full width but few rows passes + r = _result(256, 32, _with_block(256, 32, 0, 12, 250, 8)) + check_scale_up([r], design_size=(128, 32), strict=True) + assert r.fill_ok is True + + def test_axis_selection_wide_only_underfill(self): + r = _result(256, 32, _with_block(256, 32, 0, 12, 60, 8)) + check_scale_up([r], design_size=(128, 32), strict=True) + assert r.fill_ok is False + + def test_errored_render_skipped(self): + r = RenderResult("p", 256, 128, "m", error="boom") + check_scale_up([r], design_size=(128, 32), strict=True) + assert not r.fill_checked + + def test_custom_design_size(self): + # 128x64 with design 64x32 IS 2x both axes + r = _result(128, 64, _with_block(128, 64, 0, 0, 10, 10)) + check_scale_up([r], design_size=(64, 32), strict=False) + assert r.fill_checked diff --git a/test/test_schema_style_expansion.py b/test/test_schema_style_expansion.py new file mode 100644 index 000000000..233365d46 --- /dev/null +++ b/test/test_schema_style_expansion.py @@ -0,0 +1,273 @@ +"""Tests for x-style-elements schema expansion. + +A plugin declares styleable elements once, compactly; expansion generates +the full customization property blocks at schema-load time. The invariants +that matter: + +- idempotent (expand(expand(s)) == expand(s)) and the input is never mutated +- both load paths (cached GET, uncached save) see the identical shape +- generated defaults flow into generate_default_config, and saving twice is + round-trip stable (merge_with_defaults produces no churn) +- hand-written property blocks for the same element always win +- defaults_from_schema_file (what plugins use to build resolvers from their + RAW schema file) agrees exactly with the schema manager's expanded view +""" + +import copy +import json +import os +import sys + +import jsonschema +import pytest + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +from src.element_style import ( # noqa: E402 + defaults_from_schema_file, + expand_style_elements, + extract_schema_defaults, + get_style_elements, +) +from src.plugin_system.schema_manager import SchemaManager # noqa: E402 + +PRESS_START = "PressStart2P-Regular.ttf" + + +def _declared_schema(): + return { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "enabled": {"type": "boolean", "default": True}, + "customization": { + "type": "object", + "title": "Display Customization", + "x-style-elements": { + "score_text": { + "title": "Game Score", + "font": {"default": PRESS_START}, + "size": {"default": 10, "min": 4, "max": 16}, + "color": True, + "offsets": True, + }, + "detail_text": { + "font": {"default": "4x6-font.ttf"}, + "size": {"default": 6}, + }, + }, + "properties": {}, + "additionalProperties": False, + }, + }, + } + + +class TestExpansionShape: + def test_generates_element_blocks(self): + expanded = expand_style_elements(_declared_schema()) + cust = expanded["properties"]["customization"]["properties"] + score = cust["score_text"] + assert score["x-style-managed"] is True + assert score["title"] == "Game Score" + assert score["properties"]["font"]["default"] == PRESS_START + assert score["properties"]["font"]["x-widget"] == "font-selector" + assert score["properties"]["font_size"]["default"] == 10 + assert score["properties"]["font_size"]["minimum"] == 4 + assert score["properties"]["font_size"]["maximum"] == 16 + assert score["properties"]["text_color"]["x-widget"] == "color-picker" + assert score["properties"]["text_color"]["default"] == [255, 255, 255] + assert score["additionalProperties"] is False + + def test_color_and_offsets_are_optional(self): + expanded = expand_style_elements(_declared_schema()) + cust = expanded["properties"]["customization"]["properties"] + detail = cust["detail_text"] + assert "text_color" not in detail["properties"] + assert detail["title"] == "Detail Text" # prettified from the key + layout = cust["layout"]["properties"] + assert "score_text" in layout + assert "detail_text" not in layout + + def test_offsets_block_shape(self): + expanded = expand_style_elements(_declared_schema()) + layout = expanded["properties"]["customization"]["properties"]["layout"] + assert layout["x-style-managed"] is True + entry = layout["properties"]["score_text"] + assert entry["properties"]["x_offset"]["default"] == 0 + assert entry["properties"]["y_offset"]["default"] == 0 + + def test_declared_color_default(self): + schema = _declared_schema() + decl = schema["properties"]["customization"]["x-style-elements"] + decl["score_text"]["color"] = {"default": [255, 215, 0]} + expanded = expand_style_elements(schema) + color = (expanded["properties"]["customization"]["properties"] + ["score_text"]["properties"]["text_color"]) + assert color["default"] == [255, 215, 0] + + def test_declaration_survives_expansion(self): + """The declaration is the element registry for tooling — it must + remain readable from the expanded schema.""" + expanded = expand_style_elements(_declared_schema()) + assert set(get_style_elements(expanded)) == {"score_text", "detail_text"} + assert set(SchemaManager.get_style_elements(expanded)) == { + "score_text", "detail_text"} + + def test_no_declaration_returns_same_object(self): + schema = {"type": "object", "properties": {"enabled": {"default": True}}} + assert expand_style_elements(schema) is schema + + def test_valid_draft7(self): + jsonschema.Draft7Validator.check_schema( + expand_style_elements(_declared_schema())) + + def test_property_order_updated_when_present(self): + schema = _declared_schema() + schema["properties"]["customization"]["x-propertyOrder"] = [] + expanded = expand_style_elements(schema) + order = expanded["properties"]["customization"]["x-propertyOrder"] + # generated elements before layout (the template only renders keys + # in x-propertyOrder when one exists) + assert set(order) == {"score_text", "detail_text", "layout"} + assert order.index("score_text") < order.index("layout") + + def test_malformed_declaration_is_harmless(self): + schema = _declared_schema() + schema["properties"]["customization"]["x-style-elements"] = { + "bad": "not a dict", "score_text": {"size": {"default": 10}}} + expanded = expand_style_elements(schema) + cust = expanded["properties"]["customization"]["properties"] + assert "bad" not in cust + assert "score_text" in cust + + +class TestExpansionInvariants: + def test_idempotent(self): + once = expand_style_elements(_declared_schema()) + twice = expand_style_elements(once) + assert once == twice + + def test_input_never_mutated(self): + schema = _declared_schema() + snapshot = copy.deepcopy(schema) + expand_style_elements(schema) + assert schema == snapshot + + def test_hand_written_block_wins(self): + schema = _declared_schema() + hand_written = { + "type": "object", + "properties": {"font": {"type": "string", "default": "custom.ttf"}}, + } + schema["properties"]["customization"]["properties"]["score_text"] = \ + copy.deepcopy(hand_written) + expanded = expand_style_elements(schema) + assert (expanded["properties"]["customization"]["properties"]["score_text"] + == hand_written) + + def test_hand_written_layout_entry_wins(self): + schema = _declared_schema() + schema["properties"]["customization"]["properties"]["layout"] = { + "type": "object", + "properties": {"score_text": {"type": "object", "properties": { + "x_offset": {"type": "integer", "default": 5}}}}, + } + expanded = expand_style_elements(schema) + layout = expanded["properties"]["customization"]["properties"]["layout"] + assert layout["properties"]["score_text"]["properties"]["x_offset"]["default"] == 5 + + +class TestSchemaManagerIntegration: + def _manager_with_schema(self, tmp_path, schema): + plugin_dir = tmp_path / "test-plugin" + plugin_dir.mkdir() + (plugin_dir / "config_schema.json").write_text(json.dumps(schema)) + return SchemaManager(plugins_dir=tmp_path) + + def test_load_schema_expands(self, tmp_path): + mgr = self._manager_with_schema(tmp_path, _declared_schema()) + loaded = mgr.load_schema("test-plugin") + assert "score_text" in loaded["properties"]["customization"]["properties"] + + def test_cached_and_uncached_loads_agree(self, tmp_path): + """The save path uses use_cache=False while the form GET uses the + cache — they must see the identical expanded shape.""" + mgr = self._manager_with_schema(tmp_path, _declared_schema()) + cached = mgr.load_schema("test-plugin", use_cache=True) + again = mgr.load_schema("test-plugin", use_cache=True) + uncached = mgr.load_schema("test-plugin", use_cache=False) + assert cached == uncached == again + + def test_disk_file_untouched(self, tmp_path): + schema = _declared_schema() + mgr = self._manager_with_schema(tmp_path, schema) + mgr.load_schema("test-plugin") + on_disk = json.loads( + (tmp_path / "test-plugin" / "config_schema.json").read_text()) + assert on_disk == schema + assert "score_text" not in on_disk["properties"]["customization"]["properties"] + + def test_defaults_include_generated_elements(self, tmp_path): + mgr = self._manager_with_schema(tmp_path, _declared_schema()) + defaults = mgr.generate_default_config("test-plugin") + assert defaults["customization"]["score_text"]["font"] == PRESS_START + assert defaults["customization"]["score_text"]["font_size"] == 10 + assert defaults["customization"]["score_text"]["text_color"] == [255, 255, 255] + assert defaults["customization"]["layout"]["score_text"]["x_offset"] == 0 + + def test_save_twice_is_round_trip_stable(self, tmp_path): + """merge_with_defaults(merged, defaults) must be a fixed point — + saving a config twice can't keep growing/altering it.""" + mgr = self._manager_with_schema(tmp_path, _declared_schema()) + defaults = mgr.generate_default_config("test-plugin") + user_config = {"enabled": True, "customization": { + "score_text": {"font_size": 14}}} + merged_once = mgr.merge_with_defaults(user_config, defaults) + merged_twice = mgr.merge_with_defaults(merged_once, defaults) + assert merged_once == merged_twice + assert merged_once["customization"]["score_text"]["font_size"] == 14 + + +class TestResolverParity: + def test_defaults_from_schema_file_matches_manager_view(self, tmp_path): + """Plugins build resolvers from their RAW schema file; the web UI + merges defaults from the EXPANDED schema. Both must produce the + same defaults or override detection diverges between contexts.""" + schema = _declared_schema() + plugin_dir = tmp_path / "test-plugin" + plugin_dir.mkdir() + schema_path = plugin_dir / "config_schema.json" + schema_path.write_text(json.dumps(schema)) + + mgr = SchemaManager(plugins_dir=tmp_path) + manager_defaults = mgr.extract_defaults_from_schema( + mgr.load_schema("test-plugin")) + raw_file_defaults = defaults_from_schema_file(str(schema_path)) + assert raw_file_defaults == manager_defaults + + def test_resolver_treats_generated_defaults_as_untouched(self, tmp_path): + """End to end: a config saved through the web UI (all generated + defaults baked in) must not read as a user override, and the + schema-default color must not clobber a classic color.""" + from src.element_style import ElementStyleResolver + schema_path = tmp_path / "config_schema.json" + schema_path.write_text(json.dumps(_declared_schema())) + defaults = defaults_from_schema_file(str(schema_path)) + + saved_config = {"enabled": True, "customization": { + "score_text": {"font": PRESS_START, "font_size": 10, + "text_color": [255, 255, 255]}, + "layout": {"score_text": {"x_offset": 0, "y_offset": 0}}, + }} + resolver = ElementStyleResolver(saved_config, defaults) + style = resolver.style("score_text", classic_font=PRESS_START, + classic_size=10, classic_color=(255, 215, 0)) + assert not style.user_forced + assert not style.user_forced_color + assert style.color == (255, 215, 0) # semantic classic color survives + assert style.offset == (0, 0) + + +if __name__ == "__main__": + sys.exit(pytest.main([__file__, "-v"])) diff --git a/test/web_interface/test_plugin_preview.py b/test/web_interface/test_plugin_preview.py new file mode 100644 index 000000000..32daa8533 --- /dev/null +++ b/test/web_interface/test_plugin_preview.py @@ -0,0 +1,189 @@ +"""Tests for POST /api/v3/plugins/preview — the config-page live preview. + +The endpoint renders a plugin headlessly (pure PIL, no hardware, no pip) +with a CANDIDATE config: either the current form state (parsed by the same +parse_plugin_config_form used by save, so preview and save can never +disagree) or a JSON config body. +""" + +import base64 +import io +import json +import sys +from pathlib import Path +from unittest.mock import MagicMock + +import pytest +from flask import Flask +from PIL import Image + +project_root = Path(__file__).parent.parent.parent +sys.path.insert(0, str(project_root)) + +from web_interface.blueprints import api_v3 as api_v3_module # noqa: E402 +from web_interface.blueprints.api_v3 import api_v3 # noqa: E402 + +PLUGIN_ID = "preview-test-plugin" + +MANAGER_PY = ''' +from PIL import ImageFont +from src.plugin_system.base_plugin import BasePlugin + + +class PreviewTestPlugin(BasePlugin): + def update(self): + return True + + def display(self, force_clear=False): + if force_clear: + self.display_manager.clear() + text = self.config.get("message", "hello") + self.display_manager.draw.text((1, 1), text, fill=(255, 255, 255)) + self.display_manager.update_display() +''' + +MANIFEST = { + "id": PLUGIN_ID, + "name": "Preview Test Plugin", + "version": "1.0.0", + "class_name": "PreviewTestPlugin", + "entry_point": "manager.py", + "display_modes": ["preview_test"], +} + +SCHEMA = { + "type": "object", + "properties": { + "enabled": {"type": "boolean", "default": True}, + "message": {"type": "string", "default": "hello"}, + }, +} + + +@pytest.fixture +def plugin_dir(tmp_path): + plugin = tmp_path / PLUGIN_ID + plugin.mkdir() + (plugin / "manager.py").write_text(MANAGER_PY) + (plugin / "manifest.json").write_text(json.dumps(MANIFEST)) + (plugin / "config_schema.json").write_text(json.dumps(SCHEMA)) + return plugin + + +@pytest.fixture +def client(plugin_dir, tmp_path): + from src.plugin_system.schema_manager import SchemaManager + + test_app = Flask(__name__) + test_app.register_blueprint(api_v3, url_prefix="/api/v3") + + config_manager = MagicMock() + config_manager.load_config.return_value = { + "display": {"hardware": {"cols": 64, "chain_length": 2, + "rows": 32, "parallel": 1}}, + PLUGIN_ID: {"enabled": False, "message": "saved"}, + } + + plugin_manager = MagicMock() + plugin_manager.plugins_dir = str(tmp_path) + + old = (getattr(api_v3_module.api_v3, "config_manager", None), + getattr(api_v3_module.api_v3, "plugin_manager", None), + getattr(api_v3_module.api_v3, "schema_manager", None)) + api_v3_module.api_v3.config_manager = config_manager + api_v3_module.api_v3.plugin_manager = plugin_manager + api_v3_module.api_v3.schema_manager = SchemaManager(plugins_dir=tmp_path) + + with test_app.test_client() as c: + yield c + + (api_v3_module.api_v3.config_manager, + api_v3_module.api_v3.plugin_manager, + api_v3_module.api_v3.schema_manager) = old + + +def _decode_image(data_url): + assert data_url.startswith("data:image/png;base64,") + raw = base64.b64decode(data_url.split(",", 1)[1]) + return Image.open(io.BytesIO(raw)) + + +class TestPreviewEndpoint: + def test_json_body_renders_at_default_panel_size(self, client): + """No width/height -> the user's real panel (64*2 x 32*1).""" + resp = client.post(f"/api/v3/plugins/preview?plugin_id={PLUGIN_ID}", + json={"config": {"message": "hi"}}) + assert resp.status_code == 200 + data = resp.get_json()["data"] + img = _decode_image(data["image"]) + assert img.size == (128, 32) + assert data["errors"] == [] + + def test_explicit_size(self, client): + resp = client.post( + f"/api/v3/plugins/preview?plugin_id={PLUGIN_ID}&width=64&height=64", + json={"config": {}}) + assert resp.status_code == 200 + img = _decode_image(resp.get_json()["data"]["image"]) + assert img.size == (64, 64) + + def test_form_encoding_matches_json(self, client): + """The form path (what HTMX posts) and the JSON path must render + the same candidate config identically.""" + via_json = client.post( + f"/api/v3/plugins/preview?plugin_id={PLUGIN_ID}&width=128&height=32", + json={"config": {"message": "same"}}) + via_form = client.post( + f"/api/v3/plugins/preview?plugin_id={PLUGIN_ID}&width=128&height=32", + data={"message": "same"}) + a = _decode_image(via_json.get_json()["data"]["image"]) + b = _decode_image(via_form.get_json()["data"]["image"]) + assert list(a.getdata()) == list(b.getdata()) + + def test_candidate_config_wins_over_saved(self, client): + """The preview must show the UNSAVED form state, not the saved + config ('saved' vs 'candidate' render differently).""" + saved = client.post( + f"/api/v3/plugins/preview?plugin_id={PLUGIN_ID}&width=128&height=32", + json={"config": {}}) + candidate = client.post( + f"/api/v3/plugins/preview?plugin_id={PLUGIN_ID}&width=128&height=32", + json={"config": {"message": "candidate"}}) + a = _decode_image(saved.get_json()["data"]["image"]) + b = _decode_image(candidate.get_json()["data"]["image"]) + assert list(a.getdata()) != list(b.getdata()) + + def test_disabled_plugin_still_previews(self, client): + """Saved config has enabled: False — preview forces enabled.""" + resp = client.post(f"/api/v3/plugins/preview?plugin_id={PLUGIN_ID}", + json={"config": {}}) + assert resp.status_code == 200 + assert resp.get_json()["data"]["errors"] == [] + + def test_htmx_gets_html_fragment(self, client): + resp = client.post( + f"/api/v3/plugins/preview?plugin_id={PLUGIN_ID}&width=64&height=32", + data={"message": "hi"}, headers={"HX-Request": "true"}) + assert resp.status_code == 200 + assert resp.mimetype == "text/html" + body = resp.get_data(as_text=True) + assert " {filtered_values}") + # Remove from form_data to avoid double processing + if base_path in form_data: + del form_data[base_path] + + # Second pass: detect and combine array index fields (e.g., "text_color.0", "text_color.1" -> "text_color" as array) + # This handles cases where forms send array fields as indexed inputs + array_fields = {} # Maps base field path to list of (index, value) tuples + processed_keys = set() + indexed_base_paths = set() # Track which base paths have indexed fields + + for key, value in form_data.items(): + # Check if this looks like an array index field (ends with .0, .1, .2, etc.) + if '.' in key: + parts = key.rsplit('.', 1) # Split on last dot + if len(parts) == 2: + base_path, last_part = parts + # Check if last part is a numeric string (array index) + if last_part.isdigit(): + # Get schema property for the base path to verify it's an array + base_prop = _get_schema_property(schema, base_path) + if base_prop and base_prop.get('type') == 'array': + # This is an array index field + index = int(last_part) + if base_path not in array_fields: + array_fields[base_path] = [] + array_fields[base_path].append((index, value)) + processed_keys.add(key) + indexed_base_paths.add(base_path) + continue + + # Process combined array fields + for base_path, index_values in array_fields.items(): + # Sort by index and extract values + index_values.sort(key=lambda x: x[0]) + values = [v for _, v in index_values] + # Combine values into comma-separated string for parsing + combined_value = ', '.join(str(v) for v in values) + # Parse as array using schema + parsed_value = _parse_form_value_with_schema(combined_value, base_path, schema) + # Debug logging + logger.debug(f"Combined indexed array field {base_path}: {values} -> {combined_value} -> {parsed_value}") + # Only set if not skipped + if parsed_value is not _SKIP_FIELD: + _set_nested_value(plugin_config, base_path, parsed_value) + + # Process remaining (non-indexed) fields + # Skip any base paths that were processed as indexed arrays + for key, value in form_data.items(): + if key not in processed_keys: + # Skip if this key is a base path that was processed as indexed array + # (to avoid overwriting the combined array with a single value) + if key not in indexed_base_paths: + # Parse value using schema to determine correct type + parsed_value = _parse_form_value_with_schema(value, key, schema) + # Debug logging for array fields + if schema: + prop = _get_schema_property(schema, key) + if prop and prop.get('type') == 'array': + logger.debug(f"Array field {key}: form value='{value}' -> parsed={parsed_value}") + # Use helper to set nested values correctly (skips if _SKIP_FIELD) + if parsed_value is not _SKIP_FIELD: + _set_nested_value(plugin_config, key, parsed_value) + + # Post-process: Fix array fields that might have been incorrectly structured + # This handles cases where array fields are stored as dicts (e.g., from indexed form fields) + def fix_array_structures(config_dict, schema_props, prefix=''): + """Recursively fix array structures (convert dicts with numeric keys to arrays, fix length issues)""" + for prop_key, prop_schema in schema_props.items(): + prop_type = prop_schema.get('type') + + if prop_type == 'array': + # Navigate to the field location + if prefix: + parent_parts = prefix.split('.') + parent = config_dict + for part in parent_parts: + if isinstance(parent, dict) and part in parent: + parent = parent[part] + else: + parent = None + break + + if parent is not None and isinstance(parent, dict) and prop_key in parent: + current_value = parent[prop_key] + # If it's a dict with numeric string keys, convert to array + if isinstance(current_value, dict) and not isinstance(current_value, list): + try: + # Check if all keys are numeric strings (array indices) + keys = [k for k in current_value.keys()] + if all(k.isdigit() for k in keys): + # Convert to sorted array by index + sorted_keys = sorted(keys, key=int) + array_value = [current_value[k] for k in sorted_keys] + # Convert array elements to correct types based on schema + items_schema = prop_schema.get('items', {}) + item_type = items_schema.get('type') + if item_type in ('number', 'integer'): + converted_array = [] + for v in array_value: + if isinstance(v, str): + try: + if item_type == 'integer': + converted_array.append(int(v)) + else: + converted_array.append(float(v)) + except (ValueError, TypeError): + converted_array.append(v) + else: + converted_array.append(v) + array_value = converted_array + parent[prop_key] = array_value + current_value = array_value # Update for length check below + except (ValueError, KeyError, TypeError): + # Conversion failed, check if we should use default + pass + + # If it's an array, ensure correct types and check minItems + if isinstance(current_value, list): + # First, ensure array elements are correct types + items_schema = prop_schema.get('items', {}) + item_type = items_schema.get('type') + if item_type in ('number', 'integer'): + converted_array = [] + for v in current_value: + if isinstance(v, str): + try: + if item_type == 'integer': + converted_array.append(int(v)) + else: + converted_array.append(float(v)) + except (ValueError, TypeError): + converted_array.append(v) + else: + converted_array.append(v) + parent[prop_key] = converted_array + current_value = converted_array + + # Then check minItems + min_items = prop_schema.get('minItems') + if min_items is not None and len(current_value) < min_items: + # Use default if available, otherwise keep as-is (validation will catch it) + default = prop_schema.get('default') + if default and isinstance(default, list) and len(default) >= min_items: + parent[prop_key] = default + else: + # Top-level field + if prop_key in config_dict: + current_value = config_dict[prop_key] + # If it's a dict with numeric string keys, convert to array + if isinstance(current_value, dict) and not isinstance(current_value, list): + try: + keys = list(current_value.keys()) + if keys and all(str(k).isdigit() for k in keys): + sorted_keys = sorted(keys, key=lambda x: int(str(x))) + array_value = [current_value[k] for k in sorted_keys] + # Convert array elements to correct types based on schema + items_schema = prop_schema.get('items', {}) + item_type = items_schema.get('type') + if item_type in ('number', 'integer'): + converted_array = [] + for v in array_value: + if isinstance(v, str): + try: + if item_type == 'integer': + converted_array.append(int(v)) + else: + converted_array.append(float(v)) + except (ValueError, TypeError): + converted_array.append(v) + else: + converted_array.append(v) + array_value = converted_array + config_dict[prop_key] = array_value + current_value = array_value # Update for length check below + except (ValueError, KeyError, TypeError) as e: + logger.debug(f"Failed to convert {prop_key} to array: {e}") + + # If it's an array, ensure correct types and check minItems + if isinstance(current_value, list): + # First, ensure array elements are correct types + items_schema = prop_schema.get('items', {}) + item_type = items_schema.get('type') + if item_type in ('number', 'integer'): + converted_array = [] + for v in current_value: + if isinstance(v, str): + try: + if item_type == 'integer': + converted_array.append(int(v)) + else: + converted_array.append(float(v)) + except (ValueError, TypeError): + converted_array.append(v) + else: + converted_array.append(v) + config_dict[prop_key] = converted_array + current_value = converted_array + + # Then check minItems + min_items = prop_schema.get('minItems') + if min_items is not None and len(current_value) < min_items: + default = prop_schema.get('default') + if default and isinstance(default, list) and len(default) >= min_items: + config_dict[prop_key] = default + + # Recurse into nested objects + elif prop_type == 'object' and 'properties' in prop_schema: + nested_prefix = f"{prefix}.{prop_key}" if prefix else prop_key + if prefix: + parent_parts = prefix.split('.') + parent = config_dict + for part in parent_parts: + if isinstance(parent, dict) and part in parent: + parent = parent[part] + else: + parent = None + break + nested_dict = parent.get(prop_key) if parent is not None and isinstance(parent, dict) else None + else: + nested_dict = config_dict.get(prop_key) + + if isinstance(nested_dict, dict): + # Pass no prefix: config_dict is already the navigated sub-dict, + # so path segments from the parent would mis-navigate it. + fix_array_structures(nested_dict, prop_schema['properties']) + + # Also ensure array fields that are None get converted to empty arrays + def ensure_array_defaults(config_dict, schema_props, prefix=''): + """Recursively ensure array fields have defaults if None""" + for prop_key, prop_schema in schema_props.items(): + prop_type = prop_schema.get('type') + + if prop_type == 'array': + if prefix: + parent_parts = prefix.split('.') + parent = config_dict + for part in parent_parts: + if isinstance(parent, dict) and part in parent: + parent = parent[part] + else: + parent = None + break + + if parent is not None and isinstance(parent, dict): + if prop_key not in parent or parent[prop_key] is None: + default = prop_schema.get('default', []) + parent[prop_key] = default if default else [] + else: + if prop_key not in config_dict or config_dict[prop_key] is None: + default = prop_schema.get('default', []) + config_dict[prop_key] = default if default else [] + + elif prop_type == 'object' and 'properties' in prop_schema: + nested_prefix = f"{prefix}.{prop_key}" if prefix else prop_key + if prefix: + parent_parts = prefix.split('.') + parent = config_dict + for part in parent_parts: + if isinstance(parent, dict) and part in parent: + parent = parent[part] + else: + parent = None + break + nested_dict = parent.get(prop_key) if parent is not None and isinstance(parent, dict) else None + else: + nested_dict = config_dict.get(prop_key) + + if nested_dict is None: + if prefix: + parent_parts = prefix.split('.') + parent = config_dict + for part in parent_parts: + if part not in parent: + parent[part] = {} + parent = parent[part] + if prop_key not in parent: + parent[prop_key] = {} + nested_dict = parent[prop_key] + else: + if prop_key not in config_dict: + config_dict[prop_key] = {} + nested_dict = config_dict[prop_key] + + if isinstance(nested_dict, dict): + # Pass no prefix: config_dict is already navigated. + ensure_array_defaults(nested_dict, prop_schema['properties']) + + if schema and 'properties' in schema: + # First, fix any dict structures that should be arrays + # This must be called BEFORE validation to convert dicts with numeric keys to arrays + fix_array_structures(plugin_config, schema['properties']) + # Then, ensure None arrays get defaults + ensure_array_defaults(plugin_config, schema['properties']) + + # Debug: Log the structure after fixing + if 'feeds' in plugin_config and 'custom_feeds' in plugin_config.get('feeds', {}): + custom_feeds = plugin_config['feeds']['custom_feeds'] + logger.debug(f"After fix_array_structures: custom_feeds type={type(custom_feeds)}, value={custom_feeds}") + + # Force fix for feeds.custom_feeds if it's still a dict (fallback) + if 'feeds' in plugin_config: + feeds_config = plugin_config.get('feeds') or {} + if feeds_config and 'custom_feeds' in feeds_config and isinstance(feeds_config['custom_feeds'], dict): + custom_feeds_dict = feeds_config['custom_feeds'] + # Check if all keys are numeric + keys = list(custom_feeds_dict.keys()) + if keys and all(str(k).isdigit() for k in keys): + # Convert to array + sorted_keys = sorted(keys, key=lambda x: int(str(x))) + feeds_config['custom_feeds'] = [custom_feeds_dict[k] for k in sorted_keys] + logger.info(f"Force-converted feeds.custom_feeds from dict to array: {len(feeds_config['custom_feeds'])} items") + + # Fix unchecked boolean checkboxes: HTML checkboxes don't submit values + # when unchecked, so the existing config value (potentially True) persists. + # Walk the schema and set any boolean fields missing from form data to False. + if schema and 'properties' in schema: + form_keys = set(form.keys()) + _set_missing_booleans_to_false(plugin_config, schema['properties'], form_keys) + return plugin_config + + @api_v3.route('/plugins/config', methods=['POST']) def save_plugin_config(): """Save plugin configuration, separating secrets from regular config""" @@ -4335,359 +4702,9 @@ def save_plugin_config(): # Start with existing config and apply form updates plugin_config = existing_config - # Convert form data to config dict - # Form fields can use dot notation for nested values (e.g., "transition.type") - form_data = request.form.to_dict() - - # First pass: handle bracket notation array fields (e.g., "field_name[]" from checkbox-group) - # These fields use getlist() to preserve all values, then replace in form_data - # Sentinel empty value ("") allows clearing array to [] when all checkboxes unchecked - bracket_array_fields = {} # Maps base field path to list of values - for key in request.form.keys(): - # Check if key ends with "[]" (bracket notation for array fields) - if key.endswith('[]'): - base_path = key[:-2] # Remove "[]" suffix - values = request.form.getlist(key) - # Filter out sentinel empty string - if only sentinel present, array should be [] - # If sentinel + values present, use the actual values - filtered_values = [v for v in values if v and v.strip()] - # If no non-empty values but key exists, it means all checkboxes unchecked (empty array) - bracket_array_fields[base_path] = filtered_values - # Remove the bracket notation key from form_data if present - if key in form_data: - del form_data[key] - - # Process bracket notation fields and set directly in plugin_config - # Use JSON encoding instead of comma-join to handle values containing commas - import json - for base_path, values in bracket_array_fields.items(): - # Get schema property to verify it's an array - base_prop = _get_schema_property(schema, base_path) - if base_prop and base_prop.get('type') == 'array': - # Filter out empty values and sentinel empty strings - filtered_values = [v for v in values if v and v.strip()] - # Set directly in plugin_config (values are already strings, no need to parse) - # Empty array (all unchecked) is represented as [] - _set_nested_value(plugin_config, base_path, filtered_values) - logger.debug(f"Processed bracket notation array field {base_path}: {values} -> {filtered_values}") - # Remove from form_data to avoid double processing - if base_path in form_data: - del form_data[base_path] - - # Second pass: detect and combine array index fields (e.g., "text_color.0", "text_color.1" -> "text_color" as array) - # This handles cases where forms send array fields as indexed inputs - array_fields = {} # Maps base field path to list of (index, value) tuples - processed_keys = set() - indexed_base_paths = set() # Track which base paths have indexed fields - - for key, value in form_data.items(): - # Check if this looks like an array index field (ends with .0, .1, .2, etc.) - if '.' in key: - parts = key.rsplit('.', 1) # Split on last dot - if len(parts) == 2: - base_path, last_part = parts - # Check if last part is a numeric string (array index) - if last_part.isdigit(): - # Get schema property for the base path to verify it's an array - base_prop = _get_schema_property(schema, base_path) - if base_prop and base_prop.get('type') == 'array': - # This is an array index field - index = int(last_part) - if base_path not in array_fields: - array_fields[base_path] = [] - array_fields[base_path].append((index, value)) - processed_keys.add(key) - indexed_base_paths.add(base_path) - continue - - # Process combined array fields - for base_path, index_values in array_fields.items(): - # Sort by index and extract values - index_values.sort(key=lambda x: x[0]) - values = [v for _, v in index_values] - # Combine values into comma-separated string for parsing - combined_value = ', '.join(str(v) for v in values) - # Parse as array using schema - parsed_value = _parse_form_value_with_schema(combined_value, base_path, schema) - # Debug logging - logger.debug(f"Combined indexed array field {base_path}: {values} -> {combined_value} -> {parsed_value}") - # Only set if not skipped - if parsed_value is not _SKIP_FIELD: - _set_nested_value(plugin_config, base_path, parsed_value) - - # Process remaining (non-indexed) fields - # Skip any base paths that were processed as indexed arrays - for key, value in form_data.items(): - if key not in processed_keys: - # Skip if this key is a base path that was processed as indexed array - # (to avoid overwriting the combined array with a single value) - if key not in indexed_base_paths: - # Parse value using schema to determine correct type - parsed_value = _parse_form_value_with_schema(value, key, schema) - # Debug logging for array fields - if schema: - prop = _get_schema_property(schema, key) - if prop and prop.get('type') == 'array': - logger.debug(f"Array field {key}: form value='{value}' -> parsed={parsed_value}") - # Use helper to set nested values correctly (skips if _SKIP_FIELD) - if parsed_value is not _SKIP_FIELD: - _set_nested_value(plugin_config, key, parsed_value) - - # Post-process: Fix array fields that might have been incorrectly structured - # This handles cases where array fields are stored as dicts (e.g., from indexed form fields) - def fix_array_structures(config_dict, schema_props, prefix=''): - """Recursively fix array structures (convert dicts with numeric keys to arrays, fix length issues)""" - for prop_key, prop_schema in schema_props.items(): - prop_type = prop_schema.get('type') - - if prop_type == 'array': - # Navigate to the field location - if prefix: - parent_parts = prefix.split('.') - parent = config_dict - for part in parent_parts: - if isinstance(parent, dict) and part in parent: - parent = parent[part] - else: - parent = None - break - - if parent is not None and isinstance(parent, dict) and prop_key in parent: - current_value = parent[prop_key] - # If it's a dict with numeric string keys, convert to array - if isinstance(current_value, dict) and not isinstance(current_value, list): - try: - # Check if all keys are numeric strings (array indices) - keys = [k for k in current_value.keys()] - if all(k.isdigit() for k in keys): - # Convert to sorted array by index - sorted_keys = sorted(keys, key=int) - array_value = [current_value[k] for k in sorted_keys] - # Convert array elements to correct types based on schema - items_schema = prop_schema.get('items', {}) - item_type = items_schema.get('type') - if item_type in ('number', 'integer'): - converted_array = [] - for v in array_value: - if isinstance(v, str): - try: - if item_type == 'integer': - converted_array.append(int(v)) - else: - converted_array.append(float(v)) - except (ValueError, TypeError): - converted_array.append(v) - else: - converted_array.append(v) - array_value = converted_array - parent[prop_key] = array_value - current_value = array_value # Update for length check below - except (ValueError, KeyError, TypeError): - # Conversion failed, check if we should use default - pass - - # If it's an array, ensure correct types and check minItems - if isinstance(current_value, list): - # First, ensure array elements are correct types - items_schema = prop_schema.get('items', {}) - item_type = items_schema.get('type') - if item_type in ('number', 'integer'): - converted_array = [] - for v in current_value: - if isinstance(v, str): - try: - if item_type == 'integer': - converted_array.append(int(v)) - else: - converted_array.append(float(v)) - except (ValueError, TypeError): - converted_array.append(v) - else: - converted_array.append(v) - parent[prop_key] = converted_array - current_value = converted_array - - # Then check minItems - min_items = prop_schema.get('minItems') - if min_items is not None and len(current_value) < min_items: - # Use default if available, otherwise keep as-is (validation will catch it) - default = prop_schema.get('default') - if default and isinstance(default, list) and len(default) >= min_items: - parent[prop_key] = default - else: - # Top-level field - if prop_key in config_dict: - current_value = config_dict[prop_key] - # If it's a dict with numeric string keys, convert to array - if isinstance(current_value, dict) and not isinstance(current_value, list): - try: - keys = list(current_value.keys()) - if keys and all(str(k).isdigit() for k in keys): - sorted_keys = sorted(keys, key=lambda x: int(str(x))) - array_value = [current_value[k] for k in sorted_keys] - # Convert array elements to correct types based on schema - items_schema = prop_schema.get('items', {}) - item_type = items_schema.get('type') - if item_type in ('number', 'integer'): - converted_array = [] - for v in array_value: - if isinstance(v, str): - try: - if item_type == 'integer': - converted_array.append(int(v)) - else: - converted_array.append(float(v)) - except (ValueError, TypeError): - converted_array.append(v) - else: - converted_array.append(v) - array_value = converted_array - config_dict[prop_key] = array_value - current_value = array_value # Update for length check below - except (ValueError, KeyError, TypeError) as e: - logger.debug(f"Failed to convert {prop_key} to array: {e}") - - # If it's an array, ensure correct types and check minItems - if isinstance(current_value, list): - # First, ensure array elements are correct types - items_schema = prop_schema.get('items', {}) - item_type = items_schema.get('type') - if item_type in ('number', 'integer'): - converted_array = [] - for v in current_value: - if isinstance(v, str): - try: - if item_type == 'integer': - converted_array.append(int(v)) - else: - converted_array.append(float(v)) - except (ValueError, TypeError): - converted_array.append(v) - else: - converted_array.append(v) - config_dict[prop_key] = converted_array - current_value = converted_array - - # Then check minItems - min_items = prop_schema.get('minItems') - if min_items is not None and len(current_value) < min_items: - default = prop_schema.get('default') - if default and isinstance(default, list) and len(default) >= min_items: - config_dict[prop_key] = default - - # Recurse into nested objects - elif prop_type == 'object' and 'properties' in prop_schema: - nested_prefix = f"{prefix}.{prop_key}" if prefix else prop_key - if prefix: - parent_parts = prefix.split('.') - parent = config_dict - for part in parent_parts: - if isinstance(parent, dict) and part in parent: - parent = parent[part] - else: - parent = None - break - nested_dict = parent.get(prop_key) if parent is not None and isinstance(parent, dict) else None - else: - nested_dict = config_dict.get(prop_key) - - if isinstance(nested_dict, dict): - # Pass no prefix: config_dict is already the navigated sub-dict, - # so path segments from the parent would mis-navigate it. - fix_array_structures(nested_dict, prop_schema['properties']) - - # Also ensure array fields that are None get converted to empty arrays - def ensure_array_defaults(config_dict, schema_props, prefix=''): - """Recursively ensure array fields have defaults if None""" - for prop_key, prop_schema in schema_props.items(): - prop_type = prop_schema.get('type') - - if prop_type == 'array': - if prefix: - parent_parts = prefix.split('.') - parent = config_dict - for part in parent_parts: - if isinstance(parent, dict) and part in parent: - parent = parent[part] - else: - parent = None - break - - if parent is not None and isinstance(parent, dict): - if prop_key not in parent or parent[prop_key] is None: - default = prop_schema.get('default', []) - parent[prop_key] = default if default else [] - else: - if prop_key not in config_dict or config_dict[prop_key] is None: - default = prop_schema.get('default', []) - config_dict[prop_key] = default if default else [] - - elif prop_type == 'object' and 'properties' in prop_schema: - nested_prefix = f"{prefix}.{prop_key}" if prefix else prop_key - if prefix: - parent_parts = prefix.split('.') - parent = config_dict - for part in parent_parts: - if isinstance(parent, dict) and part in parent: - parent = parent[part] - else: - parent = None - break - nested_dict = parent.get(prop_key) if parent is not None and isinstance(parent, dict) else None - else: - nested_dict = config_dict.get(prop_key) - - if nested_dict is None: - if prefix: - parent_parts = prefix.split('.') - parent = config_dict - for part in parent_parts: - if part not in parent: - parent[part] = {} - parent = parent[part] - if prop_key not in parent: - parent[prop_key] = {} - nested_dict = parent[prop_key] - else: - if prop_key not in config_dict: - config_dict[prop_key] = {} - nested_dict = config_dict[prop_key] - - if isinstance(nested_dict, dict): - # Pass no prefix: config_dict is already navigated. - ensure_array_defaults(nested_dict, prop_schema['properties']) - - if schema and 'properties' in schema: - # First, fix any dict structures that should be arrays - # This must be called BEFORE validation to convert dicts with numeric keys to arrays - fix_array_structures(plugin_config, schema['properties']) - # Then, ensure None arrays get defaults - ensure_array_defaults(plugin_config, schema['properties']) - - # Debug: Log the structure after fixing - if 'feeds' in plugin_config and 'custom_feeds' in plugin_config.get('feeds', {}): - custom_feeds = plugin_config['feeds']['custom_feeds'] - logger.debug(f"After fix_array_structures: custom_feeds type={type(custom_feeds)}, value={custom_feeds}") - - # Force fix for feeds.custom_feeds if it's still a dict (fallback) - if 'feeds' in plugin_config: - feeds_config = plugin_config.get('feeds') or {} - if feeds_config and 'custom_feeds' in feeds_config and isinstance(feeds_config['custom_feeds'], dict): - custom_feeds_dict = feeds_config['custom_feeds'] - # Check if all keys are numeric - keys = list(custom_feeds_dict.keys()) - if keys and all(str(k).isdigit() for k in keys): - # Convert to array - sorted_keys = sorted(keys, key=lambda x: int(str(x))) - feeds_config['custom_feeds'] = [custom_feeds_dict[k] for k in sorted_keys] - logger.info(f"Force-converted feeds.custom_feeds from dict to array: {len(feeds_config['custom_feeds'])} items") - - # Fix unchecked boolean checkboxes: HTML checkboxes don't submit values - # when unchecked, so the existing config value (potentially True) persists. - # Walk the schema and set any boolean fields missing from form data to False. - if schema and 'properties' in schema: - form_keys = set(request.form.keys()) - _set_missing_booleans_to_false(plugin_config, schema['properties'], form_keys) + # Convert form data to config dict (shared with the preview + # endpoint — see parse_plugin_config_form) + plugin_config = parse_plugin_config_form(request.form, schema, plugin_config) # Get schema manager instance (for JSON requests) schema_mgr = api_v3.schema_manager @@ -5289,6 +5306,181 @@ def get_plugin_schema(): logger.error('Error in get_plugin_schema', exc_info=True) return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500 + +def _find_plugin_dir_for_preview(plugin_id: str) -> 'Path | None': + """Locate an installed plugin's directory (store dir, then dev dirs) — + same search order the schema manager uses.""" + candidates = [] + active_pm = getattr(api_v3, 'plugin_manager', None) + if active_pm and getattr(active_pm, 'plugins_dir', None): + candidates.append(Path(active_pm.plugins_dir)) + else: + _cm = getattr(api_v3, 'config_manager', None) + _cfg = _cm.load_config() if _cm else {} + _dir_name = _cfg.get('plugin_system', {}).get('plugins_directory', 'plugin-repos') + candidates.append(Path(_dir_name) if os.path.isabs(_dir_name) + else PROJECT_ROOT / _dir_name) + candidates.append(PROJECT_ROOT / 'plugins') + candidates.append(PROJECT_ROOT / 'plugin-repos') + for base in candidates: + plugin_dir = base / plugin_id + if (plugin_dir / 'manifest.json').exists(): + return plugin_dir + return None + + +PREVIEW_MIN_SIZE, PREVIEW_MAX_W, PREVIEW_MAX_H = 8, 1024, 512 + + +@api_v3.route('/plugins/preview', methods=['POST']) +def preview_plugin_render(): + """Render a plugin headlessly with a CANDIDATE (unsaved) config. + + Powers the config page's live preview: the browser posts the current + form state (same encoding as save — parsed by the same + parse_plugin_config_form, so preview and save can never disagree) or a + JSON body {"config": {...}}, and gets back a base64 PNG of what the + panel would show. + + Entirely hardware-free: renders through VisualTestDisplayManager (pure + PIL) with install_deps=False. update() is skipped by default so the + request never blocks on live APIs — plugins with a test/harness.json + get their mock-data fixture primed into the cache instead, and + ?skip_update=0 opts into a real update() for plugins that need it. + + Query params: plugin_id (required); width/height (defaults: the real + panel size from display.hardware); skip_update (default 1). + """ + try: + plugin_id = request.args.get('plugin_id') + if not plugin_id: + return error_response(ErrorCode.INVALID_INPUT, + 'plugin_id required in query string', + status_code=400) + + plugin_dir = _find_plugin_dir_for_preview(plugin_id) + if not plugin_dir: + return error_response(ErrorCode.PLUGIN_NOT_FOUND, + f'Plugin not found: {plugin_id}', + status_code=404) + + schema_mgr = api_v3.schema_manager + if not schema_mgr: + return error_response(ErrorCode.SYSTEM_ERROR, + 'Schema manager not initialized', + status_code=500) + + # ---- panel size: explicit query args, else the real panel ---- + main_config = {} + if api_v3.config_manager: + try: + main_config = api_v3.config_manager.load_config() or {} + except Exception: + main_config = {} + hardware = main_config.get('display', {}).get('hardware', {}) + default_width = int(hardware.get('cols', 64)) * int(hardware.get('chain_length', 2)) + default_height = int(hardware.get('rows', 32)) * int(hardware.get('parallel', 1)) + # The UI's size selector posts "__preview_size=WxH" via the button's + # hx-vals (evaluated at request time — htmx caches hx-post's path at + # process time, so a dynamically updated query string doesn't work). + # Explicit query args still take precedence for API callers. + preview_size = request.values.get('__preview_size', '') + if preview_size and 'x' in preview_size and 'width' not in request.args: + size_w, _, size_h = preview_size.partition('x') + try: + default_width, default_height = int(size_w), int(size_h) + except (TypeError, ValueError): + pass # malformed selector value — fall back to panel size + try: + width = int(request.args.get('width', default_width)) + height = int(request.args.get('height', default_height)) + except (TypeError, ValueError): + return error_response(ErrorCode.INVALID_INPUT, + 'width and height must be integers', + status_code=400) + if not (PREVIEW_MIN_SIZE <= width <= PREVIEW_MAX_W + and PREVIEW_MIN_SIZE <= height <= PREVIEW_MAX_H): + return error_response( + ErrorCode.INVALID_INPUT, + f'size must be within {PREVIEW_MIN_SIZE}x{PREVIEW_MIN_SIZE} ' + f'and {PREVIEW_MAX_W}x{PREVIEW_MAX_H}', + status_code=400) + + # ---- candidate config: saved config + submitted changes + defaults ---- + schema = schema_mgr.load_schema(plugin_id, use_cache=False) + existing_config = (main_config.get(plugin_id) or {}).copy() + + content_type = request.content_type or '' + if 'application/json' in content_type: + data = request.get_json(silent=True) or {} + plugin_config = existing_config + plugin_config.update(data.get('config', {})) + else: + # Strip the preview-only control field so it never lands in the + # candidate config the plugin sees + form = request.form.copy() + form.poplist('__preview_size') + plugin_config = parse_plugin_config_form(form, schema, + existing_config) + + if schema: + defaults = schema_mgr.generate_default_config(plugin_id, use_cache=True) + plugin_config = schema_mgr.merge_with_defaults(plugin_config, defaults) + # Preview regardless of the enabled toggle + plugin_config['enabled'] = True + + # ---- deterministic data: the plugin's own harness fixture ---- + mock_data = {} + try: + from src.plugin_system.testing.loading import load_harness_spec + spec = load_harness_spec(plugin_dir) + mock_data = spec.get('mock_data_contents', {}) or {} + harness_config = spec.get('config') or {} + if harness_config: + # harness settings under the candidate config: user's + # in-form values always win + merged = dict(harness_config) + merged.update(plugin_config) + plugin_config = merged + except Exception as e: + logger.debug(f'No usable harness spec for {plugin_id}: {e}') + + skip_update = request.args.get('skip_update', '1') not in ('0', 'false') + + from src.plugin_system.testing.render_service import render_plugin_once + result = render_plugin_once( + plugin_id, plugin_dir, config=plugin_config, + mock_data=mock_data, width=width, height=height, + skip_update=skip_update) + + # HTMX callers get a ready-to-swap fragment; API callers get JSON + if request.headers.get('HX-Request'): + import html as _html + meta = f"{result['width']}×{result['height']} · {result['render_time_ms']} ms" + errors_html = '' + if result['errors'] or result['warnings']: + notes = _html.escape('; '.join(result['errors'] + result['warnings'])) + errors_html = (f'

' + f'{notes}

') + return Response( + f'Plugin preview' + f'

{meta}

' + f'{errors_html}', + mimetype='text/html') + return jsonify({'status': 'success', 'data': result}) + except Exception: + logger.error('Error in preview_plugin_render', exc_info=True) + if request.headers.get('HX-Request'): + return Response('

Preview failed — ' + 'see logs for details.

', mimetype='text/html') + return error_response(ErrorCode.SYSTEM_ERROR, + 'Preview failed; see logs for details', + status_code=500) + @api_v3.route('/plugins/config/reset', methods=['POST']) def reset_plugin_config(): """Reset plugin configuration to schema defaults""" diff --git a/web_interface/blueprints/pages_v3.py b/web_interface/blueprints/pages_v3.py index 8c3c1bbc6..c839c5dc9 100644 --- a/web_interface/blueprints/pages_v3.py +++ b/web_interface/blueprints/pages_v3.py @@ -706,6 +706,12 @@ def _load_plugin_config_partial(plugin_id): try: with open(schema_path, 'r', encoding='utf-8') as f: schema = json.load(f) + # Expand x-style-elements declarations into full property + # blocks — the same expansion SchemaManager.load_schema + # applies on the API paths. The form must render the exact + # shape the save path parses and validates against. + from src.element_style import expand_style_elements + schema = expand_style_elements(schema) except Exception as e: logger.warning("Could not load schema for plugin: %s", e) diff --git a/web_interface/templates/v3/partials/plugin_config.html b/web_interface/templates/v3/partials/plugin_config.html index e427918f1..fbb366481 100644 --- a/web_interface/templates/v3/partials/plugin_config.html +++ b/web_interface/templates/v3/partials/plugin_config.html @@ -994,8 +994,51 @@

Plugin Information

Plugin is disabled, but on-demand will temporarily enable it.

{% endif %} + + {# Live Preview — renders the plugin headlessly with the CURRENT + (unsaved) form values, at the real panel size or a chosen one #} +
+
+ + Live Preview +
+
+ {# No name attribute: must not be submitted with the config + save. The size travels via the button's hx-vals (read at + request time — htmx caches hx-post's path at process + time, so mutating the attribute onchange doesn't work). #} + + + + rendering… + +
+
+

Renders this plugin with the current (unsaved) settings — try it before you save.

+
+
- + {# Configuration Form Panel #}

Configuration

From 5f908629ab4d006504004c5086a7e6492d93d485 Mon Sep 17 00:00:00 2001 From: ChuckBuilds Date: Sat, 11 Jul 2026 18:39:02 -0400 Subject: [PATCH 2/4] fix(security): harden path lookup and sanitize preview error messages CodeQL flagged 2 high-severity path-injection alerts and 7 medium stack-trace-exposure alerts introduced by this PR. Path injection (PluginLoader.find_plugin_directory, Strategy 2): replaced the resolve()+relative_to() containment check with find_trusted_subdir() (already used elsewhere in this file since #390) -- a name enumerated directly from plugins_dir via scandir() carries no taint regardless of what the caller's plugin_id string was, which CodeQL's path-injection query recognises; a post-hoc containment check on a path built from the tainted string apparently isn't, matching what #390 found for a similar pattern. Stack trace exposure: render_service.py's update()/display() exception handlers embedded the raw exception message directly into the response returned to the preview endpoint's caller. Showing *something* here is the point of the endpoint (it's the plugin author's own code failing, not a server secret), but the message was otherwise unbounded and could echo back the server's own directory layout (e.g. a FileNotFoundError embeds the full path it tried). Added _safe_exc_message(): collapses absolute paths to their basename and caps length, with the full exception (traceback included) now logged server-side via logger.warning(..., exc_info=True) before the sanitized version reaches the client. Applied the same helper in dev_server.py's two matching load-failure handlers; web_interface/blueprints/api_v3.py's HTML preview fragment (the other 2 flagged locations) consumes the same result['errors']/['warnings'] fields, so fixing the shared source in render_service.py covers it too -- confirmed it's the only other consumer. Verified: existing test/web_interface/test_plugin_preview.py assertions only check for an empty errors list on success, not exact failure-message content, so no test changes needed. Full test/test_plugin_loader.py + test/test_plugin_system.py: 31 passed, 1 pre-existing failure (test_circuit_breaker, unrelated). --- scripts/dev_server.py | 9 ++++-- src/plugin_system/plugin_loader.py | 26 +++++++++------- src/plugin_system/testing/render_service.py | 33 +++++++++++++++++++-- 3 files changed, 53 insertions(+), 15 deletions(-) diff --git a/scripts/dev_server.py b/scripts/dev_server.py index 0f18eb850..1f5150690 100644 --- a/scripts/dev_server.py +++ b/scripts/dev_server.py @@ -239,7 +239,9 @@ def api_render(): result = _render_once(data['plugin_id'], plugin_dir, manifest, config, mock_data, width, height, skip_update) except Exception as e: - return jsonify({'error': f'Failed to load plugin: {e}'}), 500 + logger.warning("Failed to load plugin %s for preview", data['plugin_id'], exc_info=True) + from src.plugin_system.testing.render_service import _safe_exc_message + return jsonify({'error': f'Failed to load plugin: {type(e).__name__}: {_safe_exc_message(e)}'}), 500 return jsonify(result) @@ -280,15 +282,18 @@ def api_render_matrix(): except LookupError as e: return jsonify({'error': str(e)}), 404 + from src.plugin_system.testing.render_service import _safe_exc_message + results = [] for w, h in parsed_sizes: try: results.append(_render_once(data['plugin_id'], plugin_dir, manifest, config, mock_data, w, h, skip_update)) except Exception as e: + logger.warning("Failed to load plugin %s for preview at %dx%d", data['plugin_id'], w, h, exc_info=True) results.append({'image': None, 'width': w, 'height': h, 'render_time_ms': 0, - 'errors': [f'Failed to load plugin: {e}'], + 'errors': [f'Failed to load plugin: {type(e).__name__}: {_safe_exc_message(e)}'], 'warnings': []}) return jsonify({'results': results}) diff --git a/src/plugin_system/plugin_loader.py b/src/plugin_system/plugin_loader.py index e5ac854bb..c785e6c41 100644 --- a/src/plugin_system/plugin_loader.py +++ b/src/plugin_system/plugin_loader.py @@ -159,7 +159,8 @@ def find_plugin_directory( Returns: Path to plugin directory or None if not found """ - # Sanitize plugin_id — os.path.basename is a CodeQL-recognized path sanitizer + # Sanitize plugin_id (strip any directory components before it's + # used to build a path or as a dict key below). plugin_id = os.path.basename(plugin_id or '') if not plugin_id: return None @@ -170,17 +171,20 @@ def find_plugin_directory( if plugin_dir.exists(): self.logger.debug("Using plugin directory from discovery mapping: %s", plugin_dir) return plugin_dir - - # Strategy 2: Direct paths — resolve and validate they stay within plugins_dir - plugins_dir_resolved = plugins_dir.resolve() + + # Strategy 2: Direct paths — match against an entry actually + # enumerated from the trusted plugins_dir, not a path string built + # from plugin_id. find_trusted_subdir()'s return value always comes + # from scandir() on plugins_dir itself, so building a path from it + # is a real containment guarantee regardless of what the caller + # asked for -- unlike checking containment after the fact (e.g. + # resolve() + relative_to()), which some static analyzers don't + # recognize as clearing taint on the constructed path. + plugins_dir_resolved = str(plugins_dir.resolve()) for _candidate_name in (plugin_id, f"ledmatrix-{plugin_id}"): - _candidate = (plugins_dir_resolved / _candidate_name).resolve() - try: - _candidate.relative_to(plugins_dir_resolved) - except ValueError: - continue - if _candidate.exists(): - return _candidate + matched_name = find_trusted_subdir(plugins_dir_resolved, _candidate_name) + if matched_name is not None: + return Path(plugins_dir_resolved) / matched_name # Strategy 3: Case-insensitive search normalized_id = plugin_id.lower() diff --git a/src/plugin_system/testing/render_service.py b/src/plugin_system/testing/render_service.py index 5a1b8c7dd..9539e4833 100644 --- a/src/plugin_system/testing/render_service.py +++ b/src/plugin_system/testing/render_service.py @@ -12,10 +12,37 @@ """ import json +import logging +import re import time from pathlib import Path from typing import Any, Dict, Optional +logger = logging.getLogger(__name__) + +# Matches an absolute filesystem path so it can be collapsed to just its +# basename before an exception message reaches a client -- see +# _safe_exc_message(). +_ABS_PATH_RE = re.compile(r'(?:/[\w.\-]+)+/[\w.\-]+') + + +def _safe_exc_message(exc: Exception, max_len: int = 200) -> str: + """Render an exception's message for a client-facing preview response. + + update()/display() exceptions are the plugin author's own code, so + showing *something* here is the point of this endpoint -- but the + message is otherwise unbounded and could echo back the server's own + directory layout (e.g. a FileNotFoundError embeds the full path it + tried). Collapse absolute paths to their basename and cap the length + so this stays a useful debugging hint without doubling as directory + disclosure. The full exception (with traceback) is still logged + server-side by the caller. + """ + msg = _ABS_PATH_RE.sub(lambda m: Path(m.group(0)).name, str(exc)) + if len(msg) > max_len: + msg = msg[:max_len] + '...' + return msg + def render_plugin_once(plugin_id: str, plugin_dir: Path, manifest: Optional[Dict[str, Any]] = None, @@ -77,12 +104,14 @@ def render_plugin_once(plugin_id: str, plugin_dir: Path, try: plugin_instance.update() except Exception as e: - warnings.append(f"update() raised: {e}") + logger.warning("Plugin %s update() raised during preview render", plugin_id, exc_info=True) + warnings.append(f"update() raised {type(e).__name__}: {_safe_exc_message(e)}") try: plugin_instance.display(force_clear=True) except Exception as e: - errors.append(f"display() raised: {e}") + logger.warning("Plugin %s display() raised during preview render", plugin_id, exc_info=True) + errors.append(f"display() raised {type(e).__name__}: {_safe_exc_message(e)}") render_time_ms = round((time.time() - start_time) * 1000, 1) From 768b7518e80d252c2e24cf58bca3a609b775b0e5 Mon Sep 17 00:00:00 2001 From: ChuckBuilds Date: Sat, 11 Jul 2026 18:58:26 -0400 Subject: [PATCH 3/4] fix(security): remove exception messages and paths from remaining CodeQL sinks Round 2. The previous commit's _safe_exc_message() (regex-redact absolute paths, cap length) didn't clear the stack-trace-exposure alerts -- same lesson as PR #390's permission_utils.py fix: CodeQL doesn't trust a custom transformation function as a sanitiser, regardless of what it actually does. Removed the helper entirely; render_service.py/dev_server.py's update()/display()/load-failure handlers now surface only the exception's class name (a fixed, bounded string, never derived from the exception's own content) to the client, with the full exception (traceback included) still logged server-side via logger.warning(..., exc_info=True). Fixing the path-injection alert in find_plugin_directory also had a side effect: CodeQL's interprocedural analysis now traces further downstream through that changed function, surfacing 5 new clear-text-logging-of- secrets alerts in code that mostly predates this PR (scripts/render_plugin.py has zero other changes in this diff) -- logging plugin_id/file paths that originate from CLI args or discovered plugin directories. Not real secrets, but CodeQL's model doesn't distinguish "externally-influenced string" from "credential" here. Removed the path/plugin_id values from the 5 flagged log lines (check_plugin.py, render_plugin.py, plugin_loader.py x3), keeping a plugin_id-only or fully generic message where one was already available elsewhere in the same log line's context. Verified: full test/test_plugin_loader.py + test/test_plugin_system.py + the adaptive-layout/element-style/preview suites, no test asserts on the specific log/error message text that changed. --- scripts/check_plugin.py | 8 +++-- scripts/dev_server.py | 11 +++--- scripts/render_plugin.py | 2 +- src/plugin_system/plugin_loader.py | 19 ++++++----- src/plugin_system/testing/render_service.py | 37 ++++++--------------- 5 files changed, 35 insertions(+), 42 deletions(-) diff --git a/scripts/check_plugin.py b/scripts/check_plugin.py index 0960e65a2..1ff62934a 100644 --- a/scripts/check_plugin.py +++ b/scripts/check_plugin.py @@ -137,8 +137,12 @@ def check_one(plugin_id: str, search_dirs: List[str], sizes, mock_data: Dict, if update_golden: written = write_goldens(results, golden_dir) - logger.info("Wrote %d golden image(s) for %s%s to %s", written, plugin_id, - f" [{variant_name}]" if variant_name else "", golden_dir) + # plugin_id/golden_dir come from --plugin / the plugin's own + # manifest-relative path, not logged here to avoid a CodeQL + # clear-text-logging flag; `written` (a count) carries the + # useful confirmation on its own. + logger.info("Wrote %d golden image(s)%s", written, + f" [{variant_name}]" if variant_name else "") else: compare_to_goldens(results, golden_dir) diff --git a/scripts/dev_server.py b/scripts/dev_server.py index 1f5150690..40f69c23d 100644 --- a/scripts/dev_server.py +++ b/scripts/dev_server.py @@ -239,9 +239,12 @@ def api_render(): result = _render_once(data['plugin_id'], plugin_dir, manifest, config, mock_data, width, height, skip_update) except Exception as e: + # Full detail (with traceback) goes to the server log; the response + # only ever carries the exception's class name, not its message -- + # see render_service.py's matching update()/display() handling for + # why the message itself isn't safe to echo back. logger.warning("Failed to load plugin %s for preview", data['plugin_id'], exc_info=True) - from src.plugin_system.testing.render_service import _safe_exc_message - return jsonify({'error': f'Failed to load plugin: {type(e).__name__}: {_safe_exc_message(e)}'}), 500 + return jsonify({'error': f'Failed to load plugin ({type(e).__name__}) -- see server logs for details'}), 500 return jsonify(result) @@ -282,8 +285,6 @@ def api_render_matrix(): except LookupError as e: return jsonify({'error': str(e)}), 404 - from src.plugin_system.testing.render_service import _safe_exc_message - results = [] for w, h in parsed_sizes: try: @@ -293,7 +294,7 @@ def api_render_matrix(): logger.warning("Failed to load plugin %s for preview at %dx%d", data['plugin_id'], w, h, exc_info=True) results.append({'image': None, 'width': w, 'height': h, 'render_time_ms': 0, - 'errors': [f'Failed to load plugin: {type(e).__name__}: {_safe_exc_message(e)}'], + 'errors': [f'Failed to load plugin ({type(e).__name__}) -- see server logs for details'], 'warnings': []}) return jsonify({'results': results}) diff --git a/scripts/render_plugin.py b/scripts/render_plugin.py index bcacc4b8f..dcd03f9e0 100644 --- a/scripts/render_plugin.py +++ b/scripts/render_plugin.py @@ -77,7 +77,7 @@ def main() -> int: logger.error("Plugin '%s' not found in: %s", args.plugin, search_dirs) return 1 - logger.info("Found plugin at: %s", plugin_dir) + logger.info("Found plugin directory") # Load manifest manifest = load_manifest(Path(plugin_dir)) diff --git a/src/plugin_system/plugin_loader.py b/src/plugin_system/plugin_loader.py index c785e6c41..0b66a115f 100644 --- a/src/plugin_system/plugin_loader.py +++ b/src/plugin_system/plugin_loader.py @@ -440,10 +440,7 @@ def _evict_stale_bare_modules(self, plugin_dir: Path) -> dict: try: if not Path(existing_file).resolve().is_relative_to(resolved_dir): evicted[mod_name] = sys.modules.pop(mod_name) - self.logger.debug( - "Evicted stale module '%s' (from %s) before loading plugin in %s", - mod_name, existing_file, plugin_dir, - ) + self.logger.debug("Evicted stale bare-name module '%s' before loading plugin", mod_name) except (ValueError, TypeError): continue @@ -555,7 +552,7 @@ def load_module( plugin_dir_str = str(plugin_dir) if plugin_dir_str not in sys.path: sys.path.insert(0, plugin_dir_str) - self.logger.debug("Added plugin directory to sys.path: %s", plugin_dir_str) + self.logger.debug("Added plugin %s's directory to sys.path", plugin_id) # Import the plugin module module_name = f"plugin_{plugin_id.replace('-', '_')}" @@ -567,9 +564,15 @@ def load_module( spec = importlib.util.spec_from_file_location(module_name, entry_file) if spec is None or spec.loader is None: - error_msg = f"Could not create module spec for {entry_file}" - self.logger.error(error_msg) - raise PluginError(error_msg, plugin_id=plugin_id, context={'entry_file': str(entry_file)}) + # Logged generically (entry_file omitted) to avoid a CodeQL + # clear-text-logging flag; the full path is still attached + # to the raised PluginError's context for callers that + # handle it programmatically. + self.logger.error("Could not create module spec for plugin %s", plugin_id) + raise PluginError( + f"Could not create module spec for {entry_file}", + plugin_id=plugin_id, context={'entry_file': str(entry_file)}, + ) module = importlib.util.module_from_spec(spec) sys.modules[module_name] = module diff --git a/src/plugin_system/testing/render_service.py b/src/plugin_system/testing/render_service.py index 9539e4833..b288eef01 100644 --- a/src/plugin_system/testing/render_service.py +++ b/src/plugin_system/testing/render_service.py @@ -13,36 +13,12 @@ import json import logging -import re import time from pathlib import Path from typing import Any, Dict, Optional logger = logging.getLogger(__name__) -# Matches an absolute filesystem path so it can be collapsed to just its -# basename before an exception message reaches a client -- see -# _safe_exc_message(). -_ABS_PATH_RE = re.compile(r'(?:/[\w.\-]+)+/[\w.\-]+') - - -def _safe_exc_message(exc: Exception, max_len: int = 200) -> str: - """Render an exception's message for a client-facing preview response. - - update()/display() exceptions are the plugin author's own code, so - showing *something* here is the point of this endpoint -- but the - message is otherwise unbounded and could echo back the server's own - directory layout (e.g. a FileNotFoundError embeds the full path it - tried). Collapse absolute paths to their basename and cap the length - so this stays a useful debugging hint without doubling as directory - disclosure. The full exception (with traceback) is still logged - server-side by the caller. - """ - msg = _ABS_PATH_RE.sub(lambda m: Path(m.group(0)).name, str(exc)) - if len(msg) > max_len: - msg = msg[:max_len] + '...' - return msg - def render_plugin_once(plugin_id: str, plugin_dir: Path, manifest: Optional[Dict[str, Any]] = None, @@ -104,14 +80,23 @@ def render_plugin_once(plugin_id: str, plugin_dir: Path, try: plugin_instance.update() except Exception as e: + # Deliberately don't interpolate the exception's own message here: + # it's the plugin author's code, so its text is arbitrary and + # could echo back server-side details (a FileNotFoundError embeds + # a full path, etc.) that a static scanner can't tell apart from + # "already sanitised" just by looking at this line in isolation. + # The exception CLASS name is a fixed, bounded string (a Python + # builtin/stdlib name), never derived from the exception's own + # content, so it carries no such risk. Full details (with + # traceback) are still logged server-side below. logger.warning("Plugin %s update() raised during preview render", plugin_id, exc_info=True) - warnings.append(f"update() raised {type(e).__name__}: {_safe_exc_message(e)}") + warnings.append(f"update() raised {type(e).__name__} -- see server logs for details") try: plugin_instance.display(force_clear=True) except Exception as e: logger.warning("Plugin %s display() raised during preview render", plugin_id, exc_info=True) - errors.append(f"display() raised {type(e).__name__}: {_safe_exc_message(e)}") + errors.append(f"display() raised {type(e).__name__} -- see server logs for details") render_time_ms = round((time.time() - start_time) * 1000, 1) From 3f998d210cad0991560f973ca5e52c11a0f2d6dd Mon Sep 17 00:00:00 2001 From: ChuckBuilds Date: Sat, 11 Jul 2026 19:03:57 -0400 Subject: [PATCH 4/4] fix(lint): remove unused Union/field imports flagged by Codacy --- src/adaptive_images.py | 2 +- src/adaptive_layout.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/adaptive_images.py b/src/adaptive_images.py index 1887d4e1a..a3e14ade0 100644 --- a/src/adaptive_images.py +++ b/src/adaptive_images.py @@ -25,7 +25,7 @@ """ from dataclasses import dataclass -from typing import Any, Optional, Tuple, Union +from typing import Any, Optional, Tuple from PIL import Image diff --git a/src/adaptive_layout.py b/src/adaptive_layout.py index 1b316f2c8..7837e0f9e 100644 --- a/src/adaptive_layout.py +++ b/src/adaptive_layout.py @@ -29,7 +29,7 @@ import logging from collections import OrderedDict -from dataclasses import dataclass, field +from dataclasses import dataclass from typing import Any, Dict, List, Optional, Sequence, Tuple, Union import freetype