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,873 @@
|
|
|
1
|
+
# Copyright (c) 2024-2026 Michael Winslow Czeiszperger
|
|
2
|
+
"""Theatrical transitions on the overlay-mask primitive (Class 2 — fresh start).
|
|
3
|
+
|
|
4
|
+
The broken Wipe/Slide/Fade transitions were removed in the showcase cleanup; this
|
|
5
|
+
module is their proper replacement, built on :class:`OverlayMask`: cover the old
|
|
6
|
+
content, swap content while it is fully hidden, then reveal the new content.
|
|
7
|
+
Bounded mask writes per frame (no full-2048 Python loop), no per-frame allocation,
|
|
8
|
+
strict-feasible at 20 fps.
|
|
9
|
+
|
|
10
|
+
This file ships the full Class 2 pack on the shared :class:`Transition` base:
|
|
11
|
+
:class:`IrisSnap`, :class:`VenetianShutters`, :class:`MosaicResolve`,
|
|
12
|
+
:class:`CRTCollapse`, and :class:`LightSlitRewrite`. Each writes only a bounded set
|
|
13
|
+
of mask spans/blocks per frame.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from .easing import ease, EASE_IN_OUT
|
|
17
|
+
from .overlay import OverlayMask
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
__all__ = ['Transition', 'transition_factory', 'supported_names', 'transitions_for', 'IrisSnap', 'VenetianShutters', 'MosaicResolve', 'CRTCollapse', 'LightSlitRewrite', 'PixelDissolve', 'ColumnRain', 'GradualReveal', 'ScanFold', 'HorizontalWipe', 'GlitchBars', 'DiagonalWipe', 'DropFromSky']
|
|
21
|
+
|
|
22
|
+
def _shuffle(lst):
|
|
23
|
+
"""In-place Fisher-Yates — random.shuffle is absent on CircuitPython."""
|
|
24
|
+
import random
|
|
25
|
+
for i in range(len(lst) - 1, 0, -1):
|
|
26
|
+
j = random.randint(0, i)
|
|
27
|
+
lst[i], lst[j] = lst[j], lst[i]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class Transition:
|
|
31
|
+
"""Base cover -> swap-while-covered -> reveal transition over an OverlayMask.
|
|
32
|
+
|
|
33
|
+
Two phases of ``duration_frames`` each: the cover phase drives mask coverage
|
|
34
|
+
0 -> full, then ``swap_callback`` runs ONCE while fully covered (so any glyph
|
|
35
|
+
rebuild it triggers lands on a hidden frame), then the reveal phase drives
|
|
36
|
+
coverage full -> 0. Subclasses implement ``_paint_cover`` / ``_paint_reveal``.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
def __init__(self, duration_frames=12, curve=EASE_IN_OUT, cover_color=0x000000):
|
|
40
|
+
self.half = max(1, duration_frames)
|
|
41
|
+
self.curve = curve
|
|
42
|
+
self.cover_color = cover_color
|
|
43
|
+
self._frame = 0
|
|
44
|
+
self._mask = None
|
|
45
|
+
self._swap = None
|
|
46
|
+
self._swapped = False
|
|
47
|
+
self._is_complete = False
|
|
48
|
+
|
|
49
|
+
async def start(self, display, swap_callback):
|
|
50
|
+
"""Begin the transition. ``swap_callback`` runs once, while fully covered."""
|
|
51
|
+
if self._mask is not None: # don't leak a prior mask's layer
|
|
52
|
+
self._mask.detach()
|
|
53
|
+
self._mask = OverlayMask(display)
|
|
54
|
+
self._mask.set_cover_color(1, self.cover_color)
|
|
55
|
+
self._swap = swap_callback
|
|
56
|
+
self._frame = 0
|
|
57
|
+
self._swapped = False
|
|
58
|
+
self._is_complete = False
|
|
59
|
+
await self._mask.clear()
|
|
60
|
+
|
|
61
|
+
async def render(self, display, content=None):
|
|
62
|
+
# content is passed by _display_process for transitions that need to
|
|
63
|
+
# re-render at a different position (e.g. DropFromSky). Ignored here.
|
|
64
|
+
if self._is_complete:
|
|
65
|
+
return
|
|
66
|
+
if self._frame < self.half:
|
|
67
|
+
await self._paint_cover(self._progress(self._frame))
|
|
68
|
+
else:
|
|
69
|
+
if not self._swapped:
|
|
70
|
+
await self._run_swap()
|
|
71
|
+
self._swapped = True
|
|
72
|
+
await self._paint_reveal(self._progress(self._frame - self.half))
|
|
73
|
+
self._frame += 1
|
|
74
|
+
if self._frame >= self.half * 2:
|
|
75
|
+
await self._mask.clear() # fully revealed
|
|
76
|
+
self._mask.detach() # remove the mask layer (no leak)
|
|
77
|
+
self._is_complete = True
|
|
78
|
+
|
|
79
|
+
async def _run_swap(self):
|
|
80
|
+
if self._swap is None:
|
|
81
|
+
return
|
|
82
|
+
res = self._swap()
|
|
83
|
+
if hasattr(res, "__await__"): # support sync or async swap callbacks
|
|
84
|
+
await res
|
|
85
|
+
|
|
86
|
+
def _progress(self, f):
|
|
87
|
+
"""Eased 0..255 progress through one phase."""
|
|
88
|
+
raw = 0 if self.half <= 1 else min(255, f * 255 // (self.half - 1))
|
|
89
|
+
return ease(self.curve, raw)
|
|
90
|
+
|
|
91
|
+
@property
|
|
92
|
+
def is_complete(self):
|
|
93
|
+
return self._is_complete
|
|
94
|
+
|
|
95
|
+
@property
|
|
96
|
+
def fully_covered(self):
|
|
97
|
+
"""True at the cover/reveal boundary (mask hides all content)."""
|
|
98
|
+
return self._frame == self.half
|
|
99
|
+
|
|
100
|
+
def detach(self):
|
|
101
|
+
"""Remove the transition's overlay layer from the display."""
|
|
102
|
+
if self._mask is not None:
|
|
103
|
+
self._mask.detach()
|
|
104
|
+
|
|
105
|
+
# subclasses implement (progress 0->255):
|
|
106
|
+
async def _paint_cover(self, progress): # uncovered -> covered
|
|
107
|
+
raise NotImplementedError
|
|
108
|
+
|
|
109
|
+
async def _paint_reveal(self, progress): # covered -> revealed
|
|
110
|
+
raise NotImplementedError
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class IrisSnap(Transition):
|
|
114
|
+
"""Chunky diamond aperture. A diamond of cover grows to hide the screen, then a
|
|
115
|
+
diamond hole grows to reveal it. Per frame writes at most ``height`` spans,
|
|
116
|
+
driven by a precomputed per-row radius table — bounded, never a full repaint.
|
|
117
|
+
"""
|
|
118
|
+
|
|
119
|
+
def __init__(self, duration_frames=10, curve=EASE_IN_OUT, cover_color=0x000000):
|
|
120
|
+
super().__init__(duration_frames, curve, cover_color)
|
|
121
|
+
self._w = 0
|
|
122
|
+
self._h = 0
|
|
123
|
+
self._cx = 0
|
|
124
|
+
self._dy = () # per-row |y - cy|: the radius->span lookup table
|
|
125
|
+
self._rmax = 1
|
|
126
|
+
|
|
127
|
+
async def start(self, display, swap_callback):
|
|
128
|
+
self._w = display.width
|
|
129
|
+
self._h = display.height
|
|
130
|
+
self._cx = self._w // 2
|
|
131
|
+
cy = self._h // 2
|
|
132
|
+
self._dy = tuple(abs(y - cy) for y in range(self._h))
|
|
133
|
+
self._rmax = (self._w // 2) + (self._h // 2) + 1
|
|
134
|
+
await super().start(display, swap_callback)
|
|
135
|
+
|
|
136
|
+
def _radius(self, progress):
|
|
137
|
+
return (progress * self._rmax) // 255
|
|
138
|
+
|
|
139
|
+
async def _paint_cover(self, progress):
|
|
140
|
+
m = self._mask
|
|
141
|
+
await m.clear()
|
|
142
|
+
r = self._radius(progress)
|
|
143
|
+
cx = self._cx
|
|
144
|
+
for y in range(self._h):
|
|
145
|
+
hw = r - self._dy[y]
|
|
146
|
+
if hw >= 0:
|
|
147
|
+
await m.fill_span(y, cx - hw, cx + hw + 1, 1)
|
|
148
|
+
|
|
149
|
+
async def _paint_reveal(self, progress):
|
|
150
|
+
m = self._mask
|
|
151
|
+
await m.fill_rect(0, 0, self._w, self._h, 1) # cover everything...
|
|
152
|
+
r = self._radius(progress)
|
|
153
|
+
cx = self._cx
|
|
154
|
+
for y in range(self._h): # ...then punch the diamond hole
|
|
155
|
+
hw = r - self._dy[y]
|
|
156
|
+
if hw >= 0:
|
|
157
|
+
await m.clear_rect(cx - hw, y, 2 * hw + 1, 1)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
class VenetianShutters(Transition):
|
|
161
|
+
"""Coarse horizontal bands that close (cover) then open (reveal) like blinds,
|
|
162
|
+
each band staggered slightly for a mechanical feel. Per frame writes at most
|
|
163
|
+
``bands`` (+1) spans — bounded, never a full repaint.
|
|
164
|
+
"""
|
|
165
|
+
|
|
166
|
+
def __init__(self, duration_frames=12, curve=EASE_IN_OUT, cover_color=0x000000,
|
|
167
|
+
bands=8):
|
|
168
|
+
super().__init__(duration_frames, curve, cover_color)
|
|
169
|
+
self.bands = max(2, bands)
|
|
170
|
+
self._w = 0
|
|
171
|
+
self._h = 0
|
|
172
|
+
self._band_h = 1
|
|
173
|
+
self._spread = 0
|
|
174
|
+
|
|
175
|
+
async def start(self, display, swap_callback):
|
|
176
|
+
self._w = display.width
|
|
177
|
+
self._h = display.height
|
|
178
|
+
self._band_h = (self._h + self.bands - 1) // self.bands
|
|
179
|
+
# A small per-band delay so the bands don't all move in lockstep, scaled so
|
|
180
|
+
# the last band still reaches full at progress 255.
|
|
181
|
+
self._spread = max(1, 255 // (self.bands * 3))
|
|
182
|
+
await super().start(display, swap_callback)
|
|
183
|
+
|
|
184
|
+
def _band_progress(self, progress, k):
|
|
185
|
+
denom = 255 - (self.bands - 1) * self._spread
|
|
186
|
+
if denom < 1:
|
|
187
|
+
denom = 1
|
|
188
|
+
p = progress - k * self._spread
|
|
189
|
+
if p < 0:
|
|
190
|
+
p = 0
|
|
191
|
+
p = p * 255 // denom
|
|
192
|
+
if p > 255:
|
|
193
|
+
p = 255
|
|
194
|
+
return ease(self.curve, p)
|
|
195
|
+
|
|
196
|
+
async def _paint_cover(self, progress):
|
|
197
|
+
m = self._mask
|
|
198
|
+
await m.clear()
|
|
199
|
+
for k in range(self.bands):
|
|
200
|
+
h = self._band_progress(progress, k) * self._band_h // 255
|
|
201
|
+
if h > 0:
|
|
202
|
+
top = k * self._band_h + (self._band_h - h) // 2
|
|
203
|
+
await m.fill_rect(0, top, self._w, h, 1)
|
|
204
|
+
|
|
205
|
+
async def _paint_reveal(self, progress):
|
|
206
|
+
m = self._mask
|
|
207
|
+
await m.fill_rect(0, 0, self._w, self._h, 1) # fully covered...
|
|
208
|
+
for k in range(self.bands): # ...open each band from its center
|
|
209
|
+
h = self._band_progress(progress, k) * self._band_h // 255
|
|
210
|
+
if h > 0:
|
|
211
|
+
top = k * self._band_h + (self._band_h - h) // 2
|
|
212
|
+
await m.clear_rect(0, top, self._w, h)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
class MosaicResolve(Transition):
|
|
216
|
+
"""Blocks cover (then reveal) in a fixed pseudo-random order — a mosaic that
|
|
217
|
+
dissolves in and out. Only the *newly* changed blocks are written each frame
|
|
218
|
+
(~4-12), so per-frame work stays bounded. Deterministic given ``seed``.
|
|
219
|
+
"""
|
|
220
|
+
|
|
221
|
+
def __init__(self, duration_frames=14, curve=EASE_IN_OUT, cover_color=0x000000,
|
|
222
|
+
block_w=8, block_h=4, seed=1):
|
|
223
|
+
super().__init__(duration_frames, curve, cover_color)
|
|
224
|
+
self.block_w = max(1, block_w)
|
|
225
|
+
self.block_h = max(1, block_h)
|
|
226
|
+
self.seed = seed
|
|
227
|
+
self._cols = 0
|
|
228
|
+
self._rows = 0
|
|
229
|
+
self._order = ()
|
|
230
|
+
self._covered = 0
|
|
231
|
+
self._revealed = 0
|
|
232
|
+
|
|
233
|
+
async def start(self, display, swap_callback):
|
|
234
|
+
self._cols = max(1, display.width // self.block_w)
|
|
235
|
+
self._rows = max(1, display.height // self.block_h)
|
|
236
|
+
n = self._cols * self._rows
|
|
237
|
+
# Fisher-Yates shuffle with a seeded LCG (integer state — no per-frame alloc).
|
|
238
|
+
order = list(range(n))
|
|
239
|
+
state = (self.seed * 2654435761 + 1) & 0x7FFFFFFF
|
|
240
|
+
for i in range(n - 1, 0, -1):
|
|
241
|
+
state = (state * 1103515245 + 12345) & 0x7FFFFFFF
|
|
242
|
+
j = state % (i + 1)
|
|
243
|
+
order[i], order[j] = order[j], order[i]
|
|
244
|
+
self._order = tuple(order)
|
|
245
|
+
self._covered = 0
|
|
246
|
+
self._revealed = 0
|
|
247
|
+
await super().start(display, swap_callback)
|
|
248
|
+
|
|
249
|
+
def _block_rect(self, idx):
|
|
250
|
+
r = idx // self._cols
|
|
251
|
+
c = idx % self._cols
|
|
252
|
+
return c * self.block_w, r * self.block_h, self.block_w, self.block_h
|
|
253
|
+
|
|
254
|
+
async def _paint_cover(self, progress):
|
|
255
|
+
target = ease(self.curve, progress) * len(self._order) // 255
|
|
256
|
+
while self._covered < target:
|
|
257
|
+
x, y, w, h = self._block_rect(self._order[self._covered])
|
|
258
|
+
await self._mask.fill_rect(x, y, w, h, 1)
|
|
259
|
+
self._covered += 1
|
|
260
|
+
|
|
261
|
+
async def _paint_reveal(self, progress):
|
|
262
|
+
target = ease(self.curve, progress) * len(self._order) // 255
|
|
263
|
+
while self._revealed < target:
|
|
264
|
+
x, y, w, h = self._block_rect(self._order[self._revealed])
|
|
265
|
+
await self._mask.clear_rect(x, y, w, h)
|
|
266
|
+
self._revealed += 1
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
class CRTCollapse(Transition):
|
|
270
|
+
"""A CRT power-off: the picture collapses to a center scanline (cover) then
|
|
271
|
+
blooms back open from that line (reveal). Two growing/shrinking bars per frame
|
|
272
|
+
— bounded.
|
|
273
|
+
"""
|
|
274
|
+
|
|
275
|
+
def __init__(self, duration_frames=10, curve=EASE_IN_OUT, cover_color=0x000000):
|
|
276
|
+
super().__init__(duration_frames, curve, cover_color)
|
|
277
|
+
self._w = 0
|
|
278
|
+
self._h = 0
|
|
279
|
+
|
|
280
|
+
async def start(self, display, swap_callback):
|
|
281
|
+
self._w = display.width
|
|
282
|
+
self._h = display.height
|
|
283
|
+
await super().start(display, swap_callback)
|
|
284
|
+
|
|
285
|
+
async def _paint_cover(self, progress):
|
|
286
|
+
m = self._mask
|
|
287
|
+
await m.clear()
|
|
288
|
+
half = self._h // 2
|
|
289
|
+
covered = ease(self.curve, progress) * half // 255
|
|
290
|
+
if covered > 0:
|
|
291
|
+
await m.fill_rect(0, 0, self._w, covered, 1) # top bar down
|
|
292
|
+
await m.fill_rect(0, self._h - covered, self._w, covered, 1) # bottom bar up
|
|
293
|
+
# At full progress make sure the thin center line is closed too.
|
|
294
|
+
if progress >= 255:
|
|
295
|
+
await m.fill_rect(0, 0, self._w, self._h, 1)
|
|
296
|
+
|
|
297
|
+
async def _paint_reveal(self, progress):
|
|
298
|
+
m = self._mask
|
|
299
|
+
await m.fill_rect(0, 0, self._w, self._h, 1) # fully covered...
|
|
300
|
+
slit = ease(self.curve, progress) * self._h // 255 # ...bloom open from center
|
|
301
|
+
if slit > 0:
|
|
302
|
+
await m.clear_rect(0, (self._h - slit) // 2, self._w, slit)
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
class LightSlitRewrite(Transition):
|
|
306
|
+
"""A bright vertical scanner sweeps across, covering the old content on its way
|
|
307
|
+
out and revealing the new content on its way back — the swap happens behind the
|
|
308
|
+
slit at the turn. Per frame: a cover/clear span + the bright slit — bounded.
|
|
309
|
+
"""
|
|
310
|
+
|
|
311
|
+
def __init__(self, duration_frames=12, curve=EASE_IN_OUT, cover_color=0x000000,
|
|
312
|
+
slit_px=3, slit_color=0xFFFFFF):
|
|
313
|
+
super().__init__(duration_frames, curve, cover_color)
|
|
314
|
+
self.slit_px = max(1, slit_px)
|
|
315
|
+
self.slit_color = slit_color
|
|
316
|
+
self._w = 0
|
|
317
|
+
self._h = 0
|
|
318
|
+
|
|
319
|
+
async def start(self, display, swap_callback):
|
|
320
|
+
self._w = display.width
|
|
321
|
+
self._h = display.height
|
|
322
|
+
await super().start(display, swap_callback)
|
|
323
|
+
self._mask.set_cover_color(2, self.slit_color) # index 2 = the bright slit
|
|
324
|
+
|
|
325
|
+
async def _paint_cover(self, progress):
|
|
326
|
+
m = self._mask
|
|
327
|
+
await m.clear()
|
|
328
|
+
x = ease(self.curve, progress) * self._w // 255 # slit leading edge
|
|
329
|
+
if x > 0:
|
|
330
|
+
await m.fill_rect(0, 0, x, self._h, 1) # cover everything passed
|
|
331
|
+
await m.fill_rect(x, 0, self.slit_px, self._h, 2) # the bright slit
|
|
332
|
+
|
|
333
|
+
async def _paint_reveal(self, progress):
|
|
334
|
+
m = self._mask
|
|
335
|
+
await m.fill_rect(0, 0, self._w, self._h, 1) # start fully covered
|
|
336
|
+
x = ease(self.curve, progress) * self._w // 255
|
|
337
|
+
if x > 0:
|
|
338
|
+
await m.clear_rect(0, 0, x, self._h) # reveal what the slit passed
|
|
339
|
+
await m.fill_rect(x, 0, self.slit_px, self._h, 2) # the bright slit
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
# ---------------------------------------------------------------------------
|
|
343
|
+
# Motion-friendly transitions — designed to look good under scrolling text.
|
|
344
|
+
# Each uses a delta approach: only newly changed mask regions are written
|
|
345
|
+
# per frame (no full repaint), so the text keeps scrolling cleanly beneath.
|
|
346
|
+
# ---------------------------------------------------------------------------
|
|
347
|
+
|
|
348
|
+
class PixelDissolve(Transition):
|
|
349
|
+
"""Text crumbles away as random 4×4 blocks cover the display, then the new
|
|
350
|
+
content dissolves in block-by-block in reverse order — like film grain
|
|
351
|
+
burning through. The irregular scatter works naturally with moving text.
|
|
352
|
+
"""
|
|
353
|
+
|
|
354
|
+
BLOCK = 4
|
|
355
|
+
|
|
356
|
+
def __init__(self, duration_frames=16, **kw):
|
|
357
|
+
super().__init__(duration_frames, **kw)
|
|
358
|
+
self._order = ()
|
|
359
|
+
self._cols = 0
|
|
360
|
+
self._covered = 0
|
|
361
|
+
self._revealed = 0
|
|
362
|
+
|
|
363
|
+
async def start(self, display, swap_callback):
|
|
364
|
+
bw = bh = self.BLOCK
|
|
365
|
+
self._cols = max(1, display.width // bw)
|
|
366
|
+
rows = max(1, display.height // bh)
|
|
367
|
+
order = list(range(self._cols * rows))
|
|
368
|
+
_shuffle(order)
|
|
369
|
+
self._order = tuple(order)
|
|
370
|
+
self._covered = 0
|
|
371
|
+
self._revealed = 0
|
|
372
|
+
await super().start(display, swap_callback)
|
|
373
|
+
|
|
374
|
+
async def _paint_cover(self, progress):
|
|
375
|
+
bw = bh = self.BLOCK
|
|
376
|
+
target = len(self._order) * progress // 256
|
|
377
|
+
while self._covered < target:
|
|
378
|
+
idx = self._order[self._covered]
|
|
379
|
+
x = (idx % self._cols) * bw
|
|
380
|
+
y = (idx // self._cols) * bh
|
|
381
|
+
await self._mask.fill_rect(x, y, bw, bh)
|
|
382
|
+
self._covered += 1
|
|
383
|
+
|
|
384
|
+
async def _paint_reveal(self, progress):
|
|
385
|
+
bw = bh = self.BLOCK
|
|
386
|
+
n = len(self._order)
|
|
387
|
+
target = n * progress // 256
|
|
388
|
+
while self._revealed < target:
|
|
389
|
+
idx = self._order[n - 1 - self._revealed]
|
|
390
|
+
x = (idx % self._cols) * bw
|
|
391
|
+
y = (idx // self._cols) * bh
|
|
392
|
+
await self._mask.clear_rect(x, y, bw, bh)
|
|
393
|
+
self._revealed += 1
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
class ColumnRain(Transition):
|
|
397
|
+
"""Sixteen thin drops fall from the sky in random order.
|
|
398
|
+
|
|
399
|
+
Width: 4 px per drop column (16 × 4 = 64 px). Each drop's front races
|
|
400
|
+
down the display in DRIP_FRAMES frames (~8 px/frame on a 32-tall display).
|
|
401
|
+
Columns fire one frame apart in a shuffled order — so at any moment you see
|
|
402
|
+
~4 drops at different heights scattered across the display, which reads as
|
|
403
|
+
actual rainfall rather than a directional wipe.
|
|
404
|
+
|
|
405
|
+
Both cover and reveal use a frame counter (not eased progress) so the
|
|
406
|
+
falling speed is constant and perceptible.
|
|
407
|
+
"""
|
|
408
|
+
|
|
409
|
+
NUM_COLS = 16 # 16 × 4 px = 64 px wide
|
|
410
|
+
DRIP_FRAMES = 4 # frames each front takes to traverse full height (~8 px/frame)
|
|
411
|
+
STAGGER = 1 # frames between successive drop starts
|
|
412
|
+
|
|
413
|
+
def __init__(self, **kw):
|
|
414
|
+
n, df, st = self.NUM_COLS, self.DRIP_FRAMES, self.STAGGER
|
|
415
|
+
kw.setdefault('duration_frames', (n - 1) * st + df + 1)
|
|
416
|
+
super().__init__(**kw)
|
|
417
|
+
self._col_fill = []
|
|
418
|
+
self._col_reveal = []
|
|
419
|
+
self._col_rank = [] # _col_rank[c] = which step column c fires on
|
|
420
|
+
self._cover_frame = 0
|
|
421
|
+
self._reveal_frame = 0
|
|
422
|
+
self._col_w = 0
|
|
423
|
+
self._w = 0
|
|
424
|
+
self._h = 0
|
|
425
|
+
|
|
426
|
+
async def start(self, display, swap_callback):
|
|
427
|
+
n = self.NUM_COLS
|
|
428
|
+
self._col_w = max(1, display.width // n)
|
|
429
|
+
self._w = display.width
|
|
430
|
+
self._h = display.height
|
|
431
|
+
self._col_fill = [0] * n
|
|
432
|
+
self._col_reveal = [0] * n
|
|
433
|
+
order = list(range(n))
|
|
434
|
+
_shuffle(order)
|
|
435
|
+
self._col_rank = [0] * n
|
|
436
|
+
for rank, col in enumerate(order):
|
|
437
|
+
self._col_rank[col] = rank
|
|
438
|
+
self._cover_frame = 0
|
|
439
|
+
self._reveal_frame = 0
|
|
440
|
+
await super().start(display, swap_callback)
|
|
441
|
+
|
|
442
|
+
def _drip_y(self, frame, rank):
|
|
443
|
+
"""Y position the drip front has reached given its start rank."""
|
|
444
|
+
elapsed = max(0, frame - rank * self.STAGGER)
|
|
445
|
+
return min(self._h, self._h * elapsed // self.DRIP_FRAMES) if elapsed else 0
|
|
446
|
+
|
|
447
|
+
async def _paint_cover(self, progress):
|
|
448
|
+
f = self._cover_frame
|
|
449
|
+
self._cover_frame += 1
|
|
450
|
+
cw = self._col_w
|
|
451
|
+
for c in range(self.NUM_COLS):
|
|
452
|
+
target = self._drip_y(f, self._col_rank[c])
|
|
453
|
+
if target > self._col_fill[c]:
|
|
454
|
+
await self._mask.fill_rect(c * cw, self._col_fill[c],
|
|
455
|
+
cw, target - self._col_fill[c])
|
|
456
|
+
self._col_fill[c] = target
|
|
457
|
+
|
|
458
|
+
async def _paint_reveal(self, progress):
|
|
459
|
+
if self._reveal_frame == 0:
|
|
460
|
+
# Guarantee the mask is fully opaque before the first drip clears.
|
|
461
|
+
await self._mask.fill_rect(0, 0, self._w, self._h)
|
|
462
|
+
for c in range(self.NUM_COLS):
|
|
463
|
+
self._col_reveal[c] = 0
|
|
464
|
+
f = self._reveal_frame
|
|
465
|
+
self._reveal_frame += 1
|
|
466
|
+
cw = self._col_w
|
|
467
|
+
for c in range(self.NUM_COLS):
|
|
468
|
+
target = self._drip_y(f, self._col_rank[c])
|
|
469
|
+
if target > self._col_reveal[c]:
|
|
470
|
+
await self._mask.clear_rect(c * cw, self._col_reveal[c],
|
|
471
|
+
cw, target - self._col_reveal[c])
|
|
472
|
+
self._col_reveal[c] = target
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
class GradualReveal(Transition):
|
|
476
|
+
"""Staggered vertical bands wipe in left-to-right (cover), then peel back
|
|
477
|
+
right-to-left (reveal). A clean architectural transition — not rain.
|
|
478
|
+
"""
|
|
479
|
+
|
|
480
|
+
NUM_COLS = 8
|
|
481
|
+
|
|
482
|
+
def __init__(self, duration_frames=14, **kw):
|
|
483
|
+
super().__init__(duration_frames, **kw)
|
|
484
|
+
self._col_fill = []
|
|
485
|
+
self._col_w = 0
|
|
486
|
+
self._h = 0
|
|
487
|
+
|
|
488
|
+
async def start(self, display, swap_callback):
|
|
489
|
+
n = self.NUM_COLS
|
|
490
|
+
self._col_w = max(1, display.width // n)
|
|
491
|
+
self._h = display.height
|
|
492
|
+
self._col_fill = [0] * n
|
|
493
|
+
await super().start(display, swap_callback)
|
|
494
|
+
|
|
495
|
+
def _col_progress(self, progress, c, n):
|
|
496
|
+
start = c * 255 // n
|
|
497
|
+
if progress <= start:
|
|
498
|
+
return 0
|
|
499
|
+
remaining = 255 - start
|
|
500
|
+
return min(255, (progress - start) * 255 // remaining) if remaining else 255
|
|
501
|
+
|
|
502
|
+
async def _paint_cover(self, progress):
|
|
503
|
+
n = self.NUM_COLS
|
|
504
|
+
cw = self._col_w
|
|
505
|
+
h = self._h
|
|
506
|
+
for c in range(n):
|
|
507
|
+
target_h = h * self._col_progress(progress, c, n) // 255
|
|
508
|
+
if target_h > self._col_fill[c]:
|
|
509
|
+
await self._mask.fill_rect(c * cw, self._col_fill[c],
|
|
510
|
+
cw, target_h - self._col_fill[c])
|
|
511
|
+
self._col_fill[c] = target_h
|
|
512
|
+
|
|
513
|
+
async def _paint_reveal(self, progress):
|
|
514
|
+
n = self.NUM_COLS
|
|
515
|
+
cw = self._col_w
|
|
516
|
+
h = self._h
|
|
517
|
+
for c in range(n - 1, -1, -1):
|
|
518
|
+
rev = n - 1 - c
|
|
519
|
+
cleared = h * self._col_progress(progress, rev, n) // 255
|
|
520
|
+
remaining = h - cleared
|
|
521
|
+
if self._col_fill[c] > remaining:
|
|
522
|
+
await self._mask.clear_rect(c * cw, remaining,
|
|
523
|
+
cw, self._col_fill[c] - remaining)
|
|
524
|
+
self._col_fill[c] = remaining
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
class ScanFold(Transition):
|
|
528
|
+
"""Top and bottom scanlines simultaneously fold toward the horizontal
|
|
529
|
+
centre until the screen is fully covered, then unfold outward to reveal
|
|
530
|
+
new content. Two bars per frame — very fast, good on scrolling text.
|
|
531
|
+
"""
|
|
532
|
+
|
|
533
|
+
def __init__(self, duration_frames=12, **kw):
|
|
534
|
+
super().__init__(duration_frames, **kw)
|
|
535
|
+
self._w = 0
|
|
536
|
+
self._h = 0
|
|
537
|
+
self._cov = 0 # rows covered from each edge
|
|
538
|
+
|
|
539
|
+
async def start(self, display, swap_callback):
|
|
540
|
+
self._w = display.width
|
|
541
|
+
self._h = display.height
|
|
542
|
+
self._cov = 0
|
|
543
|
+
await super().start(display, swap_callback)
|
|
544
|
+
|
|
545
|
+
async def _paint_cover(self, progress):
|
|
546
|
+
half = self._h // 2
|
|
547
|
+
target = half * progress // 255
|
|
548
|
+
if target > self._cov:
|
|
549
|
+
dy = target - self._cov
|
|
550
|
+
await self._mask.fill_rect(0, self._cov, self._w, dy) # top bar
|
|
551
|
+
await self._mask.fill_rect(0, self._h - target, self._w, dy) # bottom bar
|
|
552
|
+
self._cov = target
|
|
553
|
+
|
|
554
|
+
async def _paint_reveal(self, progress):
|
|
555
|
+
half = self._h // 2
|
|
556
|
+
target_rem = half - half * progress // 255
|
|
557
|
+
if self._cov > target_rem:
|
|
558
|
+
dy = self._cov - target_rem
|
|
559
|
+
await self._mask.clear_rect(0, target_rem, self._w, dy) # expose top
|
|
560
|
+
await self._mask.clear_rect(0, self._h - self._cov, self._w, dy) # expose bottom
|
|
561
|
+
self._cov = target_rem
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
class HorizontalWipe(Transition):
|
|
565
|
+
"""A crisp vertical edge sweeps left-to-right during cover — chasing the
|
|
566
|
+
direction text scrolls off screen — then sweeps back to reveal new content.
|
|
567
|
+
One rect per frame. Pairs well with fast-scrolling text.
|
|
568
|
+
"""
|
|
569
|
+
|
|
570
|
+
def __init__(self, duration_frames=10, **kw):
|
|
571
|
+
super().__init__(duration_frames, **kw)
|
|
572
|
+
self._w = 0
|
|
573
|
+
self._h = 0
|
|
574
|
+
self._covered_x = 0
|
|
575
|
+
|
|
576
|
+
async def start(self, display, swap_callback):
|
|
577
|
+
self._w = display.width
|
|
578
|
+
self._h = display.height
|
|
579
|
+
self._covered_x = 0
|
|
580
|
+
await super().start(display, swap_callback)
|
|
581
|
+
|
|
582
|
+
async def _paint_cover(self, progress):
|
|
583
|
+
target_x = self._w * progress // 255
|
|
584
|
+
if target_x > self._covered_x:
|
|
585
|
+
await self._mask.fill_rect(self._covered_x, 0,
|
|
586
|
+
target_x - self._covered_x, self._h)
|
|
587
|
+
self._covered_x = target_x
|
|
588
|
+
|
|
589
|
+
async def _paint_reveal(self, progress):
|
|
590
|
+
remaining_x = self._w - self._w * progress // 255
|
|
591
|
+
if self._covered_x > remaining_x:
|
|
592
|
+
await self._mask.clear_rect(remaining_x, 0,
|
|
593
|
+
self._covered_x - remaining_x, self._h)
|
|
594
|
+
self._covered_x = remaining_x
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
class GlitchBars(Transition):
|
|
598
|
+
"""Random-height horizontal bars flash onto the display in a shuffled order —
|
|
599
|
+
like a corrupted video signal. Cover bars vary from 1 to 4 rows tall;
|
|
600
|
+
reveal clears them in reverse. The irregular pattern looks especially alive
|
|
601
|
+
over moving text.
|
|
602
|
+
"""
|
|
603
|
+
|
|
604
|
+
def __init__(self, duration_frames=14, **kw):
|
|
605
|
+
super().__init__(duration_frames, **kw)
|
|
606
|
+
self._bars = ()
|
|
607
|
+
self._w = 0
|
|
608
|
+
self._covered = 0
|
|
609
|
+
self._revealed = 0
|
|
610
|
+
|
|
611
|
+
async def start(self, display, swap_callback):
|
|
612
|
+
import random
|
|
613
|
+
h = display.height
|
|
614
|
+
self._w = display.width
|
|
615
|
+
bars = []
|
|
616
|
+
y = 0
|
|
617
|
+
while y < h:
|
|
618
|
+
bh = min(random.randint(1, 4), h - y)
|
|
619
|
+
bars.append((y, bh))
|
|
620
|
+
y += bh
|
|
621
|
+
_shuffle(bars)
|
|
622
|
+
self._bars = tuple(bars)
|
|
623
|
+
self._covered = 0
|
|
624
|
+
self._revealed = 0
|
|
625
|
+
await super().start(display, swap_callback)
|
|
626
|
+
|
|
627
|
+
async def _paint_cover(self, progress):
|
|
628
|
+
n = len(self._bars)
|
|
629
|
+
target = n * progress // 256
|
|
630
|
+
while self._covered < target:
|
|
631
|
+
y, bh = self._bars[self._covered]
|
|
632
|
+
await self._mask.fill_rect(0, y, self._w, bh)
|
|
633
|
+
self._covered += 1
|
|
634
|
+
|
|
635
|
+
async def _paint_reveal(self, progress):
|
|
636
|
+
n = len(self._bars)
|
|
637
|
+
target = n * progress // 256
|
|
638
|
+
while self._revealed < target:
|
|
639
|
+
y, bh = self._bars[n - 1 - self._revealed]
|
|
640
|
+
await self._mask.clear_rect(0, y, self._w, bh)
|
|
641
|
+
self._revealed += 1
|
|
642
|
+
|
|
643
|
+
|
|
644
|
+
class DiagonalWipe(Transition):
|
|
645
|
+
"""A diagonal boundary sweeps top-left to bottom-right during cover, then
|
|
646
|
+
sweeps bottom-right to top-left during reveal. One delta span per row per
|
|
647
|
+
frame (32 spans for 64×32) — bounded. Creates a dynamic angled reveal that
|
|
648
|
+
cuts cleanly across scrolling text.
|
|
649
|
+
"""
|
|
650
|
+
|
|
651
|
+
def __init__(self, duration_frames=12, **kw):
|
|
652
|
+
super().__init__(duration_frames, **kw)
|
|
653
|
+
self._w = 0
|
|
654
|
+
self._h = 0
|
|
655
|
+
self._row_fill = []
|
|
656
|
+
|
|
657
|
+
async def start(self, display, swap_callback):
|
|
658
|
+
self._w = display.width
|
|
659
|
+
self._h = display.height
|
|
660
|
+
self._row_fill = [0] * self._h
|
|
661
|
+
await super().start(display, swap_callback)
|
|
662
|
+
|
|
663
|
+
async def _paint_cover(self, progress):
|
|
664
|
+
w = self._w
|
|
665
|
+
h = self._h
|
|
666
|
+
k = (w + h) * progress // 255
|
|
667
|
+
for row in range(h):
|
|
668
|
+
target = min(max(0, k - row), w)
|
|
669
|
+
if target > self._row_fill[row]:
|
|
670
|
+
await self._mask.fill_span(row, self._row_fill[row], target)
|
|
671
|
+
self._row_fill[row] = target
|
|
672
|
+
|
|
673
|
+
async def _paint_reveal(self, progress):
|
|
674
|
+
w = self._w
|
|
675
|
+
h = self._h
|
|
676
|
+
k_max = w + h
|
|
677
|
+
k = k_max - k_max * progress // 255
|
|
678
|
+
for row in range(h):
|
|
679
|
+
target = min(max(0, k - row), w)
|
|
680
|
+
if target < self._row_fill[row]:
|
|
681
|
+
await self._mask.clear_rect(target, row, self._row_fill[row] - target, 1)
|
|
682
|
+
self._row_fill[row] = target
|
|
683
|
+
|
|
684
|
+
|
|
685
|
+
# --- advertised feasibility metadata (US7 / FR-026) -------------------------
|
|
686
|
+
# hardware_safe: passes the strict gate at 20 fps; allocates_per_frame: MUST be
|
|
687
|
+
# False (no per-frame heap alloc); max_pixel_writes_per_frame: worst-case bounded
|
|
688
|
+
# mask write, all via the C bulk painter (never a 2048-px Python loop);
|
|
689
|
+
# modeled_frame_ms: <= the 50 ms (20 fps) device budget. On a 64x32 panel a full
|
|
690
|
+
# mask cover is 2048 px done as ONE bulk fill_region (~0.6 ms), not a loop.
|
|
691
|
+
IrisSnap.FEASIBILITY = {"hardware_safe": True, "allocates_per_frame": False,
|
|
692
|
+
"max_pixel_writes_per_frame": 2048, "modeled_frame_ms": 7.0}
|
|
693
|
+
VenetianShutters.FEASIBILITY = {"hardware_safe": True, "allocates_per_frame": False,
|
|
694
|
+
"max_pixel_writes_per_frame": 2048, "modeled_frame_ms": 8.0}
|
|
695
|
+
MosaicResolve.FEASIBILITY = {"hardware_safe": True, "allocates_per_frame": False,
|
|
696
|
+
"max_pixel_writes_per_frame": 512, "modeled_frame_ms": 6.0}
|
|
697
|
+
CRTCollapse.FEASIBILITY = {"hardware_safe": True, "allocates_per_frame": False,
|
|
698
|
+
"max_pixel_writes_per_frame": 2048, "modeled_frame_ms": 8.0}
|
|
699
|
+
LightSlitRewrite.FEASIBILITY = {"hardware_safe": True, "allocates_per_frame": False,
|
|
700
|
+
"max_pixel_writes_per_frame": 2048, "modeled_frame_ms": 8.0}
|
|
701
|
+
# Motion-friendly additions
|
|
702
|
+
PixelDissolve.FEASIBILITY = {"hardware_safe": True, "allocates_per_frame": False,
|
|
703
|
+
"max_pixel_writes_per_frame": 512, "modeled_frame_ms": 6.0}
|
|
704
|
+
ColumnRain.FEASIBILITY = {"hardware_safe": True, "allocates_per_frame": False,
|
|
705
|
+
"max_pixel_writes_per_frame": 256, "modeled_frame_ms": 4.0}
|
|
706
|
+
GradualReveal.FEASIBILITY = {"hardware_safe": True, "allocates_per_frame": False,
|
|
707
|
+
"max_pixel_writes_per_frame": 256, "modeled_frame_ms": 4.0}
|
|
708
|
+
ScanFold.FEASIBILITY = {"hardware_safe": True, "allocates_per_frame": False,
|
|
709
|
+
"max_pixel_writes_per_frame": 256, "modeled_frame_ms": 4.0}
|
|
710
|
+
HorizontalWipe.FEASIBILITY = {"hardware_safe": True, "allocates_per_frame": False,
|
|
711
|
+
"max_pixel_writes_per_frame": 192, "modeled_frame_ms": 3.0}
|
|
712
|
+
GlitchBars.FEASIBILITY = {"hardware_safe": True, "allocates_per_frame": False,
|
|
713
|
+
"max_pixel_writes_per_frame": 256, "modeled_frame_ms": 5.0}
|
|
714
|
+
DiagonalWipe.FEASIBILITY = {"hardware_safe": True, "allocates_per_frame": False,
|
|
715
|
+
"max_pixel_writes_per_frame": 384, "modeled_frame_ms": 6.0}
|
|
716
|
+
|
|
717
|
+
|
|
718
|
+
class DropFromSky:
|
|
719
|
+
"""New content slides into place from an edge — by default from the TOP (text
|
|
720
|
+
starts above its row and falls to its natural y over FALL_FRAMES frames). Set
|
|
721
|
+
``direction`` to "top" (default), "bottom", "left", or "right" to slide it in
|
|
722
|
+
from that edge instead.
|
|
723
|
+
|
|
724
|
+
Works by hooking into _display_process *before* content.render() via
|
|
725
|
+
pre_render_hook(), which temporarily sets content.x / content.y to the animated
|
|
726
|
+
position. render() then restores it so state never drifts.
|
|
727
|
+
|
|
728
|
+
No OverlayMask, no double-rendering, no layer-order issues. The Labels are
|
|
729
|
+
positioned correctly from the first draw call each frame.
|
|
730
|
+
"""
|
|
731
|
+
|
|
732
|
+
FALL_FRAMES = 10
|
|
733
|
+
_DIRECTIONS = ("top", "bottom", "left", "right")
|
|
734
|
+
|
|
735
|
+
def __init__(self, direction="top"):
|
|
736
|
+
self.direction = direction if direction in self._DIRECTIONS else "top"
|
|
737
|
+
self._frame = 0
|
|
738
|
+
self._target_x = None
|
|
739
|
+
self._target_y = None
|
|
740
|
+
self._w = 0
|
|
741
|
+
self._h = 0
|
|
742
|
+
self._is_complete = False
|
|
743
|
+
|
|
744
|
+
async def start(self, display, swap_callback):
|
|
745
|
+
self._w = display.width
|
|
746
|
+
self._h = display.height
|
|
747
|
+
self._frame = 0
|
|
748
|
+
self._target_x = None
|
|
749
|
+
self._target_y = None
|
|
750
|
+
self._is_complete = False
|
|
751
|
+
|
|
752
|
+
@property
|
|
753
|
+
def is_complete(self):
|
|
754
|
+
return self._is_complete
|
|
755
|
+
|
|
756
|
+
def pre_render_hook(self, content):
|
|
757
|
+
"""Set content.x/y to the animated entry position BEFORE content.render()."""
|
|
758
|
+
if self._is_complete:
|
|
759
|
+
return
|
|
760
|
+
if self._target_y is None:
|
|
761
|
+
self._target_y = getattr(content, 'y', 0)
|
|
762
|
+
self._target_x = getattr(content, 'x', 0)
|
|
763
|
+
# 0 -> 1 over the slide; only the axis for `direction` moves, from an
|
|
764
|
+
# off-screen edge to the content's natural position.
|
|
765
|
+
progress = min(1.0, self._frame / self.FALL_FRAMES)
|
|
766
|
+
d = self.direction
|
|
767
|
+
if d == "top" or d == "bottom":
|
|
768
|
+
start_y = 0 if d == "top" else self._h
|
|
769
|
+
if hasattr(content, 'y'):
|
|
770
|
+
content.y = int(start_y + (self._target_y - start_y) * progress)
|
|
771
|
+
else: # left / right
|
|
772
|
+
start_x = -self._w if d == "left" else self._w
|
|
773
|
+
if hasattr(content, 'x'):
|
|
774
|
+
content.x = int(start_x + (self._target_x - start_x) * progress)
|
|
775
|
+
|
|
776
|
+
async def render(self, display, content=None):
|
|
777
|
+
"""Restore content.x/y after render and advance the frame counter."""
|
|
778
|
+
if self._is_complete:
|
|
779
|
+
return
|
|
780
|
+
# Restore whichever axis we animated to its natural value.
|
|
781
|
+
if content is not None and self._target_y is not None:
|
|
782
|
+
if self.direction == "top" or self.direction == "bottom":
|
|
783
|
+
if hasattr(content, 'y'):
|
|
784
|
+
content.y = self._target_y
|
|
785
|
+
elif hasattr(content, 'x') and self._target_x is not None:
|
|
786
|
+
content.x = self._target_x
|
|
787
|
+
self._frame += 1
|
|
788
|
+
if self._frame > self.FALL_FRAMES:
|
|
789
|
+
self._is_complete = True
|
|
790
|
+
|
|
791
|
+
|
|
792
|
+
DropFromSky.FEASIBILITY = {"hardware_safe": True, "allocates_per_frame": False,
|
|
793
|
+
"max_pixel_writes_per_frame": 0, "modeled_frame_ms": 0.5}
|
|
794
|
+
|
|
795
|
+
|
|
796
|
+
# --- content pairing ---------------------------------------------------------
|
|
797
|
+
# Transitions are full-screen swaps BETWEEN content, so they are ALL tagged
|
|
798
|
+
# "fullscreen" — they work over any content (static or scrolling alike). The
|
|
799
|
+
# static/scrolling distinction lives on the content effects that render the text
|
|
800
|
+
# (the scrollers in effects/scrolling.py and the palette effects in bitmap_text.py),
|
|
801
|
+
# NOT on transitions. Surfaced in capabilities() + docs; class-level (CircuitPython
|
|
802
|
+
# can't tag functions).
|
|
803
|
+
IrisSnap.PAIRS_WITH = ("fullscreen",)
|
|
804
|
+
VenetianShutters.PAIRS_WITH = ("fullscreen",)
|
|
805
|
+
MosaicResolve.PAIRS_WITH = ("fullscreen",)
|
|
806
|
+
CRTCollapse.PAIRS_WITH = ("fullscreen",)
|
|
807
|
+
LightSlitRewrite.PAIRS_WITH = ("fullscreen",)
|
|
808
|
+
PixelDissolve.PAIRS_WITH = ("fullscreen",)
|
|
809
|
+
ColumnRain.PAIRS_WITH = ("fullscreen",)
|
|
810
|
+
GradualReveal.PAIRS_WITH = ("fullscreen",)
|
|
811
|
+
ScanFold.PAIRS_WITH = ("fullscreen",)
|
|
812
|
+
HorizontalWipe.PAIRS_WITH = ("fullscreen",)
|
|
813
|
+
GlitchBars.PAIRS_WITH = ("fullscreen",)
|
|
814
|
+
DiagonalWipe.PAIRS_WITH = ("fullscreen",)
|
|
815
|
+
DropFromSky.PAIRS_WITH = ("fullscreen",)
|
|
816
|
+
|
|
817
|
+
|
|
818
|
+
# --- transition registry -----------------------------------------------------
|
|
819
|
+
# Maps the user-facing transition name (as shown in settings / the web UI) to its
|
|
820
|
+
# class. Defined at the BOTTOM of the module so every class above is in scope.
|
|
821
|
+
#
|
|
822
|
+
# DropFromSky is intentionally included even though it does NOT subclass
|
|
823
|
+
# Transition: it is duck-typed (start/render/is_complete + pre_render_hook). So
|
|
824
|
+
# always enumerate transitions via _TRANSITION_MAP.items() — never
|
|
825
|
+
# Transition.__subclasses__(), which would silently drop it.
|
|
826
|
+
#
|
|
827
|
+
# This map is the dispatch half of the single-source-of-truth; the name half is
|
|
828
|
+
# the literal scrollkit.config.transition_names.TRANSITION_NAMES tuple. They are
|
|
829
|
+
# kept in lockstep (same names, same order) by test_transition_registry. Author
|
|
830
|
+
# new entries in both places, in UI order.
|
|
831
|
+
_TRANSITION_MAP = {
|
|
832
|
+
"Drop from Sky": DropFromSky,
|
|
833
|
+
"Pixel Dissolve": PixelDissolve,
|
|
834
|
+
"Column Rain": ColumnRain,
|
|
835
|
+
"Gradual Reveal": GradualReveal,
|
|
836
|
+
"Scan Fold": ScanFold,
|
|
837
|
+
"Horizontal Wipe": HorizontalWipe,
|
|
838
|
+
"Glitch Bars": GlitchBars,
|
|
839
|
+
"Diagonal Wipe": DiagonalWipe,
|
|
840
|
+
"Iris Snap": IrisSnap,
|
|
841
|
+
"Venetian Shutters": VenetianShutters,
|
|
842
|
+
"Mosaic Resolve": MosaicResolve,
|
|
843
|
+
"CRT Collapse": CRTCollapse,
|
|
844
|
+
"Light Slit": LightSlitRewrite,
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
|
|
848
|
+
def transition_factory(name):
|
|
849
|
+
"""Return a FRESH transition instance for a user-facing name, or None.
|
|
850
|
+
|
|
851
|
+
None means the name is not a known transition (caller decides what to do).
|
|
852
|
+
"""
|
|
853
|
+
cls = _TRANSITION_MAP.get(name)
|
|
854
|
+
return cls() if cls is not None else None
|
|
855
|
+
|
|
856
|
+
|
|
857
|
+
def supported_names():
|
|
858
|
+
"""The user-facing transition names this module can dispatch, in UI order."""
|
|
859
|
+
return tuple(_TRANSITION_MAP)
|
|
860
|
+
|
|
861
|
+
|
|
862
|
+
def transitions_for(presentation="fullscreen"):
|
|
863
|
+
"""The transition NAMES suited to `presentation` — for the ``transition_style`` setting.
|
|
864
|
+
|
|
865
|
+
Transitions are full-screen swaps between content, so they're all tagged
|
|
866
|
+
"fullscreen"; the default returns every transition. Each name is a value for the
|
|
867
|
+
``transition_style`` setting. Reads the live PAIRS_WITH tags, so it stays current
|
|
868
|
+
as transitions are added or retagged.
|
|
869
|
+
|
|
870
|
+
app.settings.set("transition_style", random.choice(transitions_for()))
|
|
871
|
+
"""
|
|
872
|
+
return tuple(name for name, cls in _TRANSITION_MAP.items()
|
|
873
|
+
if presentation in getattr(cls, "PAIRS_WITH", ()))
|