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