From af96c6ffd666eb8241074346c14e523a854c33c8 Mon Sep 17 00:00:00 2001 From: Chuck Date: Fri, 10 Jul 2026 10:28:41 -0400 Subject: [PATCH] feat(plugins): adaptive-lib discoverability + advisory version compat warning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Discoverability: re-export the adaptive layout/image API from src.common (the blessed-helpers package plugin authors already know) — canonical paths stay src.adaptive_layout / src.adaptive_images so nothing breaks. Document it in src/common/README.md and cross-link ADAPTIVE_LAYOUT.md from the developer docs authors actually read (quick reference, API reference, advanced dev, font manager, dev preview, plugin dev guide); ADAPTIVE_LAYOUT.md gains adaptive-images, composite-layouts and preserving-user-customization sections. Compat: PluginLoader now logs one advisory warning (never raises) when a plugin's manifest declares a min LEDMatrix version newer than the running core, checking the min_ledmatrix_version / requires.* / versions[] spellings found in the wild. Guarded against stale core version numbers. src/__init__.py __version__ bumped 1.0.0 -> 3.1.0 to match the latest release tag (v3.1.0) — it had never been updated and the compat check needs a truthful number. NOTE: verify this matches the intended release numbering before the next tag. Co-Authored-By: Claude Fable 5 --- docs/ADAPTIVE_LAYOUT.md | 65 +++++++++++++++++++++++++++ docs/ADVANCED_PLUGIN_DEVELOPMENT.md | 6 +++ docs/DEVELOPER_QUICK_REFERENCE.md | 6 +++ docs/DEV_PREVIEW.md | 6 +++ docs/FONT_MANAGER.md | 5 +++ docs/PLUGIN_API_REFERENCE.md | 5 +++ docs/PLUGIN_DEVELOPMENT_GUIDE.md | 5 +++ src/__init__.py | 2 +- src/common/README.md | 22 +++++++++ src/common/__init__.py | 43 ++++++++++++++++++ src/plugin_system/plugin_loader.py | 51 +++++++++++++++++++++ test/test_loader_compat_warning.py | 70 +++++++++++++++++++++++++++++ 12 files changed, 285 insertions(+), 1 deletion(-) create mode 100644 test/test_loader_compat_warning.py diff --git a/docs/ADAPTIVE_LAYOUT.md b/docs/ADAPTIVE_LAYOUT.md index 30aca11b..c589d21b 100644 --- a/docs/ADAPTIVE_LAYOUT.md +++ b/docs/ADAPTIVE_LAYOUT.md @@ -96,6 +96,71 @@ Built per (width, height); exposes facts and fit queries: `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)) +# regs.status_band — top band (replaces the magic y = 1) +# regs.score_area — center region (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`. + +## 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 diff --git a/docs/ADVANCED_PLUGIN_DEVELOPMENT.md b/docs/ADVANCED_PLUGIN_DEVELOPMENT.md index a0a33cd9..eddeb388 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 979bc6b0..93ca8e16 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 9338f97e..afb3aa1d 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 3ca551a2..74fab255 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 6e3b4ca3..751d5609 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 acd7b00a..bcb637a3 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/src/__init__.py b/src/__init__.py index 1bcb2cb4..7d47ee11 100644 --- a/src/__init__.py +++ b/src/__init__.py @@ -4,5 +4,5 @@ LEDMatrix Display System Core source package for the LED Matrix Display project. """ -__version__ = "1.0.0" +__version__ = "3.1.0" diff --git a/src/common/README.md b/src/common/README.md index 4f9b0b6b..cccaa40b 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 9ea51bed..4b6cb383 100644 --- a/src/common/__init__.py +++ b/src/common/__init__.py @@ -26,6 +26,31 @@ from src.common.scroll_helper import ScrollHelper 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 @@ __all__ = [ '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/plugin_system/plugin_loader.py b/src/plugin_system/plugin_loader.py index e5ac854b..a58412df 100644 --- a/src/plugin_system/plugin_loader.py +++ b/src/plugin_system/plugin_loader.py @@ -683,6 +683,55 @@ class PluginLoader: self.logger.error(error_msg, exc_info=True) raise PluginError(error_msg, plugin_id=plugin_id) from e + @staticmethod + def _parse_semver(value: Any) -> Optional[Tuple[int, int, int]]: + """Parse 'X.Y.Z' (extra parts/suffixes ignored) into a comparable + 3-tuple, or None when unparseable.""" + if not isinstance(value, str): + return None + parts = value.strip().lstrip('v').split('.') + try: + nums = [int(''.join(ch for ch in p if ch.isdigit()) or 0) for p in parts[:3]] + except ValueError: + return None + while len(nums) < 3: + nums.append(0) + return tuple(nums) # type: ignore[return-value] + + def _warn_if_incompatible(self, plugin_id: str, manifest: Dict[str, Any]) -> None: + """Log one warning when a plugin declares a minimum LEDMatrix version + newer than the running core. Advisory only — never raises — so a + plugin that guards optional features with try/except keeps working. + """ + declared = ( + manifest.get('min_ledmatrix_version') + or manifest.get('requires', {}).get('min_ledmatrix_version') + ) + if not declared: + versions = manifest.get('versions') or [] + if versions and isinstance(versions[0], dict): + declared = (versions[0].get('ledmatrix_min_version') + or versions[0].get('ledmatrix_min')) + needed = self._parse_semver(declared) + if needed is None: + return + + from src import __version__ as core_version + current = self._parse_semver(core_version) + # Anti-spam guard: if the core's own version number is stale (below + # the ecosystem floor every shipped plugin declares), comparing would + # warn on nearly everything — skip with a debug note instead. + if current is None or current < (2, 0, 0): + self.logger.debug( + "Skipping version compatibility check for %s: core __version__ " + "(%s) is below the ecosystem floor", plugin_id, core_version) + return + if needed > current: + self.logger.warning( + "Plugin %s declares min LEDMatrix version %s but this core is %s — " + "features it relies on may be missing; update the core or expect " + "degraded fallbacks", plugin_id, declared, core_version) + def load_plugin( self, plugin_id: str, @@ -715,6 +764,8 @@ class PluginLoader: Raises: PluginError: If loading fails """ + self._warn_if_incompatible(plugin_id, manifest) + # Install dependencies if needed if install_deps: if plugins_dir is None: diff --git a/test/test_loader_compat_warning.py b/test/test_loader_compat_warning.py new file mode 100644 index 00000000..abe5f29e --- /dev/null +++ b/test/test_loader_compat_warning.py @@ -0,0 +1,70 @@ +"""Tests for the plugin loader's advisory version-compatibility warning.""" + +import logging + +import pytest + +from src.plugin_system.plugin_loader import PluginLoader + + +@pytest.fixture +def loader(): + return PluginLoader(logger=logging.getLogger("test-loader")) + + +def _warnings(caplog): + return [r for r in caplog.records if r.levelno == logging.WARNING] + + +class TestParseSemver: + def test_basic(self, loader): + assert loader._parse_semver("3.1.0") == (3, 1, 0) + assert loader._parse_semver("v2.0") == (2, 0, 0) + assert loader._parse_semver("2.0.0-beta.1") == (2, 0, 0) + + def test_unparseable(self, loader): + assert loader._parse_semver(None) is None + assert loader._parse_semver(123) is None + + +class TestWarnIfIncompatible: + def test_warns_when_plugin_needs_newer_core(self, loader, caplog, monkeypatch): + import src + monkeypatch.setattr(src, "__version__", "3.1.0") + with caplog.at_level(logging.WARNING, logger="test-loader"): + loader._warn_if_incompatible("p", {"min_ledmatrix_version": "9.0.0"}) + assert len(_warnings(caplog)) == 1 + assert "9.0.0" in _warnings(caplog)[0].message + + def test_silent_when_compatible(self, loader, caplog, monkeypatch): + import src + monkeypatch.setattr(src, "__version__", "3.1.0") + with caplog.at_level(logging.WARNING, logger="test-loader"): + loader._warn_if_incompatible("p", {"min_ledmatrix_version": "2.0.0"}) + assert not _warnings(caplog) + + def test_silent_when_field_absent(self, loader, caplog): + with caplog.at_level(logging.WARNING, logger="test-loader"): + loader._warn_if_incompatible("p", {"name": "no version fields"}) + assert not _warnings(caplog) + + def test_reads_requires_and_versions_spellings(self, loader, caplog, monkeypatch): + import src + monkeypatch.setattr(src, "__version__", "3.1.0") + with caplog.at_level(logging.WARNING, logger="test-loader"): + loader._warn_if_incompatible( + "a", {"requires": {"min_ledmatrix_version": "9.0.0"}}) + loader._warn_if_incompatible( + "b", {"versions": [{"ledmatrix_min_version": "9.0.0"}]}) + loader._warn_if_incompatible( + "c", {"versions": [{"ledmatrix_min": "9.0.0"}]}) + assert len(_warnings(caplog)) == 3 + + def test_stale_core_version_skips_comparison(self, loader, caplog, monkeypatch): + # Anti-spam guard: a core whose __version__ is below the ecosystem + # floor must not warn about every plugin. + import src + monkeypatch.setattr(src, "__version__", "1.0.0") + with caplog.at_level(logging.WARNING, logger="test-loader"): + loader._warn_if_incompatible("p", {"min_ledmatrix_version": "2.0.0"}) + assert not _warnings(caplog)