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,363 @@
1
+ # Copyright (c) 2024-2026 Michael Winslow Czeiszperger
2
+ """Class 1 — characterful scrolling (built on the easing engine + fixed-point scroll).
3
+
4
+ Three scrolling content types that feel alive instead of a constant 1 px/frame crawl:
5
+
6
+ - :class:`KineticMarquee` — mass/inertia: accelerates in, coasts, dwells at
7
+ punctuation/keywords, overshoots then springs back. Only a single reused Label's
8
+ ``.x`` (and, briefly, ``.y`` is untouched) changes per frame; the glyph bitmap is
9
+ built once.
10
+ - :class:`WaveRider` — characters ride a precomputed integer sine path; only the
11
+ visible window of single-char Labels is realized.
12
+ - :class:`SplitFlap` — entering characters flip through a small *deterministic*
13
+ sequence of intermediate glyphs (seeded LCG, no per-frame ``random`` allocation)
14
+ before landing, staggered left-to-right.
15
+
16
+ All three are async ``DisplayContent``, run unchanged on device and simulator, do no
17
+ per-frame heap allocation on the hot path, and pass the strict feasibility gate at
18
+ 20 fps (single / few small Labels per frame; the gate tolerates the bounded,
19
+ isolated glyph rebuilds these produce).
20
+ """
21
+
22
+ import math
23
+
24
+ from ..display.content import DisplayContent, LOOP_FPS, Priority
25
+ from .easing import interp, ease, EASE_OUT_QUAD
26
+
27
+ __all__ = ["KineticMarquee", "WaveRider", "SplitFlap", "scrollers_for"]
28
+
29
+
30
+ def _delta_q(speed):
31
+ """Per-frame motion in 1/16-px units from a px/sec speed (>=1)."""
32
+ d = int(round(speed * 16 / LOOP_FPS))
33
+ return d if d > 0 else 1
34
+
35
+
36
+ class KineticMarquee(DisplayContent):
37
+ """Scrolling text with mass: eased accelerate-in, dwell at ``pause_chars``, an
38
+ overshoot+spring at each dwell, then scroll off. The whole message lives in ONE
39
+ reused Label whose ``.x`` moves per frame (glyph bitmap built once).
40
+
41
+ Advertised hardware budget (see ``FEASIBILITY``): one Label, repositioned each
42
+ frame; a single glyph rebuild on first show — strict-feasible at 20 fps.
43
+ """
44
+
45
+ FEASIBILITY = {
46
+ "hardware_safe": True,
47
+ "allocates_per_frame": False,
48
+ "max_pixel_writes_per_frame": 0, # only Label .x changes; no painter writes
49
+ "modeled_frame_ms": 6.0, # ~refresh + one repositioned label
50
+ }
51
+
52
+ ACCEL_FRAMES = 10 # eased ramp-up of the entry velocity
53
+ DWELL_FRAMES = 12 # hold when a pause char is centered (~0.6 s at 20 fps)
54
+ SPRING_PX = 3 # overshoot distance past the dwell target
55
+ SPRING_FRAMES = 3 # frames to spring back from the overshoot
56
+
57
+ def __init__(self, text, y=0, color=0xFFFFFF, speed=30,
58
+ pause_chars=".,!?;:", overshoot=True, priority=Priority.NORMAL):
59
+ super().__init__(duration=None, priority=priority)
60
+ self.text = text
61
+ self.y = y
62
+ self.color = color
63
+ self.speed = speed
64
+ self.pause_chars = pause_chars
65
+ self.overshoot = overshoot
66
+ self._pos_q = None # text left-edge x in 1/16 px
67
+ self._width = 0 # measured text width (px)
68
+ self._targets = () # pos_q values (descending) centering a pause char
69
+ self._ti = 0 # index of the next dwell target
70
+ self._dwell = 0 # remaining dwell frames
71
+ self._spring = 0 # remaining spring-back frames (moves right)
72
+ self._spring_from = 0 # pos_q at the start of the spring
73
+ self._frame = 0 # frames since first render (drives entry accel)
74
+
75
+ async def start(self):
76
+ await super().start()
77
+ self._pos_q = None
78
+ self._ti = 0
79
+ self._dwell = 0
80
+ self._spring = 0
81
+ self._frame = 0
82
+
83
+ def _setup(self, display):
84
+ self._width = display.measure_text(self.text)
85
+ center = display.width // 2
86
+ targets = []
87
+ for i, ch in enumerate(self.text):
88
+ if ch in self.pause_chars:
89
+ left = display.measure_text(self.text[:i])
90
+ right = display.measure_text(self.text[:i + 1])
91
+ glyph_center = (left + right) // 2
92
+ targets.append((center - glyph_center) << 4)
93
+ self._targets = tuple(targets)
94
+ self._pos_q = display.width << 4 # start off the right edge
95
+
96
+ async def render(self, display):
97
+ if self._pos_q is None:
98
+ self._setup(display)
99
+ await display.draw_text(self.text, self._pos_q >> 4, self.y, self.color)
100
+ self._advance()
101
+
102
+ def _advance(self):
103
+ self._frame += 1
104
+ if self._dwell > 0: # holding at a pause char
105
+ self._dwell -= 1
106
+ return
107
+ if self._spring > 0: # springing back to the target (rightward)
108
+ self._spring -= 1
109
+ done = self.SPRING_FRAMES - self._spring
110
+ self._pos_q = self._spring_from + (self.SPRING_PX << 4) * done // self.SPRING_FRAMES
111
+ if self._spring == 0:
112
+ self._dwell = self.DWELL_FRAMES
113
+ return
114
+
115
+ d = _delta_q(self.speed)
116
+ if self._frame <= self.ACCEL_FRAMES: # eased accelerate-in
117
+ prog = self._frame * 255 // self.ACCEL_FRAMES
118
+ d = interp(EASE_OUT_QUAD, 1, d, prog)
119
+ self._pos_q -= d
120
+
121
+ if self._ti < len(self._targets) and self._pos_q <= self._targets[self._ti]:
122
+ tgt = self._targets[self._ti]
123
+ self._ti += 1
124
+ if self.overshoot: # slide past, then spring back to tgt
125
+ self._pos_q = tgt - (self.SPRING_PX << 4)
126
+ self._spring_from = self._pos_q
127
+ self._spring = self.SPRING_FRAMES
128
+ else:
129
+ self._pos_q = tgt
130
+ self._dwell = self.DWELL_FRAMES
131
+
132
+ if (self._pos_q >> 4) < -self._width:
133
+ self._is_complete = True
134
+
135
+ @property
136
+ def is_complete(self):
137
+ return self._is_complete
138
+
139
+ def describe(self):
140
+ info = super().describe()
141
+ info.update({
142
+ "text": self.text, "y": self.y, "speed": self.speed,
143
+ "position": None if self._pos_q is None else (self._pos_q >> 4),
144
+ "dwelling": self._dwell > 0 or self._spring > 0,
145
+ })
146
+ return info
147
+
148
+
149
+ class WaveRider(DisplayContent):
150
+ """Characters ride a precomputed integer sine path as the message scrolls. Only
151
+ the visible window of single-char Labels is realized each frame:
152
+ ``y = baseline + wave_table[(x // step + phase) & 255]``.
153
+
154
+ Advertised hardware budget (see ``FEASIBILITY``): one small Label per visible
155
+ column (~11 on a 64-px panel), repositioned each frame; rebuilds only as
156
+ characters cross the viewport edge — strict-feasible at 20 fps.
157
+ """
158
+
159
+ FEASIBILITY = {
160
+ "hardware_safe": True,
161
+ "allocates_per_frame": False,
162
+ "max_pixel_writes_per_frame": 0, # Label moves only; no painter writes
163
+ "modeled_frame_ms": 16.0, # ~refresh + a bounded visible-window
164
+ }
165
+
166
+ PHASE_STEP = 8 # wave phase advance per frame (cycles every 32 frames)
167
+
168
+ def __init__(self, text, y=0, color=0xFFFFFF, speed=30,
169
+ amplitude=4, wavelength=16, priority=Priority.NORMAL):
170
+ super().__init__(duration=None, priority=priority)
171
+ self.text = text
172
+ self.y = y
173
+ self.color = color
174
+ self.speed = speed
175
+ self.amplitude = amplitude
176
+ self.wavelength = max(2, wavelength)
177
+ # Precompute the 256-entry integer wave table ONCE (float sin at construction
178
+ # only; the hot path is a pure table lookup). Values in [-amplitude, +amplitude].
179
+ self._wave = tuple(
180
+ int(round(amplitude * math.sin(2.0 * math.pi * i / 256.0)))
181
+ for i in range(256)
182
+ )
183
+ # 256 / wavelength_px, fixed-point so a few px of x map across the table.
184
+ self._x_scale = 256 // self.wavelength
185
+ self._pos_q = None # left-edge x of the message, 1/16 px
186
+ self._advances = () # per-char advance widths (px)
187
+ self._offsets = () # cumulative left offset of each char (px)
188
+ self._width = 0
189
+ self._phase = 0
190
+
191
+ async def start(self):
192
+ await super().start()
193
+ self._pos_q = None
194
+ self._phase = 0
195
+
196
+ def _setup(self, display):
197
+ advances = []
198
+ offsets = []
199
+ cum = 0
200
+ for ch in self.text:
201
+ offsets.append(cum)
202
+ adv = display.measure_text(ch) or 6
203
+ advances.append(adv)
204
+ cum += adv
205
+ self._advances = tuple(advances)
206
+ self._offsets = tuple(offsets)
207
+ self._width = cum
208
+ self._pos_q = display.width << 4
209
+
210
+ async def render(self, display):
211
+ if self._pos_q is None:
212
+ self._setup(display)
213
+ base_x = self._pos_q >> 4
214
+ w = display.width
215
+ baseline = self.y
216
+ wave = self._wave
217
+ scale = self._x_scale
218
+ phase = self._phase
219
+ # Realize only the visible-window characters (others are not drawn at all).
220
+ for i in range(len(self.text)):
221
+ x = base_x + self._offsets[i]
222
+ adv = self._advances[i]
223
+ if x + adv <= 0 or x >= w:
224
+ continue # off-screen: not realized
225
+ yy = baseline + wave[(x // scale + phase) & 255]
226
+ await display.draw_text(self.text[i], x, yy, self.color)
227
+ self._phase = (self._phase + self.PHASE_STEP) & 255
228
+ self._pos_q -= _delta_q(self.speed)
229
+ if (self._pos_q >> 4) < -self._width:
230
+ self._is_complete = True
231
+
232
+ @property
233
+ def is_complete(self):
234
+ return self._is_complete
235
+
236
+ def describe(self):
237
+ info = super().describe()
238
+ info.update({
239
+ "text": self.text, "y": self.y, "speed": self.speed,
240
+ "amplitude": self.amplitude, "wavelength": self.wavelength,
241
+ "position": None if self._pos_q is None else (self._pos_q >> 4),
242
+ })
243
+ return info
244
+
245
+
246
+ class SplitFlap(DisplayContent):
247
+ """A split-flap board: each cell flips through ``flip_steps`` deterministic
248
+ intermediate glyphs (seeded LCG — no per-frame ``random`` allocation) before
249
+ landing on its real character, staggered left-to-right.
250
+
251
+ Advertised hardware budget (see ``FEASIBILITY``): one small Label per cell;
252
+ glyph rebuilds happen only on the few cells actively flipping in a given frame
253
+ (bounded), then stop once landed — strict-feasible at 20 fps.
254
+ """
255
+
256
+ FEASIBILITY = {
257
+ "hardware_safe": True,
258
+ "allocates_per_frame": False,
259
+ "max_pixel_writes_per_frame": 0, # Labels only; flips are bounded rebuilds
260
+ "modeled_frame_ms": 18.0, # ~refresh + a few cells rebuilding
261
+ }
262
+
263
+ STAGGER = 2 # frames between successive cells starting to flip
264
+ HOLD_FRAMES = 20 # how long to hold the fully-landed board before completing
265
+ _ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
266
+
267
+ def __init__(self, text, y=0, color=0xFFFFFF, speed=30,
268
+ flip_steps=3, seed=1, priority=Priority.NORMAL):
269
+ super().__init__(duration=None, priority=priority)
270
+ self.text = text
271
+ self.y = y
272
+ self.color = color
273
+ self.speed = speed
274
+ self.flip_steps = max(2, min(4, flip_steps))
275
+ self.seed = seed
276
+ self._flips = () # per-cell tuple of intermediate glyph strings
277
+ self._x = () # per-cell x position (px)
278
+ self._frame = 0
279
+ self._built = False
280
+
281
+ async def start(self):
282
+ await super().start()
283
+ self._frame = 0
284
+ self._built = False
285
+
286
+ def _setup(self, display):
287
+ # Deterministic intermediate glyphs from a seeded LCG (integer state only).
288
+ state = (self.seed * 2654435761 + 1) & 0x7FFFFFFF
289
+ flips = []
290
+ xs = []
291
+ cum = 0
292
+ alpha = self._ALPHABET
293
+ for ch in self.text:
294
+ seq = []
295
+ for _ in range(self.flip_steps):
296
+ state = (state * 1103515245 + 12345) & 0x7FFFFFFF
297
+ seq.append(alpha[state % len(alpha)])
298
+ flips.append(tuple(seq))
299
+ xs.append(cum)
300
+ cum += display.measure_text(ch) or 6
301
+ self._flips = tuple(flips)
302
+ self._x = tuple(xs)
303
+ self._built = True
304
+
305
+ async def render(self, display):
306
+ if not self._built:
307
+ self._setup(display)
308
+ landed_all = True
309
+ for i, ch in enumerate(self.text):
310
+ local = self._frame - i * self.STAGGER
311
+ if local < 0:
312
+ landed_all = False
313
+ continue # this cell has not started flipping yet
314
+ if local < self.flip_steps:
315
+ glyph = self._flips[i][local]
316
+ landed_all = False
317
+ else:
318
+ glyph = ch # landed on the real character
319
+ await display.draw_text(glyph, self._x[i], self.y, self.color)
320
+ self._frame += 1
321
+ last_land = (len(self.text) - 1) * self.STAGGER + self.flip_steps
322
+ if landed_all and self._frame > last_land + self.HOLD_FRAMES:
323
+ self._is_complete = True
324
+
325
+ @property
326
+ def is_complete(self):
327
+ return self._is_complete
328
+
329
+ def describe(self):
330
+ info = super().describe()
331
+ info.update({
332
+ "text": self.text, "y": self.y, "flip_steps": self.flip_steps,
333
+ "seed": self.seed, "frame": self._frame,
334
+ })
335
+ return info
336
+
337
+
338
+ # --- content pairing ---------------------------------------------------------
339
+ # Which content presentation each scroller looks best as, surfaced in
340
+ # capabilities()/docs so app authors and AI agents pick the right effect for the
341
+ # content. Vocabulary: "static" (a held frame), "scrolling" (moving text),
342
+ # "fullscreen" (a screen-wide transition). Class-level (CircuitPython can't tag
343
+ # functions). Marquee and WaveRider ARE scrolling presentations; SplitFlap flips
344
+ # characters in place, so it reads as held/static text.
345
+ KineticMarquee.PAIRS_WITH = ("scrolling",)
346
+ WaveRider.PAIRS_WITH = ("scrolling",)
347
+ SplitFlap.PAIRS_WITH = ("static",)
348
+
349
+ _SCROLLERS = (KineticMarquee, WaveRider, SplitFlap)
350
+
351
+
352
+ def scrollers_for(presentation):
353
+ """The Class-1 scroller CLASSES suited to `presentation` ('static' | 'scrolling').
354
+
355
+ Each returned class is a ``DisplayContent`` you add to the content queue. For
356
+ scrolling text use ``scrollers_for("scrolling")``. Reads the live PAIRS_WITH
357
+ tags, so it stays current as scrollers are added or retagged.
358
+
359
+ cls = random.choice(scrollers_for("scrolling"))
360
+ app.content_queue.add(cls("Space Mountain 45 min", y=12))
361
+ """
362
+ return tuple(cls for cls in _SCROLLERS
363
+ if presentation in getattr(cls, "PAIRS_WITH", ()))