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..1ff62934a 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,59 @@ 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) + # 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) - 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 +179,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 +192,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..40f69c23d 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,73 @@ 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 + try: + plugin_dir, manifest, config, mock_data, skip_update = _parse_render_request(data) + except LookupError as e: + return jsonify({'error': str(e)}), 404 - # Load manifest - manifest_path = plugin_dir / 'manifest.json' - with open(manifest_path, 'r') as f: - manifest = json.load(f) + try: + 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) + return jsonify({'error': f'Failed to load plugin ({type(e).__name__}) -- see server logs for details'}), 500 + return jsonify(result) - # Build config: schema defaults + user overrides - config_defaults = load_config_defaults(plugin_dir) - config = {'enabled': True} - config.update(config_defaults) - config.update(user_config) - # Create display manager and mocks - from src.plugin_system.testing import VisualTestDisplayManager, MockCacheManager, MockPluginManager - from src.plugin_system.plugin_loader import PluginLoader +@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]}) - 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) +MAX_MATRIX_SIZES = 12 - # Load plugin - loader = PluginLoader() - errors = [] - warnings = [] - 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: + 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__}) -- 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/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..a3e14ade0 --- /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 + +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..7837e0f9e --- /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 +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/plugin_loader.py b/src/plugin_system/plugin_loader.py index e5ac854bb..0b66a115f 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() @@ -436,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 @@ -551,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('-', '_')}" @@ -563,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/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..b288eef01 --- /dev/null +++ b/src/plugin_system/testing/render_service.py @@ -0,0 +1,110 @@ +"""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 logging +import time +from pathlib import Path +from typing import Any, Dict, Optional + +logger = logging.getLogger(__name__) + + +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: + # 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__} -- 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__} -- see server logs for details") + + 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