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.
Files changed (111) hide show
  1. scrollkit/__init__.py +16 -0
  2. scrollkit/app/__init__.py +6 -0
  3. scrollkit/app/base.py +918 -0
  4. scrollkit/app/memory.py +70 -0
  5. scrollkit/config/__init__.py +1 -0
  6. scrollkit/config/settings_manager.py +215 -0
  7. scrollkit/config/transition_names.py +31 -0
  8. scrollkit/dev/__init__.py +41 -0
  9. scrollkit/dev/capabilities.py +374 -0
  10. scrollkit/dev/harness.py +383 -0
  11. scrollkit/dev/metrics.py +91 -0
  12. scrollkit/dev/performance.py +174 -0
  13. scrollkit/dev/validation.py +245 -0
  14. scrollkit/display/__init__.py +14 -0
  15. scrollkit/display/_graphics.py +299 -0
  16. scrollkit/display/_recording.py +165 -0
  17. scrollkit/display/_sim_backend.py +99 -0
  18. scrollkit/display/bitmap_text.py +408 -0
  19. scrollkit/display/boards.py +186 -0
  20. scrollkit/display/colors.py +176 -0
  21. scrollkit/display/content.py +604 -0
  22. scrollkit/display/gradient_text.py +167 -0
  23. scrollkit/display/interface.py +126 -0
  24. scrollkit/display/simulator.py +80 -0
  25. scrollkit/display/text_fill.py +59 -0
  26. scrollkit/display/text_pixels.py +250 -0
  27. scrollkit/display/unified.py +595 -0
  28. scrollkit/effects/__init__.py +28 -0
  29. scrollkit/effects/drip_splash.py +253 -0
  30. scrollkit/effects/easing.py +131 -0
  31. scrollkit/effects/overlay.py +92 -0
  32. scrollkit/effects/particles.py +355 -0
  33. scrollkit/effects/reveal_splash.py +132 -0
  34. scrollkit/effects/scrolling.py +363 -0
  35. scrollkit/effects/swarm_reveal.py +512 -0
  36. scrollkit/effects/text_render.py +25 -0
  37. scrollkit/effects/transitions.py +873 -0
  38. scrollkit/exceptions.py +55 -0
  39. scrollkit/network/__init__.py +1 -0
  40. scrollkit/network/http_client.py +505 -0
  41. scrollkit/network/mdns.py +44 -0
  42. scrollkit/network/wifi_manager.py +382 -0
  43. scrollkit/ota/__init__.py +13 -0
  44. scrollkit/ota/client.py +528 -0
  45. scrollkit/ota/display_progress.py +125 -0
  46. scrollkit/ota/manifest.py +206 -0
  47. scrollkit/ota/publish.py +379 -0
  48. scrollkit/simulator/ATTRIBUTION.md +20 -0
  49. scrollkit/simulator/CIRCUITPYTHON_COMPATIBILITY.md +364 -0
  50. scrollkit/simulator/LICENSE +176 -0
  51. scrollkit/simulator/README.md +87 -0
  52. scrollkit/simulator/__init__.py +11 -0
  53. scrollkit/simulator/adafruit_bitmap_font/__init__.py +5 -0
  54. scrollkit/simulator/adafruit_bitmap_font/bitmap_font.py +273 -0
  55. scrollkit/simulator/adafruit_bitmap_font/glyph_cache.py +70 -0
  56. scrollkit/simulator/adafruit_display_text/__init__.py +5 -0
  57. scrollkit/simulator/adafruit_display_text/label.py +336 -0
  58. scrollkit/simulator/bitmaptools.py +50 -0
  59. scrollkit/simulator/core/__init__.py +8 -0
  60. scrollkit/simulator/core/color_utils.py +62 -0
  61. scrollkit/simulator/core/device_benchmarks.json +254 -0
  62. scrollkit/simulator/core/feasibility.py +160 -0
  63. scrollkit/simulator/core/hardware_profile.py +191 -0
  64. scrollkit/simulator/core/led_matrix.py +307 -0
  65. scrollkit/simulator/core/matrixportal_s3_baseline.json +12 -0
  66. scrollkit/simulator/core/performance_manager.py +253 -0
  67. scrollkit/simulator/core/pixel_buffer.py +188 -0
  68. scrollkit/simulator/devices/__init__.py +7 -0
  69. scrollkit/simulator/devices/base_device.py +79 -0
  70. scrollkit/simulator/devices/matrixportal_s3.py +87 -0
  71. scrollkit/simulator/displayio/__init__.py +12 -0
  72. scrollkit/simulator/displayio/bitmap.py +158 -0
  73. scrollkit/simulator/displayio/display.py +195 -0
  74. scrollkit/simulator/displayio/fourwire.py +54 -0
  75. scrollkit/simulator/displayio/group.py +125 -0
  76. scrollkit/simulator/displayio/ondiskbitmap.py +109 -0
  77. scrollkit/simulator/displayio/palette.py +115 -0
  78. scrollkit/simulator/displayio/tilegrid.py +155 -0
  79. scrollkit/simulator/fonts/3x5.bdf +2474 -0
  80. scrollkit/simulator/fonts/Arial_16.bdf +7366 -0
  81. scrollkit/simulator/fonts/Arial_16.bdf.license +3 -0
  82. scrollkit/simulator/fonts/Arial_Bold_12.bdf +6131 -0
  83. scrollkit/simulator/fonts/Arial_Bold_12.bdf.license +2 -0
  84. scrollkit/simulator/fonts/Arial_Bold_18.bdf +32653 -0
  85. scrollkit/simulator/fonts/Arial_Bold_18.bdf.license +2 -0
  86. scrollkit/simulator/fonts/Junction_regular_24.bdf +8676 -0
  87. scrollkit/simulator/fonts/Junction_regular_24.bdf.license +3 -0
  88. scrollkit/simulator/fonts/LeagueSpartan-Bold-16.bdf +12458 -0
  89. scrollkit/simulator/fonts/LeagueSpartan-Bold-16.bdf.license +1921 -0
  90. scrollkit/simulator/fonts/LeagueSpartan_Bold_16.bdf +12458 -0
  91. scrollkit/simulator/fonts/LeagueSpartan_Bold_16.bdf.license +4 -0
  92. scrollkit/simulator/fonts/LibreBodoniv2002-Bold-27.bdf +16818 -0
  93. scrollkit/simulator/fonts/LibreBodoniv2002-Bold-27.bdf.license +1921 -0
  94. scrollkit/simulator/fonts/tom-thumb.bdf +2353 -0
  95. scrollkit/simulator/fonts/viii-bold.bdf +2673 -0
  96. scrollkit/simulator/fonts/viii.bdf +2659 -0
  97. scrollkit/simulator/terminalio/__init__.py +20 -0
  98. scrollkit/utils/__init__.py +1 -0
  99. scrollkit/utils/color_utils.py +54 -0
  100. scrollkit/utils/diagnostics.py +227 -0
  101. scrollkit/utils/error_handler.py +347 -0
  102. scrollkit/utils/system_utils.py +245 -0
  103. scrollkit/utils/url_utils.py +46 -0
  104. scrollkit/web/__init__.py +6 -0
  105. scrollkit/web/settings_server.py +328 -0
  106. scrollkit/web/wifi_setup.py +331 -0
  107. scrollkit-0.8.3.dist-info/METADATA +248 -0
  108. scrollkit-0.8.3.dist-info/RECORD +111 -0
  109. scrollkit-0.8.3.dist-info/WHEEL +5 -0
  110. scrollkit-0.8.3.dist-info/licenses/LICENSE +31 -0
  111. 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
+ """