mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 14:25:08 +00:00
* 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>
600 lines
23 KiB
Python
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.
|