scrollkit 0.8.3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- scrollkit/__init__.py +16 -0
- scrollkit/app/__init__.py +6 -0
- scrollkit/app/base.py +918 -0
- scrollkit/app/memory.py +70 -0
- scrollkit/config/__init__.py +1 -0
- scrollkit/config/settings_manager.py +215 -0
- scrollkit/config/transition_names.py +31 -0
- scrollkit/dev/__init__.py +41 -0
- scrollkit/dev/capabilities.py +374 -0
- scrollkit/dev/harness.py +383 -0
- scrollkit/dev/metrics.py +91 -0
- scrollkit/dev/performance.py +174 -0
- scrollkit/dev/validation.py +245 -0
- scrollkit/display/__init__.py +14 -0
- scrollkit/display/_graphics.py +299 -0
- scrollkit/display/_recording.py +165 -0
- scrollkit/display/_sim_backend.py +99 -0
- scrollkit/display/bitmap_text.py +408 -0
- scrollkit/display/boards.py +186 -0
- scrollkit/display/colors.py +176 -0
- scrollkit/display/content.py +604 -0
- scrollkit/display/gradient_text.py +167 -0
- scrollkit/display/interface.py +126 -0
- scrollkit/display/simulator.py +80 -0
- scrollkit/display/text_fill.py +59 -0
- scrollkit/display/text_pixels.py +250 -0
- scrollkit/display/unified.py +595 -0
- scrollkit/effects/__init__.py +28 -0
- scrollkit/effects/drip_splash.py +253 -0
- scrollkit/effects/easing.py +131 -0
- scrollkit/effects/overlay.py +92 -0
- scrollkit/effects/particles.py +355 -0
- scrollkit/effects/reveal_splash.py +132 -0
- scrollkit/effects/scrolling.py +363 -0
- scrollkit/effects/swarm_reveal.py +512 -0
- scrollkit/effects/text_render.py +25 -0
- scrollkit/effects/transitions.py +873 -0
- scrollkit/exceptions.py +55 -0
- scrollkit/network/__init__.py +1 -0
- scrollkit/network/http_client.py +505 -0
- scrollkit/network/mdns.py +44 -0
- scrollkit/network/wifi_manager.py +382 -0
- scrollkit/ota/__init__.py +13 -0
- scrollkit/ota/client.py +528 -0
- scrollkit/ota/display_progress.py +125 -0
- scrollkit/ota/manifest.py +206 -0
- scrollkit/ota/publish.py +379 -0
- scrollkit/simulator/ATTRIBUTION.md +20 -0
- scrollkit/simulator/CIRCUITPYTHON_COMPATIBILITY.md +364 -0
- scrollkit/simulator/LICENSE +176 -0
- scrollkit/simulator/README.md +87 -0
- scrollkit/simulator/__init__.py +11 -0
- scrollkit/simulator/adafruit_bitmap_font/__init__.py +5 -0
- scrollkit/simulator/adafruit_bitmap_font/bitmap_font.py +273 -0
- scrollkit/simulator/adafruit_bitmap_font/glyph_cache.py +70 -0
- scrollkit/simulator/adafruit_display_text/__init__.py +5 -0
- scrollkit/simulator/adafruit_display_text/label.py +336 -0
- scrollkit/simulator/bitmaptools.py +50 -0
- scrollkit/simulator/core/__init__.py +8 -0
- scrollkit/simulator/core/color_utils.py +62 -0
- scrollkit/simulator/core/device_benchmarks.json +254 -0
- scrollkit/simulator/core/feasibility.py +160 -0
- scrollkit/simulator/core/hardware_profile.py +191 -0
- scrollkit/simulator/core/led_matrix.py +307 -0
- scrollkit/simulator/core/matrixportal_s3_baseline.json +12 -0
- scrollkit/simulator/core/performance_manager.py +253 -0
- scrollkit/simulator/core/pixel_buffer.py +188 -0
- scrollkit/simulator/devices/__init__.py +7 -0
- scrollkit/simulator/devices/base_device.py +79 -0
- scrollkit/simulator/devices/matrixportal_s3.py +87 -0
- scrollkit/simulator/displayio/__init__.py +12 -0
- scrollkit/simulator/displayio/bitmap.py +158 -0
- scrollkit/simulator/displayio/display.py +195 -0
- scrollkit/simulator/displayio/fourwire.py +54 -0
- scrollkit/simulator/displayio/group.py +125 -0
- scrollkit/simulator/displayio/ondiskbitmap.py +109 -0
- scrollkit/simulator/displayio/palette.py +115 -0
- scrollkit/simulator/displayio/tilegrid.py +155 -0
- scrollkit/simulator/fonts/3x5.bdf +2474 -0
- scrollkit/simulator/fonts/Arial_16.bdf +7366 -0
- scrollkit/simulator/fonts/Arial_16.bdf.license +3 -0
- scrollkit/simulator/fonts/Arial_Bold_12.bdf +6131 -0
- scrollkit/simulator/fonts/Arial_Bold_12.bdf.license +2 -0
- scrollkit/simulator/fonts/Arial_Bold_18.bdf +32653 -0
- scrollkit/simulator/fonts/Arial_Bold_18.bdf.license +2 -0
- scrollkit/simulator/fonts/Junction_regular_24.bdf +8676 -0
- scrollkit/simulator/fonts/Junction_regular_24.bdf.license +3 -0
- scrollkit/simulator/fonts/LeagueSpartan-Bold-16.bdf +12458 -0
- scrollkit/simulator/fonts/LeagueSpartan-Bold-16.bdf.license +1921 -0
- scrollkit/simulator/fonts/LeagueSpartan_Bold_16.bdf +12458 -0
- scrollkit/simulator/fonts/LeagueSpartan_Bold_16.bdf.license +4 -0
- scrollkit/simulator/fonts/LibreBodoniv2002-Bold-27.bdf +16818 -0
- scrollkit/simulator/fonts/LibreBodoniv2002-Bold-27.bdf.license +1921 -0
- scrollkit/simulator/fonts/tom-thumb.bdf +2353 -0
- scrollkit/simulator/fonts/viii-bold.bdf +2673 -0
- scrollkit/simulator/fonts/viii.bdf +2659 -0
- scrollkit/simulator/terminalio/__init__.py +20 -0
- scrollkit/utils/__init__.py +1 -0
- scrollkit/utils/color_utils.py +54 -0
- scrollkit/utils/diagnostics.py +227 -0
- scrollkit/utils/error_handler.py +347 -0
- scrollkit/utils/system_utils.py +245 -0
- scrollkit/utils/url_utils.py +46 -0
- scrollkit/web/__init__.py +6 -0
- scrollkit/web/settings_server.py +328 -0
- scrollkit/web/wifi_setup.py +331 -0
- scrollkit-0.8.3.dist-info/METADATA +248 -0
- scrollkit-0.8.3.dist-info/RECORD +111 -0
- scrollkit-0.8.3.dist-info/WHEEL +5 -0
- scrollkit-0.8.3.dist-info/licenses/LICENSE +31 -0
- scrollkit-0.8.3.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,595 @@
|
|
|
1
|
+
# Copyright (c) 2024-2026 Michael Winslow Czeiszperger
|
|
2
|
+
"""Unified display implementation for SLDK.
|
|
3
|
+
|
|
4
|
+
Provides a unified interface that works on both CircuitPython hardware
|
|
5
|
+
and desktop development environments using the LED simulator.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import sys
|
|
11
|
+
import gc
|
|
12
|
+
try:
|
|
13
|
+
from typing import Any, Dict, List, Optional
|
|
14
|
+
except ImportError: # CircuitPython has no 'typing' module
|
|
15
|
+
pass
|
|
16
|
+
|
|
17
|
+
import asyncio
|
|
18
|
+
|
|
19
|
+
# Platform detection
|
|
20
|
+
IS_CIRCUITPYTHON = hasattr(sys, 'implementation') and sys.implementation.name == 'circuitpython'
|
|
21
|
+
|
|
22
|
+
# Conditional imports based on platform
|
|
23
|
+
if IS_CIRCUITPYTHON:
|
|
24
|
+
import displayio
|
|
25
|
+
import terminalio
|
|
26
|
+
try:
|
|
27
|
+
from adafruit_bitmap_font import bitmap_font
|
|
28
|
+
except ImportError:
|
|
29
|
+
bitmap_font = None
|
|
30
|
+
from adafruit_display_text.label import Label
|
|
31
|
+
else:
|
|
32
|
+
# SLDK Simulator imports - these will be optional. The simulator device itself
|
|
33
|
+
# is built inside _sim_backend.create_sim_device; here we just probe that the
|
|
34
|
+
# simulator rendering stack (displayio/Label/terminalio) is importable.
|
|
35
|
+
try:
|
|
36
|
+
from scrollkit.simulator import displayio
|
|
37
|
+
from scrollkit.simulator.adafruit_bitmap_font import bitmap_font
|
|
38
|
+
from scrollkit.simulator.adafruit_display_text.label import Label
|
|
39
|
+
from scrollkit.simulator.terminalio import FONT as terminalio_FONT
|
|
40
|
+
# Create module alias for consistency
|
|
41
|
+
from scrollkit.simulator import terminalio as simulator_terminalio
|
|
42
|
+
terminalio = simulator_terminalio
|
|
43
|
+
terminalio.FONT = terminalio_FONT
|
|
44
|
+
LED_SIMULATOR_AVAILABLE = True
|
|
45
|
+
except ImportError:
|
|
46
|
+
LED_SIMULATOR_AVAILABLE = False
|
|
47
|
+
displayio = None
|
|
48
|
+
terminalio = None
|
|
49
|
+
bitmap_font = None
|
|
50
|
+
Label = None
|
|
51
|
+
|
|
52
|
+
from .interface import DisplayInterface
|
|
53
|
+
from ._graphics import GraphicsMixin
|
|
54
|
+
from .boards import resolve_board
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
__all__ = ['UnifiedDisplay', 'IS_CIRCUITPYTHON']
|
|
58
|
+
|
|
59
|
+
class UnifiedDisplay(GraphicsMixin, DisplayInterface):
|
|
60
|
+
"""Unified display that auto-detects hardware vs simulator."""
|
|
61
|
+
|
|
62
|
+
# Window title for create_window(); SimulatorDisplay overrides.
|
|
63
|
+
_WINDOW_TITLE = "SLDK Display"
|
|
64
|
+
# Whether show() creates the pygame window automatically on desktop.
|
|
65
|
+
# False here (UnifiedDisplay stays headless unless create_window() is
|
|
66
|
+
# called); SimulatorDisplay — the interactive/dev display — sets True.
|
|
67
|
+
_AUTO_WINDOW = False
|
|
68
|
+
# Soft cap: warn once past this many recorded frames (~25s at 20 FPS) so a
|
|
69
|
+
# recording left on by accident doesn't silently eat memory.
|
|
70
|
+
_RECORDING_WARN_FRAMES = 500
|
|
71
|
+
|
|
72
|
+
def __init__(self, width=None, height=None, bit_depth: int = 4, board=None,
|
|
73
|
+
*, hardware_timing: bool = False, throttle: bool = False,
|
|
74
|
+
strict: bool = False, pitch=None):
|
|
75
|
+
"""Initialize unified display.
|
|
76
|
+
|
|
77
|
+
Args:
|
|
78
|
+
width: Display width in pixels. ``None`` uses the board's default
|
|
79
|
+
(64 on both supported boards).
|
|
80
|
+
height: Display height in pixels. ``None`` uses the board's default
|
|
81
|
+
(32 on both supported boards).
|
|
82
|
+
bit_depth: Color bits per channel on hardware (1-6). Measured on a
|
|
83
|
+
MatrixPortal S3, a full refresh costs ~4.5 ms at bit_depth<=4 but
|
|
84
|
+
~13.7 ms at bit_depth 6 (~3x) — so 4 is the speed/quality sweet
|
|
85
|
+
spot for scrolling/data displays. Raise to 6 only if you need
|
|
86
|
+
smooth color gradients and can afford the lower frame rate.
|
|
87
|
+
board: Canonical board id (e.g. ``"adafruit_matrixportal_s3"`` or
|
|
88
|
+
``"pimoroni_interstate75_w"``). ``None`` auto-detects on hardware
|
|
89
|
+
via ``board.board_id``, honors ``SCROLLKIT_HW_BOARD``, and falls
|
|
90
|
+
back to the MatrixPortal S3 (see ``display/boards.py``).
|
|
91
|
+
hardware_timing: (desktop only) model how slow the real device would
|
|
92
|
+
run — read the estimate via feasibility_report(). Ignored on
|
|
93
|
+
hardware (the device IS the timing). Also enabled by
|
|
94
|
+
SCROLLKIT_HW_SIM=1.
|
|
95
|
+
throttle: (desktop only) with hardware_timing, also sleep so the
|
|
96
|
+
window crawls at the modeled hardware speed. Also
|
|
97
|
+
SCROLLKIT_HW_THROTTLE=1.
|
|
98
|
+
strict: (desktop only) enforce the feasibility gate — a sustained
|
|
99
|
+
over-budget run raises FeasibilityError. Implies
|
|
100
|
+
hardware_timing. Also SCROLLKIT_HW_STRICT=1.
|
|
101
|
+
pitch: (desktop only) LED pitch (mm) -> on-screen LED size for
|
|
102
|
+
recordings/screenshots. ``None`` uses the simulator default.
|
|
103
|
+
"""
|
|
104
|
+
spec = resolve_board(board)
|
|
105
|
+
self._board_id: str = spec.board_id
|
|
106
|
+
self._board_spec = spec
|
|
107
|
+
self._width: int = spec.default_width if width is None else width
|
|
108
|
+
self._height: int = spec.default_height if height is None else height
|
|
109
|
+
self._bit_depth: int = bit_depth
|
|
110
|
+
# Hardware-realism simulation (desktop opt-in; env vars honored too).
|
|
111
|
+
self._hardware_timing: bool = hardware_timing
|
|
112
|
+
self._throttle: bool = throttle
|
|
113
|
+
self._strict: bool = strict
|
|
114
|
+
self._pitch = pitch
|
|
115
|
+
# Brightness default is per-platform ON PURPOSE: hardware boots dim
|
|
116
|
+
# (0.3 — a full-white 64x32 panel at 1.0 is a real power/eye hazard);
|
|
117
|
+
# desktop boots at 1.0 so content is clearly visible in the simulator
|
|
118
|
+
# (both desktop entry points agree; the LED renderer also applies its
|
|
119
|
+
# high-visibility enhancement at 1.0). Apps that manage brightness get
|
|
120
|
+
# the settings default (0.5) applied via _apply_library_settings.
|
|
121
|
+
self._brightness: float = 0.3 if IS_CIRCUITPYTHON else 1.0
|
|
122
|
+
|
|
123
|
+
# Platform specific components
|
|
124
|
+
self.hardware: Any = None
|
|
125
|
+
self.matrix: Any = None
|
|
126
|
+
self.display: Any = None
|
|
127
|
+
self.device: Any = None # For simulator
|
|
128
|
+
self._perf: Any = None # hardware-realism PerformanceManager (opt-in, desktop)
|
|
129
|
+
|
|
130
|
+
# Display components
|
|
131
|
+
# main_group = [_content_group (below), _layer_group (above)]; labels +
|
|
132
|
+
# fill live in _content_group, persistent effect layers in _layer_group
|
|
133
|
+
# (never disturbed by the per-frame label reset). See GraphicsMixin / D11.
|
|
134
|
+
self.main_group: Any = None
|
|
135
|
+
self._content_group: Any = None
|
|
136
|
+
self._layer_group: Any = None
|
|
137
|
+
self._gfx: Any = None
|
|
138
|
+
self._initialized: bool = False
|
|
139
|
+
|
|
140
|
+
# For text rendering
|
|
141
|
+
self.font: Any = None
|
|
142
|
+
# Reusable Label pool, indexed by draw-order within a frame. draw_text()
|
|
143
|
+
# pulls the next slot and mutates it in place; scrolling/static text then
|
|
144
|
+
# reuses one Label forever instead of allocating (and leaking) a new
|
|
145
|
+
# Label+Group every frame. Reset each frame in clear().
|
|
146
|
+
self._label_pool: List[Any] = []
|
|
147
|
+
self._label_idx: int = 0
|
|
148
|
+
# Parallel pool for integer-scaled labels (draw_text_scaled). Kept
|
|
149
|
+
# separate from _label_pool so a scaled and an unscaled draw in the same
|
|
150
|
+
# frame never thrash one Label's .scale attribute. Same discipline:
|
|
151
|
+
# reuse + mutate in place, reset index in clear(), hide-unused in show().
|
|
152
|
+
self._scaled_pool: List[Any] = []
|
|
153
|
+
self._scaled_idx: int = 0
|
|
154
|
+
|
|
155
|
+
# Desktop extras (no-ops on hardware): pygame window + frame recording.
|
|
156
|
+
# None = not recording; a list = capturing (H, W, 3) uint8 frames.
|
|
157
|
+
self._window_created: bool = False
|
|
158
|
+
self._recording = None
|
|
159
|
+
|
|
160
|
+
@property
|
|
161
|
+
def width(self) -> int:
|
|
162
|
+
"""Display width in pixels."""
|
|
163
|
+
return self._width
|
|
164
|
+
|
|
165
|
+
@property
|
|
166
|
+
def height(self) -> int:
|
|
167
|
+
"""Display height in pixels."""
|
|
168
|
+
return self._height
|
|
169
|
+
|
|
170
|
+
async def initialize(self) -> None:
|
|
171
|
+
"""Initialize the display hardware or simulator."""
|
|
172
|
+
if self._initialized:
|
|
173
|
+
return
|
|
174
|
+
|
|
175
|
+
try:
|
|
176
|
+
# Platform-specific hardware initialization
|
|
177
|
+
self._initialize_hardware()
|
|
178
|
+
|
|
179
|
+
# Set up display groups (content below, layers above) + cache gfx.
|
|
180
|
+
self.main_group = displayio.Group()
|
|
181
|
+
self.display.root_group = self.main_group
|
|
182
|
+
if IS_CIRCUITPYTHON:
|
|
183
|
+
import bitmaptools as _bitmaptools
|
|
184
|
+
else:
|
|
185
|
+
from scrollkit.simulator import bitmaptools as _bitmaptools
|
|
186
|
+
self._init_graphics(displayio, _bitmaptools)
|
|
187
|
+
|
|
188
|
+
# Load default font
|
|
189
|
+
self.font = terminalio.FONT if terminalio else None
|
|
190
|
+
|
|
191
|
+
# Set initial brightness
|
|
192
|
+
await self.set_brightness(self._brightness)
|
|
193
|
+
|
|
194
|
+
self._initialized = True
|
|
195
|
+
print(f"Display initialized ({'CircuitPython' if IS_CIRCUITPYTHON else 'Simulator'})")
|
|
196
|
+
|
|
197
|
+
except (ImportError, OSError) as e:
|
|
198
|
+
print(f"Failed to initialize display: {e}")
|
|
199
|
+
raise
|
|
200
|
+
|
|
201
|
+
def _initialize_hardware(self) -> None:
|
|
202
|
+
"""Initialize the display hardware/simulator."""
|
|
203
|
+
if IS_CIRCUITPYTHON:
|
|
204
|
+
# Build the RGB matrix via the resolved board's constructor. bit_depth
|
|
205
|
+
# is passed explicitly (4 by default) rather than relying on a library
|
|
206
|
+
# default — it's the single biggest refresh-cost lever (~3x between
|
|
207
|
+
# bit_depth 4 and 6). Per-board pin wiring lives in display/boards.py.
|
|
208
|
+
try:
|
|
209
|
+
self.hardware, self.display, self.matrix = (
|
|
210
|
+
self._board_spec.make_matrix(
|
|
211
|
+
self._width, self._height, self._bit_depth))
|
|
212
|
+
except ImportError:
|
|
213
|
+
raise ImportError(
|
|
214
|
+
"No compatible hardware found for board %r" % self._board_id)
|
|
215
|
+
else:
|
|
216
|
+
if not LED_SIMULATOR_AVAILABLE:
|
|
217
|
+
raise ImportError(
|
|
218
|
+
"LED simulator not available. "
|
|
219
|
+
'Install with: pip install "scrollkit[simulator]"'
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
# Build + initialize the simulator device (and its hardware-timing
|
|
223
|
+
# model, opt-in via the constructor flags or SCROLLKIT_HW_* env
|
|
224
|
+
# vars) through the shared backend helper.
|
|
225
|
+
from ._sim_backend import create_sim_device
|
|
226
|
+
self.device, self.matrix, self.display, self._perf = create_sim_device(
|
|
227
|
+
self._width, self._height, self._board_id, pitch=self._pitch,
|
|
228
|
+
hardware_timing=self._hardware_timing, throttle=self._throttle,
|
|
229
|
+
strict=self._strict)
|
|
230
|
+
self.hardware = self.device # For compatibility
|
|
231
|
+
|
|
232
|
+
def feasibility_report(self):
|
|
233
|
+
"""Estimate how this app would perform on the real hardware.
|
|
234
|
+
|
|
235
|
+
Mirrors SimulatorDisplay.feasibility_report(); returns a disabled stub
|
|
236
|
+
unless hardware timing was enabled (via the SCROLLKIT_HW_* env vars).
|
|
237
|
+
"""
|
|
238
|
+
if self._perf is None:
|
|
239
|
+
from ._sim_backend import disabled_feasibility_report
|
|
240
|
+
return disabled_feasibility_report(
|
|
241
|
+
"enable with hardware_timing=True or SCROLLKIT_HW_SIM=1")
|
|
242
|
+
return self._perf.report()
|
|
243
|
+
|
|
244
|
+
# clear() / set_pixel() / fill() / _hide_unused_labels() come from
|
|
245
|
+
# GraphicsMixin — ONE per-frame surface for hardware and simulator.
|
|
246
|
+
|
|
247
|
+
async def show(self) -> bool:
|
|
248
|
+
"""Update the physical display."""
|
|
249
|
+
self._hide_unused_labels()
|
|
250
|
+
if IS_CIRCUITPYTHON:
|
|
251
|
+
# self.display is the displayio display on every board (the S3's
|
|
252
|
+
# Matrix wrapper display or the Interstate 75's FramebufferDisplay).
|
|
253
|
+
# self.hardware may be a raw rgbmatrix.RGBMatrix with no .display.
|
|
254
|
+
if self.display:
|
|
255
|
+
self.display.refresh(minimum_frames_per_second=0)
|
|
256
|
+
return True
|
|
257
|
+
else:
|
|
258
|
+
# Simulator needs pygame event handling
|
|
259
|
+
return await self._update_simulator()
|
|
260
|
+
|
|
261
|
+
async def _update_simulator(self) -> bool:
|
|
262
|
+
"""Update the simulator display (the ONE desktop frame path)."""
|
|
263
|
+
if not self.display:
|
|
264
|
+
return True
|
|
265
|
+
|
|
266
|
+
try:
|
|
267
|
+
import pygame
|
|
268
|
+
|
|
269
|
+
# The interactive display (SimulatorDisplay) opens its window on
|
|
270
|
+
# first show; UnifiedDisplay stays headless unless asked.
|
|
271
|
+
if self._AUTO_WINDOW and not self._window_created:
|
|
272
|
+
await self.create_window()
|
|
273
|
+
|
|
274
|
+
has_window = pygame.get_init() and pygame.display.get_surface()
|
|
275
|
+
|
|
276
|
+
# Handle events to keep the window responsive.
|
|
277
|
+
if has_window:
|
|
278
|
+
for event in pygame.event.get():
|
|
279
|
+
if event.type == pygame.QUIT:
|
|
280
|
+
return False
|
|
281
|
+
elif event.type == pygame.KEYDOWN:
|
|
282
|
+
if event.key == pygame.K_ESCAPE:
|
|
283
|
+
return False
|
|
284
|
+
|
|
285
|
+
# display.refresh() already renders the matrix to its surface exactly
|
|
286
|
+
# once (== one modeled hardware frame). Do NOT call matrix.render()
|
|
287
|
+
# again below — a second render would double-count modeled frames and
|
|
288
|
+
# make the feasibility estimate disagree with the throttle crawl.
|
|
289
|
+
self.display.refresh(minimum_frames_per_second=0)
|
|
290
|
+
|
|
291
|
+
# If recording (opt-in), grab this finished frame for save_gif().
|
|
292
|
+
if self._recording is not None:
|
|
293
|
+
self._capture_recording_frame()
|
|
294
|
+
|
|
295
|
+
if has_window:
|
|
296
|
+
# Blit the rendered LED matrix surface to the pygame window.
|
|
297
|
+
screen = pygame.display.get_surface()
|
|
298
|
+
if screen and hasattr(self.matrix, 'get_surface'):
|
|
299
|
+
matrix_surface = self.matrix.get_surface()
|
|
300
|
+
if matrix_surface:
|
|
301
|
+
screen.fill((0, 0, 0))
|
|
302
|
+
screen.blit(matrix_surface, (0, 0))
|
|
303
|
+
pygame.display.flip()
|
|
304
|
+
|
|
305
|
+
# Small yield for responsiveness
|
|
306
|
+
await asyncio.sleep(0.001)
|
|
307
|
+
|
|
308
|
+
except ImportError:
|
|
309
|
+
# Pygame not available
|
|
310
|
+
if self.display:
|
|
311
|
+
self.display.refresh(minimum_frames_per_second=0)
|
|
312
|
+
|
|
313
|
+
return True
|
|
314
|
+
|
|
315
|
+
# ------------------------------------------------------------------
|
|
316
|
+
# Screenshot + recording (desktop-only, opt-in; None/no-op on hardware).
|
|
317
|
+
# The heavy pygame/Pillow/ffmpeg work lives in display/_recording.py and
|
|
318
|
+
# is imported lazily so the device never pays for it.
|
|
319
|
+
# ------------------------------------------------------------------
|
|
320
|
+
|
|
321
|
+
def screenshot(self, path):
|
|
322
|
+
"""Save the current display frame to an image file (PNG by extension).
|
|
323
|
+
|
|
324
|
+
Renders whatever is currently on the simulated matrix to ``path``.
|
|
325
|
+
Returns the path on success or None if unavailable (e.g. on hardware).
|
|
326
|
+
|
|
327
|
+
Example::
|
|
328
|
+
|
|
329
|
+
await display.show()
|
|
330
|
+
display.screenshot("frame.png")
|
|
331
|
+
"""
|
|
332
|
+
if IS_CIRCUITPYTHON:
|
|
333
|
+
return None
|
|
334
|
+
from ._recording import save_surface_png
|
|
335
|
+
return save_surface_png(self.matrix, path)
|
|
336
|
+
|
|
337
|
+
def start_recording(self):
|
|
338
|
+
"""Begin capturing each shown frame for a later :meth:`save_gif`.
|
|
339
|
+
|
|
340
|
+
After this, every :meth:`show` appends the current LED-panel frame. Call
|
|
341
|
+
:meth:`save_gif`/:meth:`save_video` to encode them or
|
|
342
|
+
:meth:`stop_recording` to discard. No-op (returns None) on hardware.
|
|
343
|
+
Returns ``self`` so the call can be chained.
|
|
344
|
+
"""
|
|
345
|
+
if IS_CIRCUITPYTHON:
|
|
346
|
+
return None
|
|
347
|
+
self._recording = []
|
|
348
|
+
return self
|
|
349
|
+
|
|
350
|
+
def stop_recording(self) -> None:
|
|
351
|
+
"""Stop capturing frames and discard anything not yet saved."""
|
|
352
|
+
self._recording = None
|
|
353
|
+
|
|
354
|
+
@property
|
|
355
|
+
def is_recording(self) -> bool:
|
|
356
|
+
"""True while frames are being captured for :meth:`save_gif`."""
|
|
357
|
+
return self._recording is not None
|
|
358
|
+
|
|
359
|
+
def _capture_recording_frame(self) -> None:
|
|
360
|
+
"""Append the current LED-panel surface to the recording (internal)."""
|
|
361
|
+
from ._recording import capture_frame
|
|
362
|
+
frame = capture_frame(self.matrix)
|
|
363
|
+
if frame is None:
|
|
364
|
+
return
|
|
365
|
+
self._recording.append(frame)
|
|
366
|
+
if len(self._recording) == self._RECORDING_WARN_FRAMES:
|
|
367
|
+
print("display: recording is %d frames and growing; call "
|
|
368
|
+
"save_gif()/stop_recording() to release memory."
|
|
369
|
+
% self._RECORDING_WARN_FRAMES)
|
|
370
|
+
|
|
371
|
+
def save_gif(self, path, *, fps: int = 20, target_width: int = 360,
|
|
372
|
+
max_colors: int = 48, loop: int = 0, frame_step: int = 1,
|
|
373
|
+
disposal: int = 1):
|
|
374
|
+
"""Encode the recorded frames to an animated GIF and clear the buffer.
|
|
375
|
+
|
|
376
|
+
Frames captured since :meth:`start_recording` are downscaled to
|
|
377
|
+
``target_width`` and share ONE adaptive palette (≤ ``max_colors``) so
|
|
378
|
+
colors stay stable across the loop and the file stays small.
|
|
379
|
+
``frame_step`` keeps only every Nth frame (per-frame duration is
|
|
380
|
+
lengthened to keep playback speed correct); ``loop=0`` loops forever.
|
|
381
|
+
|
|
382
|
+
Returns the path, or None when there is nothing to save or
|
|
383
|
+
pygame/Pillow isn't available (e.g. on hardware) — mirroring
|
|
384
|
+
:meth:`screenshot`. Recording is stopped afterward either way.
|
|
385
|
+
|
|
386
|
+
Example::
|
|
387
|
+
|
|
388
|
+
display.start_recording()
|
|
389
|
+
for _ in range(60):
|
|
390
|
+
await content.render(display)
|
|
391
|
+
await display.show()
|
|
392
|
+
display.save_gif("demo.gif")
|
|
393
|
+
"""
|
|
394
|
+
frames = self._recording or []
|
|
395
|
+
self._recording = None
|
|
396
|
+
if not frames or IS_CIRCUITPYTHON:
|
|
397
|
+
return None
|
|
398
|
+
from ._recording import encode_gif
|
|
399
|
+
return encode_gif(frames, path, fps=fps, target_width=target_width,
|
|
400
|
+
max_colors=max_colors, loop=loop,
|
|
401
|
+
frame_step=frame_step, disposal=disposal)
|
|
402
|
+
|
|
403
|
+
def save_video(self, path, *, fps: int = 24, target_width=None,
|
|
404
|
+
crf: int = 20, preset: str = "medium",
|
|
405
|
+
border: int = 0, border_color=(10, 10, 13)):
|
|
406
|
+
"""Encode the recorded frames to an MP4 (H.264) via ffmpeg; clear buffer.
|
|
407
|
+
|
|
408
|
+
The web-friendly sibling of :meth:`save_gif`: for full-colour animation
|
|
409
|
+
an MP4 is far smaller and smoother than a GIF. Frames are piped straight
|
|
410
|
+
to ``ffmpeg`` (which must be on PATH). ``target_width`` optionally
|
|
411
|
+
downscales (kept even, as yuv420p requires); ``crf`` trades size for
|
|
412
|
+
quality (≈18 best … 24 smaller); ``border`` adds a dark bezel of that
|
|
413
|
+
many pixels (``border_color`` RGB) so edge rows aren't flush with the
|
|
414
|
+
frame boundary.
|
|
415
|
+
|
|
416
|
+
Returns the path, or None when there are no frames or ffmpeg/encode is
|
|
417
|
+
unavailable (mirroring :meth:`save_gif`). Recording stops either way.
|
|
418
|
+
"""
|
|
419
|
+
frames = self._recording or []
|
|
420
|
+
self._recording = None
|
|
421
|
+
if not frames or IS_CIRCUITPYTHON:
|
|
422
|
+
return None
|
|
423
|
+
from ._recording import encode_video
|
|
424
|
+
return encode_video(frames, path, fps=fps, target_width=target_width,
|
|
425
|
+
crf=crf, preset=preset, border=border,
|
|
426
|
+
border_color=border_color)
|
|
427
|
+
|
|
428
|
+
async def set_brightness(self, brightness: float) -> None:
|
|
429
|
+
"""Set display brightness.
|
|
430
|
+
|
|
431
|
+
Args:
|
|
432
|
+
brightness: Float between 0.0 and 1.0
|
|
433
|
+
"""
|
|
434
|
+
self._brightness = max(0.0, min(1.0, brightness))
|
|
435
|
+
|
|
436
|
+
if self.display:
|
|
437
|
+
try:
|
|
438
|
+
self.display.brightness = self._brightness
|
|
439
|
+
except (AttributeError, TypeError) as e:
|
|
440
|
+
print(f"Failed to set brightness: {e}")
|
|
441
|
+
|
|
442
|
+
async def draw_text(self, text: str, x: int = 0, y: int = 0, color: int = 0xFFFFFF, font: Any = None) -> None:
|
|
443
|
+
"""Draw text on display using displayio labels.
|
|
444
|
+
|
|
445
|
+
Args:
|
|
446
|
+
text: Text to display
|
|
447
|
+
x: Starting X coordinate
|
|
448
|
+
y: Starting Y coordinate
|
|
449
|
+
color: Text color as 24-bit RGB
|
|
450
|
+
font: Font to use (uses default if None)
|
|
451
|
+
"""
|
|
452
|
+
if not Label:
|
|
453
|
+
# Label class not available
|
|
454
|
+
return
|
|
455
|
+
|
|
456
|
+
# Use provided font or default
|
|
457
|
+
if font is None:
|
|
458
|
+
font = self.font
|
|
459
|
+
if font is None:
|
|
460
|
+
return
|
|
461
|
+
|
|
462
|
+
# Pull the next Label slot for this frame and mutate it in place. Only
|
|
463
|
+
# touch .text when it actually changed (a text change rebuilds the glyph
|
|
464
|
+
# bitmap — the dominant per-frame cost on hardware; moving .x/.y is cheap).
|
|
465
|
+
idx = self._label_idx
|
|
466
|
+
if idx < len(self._label_pool):
|
|
467
|
+
label = self._label_pool[idx]
|
|
468
|
+
if label.text != text:
|
|
469
|
+
label.text = text
|
|
470
|
+
if label.color != color:
|
|
471
|
+
label.color = color
|
|
472
|
+
label.x = x
|
|
473
|
+
label.y = y
|
|
474
|
+
if hasattr(label, "hidden"):
|
|
475
|
+
label.hidden = False
|
|
476
|
+
else:
|
|
477
|
+
label = Label(font, text=text, color=color)
|
|
478
|
+
label.x = x
|
|
479
|
+
label.y = y
|
|
480
|
+
self._label_pool.append(label)
|
|
481
|
+
self._content_group.append(label) # added directly; no per-label wrapper Group
|
|
482
|
+
self._label_idx += 1
|
|
483
|
+
|
|
484
|
+
async def draw_text_scaled(self, text: str, x: int = 0, y: int = 0,
|
|
485
|
+
color: int = 0xFFFFFF, scale: int = 2,
|
|
486
|
+
font: Any = None) -> None:
|
|
487
|
+
"""Draw integer-scaled text (e.g. a large number), reusing a pooled Label.
|
|
488
|
+
|
|
489
|
+
Like :meth:`draw_text` but renders at ``scale`` x the font's native size.
|
|
490
|
+
Uses a dedicated scaled-Label pool so mixing scaled and unscaled draws in
|
|
491
|
+
the same frame never thrashes a Label's ``.scale``; per-frame work stays
|
|
492
|
+
allocation-free after the first frame.
|
|
493
|
+
|
|
494
|
+
Args:
|
|
495
|
+
text: Text to display.
|
|
496
|
+
x: Starting X coordinate.
|
|
497
|
+
y: Starting Y coordinate (the font baseline, as with ``draw_text``).
|
|
498
|
+
color: Text color as 24-bit RGB.
|
|
499
|
+
scale: Integer magnification factor (>=1).
|
|
500
|
+
font: Font to use (default font if None).
|
|
501
|
+
"""
|
|
502
|
+
if not Label:
|
|
503
|
+
return
|
|
504
|
+
if font is None:
|
|
505
|
+
font = self.font
|
|
506
|
+
if font is None:
|
|
507
|
+
return
|
|
508
|
+
if scale < 1:
|
|
509
|
+
scale = 1
|
|
510
|
+
|
|
511
|
+
idx = self._scaled_idx
|
|
512
|
+
if idx < len(self._scaled_pool):
|
|
513
|
+
label = self._scaled_pool[idx]
|
|
514
|
+
if label.text != text:
|
|
515
|
+
label.text = text
|
|
516
|
+
if label.color != color:
|
|
517
|
+
label.color = color
|
|
518
|
+
if getattr(label, "scale", scale) != scale:
|
|
519
|
+
label.scale = scale
|
|
520
|
+
label.x = x
|
|
521
|
+
label.y = y
|
|
522
|
+
if hasattr(label, "hidden"):
|
|
523
|
+
label.hidden = False
|
|
524
|
+
else:
|
|
525
|
+
label = Label(font, text=text, color=color, scale=scale)
|
|
526
|
+
label.x = x
|
|
527
|
+
label.y = y
|
|
528
|
+
self._scaled_pool.append(label)
|
|
529
|
+
self._content_group.append(label)
|
|
530
|
+
self._scaled_idx += 1
|
|
531
|
+
|
|
532
|
+
def _convert_color(self, color: Any) -> int:
|
|
533
|
+
"""Convert color to platform-appropriate format.
|
|
534
|
+
|
|
535
|
+
Args:
|
|
536
|
+
color: Color value (int or hex string)
|
|
537
|
+
|
|
538
|
+
Returns:
|
|
539
|
+
Integer color value
|
|
540
|
+
"""
|
|
541
|
+
if isinstance(color, str):
|
|
542
|
+
return int(color, 16)
|
|
543
|
+
return int(color)
|
|
544
|
+
|
|
545
|
+
async def create_window(self, title: str = None) -> None:
|
|
546
|
+
"""Create the pygame display window (desktop only; no-op on hardware).
|
|
547
|
+
|
|
548
|
+
Args:
|
|
549
|
+
title: Window title (default: the class's _WINDOW_TITLE)
|
|
550
|
+
"""
|
|
551
|
+
if IS_CIRCUITPYTHON or self._window_created:
|
|
552
|
+
return
|
|
553
|
+
|
|
554
|
+
try:
|
|
555
|
+
import os as _os
|
|
556
|
+
import pygame
|
|
557
|
+
|
|
558
|
+
# Ask SDL for a visible, sanely-placed window.
|
|
559
|
+
_os.environ['SDL_VIDEO_WINDOW_POS'] = '100,100'
|
|
560
|
+
_os.environ['SDL_VIDEO_CENTERED'] = '1'
|
|
561
|
+
|
|
562
|
+
if not pygame.get_init():
|
|
563
|
+
pygame.init()
|
|
564
|
+
|
|
565
|
+
if hasattr(self.matrix, 'surface_width'):
|
|
566
|
+
width = self.matrix.surface_width
|
|
567
|
+
height = self.matrix.surface_height
|
|
568
|
+
else:
|
|
569
|
+
scale = getattr(self, "_scale", 10)
|
|
570
|
+
width = self._width * scale
|
|
571
|
+
height = self._height * scale
|
|
572
|
+
|
|
573
|
+
screen = pygame.display.set_mode((width, height), pygame.SHOWN)
|
|
574
|
+
pygame.display.set_caption(title or self._WINDOW_TITLE)
|
|
575
|
+
screen.fill((50, 50, 50))
|
|
576
|
+
pygame.display.flip()
|
|
577
|
+
self._window_created = True
|
|
578
|
+
|
|
579
|
+
except ImportError:
|
|
580
|
+
print("Pygame not available for window creation")
|
|
581
|
+
|
|
582
|
+
async def run_event_loop(self) -> None:
|
|
583
|
+
"""Run the display event loop (simulator only).
|
|
584
|
+
|
|
585
|
+
This keeps the simulator window responsive.
|
|
586
|
+
Should be run as a background task.
|
|
587
|
+
"""
|
|
588
|
+
if IS_CIRCUITPYTHON:
|
|
589
|
+
return
|
|
590
|
+
|
|
591
|
+
while True:
|
|
592
|
+
result = await self.show()
|
|
593
|
+
if not result:
|
|
594
|
+
break
|
|
595
|
+
await asyncio.sleep(0.01) # ~100 FPS max
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# Copyright (c) 2024-2026 Michael Winslow Czeiszperger
|
|
2
|
+
"""ScrollKit Effects package.
|
|
3
|
+
|
|
4
|
+
Kept deliberately empty of imports: importing ``scrollkit.effects`` must not
|
|
5
|
+
pull in every standalone visual system, so a device build that only uses
|
|
6
|
+
transitions (``app.base``'s lazy ``from ..effects.transitions import
|
|
7
|
+
transition_factory``) never loads the particle engine or the splash reveals
|
|
8
|
+
into RAM. Import each submodule directly instead:
|
|
9
|
+
|
|
10
|
+
- **Transitions** (the one content-swap contract) —
|
|
11
|
+
``from scrollkit.effects.transitions import Transition, transition_factory,
|
|
12
|
+
supported_names``
|
|
13
|
+
- **Content scrollers** (Class 1 — characterful scrolling) —
|
|
14
|
+
``from scrollkit.effects.scrolling import KineticMarquee, WaveRider, SplitFlap``
|
|
15
|
+
- **Splash / reveal animations** —
|
|
16
|
+
``from scrollkit.effects.reveal_splash import show_reveal_splash, pixels_from_text``,
|
|
17
|
+
``from scrollkit.effects.drip_splash import show_drip_splash, DripReveal``,
|
|
18
|
+
``from scrollkit.effects.swarm_reveal import show_swarm_splash, SwarmReveal``
|
|
19
|
+
- **Particles** —
|
|
20
|
+
``from scrollkit.effects.particles import ParticleEngine, Sparkle, Snow``
|
|
21
|
+
- **Text-rendering helpers** —
|
|
22
|
+
``from scrollkit.effects.text_render import pixels_from_font_text, font_text_width``
|
|
23
|
+
|
|
24
|
+
The old ``Effect``/``EffectRegistry``/``EffectsEngine``/``SimpleEffect`` systems
|
|
25
|
+
were removed: there is one transition contract (``effects.transitions.Transition``)
|
|
26
|
+
plus these standalone helpers — no overlapping per-frame "effect" base classes,
|
|
27
|
+
and no plugin/registry architecture layered on top of them.
|
|
28
|
+
"""
|