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,355 @@
1
+ # Copyright (c) 2024-2026 Michael Winslow Czeiszperger
2
+ """Particle effects for SLDK.
3
+
4
+ Simple particle systems optimized for ESP32 memory constraints.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ try:
10
+ from typing import Any, List, Optional
11
+ except ImportError: # CircuitPython has no 'typing' module
12
+ pass
13
+
14
+ from ..display.colors import multi_gradient
15
+
16
+ # Default ember fire ramp: a smooth 24-step gradient (deep red -> orange -> yellow ->
17
+ # near-white), built ONCE. Far richer than a handful of fixed primaries; pass your own
18
+ # ``colors`` to recolour the fire (e.g. a blue/green flame).
19
+ _FIRE_RAMP = multi_gradient((0x330000, 0xCC1100, 0xFF4400, 0xFF8800, 0xFFCC22, 0xFFF0A0), 24)
20
+
21
+ try:
22
+ import time
23
+ get_time = time.monotonic
24
+ except (ImportError, AttributeError):
25
+ import time
26
+ get_time = time.time
27
+
28
+ try:
29
+ import random
30
+ except ImportError:
31
+ # Simple random for CircuitPython if needed
32
+ class SimpleRandom:
33
+ def __init__(self):
34
+ self.seed = int(get_time() * 1000) % 65536
35
+
36
+ def randint(self, a, b):
37
+ self.seed = (self.seed * 1103515245 + 12345) & 0x7fffffff
38
+ return a + (self.seed % (b - a + 1))
39
+
40
+ def random(self):
41
+ return self.randint(0, 1000) / 1000.0
42
+
43
+ random = SimpleRandom()
44
+
45
+
46
+ __all__ = ['ParticleEngine', 'Particle', 'Sparkle', 'RainDrop', 'Ember', 'Snow']
47
+
48
+ class ParticleEngine:
49
+ """Lightweight particle engine for ESP32."""
50
+
51
+ def __init__(self, max_particles: int = 8) -> None:
52
+ """Initialize particle engine.
53
+
54
+ Args:
55
+ max_particles: Maximum number of particles (keep low for ESP32)
56
+ """
57
+ self.max_particles: int = max_particles
58
+ self.particles: list = []
59
+ self.last_spawn_time: float = 0
60
+
61
+ def add_particle(self, particle: Any) -> bool:
62
+ """Add particle to system.
63
+
64
+ Args:
65
+ particle: Particle instance
66
+ """
67
+ if len(self.particles) < self.max_particles:
68
+ particle.spawn_time = get_time()
69
+ self.particles.append(particle)
70
+ return True
71
+ return False
72
+
73
+ async def update(self, display: Any) -> None:
74
+ """Update all particles.
75
+
76
+ Args:
77
+ display: Display interface
78
+ """
79
+ current_time = get_time()
80
+
81
+ # Update particles (backwards to allow removal)
82
+ for i in range(len(self.particles) - 1, -1, -1):
83
+ particle = self.particles[i]
84
+ elapsed = current_time - particle.spawn_time
85
+
86
+ # Remove dead particles
87
+ if particle.is_dead(elapsed):
88
+ self.particles.pop(i)
89
+ continue
90
+
91
+ # Update and render particle
92
+ particle.update(elapsed)
93
+ await particle.render(display)
94
+
95
+ def clear_particles(self) -> None:
96
+ """Remove all particles."""
97
+ self.particles.clear()
98
+
99
+ def get_particle_count(self) -> int:
100
+ """Get current particle count."""
101
+ return len(self.particles)
102
+
103
+
104
+ class Particle:
105
+ """Base particle class."""
106
+
107
+ def __init__(self, x: int, y: int, lifetime: float = 2.0) -> None:
108
+ """Initialize particle.
109
+
110
+ Args:
111
+ x: Starting X position
112
+ y: Starting Y position
113
+ lifetime: Particle lifetime in seconds
114
+ """
115
+ self.start_x: int = x
116
+ self.start_y: int = y
117
+ self.x: float = float(x)
118
+ self.y: float = float(y)
119
+ self.lifetime: float = lifetime
120
+ self.spawn_time: float = 0
121
+
122
+ def update(self, elapsed_time: float) -> None:
123
+ """Update particle physics.
124
+
125
+ Args:
126
+ elapsed_time: Time since spawn
127
+ """
128
+ pass # Override in subclasses
129
+
130
+ async def render(self, display: Any) -> None:
131
+ """Render particle to display.
132
+
133
+ Args:
134
+ display: Display interface
135
+ """
136
+ pass # Override in subclasses
137
+
138
+ def is_dead(self, elapsed_time: float) -> bool:
139
+ """Check if particle should be removed.
140
+
141
+ Args:
142
+ elapsed_time: Time since spawn
143
+
144
+ Returns:
145
+ bool: True if particle is dead
146
+ """
147
+ return elapsed_time > self.lifetime
148
+
149
+ def get_life_ratio(self, elapsed_time: float) -> float:
150
+ """Get life ratio (0.0 = just born, 1.0 = about to die).
151
+
152
+ Args:
153
+ elapsed_time: Time since spawn
154
+
155
+ Returns:
156
+ float: Life ratio
157
+ """
158
+ return min(elapsed_time / self.lifetime, 1.0)
159
+
160
+
161
+ class Sparkle(Particle):
162
+ """Simple sparkle particle."""
163
+
164
+ def __init__(self, x: int, y: int, color: int = 0xFFFFFF, lifetime: float = 1.0) -> None:
165
+ """Initialize sparkle.
166
+
167
+ Args:
168
+ x: X position
169
+ y: Y position
170
+ color: Sparkle color
171
+ lifetime: How long sparkle lasts
172
+ """
173
+ super().__init__(x, y, lifetime)
174
+ self.color: int = color
175
+ self.peak_time: float = lifetime * 0.2 # Peak brightness at 20% of lifetime
176
+
177
+ async def render(self, display: Any) -> None:
178
+ """Render sparkle with fading."""
179
+ # Check bounds
180
+ x = int(self.x)
181
+ y = int(self.y)
182
+
183
+ if not (0 <= x < display.width and 0 <= y < display.height):
184
+ return
185
+
186
+ # Calculate brightness based on age
187
+ life_ratio = self.get_life_ratio(get_time() - self.spawn_time)
188
+
189
+ if life_ratio < 0.2:
190
+ # Growing phase
191
+ brightness = life_ratio / 0.2
192
+ else:
193
+ # Fading phase
194
+ brightness = 1.0 - ((life_ratio - 0.2) / 0.8)
195
+
196
+ brightness = max(0.0, min(1.0, brightness))
197
+
198
+ # Apply brightness to color
199
+ r = int(((self.color >> 16) & 0xFF) * brightness)
200
+ g = int(((self.color >> 8) & 0xFF) * brightness)
201
+ b = int((self.color & 0xFF) * brightness)
202
+
203
+ final_color = (r << 16) | (g << 8) | b
204
+ await display.set_pixel(x, y, final_color)
205
+
206
+
207
+ class RainDrop(Particle):
208
+ """Rain drop particle that falls down."""
209
+
210
+ def __init__(self, x: int, y: int, speed: float = 10.0, color: int = 0x0080FF, lifetime: float = 3.0) -> None:
211
+ """Initialize rain drop.
212
+
213
+ Args:
214
+ x: Starting X position
215
+ y: Starting Y position
216
+ speed: Fall speed (pixels per second)
217
+ color: Drop color
218
+ lifetime: Maximum lifetime
219
+ """
220
+ super().__init__(x, y, lifetime)
221
+ self.speed: float = speed
222
+ self.color: int = color
223
+
224
+ def update(self, elapsed_time: float) -> None:
225
+ """Update rain drop physics."""
226
+ # Move down
227
+ self.y = self.start_y + (self.speed * elapsed_time)
228
+
229
+ async def render(self, display: Any) -> None:
230
+ """Render rain drop."""
231
+ # Learn the real panel height for is_dead() (no display arg there).
232
+ self._bottom = display.height
233
+ x = int(self.x)
234
+ y = int(self.y)
235
+
236
+ # Check if still on screen
237
+ if not (0 <= x < display.width and 0 <= y < display.height):
238
+ return
239
+
240
+ await display.set_pixel(x, y, self.color)
241
+
242
+ def is_dead(self, elapsed_time: float) -> bool:
243
+ """Rain drop dies when it falls off the bottom of the panel."""
244
+ bottom = getattr(self, "_bottom", 32) # real height learned in render()
245
+ return (self.y >= bottom) or super().is_dead(elapsed_time)
246
+
247
+
248
+ class Ember(Particle):
249
+ """Fire ember that rises and fades."""
250
+
251
+ def __init__(self, x: int, y: int, speed: float = 5.0, drift: float = 2.0,
252
+ lifetime: float = 2.0, colors=None) -> None:
253
+ """Initialize ember.
254
+
255
+ Args:
256
+ x: Starting X position
257
+ y: Starting Y position
258
+ speed: Rise speed
259
+ drift: Horizontal drift amount
260
+ lifetime: Ember lifetime
261
+ colors: Optional colour ramp (sequence of 0xRRGGBB, coolest first) the
262
+ ember walks as it ages. Defaults to a smooth 24-step fire gradient;
263
+ pass e.g. ``colors.gradient(0x001133, 0x66CCFF, 24)`` for a blue flame.
264
+ """
265
+ super().__init__(x, y, lifetime)
266
+ self.speed = speed
267
+ self.drift = drift
268
+ self.drift_direction = 1 if random.random() > 0.5 else -1
269
+
270
+ # Smooth fire ramp (deep red -> orange -> yellow -> near-white) by default.
271
+ self.colors = list(colors) if colors is not None else _FIRE_RAMP
272
+
273
+ def update(self, elapsed_time: float) -> None:
274
+ """Update ember physics."""
275
+ # Move up
276
+ self.y = self.start_y - (self.speed * elapsed_time)
277
+
278
+ # Drift sideways
279
+ drift_amount = self.drift * elapsed_time * self.drift_direction
280
+ self.x = self.start_x + drift_amount
281
+
282
+ async def render(self, display: Any) -> None:
283
+ """Render ember with color transition."""
284
+ x = int(self.x)
285
+ y = int(self.y)
286
+
287
+ if not (0 <= x < display.width and 0 <= y < display.height):
288
+ return
289
+
290
+ # Color changes over lifetime
291
+ life_ratio = self.get_life_ratio(get_time() - self.spawn_time)
292
+ color_index = int(life_ratio * (len(self.colors) - 1))
293
+ color_index = min(color_index, len(self.colors) - 1)
294
+
295
+ # Fade brightness over time
296
+ brightness = 1.0 - life_ratio
297
+ color = self.colors[color_index]
298
+
299
+ r = int(((color >> 16) & 0xFF) * brightness)
300
+ g = int(((color >> 8) & 0xFF) * brightness)
301
+ b = int((color & 0xFF) * brightness)
302
+
303
+ final_color = (r << 16) | (g << 8) | b
304
+ await display.set_pixel(x, y, final_color)
305
+
306
+ def is_dead(self, elapsed_time: float) -> bool:
307
+ """Ember dies when it rises off screen or expires."""
308
+ return (self.y < 0) or super().is_dead(elapsed_time)
309
+
310
+
311
+ class Snow(Particle):
312
+ """Snow flake that falls gently."""
313
+
314
+ def __init__(self, x: int, y: int, speed: float = 3.0, sway: float = 1.0, lifetime: float = 5.0) -> None:
315
+ """Initialize snow flake.
316
+
317
+ Args:
318
+ x: Starting X position
319
+ y: Starting Y position
320
+ speed: Fall speed
321
+ sway: Side-to-side sway amount
322
+ lifetime: Snow lifetime
323
+ """
324
+ super().__init__(x, y, lifetime)
325
+ self.speed = speed
326
+ self.sway = sway
327
+ self.sway_phase: float = random.random() * 6.28 # Random phase for sway
328
+
329
+ def update(self, elapsed_time: float) -> None:
330
+ """Update snow physics."""
331
+ # Fall down
332
+ self.y = self.start_y + (self.speed * elapsed_time)
333
+
334
+ # Sway side to side
335
+ import math
336
+ sway_offset = self.sway * math.sin(elapsed_time * 2 + self.sway_phase)
337
+ self.x = self.start_x + sway_offset
338
+
339
+ async def render(self, display: Any) -> None:
340
+ """Render snow flake."""
341
+ # Learn the real panel height for is_dead() (no display arg there).
342
+ self._bottom = display.height
343
+ x = int(self.x)
344
+ y = int(self.y)
345
+
346
+ if not (0 <= x < display.width and 0 <= y < display.height):
347
+ return
348
+
349
+ # White snow flake
350
+ await display.set_pixel(x, y, 0xFFFFFF)
351
+
352
+ def is_dead(self, elapsed_time: float) -> bool:
353
+ """Snow dies when it falls off the bottom of the panel."""
354
+ bottom = getattr(self, "_bottom", 32) # real height learned in render()
355
+ return (self.y >= bottom) or super().is_dead(elapsed_time)
@@ -0,0 +1,132 @@
1
+ # Copyright (c) 2024-2026 Michael Winslow Czeiszperger
2
+ """Reveal-splash animation — all LEDs on, wink off non-text pixels to reveal art.
3
+
4
+ Drop-in library version of the ThemeParkWaits reveal animation. The caller
5
+ supplies the list of pixels that should remain on (the "text" or "logo"); the
6
+ rest wink off in random order until the image is revealed, then hold briefly.
7
+
8
+ Pixel-exact via displayio Bitmap so Label y-origin issues don't apply. Uses
9
+ ``display.gfx`` (the same graphics context OverlayMask uses) rather than a bare
10
+ ``import displayio`` — this guarantees the simulator's displayio is used on
11
+ desktop and the hardware one on CircuitPython.
12
+
13
+ Typical usage::
14
+
15
+ from scrollkit.effects.reveal_splash import show_reveal_splash, pixels_from_text
16
+
17
+ # Build a pixel list from text using the built-in 5x7 font.
18
+ px = pixels_from_text("SCROLL", x=14, y=8)
19
+ px += pixels_from_text("KIT", x=23, y=20)
20
+ await show_reveal_splash(display, px)
21
+
22
+ # Or supply your own pixel-art coordinates.
23
+ await show_reveal_splash(display, my_logo_pixels, color=0x00FF88)
24
+ """
25
+
26
+ import asyncio
27
+ import random
28
+
29
+
30
+ __all__ = ['show_reveal_splash', 'pixels_from_text']
31
+
32
+ def _simple_shuffle(lst):
33
+ """In-place Fisher-Yates shuffle — random.shuffle is not on CircuitPython."""
34
+ for i in range(len(lst) - 1, 0, -1):
35
+ j = random.randint(0, i)
36
+ lst[i], lst[j] = lst[j], lst[i]
37
+
38
+
39
+ def pixels_from_text(text, x=0, y=0):
40
+ """Return ``[(col, row), ...]`` for ``text`` rendered in the built-in 5×7 font.
41
+
42
+ The coordinates are absolute display pixels: each glyph starts at ``x`` and
43
+ advances 6 px (5 wide + 1 gap) per character; glyphs are 7 rows tall
44
+ starting at ``y``. Pixels that fall outside the font table (unknown chars)
45
+ are silently skipped.
46
+
47
+ Args:
48
+ text: String to render (case-insensitive; unknown chars are skipped).
49
+ x: Left edge of the first character in display pixels.
50
+ y: Top edge of the text in display pixels.
51
+
52
+ Returns:
53
+ List of ``(x, y)`` integer tuples for every lit pixel.
54
+
55
+ Example — "SCROLL" centred on the top half of a 64×32 display::
56
+
57
+ px = pixels_from_text("SCROLL", x=14, y=8)
58
+ """
59
+ from ..display.bitmap_text import FONT_5x7 as _FONT_5x7 # lazy — keep device RAM low
60
+ pixels = []
61
+ cx = x
62
+ for ch in text:
63
+ rows = _FONT_5x7.get(ch.upper())
64
+ if rows is not None:
65
+ for ry, row in enumerate(rows):
66
+ for rx, cell in enumerate(row):
67
+ if cell != " ":
68
+ pixels.append((cx + rx, y + ry))
69
+ cx += 6 # CELL_W = glyph(5) + gap(1)
70
+ return pixels
71
+
72
+
73
+ async def show_reveal_splash(
74
+ display,
75
+ pixels,
76
+ color=0xFFFF00,
77
+ off_per_frame=14,
78
+ hold_seconds=2.0,
79
+ ):
80
+ """Play a reveal animation on ``display``.
81
+
82
+ All LEDs start on (``color``), then non-``pixels`` LEDs wink off in
83
+ random order ``off_per_frame`` at a time until only the ``pixels`` remain.
84
+ The image then holds for ``hold_seconds`` before the overlay is removed.
85
+
86
+ Args:
87
+ display: A ScrollKit ``UnifiedDisplay`` (must be initialised).
88
+ pixels: Iterable of ``(x, y)`` tuples — pixels that stay on.
89
+ Everything else will be turned off during the reveal.
90
+ color: 24-bit RGB fill color (default: yellow 0xFFFF00).
91
+ off_per_frame: Pixels turned off per animation step (speed knob).
92
+ 14 is calibrated for the 64×32 MatrixPortal S3 at ~20 fps.
93
+ hold_seconds: Seconds to hold the revealed image before finishing.
94
+ """
95
+ gfx = display.gfx
96
+ w, h = display.width, display.height
97
+
98
+ bitmap = gfx.Bitmap(w, h, 2)
99
+ palette = gfx.Palette(2)
100
+ palette[0] = 0x000000
101
+ palette[1] = color
102
+ tilegrid = gfx.TileGrid(bitmap, pixel_shader=palette)
103
+ display.add_layer(tilegrid)
104
+
105
+ target_set = set(pixels)
106
+
107
+ # Fill all pixels on in one C bulk call (never a per-pixel Python loop).
108
+ try:
109
+ bitmap.fill(1)
110
+ except (AttributeError, TypeError):
111
+ for x in range(w):
112
+ for y in range(h):
113
+ bitmap[x, y] = 1
114
+
115
+ # Non-target pixels to wink off, in random order.
116
+ to_off = [(x, y) for x in range(w) for y in range(h)
117
+ if (x, y) not in target_set]
118
+ _simple_shuffle(to_off)
119
+
120
+ await display.show()
121
+
122
+ while to_off:
123
+ batch = min(off_per_frame, len(to_off))
124
+ for _ in range(batch):
125
+ px = to_off.pop()
126
+ bitmap[px[0], px[1]] = 0
127
+ await display.show()
128
+ await asyncio.sleep(0.02)
129
+
130
+ await display.show()
131
+ await asyncio.sleep(hold_seconds)
132
+ display.remove_layer(tilegrid)