From 279fff271e3dc4aea96166573e7f68c54f1e0c92 Mon Sep 17 00:00:00 2001 From: Chuck Date: Thu, 9 Jul 2026 16:08:21 -0400 Subject: [PATCH 1/3] feat(text-display): auto font mode via adaptive layout system (v1.1.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add font_mode config option: 'manual' (default, unchanged behavior) or 'auto', which picks the largest crisp font that fits the panel — static text is fitted to both dimensions (ellipsized only as a last resort), scrolling text is fitted to the panel height. Uses the new core adaptive-layout ladder API (TTF-only rungs: press_start 32/24/16/8, 5by7.regular@8, 4x6-font@6) since this plugin renders via PIL draw.text. Declares display.design_size 128x32 in the manifest. Requires a core with src/adaptive_layout.py for auto mode; falls back to manual on older cores. Co-Authored-By: Claude Fable 5 --- plugins.json | 54 +-------- plugins/text-display/README.md | 5 +- plugins/text-display/config_schema.json | 10 +- plugins/text-display/manager.py | 141 ++++++++++++++++++++---- plugins/text-display/manifest.json | 15 ++- 5 files changed, 148 insertions(+), 77 deletions(-) diff --git a/plugins.json b/plugins.json index 1b9aca0d..ecde7403 100644 --- a/plugins.json +++ b/plugins.json @@ -1,6 +1,6 @@ { "version": "1.0.0", - "last_updated": "2026-07-10", + "last_updated": "2026-07-11", "plugins": [ { "id": "7-segment-clock", @@ -242,29 +242,6 @@ "latest_version": "1.0.1", "icon": "fa-globe" }, - { - "id": "gif-player", - "name": "GIF Player", - "description": "Plays animated GIFs in the display rotation. Includes a web portal to upload, reorder, toggle, and delete animations.", - "author": "ant456", - "category": "media", - "tags": [ - "gif", - "animation", - "upload", - "portal", - "display" - ], - "repo": "https://github.com/ant456/ledmatrix-gif-player", - "branch": "main", - "plugin_path": "", - "stars": 0, - "downloads": 0, - "last_updated": "2026-06-17", - "verified": false, - "screenshot": "", - "latest_version": "1.0.0" - }, { "id": "hello-world", "name": "Hello World", @@ -654,31 +631,6 @@ "screenshot": "", "latest_version": "1.4.8" }, - { - "id": "plex-marquee", - "name": "Plex Marquee", - "description": "Cinema marquee display powered by Fanart.tv — shows banner art for whatever is currently playing on Plex, with a web portal to browse and pick banners for your library.", - "author": "ant456", - "category": "media", - "tags": [ - "plex", - "cinema", - "marquee", - "fanart", - "tmdb", - "banner", - "media" - ], - "repo": "https://github.com/ant456/ledmatrix-plex-marquee", - "branch": "main", - "plugin_path": "", - "stars": 0, - "downloads": 0, - "last_updated": "2026-06-22", - "verified": false, - "screenshot": "", - "latest_version": "1.0.0" - }, { "id": "soccer-scoreboard", "name": "Soccer Scoreboard", @@ -801,10 +753,10 @@ "plugin_path": "plugins/text-display", "stars": 0, "downloads": 0, - "last_updated": "2026-05-15", + "last_updated": "2026-07-09", "verified": true, "screenshot": "", - "latest_version": "1.0.3" + "latest_version": "1.1.0" }, { "id": "tide-display", diff --git a/plugins/text-display/README.md b/plugins/text-display/README.md index 34b4c606..dc5924a9 100644 --- a/plugins/text-display/README.md +++ b/plugins/text-display/README.md @@ -57,8 +57,9 @@ from it. Key options: |---|---|---| | `enabled` | `false` | Master switch | | `text` | `"Subscribe to ChuckBuilds"` | The message to display | -| `font_path` | `assets/fonts/PressStart2P-Regular.ttf` | Path to TTF or BDF font file | -| `font_size` | `8` | Font size in pixels | +| `font_mode` | `manual` | `manual` uses `font_path`/`font_size` as-is. `auto` picks the largest crisp font that fits the display: static text is sized to fit the whole panel, scrolling text is sized to the panel height — so the same config is readable on a 64x32 and fills a 128x64. | +| `font_path` | `assets/fonts/PressStart2P-Regular.ttf` | Path to TTF or BDF font file (manual mode) | +| `font_size` | `8` | Font size in pixels (manual mode) | | `scroll` | `true` | Enable horizontal scrolling animation | | `scroll_speed` | `1` | Speed multiplier (≈ pixels per frame). Higher = faster. | | `scroll_delay` | `0.01` | Sleep between scroll steps in seconds. Lower = smoother but more CPU | diff --git a/plugins/text-display/config_schema.json b/plugins/text-display/config_schema.json index c238cdb9..885729fa 100644 --- a/plugins/text-display/config_schema.json +++ b/plugins/text-display/config_schema.json @@ -27,17 +27,23 @@ "default": "Subscribe to ChuckBuilds", "description": "The text message to display on the LED matrix" }, + "font_mode": { + "type": "string", + "enum": ["manual", "auto"], + "default": "manual", + "description": "Font sizing mode. 'manual' uses font_path/font_size as configured. 'auto' picks the largest crisp font that fits the display: static text is sized to fit the panel, scrolling text is sized to the panel height." + }, "font_path": { "type": "string", "default": "assets/fonts/PressStart2P-Regular.ttf", - "description": "Path to font file (TTF or BDF format). Relative to project root or absolute path." + "description": "Path to font file (TTF or BDF format). Relative to project root or absolute path. Used in manual font mode." }, "font_size": { "type": "number", "default": 8, "minimum": 4, "maximum": 32, - "description": "Font size in pixels" + "description": "Font size in pixels. Used in manual font mode." }, "scroll": { "type": "boolean", diff --git a/plugins/text-display/manager.py b/plugins/text-display/manager.py index 11f82a40..58085a51 100644 --- a/plugins/text-display/manager.py +++ b/plugins/text-display/manager.py @@ -24,6 +24,19 @@ from src.plugin_system.base_plugin import BasePlugin from src.common.scroll_helper import ScrollHelper +try: + from src.adaptive_layout import FontStep, LADDER_ARCADE + # Prefer PressStart2P (the plugin's classic look) at its crisp integer + # sizes, then step to the narrower small TTF pixel fonts. TTF-only on + # purpose: this plugin renders with PIL draw.text(), which can't take + # the freetype BDF faces the grid ladder returns. + AUTO_FONT_LADDER = LADDER_ARCADE + ( + FontStep("5by7.regular", 8), + FontStep("4x6-font", 6), + ) +except ImportError: # older LEDMatrix core without the adaptive layout system + AUTO_FONT_LADDER = None + logger = logging.getLogger(__name__) @@ -35,8 +48,11 @@ class TextDisplayPlugin(BasePlugin): Configuration options: text (str): Message to display - font_path (str): Path to TTF or BDF font file - font_size (int): Font size in pixels + font_mode (str): "manual" uses font_path/font_size as-is (default); + "auto" picks the largest crisp font that fits the panel — static + text fits both dimensions, scrolling text fits height only + font_path (str): Path to TTF or BDF font file (manual mode) + font_size (int): Font size in pixels (manual mode) scroll (bool): Enable scrolling animation scroll_speed (float): Scroll speed in pixels per frame (default: 1, range: 0.1-10) scroll_delay (float): Delay in seconds per frame (default: 0.01, range: 0.001-0.1) @@ -55,6 +71,13 @@ def __init__(self, plugin_id: str, config: Dict[str, Any], # the auto-generated web UI form populates from, so the code default # must match what the form prefill shows. self.text = config.get('text', 'Subscribe to ChuckBuilds') + self.font_mode = config.get('font_mode', 'manual') + if self.font_mode == 'auto' and AUTO_FONT_LADDER is None: + self.logger.warning( + "font_mode 'auto' requires a LEDMatrix core with the adaptive " + "layout system; falling back to manual font settings" + ) + self.font_mode = 'manual' self.font_path = config.get('font_path', 'assets/fonts/PressStart2P-Regular.ttf') self.font_size = config.get('font_size', 8) self.scroll_enabled = config.get('scroll', True) @@ -87,7 +110,11 @@ def __init__(self, plugin_id: str, config: Dict[str, Any], self.bg_color = (0, 0, 0) # State - self.font = self._load_font() + # render_text is what actually gets drawn/measured — same as text in + # manual mode; in auto static mode it may be ellipsized to fit. + self.render_text = self.text + self._auto_fit_dims = None # (w, h) the auto font was fitted for + self.font = self._resolve_font() self.text_width = 0 self.text_image_cache = None @@ -171,6 +198,58 @@ def _register_fonts(self): except Exception as e: self.logger.warning(f"Error registering fonts: {e}") + def _resolve_font(self): + """Pick the font per font_mode: auto-fit to the panel, or manual.""" + if self.font_mode == 'auto': + font = self._resolve_auto_font() + if font is not None: + return font + self.render_text = self.text + return self._load_font() + + def _resolve_auto_font(self): + """Largest crisp font that fits the panel (adaptive layout system). + + Static text is fitted to both dimensions (ellipsized only if even the + smallest rung is too wide); scrolling text is fitted by height only, + since width is unconstrained by design. + """ + if AUTO_FONT_LADDER is None: + return None + try: + ctx = self.layout + box = ctx.bounds.inset(1) + if self.scroll_enabled: + fit = ctx.fit_text(self.text, (10 ** 9, box.h), + ladder=AUTO_FONT_LADDER, ellipsis=False) + else: + fit = ctx.fit_text(self.text, box, ladder=AUTO_FONT_LADDER) + self.render_text = fit.text + self.font_size = fit.size_px + self._auto_fit_dims = (ctx.width, ctx.height) + self.logger.info( + f"Auto font: {fit.family} @ {fit.size_px}px on " + f"{ctx.width}x{ctx.height} (fits={fit.fits})" + ) + return fit.font + except Exception as e: + self.logger.warning(f"Auto font sizing failed, using manual font: {e}") + return None + + def _ensure_auto_font_current(self): + """Re-fit the auto font when the logical display size changes + (Vegas segments, double-sided logical screens, harness sizes).""" + if self.font_mode != 'auto' or self._auto_fit_dims is None: + return + dims = (self.display_manager.matrix.width, self.display_manager.matrix.height) + if dims == self._auto_fit_dims: + return + self.font = self._resolve_font() + self._calculate_text_dimensions() + self.text_image_cache = None + if self.scroll_helper: + self.scroll_helper.reset_scroll() + def _load_font(self): """Load the specified font file (TTF or BDF).""" font_path = self.font_path @@ -242,16 +321,16 @@ def _calculate_text_dimensions(self): temp_draw = ImageDraw.Draw(temp_img) if isinstance(self.font, ImageFont.FreeTypeFont) or isinstance(self.font, ImageFont.ImageFont): - bbox = temp_draw.textbbox((0, 0), self.text, font=self.font) + bbox = temp_draw.textbbox((0, 0), self.render_text, font=self.font) self.text_width = bbox[2] - bbox[0] else: # Default fallback - self.text_width = len(self.text) * 8 - - self.logger.info(f"Text width calculated: {self.text_width}px for '{self.text[:30]}...'") + self.text_width = len(self.render_text) * 8 + + self.logger.info(f"Text width calculated: {self.text_width}px for '{self.render_text[:30]}...'") except Exception as e: self.logger.error(f"Error calculating text width: {e}") - self.text_width = len(self.text) * 8 + self.text_width = len(self.render_text) * 8 def _create_text_cache(self): """Pre-render the text onto an image for smooth scrolling using ScrollHelper.""" @@ -275,12 +354,12 @@ def _create_text_cache(self): # Calculate vertical centering temp_img = Image.new('RGB', (1, 1)) temp_draw = ImageDraw.Draw(temp_img) - bbox = temp_draw.textbbox((0, 0), self.text, font=self.font) + bbox = temp_draw.textbbox((0, 0), self.render_text, font=self.font) text_height = bbox[3] - bbox[1] y_pos = (matrix_height - text_height) // 2 - bbox[1] - + # Draw text starting after the initial display_width padding - draw.text((matrix_width, y_pos), self.text, font=self.font, fill=self.text_color) + draw.text((matrix_width, y_pos), self.render_text, font=self.font, fill=self.text_color) # Ensure image is in RGB mode (required for numpy conversion) if self.text_image_cache.mode != 'RGB': @@ -340,8 +419,9 @@ def display(self, force_clear: bool = False) -> None: """ if not self.text: return - + try: + self._ensure_auto_font_current() matrix_width = self.display_manager.matrix.width matrix_height = self.display_manager.matrix.height @@ -417,10 +497,10 @@ def display(self, force_clear: bool = False) -> None: # Fallback: direct draw img = Image.new('RGB', (matrix_width, matrix_height), self.bg_color) draw = ImageDraw.Draw(img) - bbox = draw.textbbox((0, 0), self.text, font=self.font) + bbox = draw.textbbox((0, 0), self.render_text, font=self.font) text_height = bbox[3] - bbox[1] y_pos = (matrix_height - text_height) // 2 - bbox[1] - draw.text((0, y_pos), self.text, font=self.font, fill=self.text_color) + draw.text((0, y_pos), self.render_text, font=self.font, fill=self.text_color) if not hasattr(self.display_manager, 'image') or self.display_manager.image is None: self.display_manager.image = img else: @@ -430,24 +510,24 @@ def display(self, force_clear: bool = False) -> None: # Fallback: static text if cache creation failed img = Image.new('RGB', (matrix_width, matrix_height), self.bg_color) draw = ImageDraw.Draw(img) - bbox = draw.textbbox((0, 0), self.text, font=self.font) + bbox = draw.textbbox((0, 0), self.render_text, font=self.font) text_width = bbox[2] - bbox[0] text_height = bbox[3] - bbox[1] x_pos = (matrix_width - text_width) // 2 y_pos = (matrix_height - text_height) // 2 - bbox[1] - draw.text((x_pos, y_pos), self.text, font=self.font, fill=self.text_color) + draw.text((x_pos, y_pos), self.render_text, font=self.font, fill=self.text_color) self.display_manager.image = img self.display_manager.update_display() else: # Static text (centered) img = Image.new('RGB', (matrix_width, matrix_height), self.bg_color) draw = ImageDraw.Draw(img) - bbox = draw.textbbox((0, 0), self.text, font=self.font) + bbox = draw.textbbox((0, 0), self.render_text, font=self.font) text_width = bbox[2] - bbox[0] text_height = bbox[3] - bbox[1] x_pos = (matrix_width - text_width) // 2 y_pos = (matrix_height - text_height) // 2 - bbox[1] - draw.text((x_pos, y_pos), self.text, font=self.font, fill=self.text_color) + draw.text((x_pos, y_pos), self.render_text, font=self.font, fill=self.text_color) self.display_manager.image = img self.display_manager.update_display() @@ -494,6 +574,10 @@ def _log_frame_rate(self): def set_text(self, text: str): """Update the displayed text.""" self.text = text + self.render_text = text + if self.font_mode == 'auto': + # New text may fit a different ladder rung + self.font = self._resolve_font() self._calculate_text_dimensions() self.text_image_cache = None if self.scroll_helper: @@ -591,12 +675,28 @@ def on_config_change(self, new_config: Dict[str, Any]) -> None: self.logger.info(f"Scroll {'enabled' if self.scroll_enabled else 'disabled'}") # Update font settings if changed + new_font_mode = new_config.get('font_mode', self.font_mode) + if new_font_mode == 'auto' and AUTO_FONT_LADDER is None: + self.logger.warning( + "font_mode 'auto' requires a LEDMatrix core with the adaptive " + "layout system; staying on manual font settings" + ) + new_font_mode = 'manual' new_font_path = new_config.get('font_path', self.font_path) new_font_size = new_config.get('font_size', self.font_size) - if new_font_path != self.font_path or new_font_size != self.font_size: + font_changed = ( + new_font_mode != self.font_mode + or new_font_path != self.font_path + # In auto mode font_size holds the fitted size, not the config value + or (new_font_mode == 'manual' and new_font_size != self.font_size) + # Toggling scroll changes the auto-fit box (height-only vs both) + or (new_font_mode == 'auto' and old_scroll_enabled != self.scroll_enabled) + ) + if font_changed: + self.font_mode = new_font_mode self.font_path = new_font_path self.font_size = new_font_size - self.font = self._load_font() + self.font = self._resolve_font() self._calculate_text_dimensions() self.text_image_cache = None # Invalidate cache when font changes self._register_fonts() @@ -628,6 +728,7 @@ def get_info(self) -> Dict[str, Any]: 'target_fps': self.target_fps, 'pixels_per_second': round(pixels_per_second, 1), # calculated from frame-based settings 'scroll_loop': self.scroll_loop, + 'font_mode': self.font_mode, 'font_path': self.font_path, 'font_size': self.font_size }) diff --git a/plugins/text-display/manifest.json b/plugins/text-display/manifest.json index 665eba8a..dab74517 100644 --- a/plugins/text-display/manifest.json +++ b/plugins/text-display/manifest.json @@ -1,7 +1,7 @@ { "id": "text-display", "name": "Text Display", - "version": "1.0.3", + "version": "1.1.0", "author": "ChuckBuilds", "description": "Display custom scrolling or static text with configurable fonts, colors, and scroll speed. Perfect for announcements, messages, or custom displays.", "category": "display", @@ -17,7 +17,18 @@ ], "entry_point": "manager.py", "class_name": "TextDisplayPlugin", + "display": { + "design_size": { + "width": 128, + "height": 32 + } + }, "versions": [ + { + "released": "2026-07-09", + "version": "1.1.0", + "ledmatrix_min": "2.0.0" + }, { "released": "2026-05-15", "version": "1.0.3", @@ -34,7 +45,7 @@ "ledmatrix_min_version": "2.0.0" } ], - "last_updated": "2026-05-15", + "last_updated": "2026-07-09", "stars": 0, "downloads": 0, "verified": true, From de20b9803968d446dca9b89994c9c3886ed28848 Mon Sep 17 00:00:00 2001 From: Chuck Date: Fri, 10 Jul 2026 11:57:08 -0400 Subject: [PATCH 2/3] fix(text-display): eliminate antialiased/blurry text in font_mode auto (v1.1.1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit measure_font_crispness (added to LEDMatrix core's adaptive_layout.py) found that two of the three AUTO_FONT_LADDER rungs were never actually crisp: - '5by7.regular' renders 36-79% antialiased at every size from 5px to 24px tested — it isn't a true pixel-grid font in PIL's TTF rasterizer and should never have been in a 'crisp' ladder. Dropped entirely. - '4x6-font' at 6px (the size used) is ~33% antialiased; 7px is its only genuinely crisp size (0% antialiased) despite reporting the same ink height via measure_ink. Corrected 6 -> 7. PressStart2P (LADDER_ARCADE's 32/24/16/8 rungs) was already correct — crisp only at exact multiples of its 8px design grid, confirmed by the same measurement. Added test_font_mode_auto.py asserting every ladder rung measures 0% antialiased, so a future ladder change can't silently regress this. Co-Authored-By: Claude Fable 5 --- plugins.json | 52 ++++++++++++++++++- plugins/text-display/manager.py | 17 +++++-- plugins/text-display/manifest.json | 10 +++- plugins/text-display/test_font_mode_auto.py | 55 +++++++++++++++++++++ 4 files changed, 125 insertions(+), 9 deletions(-) create mode 100644 plugins/text-display/test_font_mode_auto.py diff --git a/plugins.json b/plugins.json index ecde7403..86ba4602 100644 --- a/plugins.json +++ b/plugins.json @@ -242,6 +242,29 @@ "latest_version": "1.0.1", "icon": "fa-globe" }, + { + "id": "gif-player", + "name": "GIF Player", + "description": "Plays animated GIFs in the display rotation. Includes a web portal to upload, reorder, toggle, and delete animations.", + "author": "ant456", + "category": "media", + "tags": [ + "gif", + "animation", + "upload", + "portal", + "display" + ], + "repo": "https://github.com/ant456/ledmatrix-gif-player", + "branch": "main", + "plugin_path": "", + "stars": 0, + "downloads": 0, + "last_updated": "2026-06-17", + "verified": false, + "screenshot": "", + "latest_version": "1.0.0" + }, { "id": "hello-world", "name": "Hello World", @@ -631,6 +654,31 @@ "screenshot": "", "latest_version": "1.4.8" }, + { + "id": "plex-marquee", + "name": "Plex Marquee", + "description": "Cinema marquee display powered by Fanart.tv — shows banner art for whatever is currently playing on Plex, with a web portal to browse and pick banners for your library.", + "author": "ant456", + "category": "media", + "tags": [ + "plex", + "cinema", + "marquee", + "fanart", + "tmdb", + "banner", + "media" + ], + "repo": "https://github.com/ant456/ledmatrix-plex-marquee", + "branch": "main", + "plugin_path": "", + "stars": 0, + "downloads": 0, + "last_updated": "2026-06-22", + "verified": false, + "screenshot": "", + "latest_version": "1.0.0" + }, { "id": "soccer-scoreboard", "name": "Soccer Scoreboard", @@ -753,10 +801,10 @@ "plugin_path": "plugins/text-display", "stars": 0, "downloads": 0, - "last_updated": "2026-07-09", + "last_updated": "2026-07-10", "verified": true, "screenshot": "", - "latest_version": "1.1.0" + "latest_version": "1.1.1" }, { "id": "tide-display", diff --git a/plugins/text-display/manager.py b/plugins/text-display/manager.py index 58085a51..9ffff315 100644 --- a/plugins/text-display/manager.py +++ b/plugins/text-display/manager.py @@ -27,12 +27,19 @@ try: from src.adaptive_layout import FontStep, LADDER_ARCADE # Prefer PressStart2P (the plugin's classic look) at its crisp integer - # sizes, then step to the narrower small TTF pixel fonts. TTF-only on - # purpose: this plugin renders with PIL draw.text(), which can't take - # the freetype BDF faces the grid ladder returns. + # sizes, then step down to 4x6-font for panels too small for even 8px. + # TTF-only on purpose: this plugin renders with PIL draw.text(), which + # can't take the freetype BDF faces the grid ladder returns. + # + # Both rungs are verified crisp (measure_font_crispness == 0.0, see + # test_font_mode_auto.py). PressStart2P only rasterizes without + # antialiasing at exact multiples of 8px. "5by7.regular" was dropped — + # it renders visibly antialiased at every size tested (never below + # ~30% gray pixels) — and "4x6-font" moved from 6px to 7px, its only + # crisp size; 6px is ~33% antialiased despite measuring the same ink + # height. AUTO_FONT_LADDER = LADDER_ARCADE + ( - FontStep("5by7.regular", 8), - FontStep("4x6-font", 6), + FontStep("4x6-font", 7), ) except ImportError: # older LEDMatrix core without the adaptive layout system AUTO_FONT_LADDER = None diff --git a/plugins/text-display/manifest.json b/plugins/text-display/manifest.json index dab74517..82f6d6a6 100644 --- a/plugins/text-display/manifest.json +++ b/plugins/text-display/manifest.json @@ -1,7 +1,7 @@ { "id": "text-display", "name": "Text Display", - "version": "1.1.0", + "version": "1.1.1", "author": "ChuckBuilds", "description": "Display custom scrolling or static text with configurable fonts, colors, and scroll speed. Perfect for announcements, messages, or custom displays.", "category": "display", @@ -24,6 +24,12 @@ } }, "versions": [ + { + "released": "2026-07-10", + "version": "1.1.1", + "ledmatrix_min": "2.0.0", + "notes": "Fix font_mode auto rendering visibly antialiased/blurry text on small panels: dropped 5by7.regular (never crisp at any tested size) and corrected 4x6-font from 6px to 7px (its actual crisp native size)." + }, { "released": "2026-07-09", "version": "1.1.0", @@ -45,7 +51,7 @@ "ledmatrix_min_version": "2.0.0" } ], - "last_updated": "2026-07-09", + "last_updated": "2026-07-10", "stars": 0, "downloads": 0, "verified": true, diff --git a/plugins/text-display/test_font_mode_auto.py b/plugins/text-display/test_font_mode_auto.py new file mode 100644 index 00000000..cd2e3c95 --- /dev/null +++ b/plugins/text-display/test_font_mode_auto.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python3 +""" +Regression test for font_mode: "auto" font-ladder crispness. + +PIL antialiases TTF outlines by default; a "pixel-style" font (PressStart2P, +4x6-font) only rasterizes without antialiasing at specific sizes (for +PressStart2P: exact multiples of its 8px design grid). A ladder rung at an +unverified size silently renders blurry on an LED panel. This test measures +actual rendered pixels for every rung in AUTO_FONT_LADDER and fails if any +of them are not crisp — the class of bug fixed here: "5by7.regular" (never +crisp at any size) and "4x6-font" at 6px (33% antialiased; 7px is crisp) +were both in the ladder before this test existed. + +Run from the core LEDMatrix tree (needs src.font_manager, assets/fonts): + cd /path/to/LEDMatrix + python -m pytest /path/to/text-display/test_font_mode_auto.py -q +""" + +import os +import sys + +import pytest + +PLUGIN_DIR = os.path.dirname(os.path.abspath(__file__)) +if PLUGIN_DIR not in sys.path: + sys.path.insert(0, PLUGIN_DIR) + +from manager import AUTO_FONT_LADDER # noqa: E402 + +pytestmark = pytest.mark.skipif( + AUTO_FONT_LADDER is None, + reason="core without src.adaptive_layout — auto font_mode falls back to manual", +) + + +def test_every_ladder_rung_is_crisp(): + from src.adaptive_layout import measure_font_crispness + from src.font_manager import FontManager + + fm = FontManager({}) + offenders = [] + for step in AUTO_FONT_LADDER: + font = fm.get_font(step.family, step.size_px) + crispness = measure_font_crispness(font, "Subscribe 123") + if crispness > 0.02: # allow a hair of tolerance for diagonal strokes + offenders.append(f"{step.family}@{step.size_px}px: {crispness:.1%} antialiased") + assert not offenders, "Blurry ladder rung(s):\n " + "\n ".join(offenders) + + +def test_ladder_is_nonempty(): + assert len(AUTO_FONT_LADDER) >= 1 + + +if __name__ == "__main__": + sys.exit(pytest.main([__file__, "-q"])) From 9fb1f0a287bfe59b421c9a33dfdae39972499894 Mon Sep 17 00:00:00 2001 From: Chuck Date: Sat, 11 Jul 2026 11:32:14 -0400 Subject: [PATCH 3/3] docs(text-display): show font_mode in the example configuration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses CodeRabbit review on #185 — the example is what users copy, so the new key should be visible there. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01FqzC1nzTWL4kaqgMaQZFam --- plugins/text-display/README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/plugins/text-display/README.md b/plugins/text-display/README.md index dc5924a9..378ad4d3 100644 --- a/plugins/text-display/README.md +++ b/plugins/text-display/README.md @@ -33,6 +33,7 @@ Display custom scrolling or static text messages on your LED matrix with configu { "enabled": true, "text": "Subscribe to ChuckBuilds!", + "font_mode": "manual", "font_path": "assets/fonts/PressStart2P-Regular.ttf", "font_size": 8, "scroll": true,