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,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)
|