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,512 @@
1
+ # Copyright (c) 2024-2026 Michael Winslow Czeiszperger
2
+ """Swarm-reveal animation — a flock of "birds" (or bees) assembles the target image.
3
+
4
+ A small flock flies in with classic boids flocking (separation / alignment /
5
+ cohesion); as birds pass over target pixels they "capture" them — the captured
6
+ pixels light up permanently and accumulate into the text/logo. Once every target
7
+ pixel is captured the birds disperse, leaving the assembled image.
8
+
9
+ This is a device-feasible reimagining of the original 150-200 bird boids demo
10
+ (which only ran via precomputed paths). The cost killers there were three
11
+ separate O(n^2) neighbor loops and an O(birds x pixels) target scan *every frame*.
12
+ Here:
13
+
14
+ * a **small flock** (the plan's own observation: fewer birds flock more visibly),
15
+ * **one combined neighbor pass** computing all three rules together with squared
16
+ distances (no per-pair sqrt for the radius gates),
17
+ * **O(1) opportunistic capture** — each bird only checks the few integer cells
18
+ around itself against the remaining-pixel set, never the whole target list,
19
+ * **O(1) steer-target assignment** — birds pull a target from a shuffled queue,
20
+
21
+ so per-frame work is dominated by the ~n^2 neighbor pass at a small n. Measured
22
+ on a MatrixPortal S3 (incl. the panel refresh): 14 birds ~25 ms/frame (safe),
23
+ 20 ~34 ms, 28 ~48 ms — so the default ``num_birds=14`` keeps headroom under the
24
+ 50 ms / 20 fps budget while the desktop simulator can use more for a denser flock.
25
+
26
+ Two transparent overlay layers via ``display.gfx`` (sim + device identical): the
27
+ captured-text layer is written incrementally (never fully redrawn); the birds
28
+ layer is cleared and redrawn each frame (a handful of pixels).
29
+
30
+ Frame-driven :class:`SwarmReveal` (call :meth:`step` once per frame) plus a
31
+ blocking :func:`show_swarm_splash` convenience wrapper, mirroring
32
+ :class:`scrollkit.effects.DripReveal`.
33
+ """
34
+
35
+ import asyncio
36
+ import math
37
+ import random
38
+
39
+
40
+ # Boids tuning (radii stored squared to avoid sqrt in the gates).
41
+ _SEP_R2 = 5.0 * 5.0
42
+ _ALI_R2 = 15.0 * 15.0
43
+ _COH_R2 = 20.0 * 20.0
44
+ _SEP_W = 0.40
45
+ _ALI_W = 0.30
46
+ _COH_W = 0.20
47
+ _COH_GAIN = 0.02 # cohesion pulls gently toward the local center
48
+ _MAX_NUDGE = 0.10 # random per-frame jitter (organic wander)
49
+ _CAPTURE_DIST = 0.9 # a bird captures its target once this close to it
50
+
51
+
52
+ __all__ = ['SwarmReveal', 'show_swarm_splash']
53
+
54
+ class _Bird:
55
+ def __init__(self, x, y, vx, vy):
56
+ self.x = x
57
+ self.y = y
58
+ self.vx = vx
59
+ self.vy = vy
60
+ self.phase = random.uniform(0, 6.2832) # wing-flap phase offset
61
+ self.spd = random.uniform(0.7, 1.3) # individual speed variation
62
+ self.target = None # (x, y) it steers toward
63
+
64
+
65
+ class SwarmReveal:
66
+ """Frame-driven swarm that assembles ``pixels`` then disperses.
67
+
68
+ Build it with the target ``pixels`` (e.g. from
69
+ :func:`pixels_from_text` / :func:`pixels_from_font_text`), :meth:`start` it
70
+ with the display, then call :meth:`step` once per frame until it reports
71
+ complete. The captured-text overlay persists until :meth:`detach`.
72
+
73
+ Args:
74
+ pixels: Iterable of ``(x, y)`` target cells to assemble.
75
+ text_color: 24-bit RGB of the captured/assembled image. Used as the
76
+ single flat color when ``text_colors`` is ``None``.
77
+ text_colors: Optional sequence of 24-bit ``0xRRGGBB`` colors forming a
78
+ ramp (low->high) for the assembled image — e.g. a range
79
+ of yellows->oranges. When ``None`` (the default) the
80
+ image is the single ``text_color`` (a 2-entry palette,
81
+ byte-for-byte the original behavior). When given, the
82
+ text layer uses a ``len(text_colors) + 1`` palette and
83
+ each target pixel's ramp stop is precomputed once in
84
+ :meth:`start` (no per-frame / per-capture float math), so
85
+ the measured ``num_birds**2`` frame budget is unaffected.
86
+ color_axis: How the ramp maps across the image when ``text_colors``
87
+ is set: ``"vertical"`` (default — top of the glyph is
88
+ ``text_colors[0]``, bottom is ``text_colors[-1]``),
89
+ ``"horizontal"`` (left->right), or ``"diagonal"``
90
+ (top-left->bottom-right). The ramp spans exactly the
91
+ bounding box of the target pixels, not the whole panel.
92
+ bird_color: 24-bit RGB of the flock.
93
+ num_birds: Flock size. **This is the hardware-feasibility knob.**
94
+ The per-frame cost grows ~``num_birds**2`` (the boids
95
+ neighbor pass). MEASURED per-frame time on a MatrixPortal
96
+ S3 (bit_depth 4, incl. the panel refresh):
97
+
98
+ 14 birds -> ~25 ms (the default; safe headroom)
99
+ 20 birds -> ~34 ms (usable, near the ceiling)
100
+ 28 birds -> ~48 ms (at the 50 ms / 20 fps limit)
101
+ 40 birds -> ~95 ms (too slow: ~10 fps)
102
+ 100 birds -> ~0.6 s (unusable on-device)
103
+
104
+ **On-device keep num_birds <= ~20.** The desktop
105
+ simulator has no such limit — use more there for a denser
106
+ flock. Fewer birds also flock more visibly.
107
+ bird_speed: Pixels per frame a bird may travel (a higher value fills
108
+ faster but flocks less tightly).
109
+ disperse_frames: Frames the flock flies off after the image is complete.
110
+ """
111
+
112
+ def __init__(self, pixels, text_color=0xFFCC00, bird_color=0xFFE08A,
113
+ num_birds=14, bird_speed=2.4, disperse_frames=18,
114
+ text_colors=None, color_axis="vertical"):
115
+ self.pixels = pixels
116
+ self.text_color = text_color
117
+ # A ramp of >=1 colors (low->high). Empty/None -> single-color path.
118
+ self.text_colors = tuple(text_colors) if text_colors else None
119
+ self.color_axis = color_axis if color_axis in (
120
+ "vertical", "horizontal", "diagonal") else "vertical"
121
+ self.bird_color = bird_color
122
+ self.num_birds = num_birds if num_birds > 1 else 1
123
+ self.bird_speed = bird_speed if bird_speed > 0.5 else 0.5
124
+ self.disperse_frames = disperse_frames if disperse_frames > 0 else 0
125
+
126
+ self._display = None
127
+ self._gfx = None
128
+ self._w = 0
129
+ self._h = 0
130
+ self._text_bmp = None
131
+ self._text_tile = None
132
+ self._index_map = None # {(x, y): palette_index} ramp lookup (gradient only)
133
+ self._birds_bmp = None
134
+ self._birds_tile = None
135
+
136
+ self._remaining = set() # uncaptured target cells
137
+ self._total = 0
138
+ self._queue = [] # shuffled targets for O(1) steer assignment
139
+ self._qi = 0
140
+ self._birds = []
141
+ self._t = 0.0 # animation clock (frames * dt)
142
+ self._disperse_left = -1 # >=0 once all captured (counts down)
143
+ self._complete = False
144
+
145
+ # --- lifecycle ------------------------------------------------------------
146
+ def start(self, display):
147
+ gfx = display.gfx
148
+ self._display = display
149
+ self._gfx = gfx
150
+ w, h = display.width, display.height
151
+ self._w, self._h = w, h
152
+
153
+ # Captured-text layer (written incrementally; never fully redrawn).
154
+ # Single-color path: a 2-entry palette (index 1 = text_color). Gradient
155
+ # path: a (len(text_colors) + 1)-entry palette holding the ramp, with the
156
+ # per-pixel index precomputed below once _remaining is known.
157
+ self._index_map = None
158
+ if self.text_colors is None:
159
+ self._text_bmp = gfx.Bitmap(w, h, 2)
160
+ tpal = gfx.Palette(2)
161
+ tpal.make_transparent(0)
162
+ tpal[1] = self.text_color
163
+ else:
164
+ n = len(self.text_colors)
165
+ self._text_bmp = gfx.Bitmap(w, h, n + 1)
166
+ tpal = gfx.Palette(n + 1)
167
+ if hasattr(tpal, "make_transparent"):
168
+ tpal.make_transparent(0)
169
+ for i, c in enumerate(self.text_colors):
170
+ tpal[i + 1] = c
171
+ self._text_tile = gfx.TileGrid(self._text_bmp, pixel_shader=tpal)
172
+
173
+ # Birds layer (cleared + redrawn each frame). Added AFTER text so birds
174
+ # fly in front; both have a transparent background.
175
+ self._birds_bmp = gfx.Bitmap(w, h, 2)
176
+ bpal = gfx.Palette(2)
177
+ bpal.make_transparent(0)
178
+ bpal[1] = self.bird_color
179
+ self._birds_tile = gfx.TileGrid(self._birds_bmp, pixel_shader=bpal)
180
+
181
+ display.add_layer(self._text_tile)
182
+ display.add_layer(self._birds_tile)
183
+
184
+ # Targets in bounds; shuffled queue for steer-target assignment.
185
+ self._remaining = set((x, y) for (x, y) in self.pixels
186
+ if 0 <= x < w and 0 <= y < h)
187
+ self._total = len(self._remaining)
188
+ self._queue = list(self._remaining)
189
+ _shuffle(self._queue)
190
+ self._qi = 0
191
+
192
+ # Gradient path: precompute each in-bounds target cell's palette index
193
+ # ONCE here so the per-capture write in step() is a single O(1) dict
194
+ # lookup with no float math (keeps the num_birds^2 frame budget intact).
195
+ if self.text_colors is not None:
196
+ self._index_map = self._build_index_map(self._remaining)
197
+
198
+ # Spawn the flock from the screen edges in small clusters.
199
+ self._birds = [self._spawn_bird() for _ in range(self.num_birds)]
200
+ for b in self._birds:
201
+ b.target = self._next_target()
202
+
203
+ self._t = 0.0
204
+ self._disperse_left = -1
205
+ self._complete = False
206
+
207
+ def detach(self):
208
+ """Remove both overlay layers (no-op if already detached)."""
209
+ if self._display is None:
210
+ return
211
+ if self._text_tile is not None:
212
+ self._display.remove_layer(self._text_tile)
213
+ self._text_tile = None
214
+ if self._birds_tile is not None:
215
+ self._display.remove_layer(self._birds_tile)
216
+ self._birds_tile = None
217
+
218
+ # --- helpers --------------------------------------------------------------
219
+ def _spawn_bird(self):
220
+ w, h = self._w, self._h
221
+ edge = random.randint(0, 3)
222
+ s = self.bird_speed
223
+ if edge == 0: # left
224
+ return _Bird(-1.0, random.uniform(0, h), s, random.uniform(-0.3, 0.3))
225
+ if edge == 1: # right
226
+ return _Bird(w + 1.0, random.uniform(0, h), -s, random.uniform(-0.3, 0.3))
227
+ if edge == 2: # top
228
+ return _Bird(random.uniform(0, w), -1.0, random.uniform(-0.3, 0.3), s)
229
+ return _Bird(random.uniform(0, w), h + 1.0, random.uniform(-0.3, 0.3), -s) # bottom
230
+
231
+ def _next_target(self):
232
+ """Pop the next still-uncaptured target from the shuffled queue (O(1) amortized)."""
233
+ q = self._queue
234
+ n = len(q)
235
+ while self._qi < n:
236
+ p = q[self._qi]
237
+ self._qi += 1
238
+ if p in self._remaining:
239
+ return p
240
+ return None
241
+
242
+ def _build_index_map(self, cells):
243
+ """Precompute ``{(x, y): palette_index}`` mapping the ramp over ``cells``.
244
+
245
+ The ramp (palette indices ``1..len(text_colors)``) spans exactly the
246
+ bounding box of the target ``cells`` along ``color_axis`` — so the full
247
+ ramp covers the glyph extent, not the whole panel. Done once in
248
+ :meth:`start`; the float math here never runs per frame or per capture.
249
+ """
250
+ colors = self.text_colors
251
+ last = len(colors) - 1 # ramp index range 0..last
252
+ if not cells:
253
+ return {}
254
+ xs = [x for (x, y) in cells]
255
+ ys = [y for (x, y) in cells]
256
+ min_x, max_x = min(xs), max(xs)
257
+ min_y, max_y = min(ys), max(ys)
258
+ span_x = max_x - min_x
259
+ span_y = max_y - min_y
260
+ axis = self.color_axis
261
+ index_map = {}
262
+ for (x, y) in cells:
263
+ if axis == "horizontal":
264
+ num, den = (x - min_x), span_x
265
+ elif axis == "diagonal":
266
+ num, den = (x - min_x) + (y - min_y), span_x + span_y
267
+ else: # vertical (default)
268
+ num, den = (y - min_y), span_y
269
+ if den <= 0 or last == 0:
270
+ ramp_i = 0
271
+ else:
272
+ # Nearest ramp stop; +0.5 rounds to nearest (num, last, den >= 0).
273
+ ramp_i = int(num * last / den + 0.5)
274
+ if ramp_i > last:
275
+ ramp_i = last
276
+ index_map[(x, y)] = ramp_i + 1 # palette indices start at 1
277
+ return index_map
278
+
279
+ def _flock(self, b):
280
+ """All three boids rules in one neighbor pass (squared-distance gates)."""
281
+ sx = sy = ax = ay = cx = cy = 0.0
282
+ sc = ac = cc = 0
283
+ bx, by = b.x, b.y
284
+ for o in self._birds:
285
+ if o is b:
286
+ continue
287
+ dx = bx - o.x
288
+ dy = by - o.y
289
+ d2 = dx * dx + dy * dy
290
+ if d2 >= _COH_R2 or d2 == 0.0:
291
+ continue
292
+ cx += o.x
293
+ cy += o.y
294
+ cc += 1
295
+ if d2 < _ALI_R2:
296
+ ax += o.vx
297
+ ay += o.vy
298
+ ac += 1
299
+ if d2 < _SEP_R2:
300
+ inv = 1.0 / d2 # steer away, stronger when closer
301
+ sx += dx * inv
302
+ sy += dy * inv
303
+ sc += 1
304
+ fx = fy = 0.0
305
+ if sc:
306
+ fx += (sx / sc) * _SEP_W
307
+ fy += (sy / sc) * _SEP_W
308
+ if ac:
309
+ fx += ((ax / ac) - b.vx) * _ALI_W
310
+ fy += ((ay / ac) - b.vy) * _ALI_W
311
+ if cc:
312
+ fx += ((cx / cc) - bx) * _COH_GAIN * _COH_W
313
+ fy += ((cy / cc) - by) * _COH_GAIN * _COH_W
314
+ return fx, fy
315
+
316
+ # --- per-frame ------------------------------------------------------------
317
+ @property
318
+ def is_complete(self):
319
+ return self._complete
320
+
321
+ def step(self):
322
+ """Advance one frame; render into the overlays. Returns True when done."""
323
+ if self._complete:
324
+ return True
325
+ self._t += 0.05
326
+ w, h = self._w, self._h
327
+ speed = self.bird_speed
328
+ dispersing = self._disperse_left >= 0
329
+ captured_ratio = 0.0
330
+ if self._total:
331
+ captured_ratio = (self._total - len(self._remaining)) / self._total
332
+
333
+ for b in self._birds:
334
+ fx, fy = self._flock(b)
335
+
336
+ if dispersing:
337
+ # Head for the nearest edge so the flock clears off-screen.
338
+ ex = -4.0 if b.x < w * 0.5 else w + 4.0
339
+ ey = -4.0 if b.y < h * 0.5 else h + 4.0
340
+ b.vx += fx + (ex - b.x) * 0.04
341
+ b.vy += fy + (ey - b.y) * 0.04
342
+ else:
343
+ if b.target is None or b.target not in self._remaining:
344
+ b.target = self._next_target()
345
+ if b.target is not None:
346
+ tx, ty = b.target
347
+ dx = tx - b.x
348
+ dy = ty - b.y
349
+ dist = math.sqrt(dx * dx + dy * dy)
350
+ if dist < _CAPTURE_DIST:
351
+ # Arrived: this bird DELIVERS its one pixel. (Deliberate
352
+ # capture — birds don't blanket-paint, so the flock stays
353
+ # the show.) Light it permanently; resume flocking.
354
+ self._remaining.discard(b.target)
355
+ # Single-color: write 1 (unchanged). Gradient: a single
356
+ # precomputed O(1) lookup — no per-capture float math.
357
+ if self._index_map is None:
358
+ self._text_bmp[tx, ty] = 1
359
+ else:
360
+ self._text_bmp[tx, ty] = self._index_map.get(b.target, 1)
361
+ b.target = None
362
+ b.vx += fx
363
+ b.vy += fy
364
+ elif dist < 1.5:
365
+ # Precision: ignore flocking, settle onto the pixel.
366
+ b.vx = dx * 0.3
367
+ b.vy = dy * 0.3
368
+ elif dist < 3.0:
369
+ nvx = (dx / dist) * 0.6
370
+ nvy = (dy / dist) * 0.6
371
+ b.vx = nvx + fx * 0.3
372
+ b.vy = nvy + fy * 0.3
373
+ else:
374
+ # Flock, with attraction to the target growing as the
375
+ # image fills (weak early -> stronger late).
376
+ aw = 0.1 + captured_ratio * 0.5
377
+ b.vx += fx + (dx / dist) * speed * aw
378
+ b.vy += fy + (dy / dist) * speed * aw
379
+ else:
380
+ b.vx += fx
381
+ b.vy += fy
382
+
383
+ # Organic wing-flap + a little randomness.
384
+ b.vx += 0.15 * math.sin(b.phase + self._t * 10.0) * b.spd
385
+ b.vy += 0.10 * math.cos(b.phase + self._t * 8.0) * b.spd
386
+ b.vx += random.uniform(-_MAX_NUDGE, _MAX_NUDGE)
387
+ b.vy += random.uniform(-_MAX_NUDGE, _MAX_NUDGE)
388
+
389
+ # Clamp speed (and keep a floor so birds never stall).
390
+ sp = math.sqrt(b.vx * b.vx + b.vy * b.vy)
391
+ if sp > speed:
392
+ b.vx = (b.vx / sp) * speed
393
+ b.vy = (b.vy / sp) * speed
394
+ elif 0.0 < sp < 0.3:
395
+ b.vx = (b.vx / sp) * 0.3
396
+ b.vy = (b.vy / sp) * 0.3
397
+
398
+ b.x += b.vx
399
+ b.y += b.vy
400
+
401
+ # Wrap around the edges so birds stay in play while capturing.
402
+ if not dispersing:
403
+ if b.x < -2:
404
+ b.x = w + 2
405
+ elif b.x > w + 2:
406
+ b.x = -2
407
+ if b.y < -2:
408
+ b.y = h + 2
409
+ elif b.y > h + 2:
410
+ b.y = -2
411
+
412
+ # Begin dispersing once every pixel is captured.
413
+ if self._disperse_left < 0 and not self._remaining:
414
+ self._disperse_left = self.disperse_frames
415
+
416
+ # Render the birds layer: clear, then one pixel per bird (skip when done).
417
+ bmp = self._birds_bmp
418
+ try:
419
+ bmp.fill(0)
420
+ except (AttributeError, TypeError):
421
+ for xx in range(w):
422
+ for yy in range(h):
423
+ bmp[xx, yy] = 0
424
+ if self._disperse_left != 0:
425
+ for b in self._birds:
426
+ ix = int(b.x + 0.5)
427
+ iy = int(b.y + 0.5)
428
+ if 0 <= ix < w and 0 <= iy < h:
429
+ bmp[ix, iy] = 1
430
+
431
+ if dispersing:
432
+ self._disperse_left -= 1
433
+ if self._disperse_left <= 0:
434
+ self._complete = True
435
+ return self._complete
436
+
437
+
438
+ def _shuffle(lst):
439
+ """In-place Fisher-Yates (random.shuffle is absent on CircuitPython)."""
440
+ for i in range(len(lst) - 1, 0, -1):
441
+ j = random.randint(0, i)
442
+ lst[i], lst[j] = lst[j], lst[i]
443
+
444
+
445
+ async def show_swarm_splash(
446
+ display,
447
+ pixels,
448
+ text_color=0xFFCC00,
449
+ bird_color=0xFFE08A,
450
+ num_birds=14,
451
+ bird_speed=2.4,
452
+ hold_seconds=2.0,
453
+ max_steps=2000,
454
+ text_colors=None,
455
+ color_axis="vertical",
456
+ ):
457
+ """Play a swarm-assembles-the-image animation (blocking convenience wrapper).
458
+
459
+ A flock flies in, captures the ``pixels`` into the text layer, then disperses;
460
+ the assembled image holds for ``hold_seconds`` before the overlays are removed.
461
+
462
+ Args mirror :class:`SwarmReveal`. ``max_steps`` bounds the run so an
463
+ unreachable pixel can never hang the loop.
464
+
465
+ Hardware note: cost grows ~``num_birds**2``. On a MatrixPortal S3 keep
466
+ ``num_birds <= ~20`` (14 ~25 ms/frame, 20 ~34 ms, 28 ~48 ms at the 20 fps
467
+ limit, 100 ~0.6 s/frame = unusable). See :class:`SwarmReveal` for the full
468
+ measured table. The desktop simulator has no such limit.
469
+
470
+ Returns ``True`` when finished normally, ``False`` if the display reported a
471
+ close (simulator window closed). On hardware ``show()`` never closes, so this
472
+ is always ``True`` there.
473
+ """
474
+ swarm = SwarmReveal(pixels, text_color=text_color, bird_color=bird_color,
475
+ num_birds=num_birds, bird_speed=bird_speed,
476
+ text_colors=text_colors, color_axis=color_axis)
477
+ swarm.start(display)
478
+ steps = 0
479
+ while not swarm.is_complete and steps < max_steps:
480
+ swarm.step()
481
+ steps += 1
482
+ if await display.show() is False:
483
+ swarm.detach()
484
+ return False
485
+ await asyncio.sleep(0.05)
486
+
487
+ if await display.show() is False:
488
+ swarm.detach()
489
+ return False
490
+ await asyncio.sleep(hold_seconds)
491
+ swarm.detach()
492
+ return True
493
+
494
+
495
+ # --- advertised feasibility metadata (US7 / FR-026) -------------------------
496
+ # Per frame the cost is the ~num_birds^2 combined neighbor pass (one pass, squared
497
+ # distances) plus O(1) capture/steer per bird and a birds-layer redraw of
498
+ # num_birds pixels. No per-frame heap allocation (flock list + target queue built
499
+ # once in start()). MEASURED on a MatrixPortal S3 (bit_depth 4), per-frame work
500
+ # incl. refresh: 14 birds ~25 ms avg / 37 ms max (safe); 20 ~34/50 (at the
501
+ # ceiling); 28 ~48/68 (over). Default num_birds=14 keeps headroom under the
502
+ # 50 ms / 20 fps budget; the desktop simulator can use more for a denser flock.
503
+ _FEASIBILITY = {
504
+ "hardware_safe": True,
505
+ "allocates_per_frame": False,
506
+ "max_pixel_writes_per_frame": 24, # ~num_birds + a few captures per frame
507
+ "modeled_frame_ms": 25.0, # measured: 14 birds avg on S3 (incl. refresh)
508
+ "note": "cost ~ num_birds^2; measured 14->25ms, 20->34ms, 28->48ms on S3",
509
+ }
510
+ # CLASS only — CircuitPython can't set attributes on function objects, so the
511
+ # wrapper does NOT carry FEASIBILITY (read SwarmReveal.FEASIBILITY instead).
512
+ SwarmReveal.FEASIBILITY = _FEASIBILITY
@@ -0,0 +1,25 @@
1
+ # Copyright (c) 2024-2026 Michael Winslow Czeiszperger
2
+ """Back-compat shim — the implementation now lives in ``display.text_pixels``.
3
+
4
+ ``pixels_from_font_text`` / ``font_text_width`` (and the private ``_glyph_fields``
5
+ / ``_MISSING_ADVANCE``) were relocated to ``scrollkit.display.text_pixels`` so the
6
+ gradient text-fill renderer in ``display/`` can share the one glyph→pixel
7
+ function without ``display`` importing ``effects`` (which would form a cycle and
8
+ pull the RAM-heavy particle/splash modules in just to render text).
9
+
10
+ Import from either this module or the real implementation directly:
11
+ ``from scrollkit.effects.text_render import pixels_from_font_text`` and
12
+ ``from scrollkit.display.text_pixels import pixels_from_font_text`` both
13
+ resolve to the same function. (``scrollkit.effects`` itself is deliberately
14
+ import-free — see its ``__init__.py`` — so the package-level
15
+ ``from scrollkit.effects import pixels_from_font_text`` no longer works.)
16
+ """
17
+
18
+ from ..display.text_pixels import ( # noqa: F401 (re-export)
19
+ _MISSING_ADVANCE,
20
+ _glyph_fields,
21
+ font_text_width,
22
+ pixels_from_font_text,
23
+ )
24
+
25
+ __all__ = ["pixels_from_font_text", "font_text_width"]