Files
LEDMatrix/src/plugin_system/testing/visual_display_manager.py
T
ChuckandClaude Opus 5 968b953a51 fix(display): pin one text layout engine, and give the 5x7 BDF face a size (#539)
* fix(display): pin one text layout engine, and give the 5x7 face a size

Two ways a font could render differently on two machines running the same
code, both found while diagnosing four plugins whose golden images passed on
the machine that generated them and failed everywhere else.

**Layout engine.** `ImageFont.truetype` picks its engine at load time: Raqm
where the host Pillow was built with libraqm, Basic otherwise. The two round
fractional glyph advances differently. `PressStart2P-Regular.ttf` at 8px has
whole-pixel advances, so they agree — which is why most of the fleet matched
everywhere and hid this. `4x6-font.ttf` at 6px does not: glyph positions drift
cumulatively along a run, and the four plugins that draw body text in it
(geochron, of-the-day, christmas-countdown, ledmatrix-weather's almanac) are
exactly the four whose goldens travelled badly.

Every core font load now goes through `src/common/font_layout.load_truetype`,
which pins the Basic engine, so a render depends on the font file and the size
and nothing else. Basic gives up complex-script shaping and kerning pairs;
neither applies to bitmap-grid faces on an LED panel. Output is unchanged on a
host without libraqm.

**Zero font height.** `DisplayManager` built the 5x7 BDF face with
`freetype.Face(path)` and never called `set_char_size`, so `face.size.height`
stayed 0 and `get_font_height()` returned 0 for it — callers stacking rows by
`prev_y + prev_height + gap` drew two lines on top of each other. The
start-up line `Calendar font size: 0 pixels` has been printing the symptom all
along. `font_manager._load_bdf_font` already called `set_char_size`, so
whether measurement worked depended on which path loaded the face.

`DisplayManager` now sets it too, and `get_font_height()` falls back to the
strike the file declares rather than returning a zero line height.

Fixes ChuckBuilds/ledmatrix-plugins#397
Refs ChuckBuilds/ledmatrix-plugins#371, #375, #378, #391

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(display): give the startup banner a rung that fits a full address at 64px

CI caught what pinning the layout engine exposed rather than caused.
`_fitting_font` walks PressStart2P then 4x6 at 6px, and "255.255.255.255" --
the widest thing the startup banner ever shows -- measures 66px at 4x6/6px
against the 62 a 64x32 panel has to give. It used to squeak in only because
the measurement depended on which layout engine the host Pillow happened to
have; with the engine pinned it does not, so the rung the worst case actually
needs is now in the ladder instead of implied: 4x6 at 5px, which measures 51.

The fallback was wrong in the same place. When nothing in the ladder fit, it
returned `self.font` -- the *widest* option, and precisely how "Initializing"
came to run off the side of a 64px panel to begin with. It returns the
narrowest face that loaded now.

test/test_initializing_screen.py: 34 passed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(display): name the exceptions the BDF strike read can raise

Codacy flagged the try/except/pass. It was already narrow in intent -- a
malformed strike table on the measurement path must degrade to "size unknown"
rather than take the display down -- but a bare `except Exception: pass` says
neither of those things and hides a genuinely broken font behind a silent 8px
fallback. It now catches what reading `available_sizes` can actually raise and
logs which face failed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore: drop logo PNGs the render harness downloaded into the worktree

These are fetched at runtime by the logo cache; they are not source, and they
rode in on a `git add -A` while I was running check_plugin.py against this
branch. Nothing in the change needs them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 09:40:00 -04:00

600 lines
23 KiB
Python

"""
Visual Test Display Manager for LEDMatrix.
A display manager that performs real pixel rendering using PIL,
without requiring hardware or the RGBMatrixEmulator. Used for:
- Local dev preview server
- CLI render script (AI visual feedback)
- Visual assertions in pytest
Unlike MockDisplayManager (which logs calls but doesn't render) or
MagicMock (which tracks nothing visual), this class creates a real
PIL Image canvas and draws text using the actual project fonts.
MAINTENANCE WARNING: this class is a deliberate fork of
src/display_manager.py so it can run without hardware. It mirrors
these DisplayManager methods by name and behavior: _load_fonts,
_draw_bdf_text, get_font_height, get_text_width, draw_text,
draw_text_with_icons, draw_weather_icon (and the _draw_sun/_draw_cloud/
_draw_rain/_draw_snow/_draw_storm family), format_date_with_ordinal,
capture_mode, set_scrolling_state, is_currently_scrolling,
process_deferred_updates, update_display, render_size. A behavior
change to any of those in DisplayManager must be mirrored here, or
plugin visual tests will pass against stale behavior.
"""
import math
import os
import time
from contextlib import contextmanager
from pathlib import Path
from typing import Any, List, Optional, Tuple
from PIL import Image, ImageDraw, ImageFont
from src.common.font_layout import load_truetype
from src.logging_config import get_logger
logger = get_logger(__name__)
class _MatrixProxy:
"""Lightweight proxy so plugins can access display_manager.matrix.width/height."""
def __init__(self, width: int, height: int):
self.width = width
self.height = height
class VisualTestDisplayManager:
"""
Display manager that renders real pixels for testing and development.
Implements the same interface that plugins expect from DisplayManager,
but operates entirely in-memory with PIL — no hardware, no singleton,
no emulator dependency.
"""
# Weather icon color constants (same as DisplayManager)
WEATHER_COLORS = {
'sun': (255, 200, 0),
'cloud': (200, 200, 200),
'rain': (0, 100, 255),
'snow': (220, 220, 255),
'storm': (255, 255, 0),
}
def __init__(self, width: int = 128, height: int = 32):
self._width = width
self._height = height
# Canvas
self.image = Image.new('RGB', (width, height), (0, 0, 0))
self.draw = ImageDraw.Draw(self.image)
self.draw.fontmode = "1" # Match production: 1-bit text, so goldens show what the panel shows.
# Matrix proxy (plugins access display_manager.matrix.width/height)
self.matrix = _MatrixProxy(width, height)
# Set while inside capture_mode(); mirrors DisplayManager's flag.
self._capture_mode_active = False
# Scrolling state (interface compat, no-op)
self._scrolling_state = {
'is_scrolling': False,
'last_scroll_activity': 0,
'scroll_inactivity_threshold': 2.0,
'deferred_updates': [],
'max_deferred_updates': 50,
'deferred_update_ttl': 300.0,
}
# Call tracking (preserves MockDisplayManager capabilities)
self.clear_called = False
self.update_called = False
self.draw_calls = []
# Load fonts
self._load_fonts()
# ------------------------------------------------------------------
# Properties
# ------------------------------------------------------------------
@property
def width(self) -> int:
return self.image.width
@property
def height(self) -> int:
return self.image.height
@property
def display_width(self) -> int:
return self.image.width
@property
def display_height(self) -> int:
return self.image.height
# ------------------------------------------------------------------
# Font loading
# ------------------------------------------------------------------
def _find_project_root(self) -> Optional[Path]:
"""Walk up from this file to find the project root (contains assets/fonts)."""
current = Path(__file__).resolve().parent
for _ in range(10):
if (current / 'assets' / 'fonts').exists():
return current
current = current.parent
return None
def _load_fonts(self):
"""Load fonts with graceful fallback, matching DisplayManager._load_fonts()."""
project_root = self._find_project_root()
try:
if project_root is None:
raise FileNotFoundError("Could not find project root with assets/fonts")
fonts_dir = project_root / 'assets' / 'fonts'
# Press Start 2P — regular and small (both 8px)
ttf_path = str(fonts_dir / 'PressStart2P-Regular.ttf')
self.regular_font = load_truetype(ttf_path, 8)
self.small_font = load_truetype(ttf_path, 8)
self.font = self.regular_font # alias used by some code paths
# 5x7 BDF font via freetype
try:
import freetype
bdf_path = str(fonts_dir / '5x7.bdf')
if not os.path.exists(bdf_path):
raise FileNotFoundError(f"BDF font not found: {bdf_path}")
face = freetype.Face(bdf_path)
self.calendar_font = face
self.bdf_5x7_font = face
except (ImportError, FileNotFoundError, OSError) as e:
logger.debug("BDF font not available, using small_font as fallback: %s", e)
self.calendar_font = self.small_font
self.bdf_5x7_font = self.small_font
# 4x6 extra small TTF
try:
xs_path = str(fonts_dir / '4x6-font.ttf')
self.extra_small_font = load_truetype(xs_path, 6)
except (FileNotFoundError, OSError) as e:
logger.debug("Extra small font not available, using fallback: %s", e)
self.extra_small_font = self.small_font
except (FileNotFoundError, OSError) as e:
logger.debug("Font loading fallback: %s", e)
self.regular_font = ImageFont.load_default()
self.small_font = self.regular_font
self.font = self.regular_font
self.calendar_font = self.regular_font
self.bdf_5x7_font = self.regular_font
self.extra_small_font = self.regular_font
# ------------------------------------------------------------------
# Core display methods
# ------------------------------------------------------------------
def clear(self):
"""Clear the display to black."""
self.clear_called = True
self.image = Image.new('RGB', (self._width, self._height), (0, 0, 0))
self.draw = ImageDraw.Draw(self.image)
self.draw.fontmode = "1" # Match production: 1-bit text, so goldens show what the panel shows.
def update_display(self):
"""No-op for hardware; marks that display was updated."""
self.update_called = True
@contextmanager
def render_size(self, width: int, height: Optional[int] = None):
"""
Interface parity with DisplayManager.render_size().
Vegas mode narrows the canvas so plugins lay out compactly instead of
being cropped. The harness must offer the same context or that path
cannot be exercised offline — and because the adapter catches broadly,
a missing method shows up as "no content" rather than an error.
"""
prev_image = self.image
prev_draw = self.draw
prev_w, prev_h = self._width, self._height
target_w = max(1, min(int(width), prev_w))
target_h = max(1, min(int(height) if height else prev_h, prev_h))
try:
self._width, self._height = target_w, target_h
self.matrix = _MatrixProxy(target_w, target_h)
self.image = Image.new('RGB', (target_w, target_h), (0, 0, 0))
self.draw = ImageDraw.Draw(self.image)
self.draw.fontmode = "1" # Match production: 1-bit text, so goldens show what the panel shows.
yield
finally:
self._width, self._height = prev_w, prev_h
self.matrix = _MatrixProxy(prev_w, prev_h)
self.image = prev_image
self.draw = prev_draw
@contextmanager
def capture_mode(self):
"""
Interface parity with DisplayManager.capture_mode().
There is no hardware to suppress here, but Vegas mode's PluginAdapter
wraps every off-screen content fetch in this context, so the harness
must provide it for that code path to be exercisable in tests.
"""
self._capture_mode_active = True
try:
yield
finally:
self._capture_mode_active = False
def draw_text(self, text: str, x: Optional[int] = None, y: Optional[int] = None,
color: Tuple[int, int, int] = (255, 255, 255), small_font: bool = False,
font: Optional[Any] = None, centered: bool = False) -> None:
"""Draw text on the canvas, matching DisplayManager.draw_text() signature."""
# Track the call
self.draw_calls.append({
'type': 'text', 'text': text, 'x': x, 'y': y,
'color': color, 'font': font,
})
try:
# Normalize color to tuple (plugins may pass lists from JSON config)
if isinstance(color, list):
color = tuple(color)
# Select font
if font:
current_font = font
else:
current_font = self.small_font if small_font else self.regular_font
# Calculate x position
if x is None:
text_width = self.get_text_width(text, current_font)
x = (self.width - text_width) // 2
elif centered:
text_width = self.get_text_width(text, current_font)
x = x - (text_width // 2)
if y is None:
y = 0
# Draw
try:
import freetype
is_bdf = isinstance(current_font, freetype.Face)
except ImportError:
is_bdf = False
if is_bdf:
self._draw_bdf_text(text, x, y, color, current_font)
else:
self.draw.text((x, y), text, font=current_font, fill=color)
except Exception as e:
logger.debug(f"Error drawing text: {e}")
def draw_image(self, image: Image.Image, x: int, y: int):
"""Draw an image on the display."""
self.draw_calls.append({
'type': 'image', 'image': image, 'x': x, 'y': y,
})
try:
self.image.paste(image, (x, y))
except Exception as e:
logger.debug(f"Error drawing image: {e}")
def _draw_bdf_text(self, text, x, y, color=(255, 255, 255), font=None):
"""Draw text using BDF font with proper bitmap handling.
Replicated from DisplayManager._draw_bdf_text().
"""
try:
if isinstance(color, list):
color = tuple(color)
face = font if font else self.calendar_font
# Compute baseline from font ascender
try:
ascender_px = face.size.ascender >> 6
except Exception:
ascender_px = 0
baseline_y = y + ascender_px
for char in text:
face.load_char(char)
bitmap = face.glyph.bitmap
glyph_left = face.glyph.bitmap_left
glyph_top = face.glyph.bitmap_top
for i in range(bitmap.rows):
for j in range(bitmap.width):
byte_index = i * bitmap.pitch + (j // 8)
if byte_index < len(bitmap.buffer):
byte = bitmap.buffer[byte_index]
if byte & (1 << (7 - (j % 8))):
pixel_x = x + glyph_left + j
pixel_y = baseline_y - glyph_top + i
if 0 <= pixel_x < self.width and 0 <= pixel_y < self.height:
self.draw.point((pixel_x, pixel_y), fill=color)
x += face.glyph.advance.x >> 6
except Exception as e:
logger.debug(f"Error drawing BDF text: {e}")
# ------------------------------------------------------------------
# Text measurement
# ------------------------------------------------------------------
def get_text_width(self, text: str, font=None) -> int:
"""Get text width in pixels, matching DisplayManager.get_text_width()."""
if font is None:
font = self.regular_font
try:
try:
import freetype
is_bdf = isinstance(font, freetype.Face)
except ImportError:
is_bdf = False
if is_bdf:
width = 0
for char in text:
font.load_char(char)
width += font.glyph.advance.x >> 6
return width
else:
bbox = self.draw.textbbox((0, 0), text, font=font)
return bbox[2] - bbox[0]
except Exception:
return 0
def get_font_height(self, font=None) -> int:
"""Get font height in pixels, matching DisplayManager.get_font_height()."""
if font is None:
font = self.regular_font
try:
try:
import freetype
is_bdf = isinstance(font, freetype.Face)
except ImportError:
is_bdf = False
if is_bdf:
return font.size.height >> 6
else:
ascent, descent = font.getmetrics()
return ascent + descent
except Exception:
if hasattr(font, 'size'):
return font.size
return 8
# ------------------------------------------------------------------
# Weather drawing helpers
# ------------------------------------------------------------------
def draw_sun(self, x: int, y: int, size: int = 16):
"""Draw a sun icon using yellow circles and lines."""
self._draw_sun(x, y, size)
def draw_cloud(self, x: int, y: int, size: int = 16, color: Tuple[int, int, int] = (200, 200, 200)):
"""Draw a cloud icon."""
self._draw_cloud(x, y, size, color)
def draw_rain(self, x: int, y: int, size: int = 16):
"""Draw rain icon with cloud and droplets."""
self._draw_rain(x, y, size)
def draw_snow(self, x: int, y: int, size: int = 16):
"""Draw snow icon with cloud and snowflakes."""
self._draw_snow(x, y, size)
def _draw_sun(self, x: int, y: int, size: int) -> None:
"""Draw a sun icon with rays (internal weather icon version)."""
center_x, center_y = x + size // 2, y + size // 2
radius = size // 4
ray_length = size // 3
self.draw.ellipse(
[center_x - radius, center_y - radius,
center_x + radius, center_y + radius],
fill=self.WEATHER_COLORS['sun'],
)
for angle in range(0, 360, 45):
rad = math.radians(angle)
start_x = center_x + int((radius + 2) * math.cos(rad))
start_y = center_y + int((radius + 2) * math.sin(rad))
end_x = center_x + int((radius + ray_length) * math.cos(rad))
end_y = center_y + int((radius + ray_length) * math.sin(rad))
self.draw.line([start_x, start_y, end_x, end_y], fill=self.WEATHER_COLORS['sun'], width=2)
def _draw_cloud(self, x: int, y: int, size: int, color: Optional[Tuple[int, int, int]] = None) -> None:
"""Draw a cloud using multiple circles (internal weather icon version)."""
cloud_color = color if color is not None else self.WEATHER_COLORS['cloud']
base_y = y + size // 2
circle_radius = size // 4
positions = [
(x + size // 3, base_y),
(x + size // 2, base_y - size // 6),
(x + 2 * size // 3, base_y),
]
for cx, cy in positions:
self.draw.ellipse(
[cx - circle_radius, cy - circle_radius,
cx + circle_radius, cy + circle_radius],
fill=cloud_color,
)
def _draw_rain(self, x: int, y: int, size: int) -> None:
"""Draw rain drops falling from a cloud."""
self._draw_cloud(x, y, size)
rain_color = self.WEATHER_COLORS['rain']
drop_size = size // 8
drops = [
(x + size // 4, y + 2 * size // 3),
(x + size // 2, y + 3 * size // 4),
(x + 3 * size // 4, y + 2 * size // 3),
]
for dx, dy in drops:
self.draw.line([dx, dy, dx - drop_size // 2, dy + drop_size], fill=rain_color, width=2)
def _draw_snow(self, x: int, y: int, size: int) -> None:
"""Draw snowflakes falling from a cloud."""
self._draw_cloud(x, y, size)
snow_color = self.WEATHER_COLORS['snow']
flake_size = size // 6
flakes = [
(x + size // 4, y + 2 * size // 3),
(x + size // 2, y + 3 * size // 4),
(x + 3 * size // 4, y + 2 * size // 3),
]
for fx, fy in flakes:
for angle in range(0, 360, 60):
rad = math.radians(angle)
end_x = fx + int(flake_size * math.cos(rad))
end_y = fy + int(flake_size * math.sin(rad))
self.draw.line([fx, fy, end_x, end_y], fill=snow_color, width=1)
def _draw_storm(self, x: int, y: int, size: int) -> None:
"""Draw a storm cloud with lightning bolt."""
self._draw_cloud(x, y, size)
bolt_color = self.WEATHER_COLORS['storm']
bolt_points = [
(x + size // 2, y + size // 2),
(x + 3 * size // 5, y + 2 * size // 3),
(x + 2 * size // 5, y + 2 * size // 3),
(x + size // 2, y + 5 * size // 6),
]
self.draw.polygon(bolt_points, fill=bolt_color)
def draw_weather_icon(self, condition: str, x: int, y: int, size: int = 16) -> None:
"""Draw a weather icon based on the condition."""
cond = condition.lower()
if cond in ('clear', 'sunny'):
self._draw_sun(x, y, size)
elif cond in ('clouds', 'cloudy', 'partly cloudy'):
self._draw_cloud(x, y, size)
elif cond in ('rain', 'drizzle', 'shower'):
self._draw_rain(x, y, size)
elif cond in ('snow', 'sleet', 'hail'):
self._draw_snow(x, y, size)
elif cond in ('thunderstorm', 'storm'):
self._draw_storm(x, y, size)
else:
self._draw_sun(x, y, size)
def draw_text_with_icons(self, text: str, icons: List[tuple] = None,
x: int = None, y: int = None,
color: tuple = (255, 255, 255)):
"""Draw text with weather icons at specified positions."""
self.draw_text(text, x, y, color)
if icons:
for icon_type, icon_x, icon_y in icons:
self.draw_weather_icon(icon_type, icon_x, icon_y)
self.update_display()
# ------------------------------------------------------------------
# Scrolling state (no-op interface compat)
# ------------------------------------------------------------------
def set_scrolling_state(self, is_scrolling: bool, frame_hold: int = 1):
"""Set the current scrolling state (no-op for testing).
``frame_hold`` mirrors the DisplayManager signature this change adds.
The two are kept in step deliberately: a double that accepts arguments
production does not lets a call pass every harness run and then raise
TypeError on the panel, and a double that lacks one production has
fails every render of a plugin that legitimately paces its scroll.
Plugins begin passing it in ledmatrix-plugins#462.
"""
self._scrolling_state['is_scrolling'] = is_scrolling
self._scrolling_state['frame_hold'] = frame_hold
if is_scrolling:
self._scrolling_state['last_scroll_activity'] = time.time()
def is_currently_scrolling(self) -> bool:
"""Check if display is currently scrolling."""
return self._scrolling_state['is_scrolling']
def process_deferred_updates(self):
"""Process any deferred updates (no-op for testing).
Several ticker-style plugins (news, odds-ticker, leaderboard,
stock-news, stocks) call this unconditionally between
set_scrolling_state() and their scroll-position update, mirroring the
real display_manager's deferred-update queue. This double has no such
queue, so there is nothing to process — the no-op just lets those
plugins render under the harness instead of raising AttributeError.
"""
pass
# ------------------------------------------------------------------
# Utility methods
# ------------------------------------------------------------------
def format_date_with_ordinal(self, dt):
"""Formats a datetime object into 'Mon Aug 30th' style."""
day = dt.day
if 11 <= day <= 13:
suffix = 'th'
else:
suffix = {1: 'st', 2: 'nd', 3: 'rd'}.get(day % 10, 'th')
return dt.strftime(f"%b %-d{suffix}")
# ------------------------------------------------------------------
# Snapshot / image capture
# ------------------------------------------------------------------
def save_snapshot(self, path: str) -> None:
"""Save the current display as a PNG image."""
self.image.save(path, format='PNG')
def get_image(self) -> Image.Image:
"""Return the current display image."""
return self.image
def get_image_base64(self) -> str:
"""Return the current display as a base64-encoded PNG string."""
import base64
import io
buffer = io.BytesIO()
self.image.save(buffer, format='PNG')
return base64.b64encode(buffer.getvalue()).decode('utf-8')
# ------------------------------------------------------------------
# Cleanup / reset
# ------------------------------------------------------------------
def reset(self):
"""Reset all tracking state (for test reuse)."""
self.clear_called = False
self.update_called = False
self.draw_calls = []
self.image = Image.new('RGB', (self._width, self._height), (0, 0, 0))
self.draw = ImageDraw.Draw(self.image)
self.draw.fontmode = "1" # Match production: 1-bit text, so goldens show what the panel shows.
self._scrolling_state = {
'is_scrolling': False,
'last_scroll_activity': 0,
'scroll_inactivity_threshold': 2.0,
'deferred_updates': [],
'max_deferred_updates': 50,
'deferred_update_ttl': 300.0,
}
def cleanup(self):
"""Clean up resources."""
self.image = Image.new('RGB', (self._width, self._height), (0, 0, 0))
self.draw = ImageDraw.Draw(self.image)
self.draw.fontmode = "1" # Match production: 1-bit text, so goldens show what the panel shows.