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