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,253 @@
|
|
|
1
|
+
# Copyright (c) 2024-2026 Michael Winslow Czeiszperger
|
|
2
|
+
"""Drip-splash animation — LEDs drip in from an edge (default the top) into place.
|
|
3
|
+
|
|
4
|
+
The inverse of :func:`scrollkit.effects.show_reveal_splash`. The screen starts
|
|
5
|
+
blank; every lit pixel of the target image appears at the top of its column
|
|
6
|
+
(y=0) and falls straight down, one row at a time, until it reaches its
|
|
7
|
+
destination row — where it stops. Pixels accumulate column-by-column until the
|
|
8
|
+
full text/image is assembled.
|
|
9
|
+
|
|
10
|
+
Within a column the *bottom-most* target launches first, so a higher-stopping
|
|
11
|
+
drop can never overtake an already-settled one. This preserves gaps (the holes
|
|
12
|
+
in a ``:`` or the space between glyphs) with no per-drop collision checks.
|
|
13
|
+
|
|
14
|
+
Each frame is one cheap whole-bitmap ``fill(0)`` (a single C call, ~9 µs on the
|
|
15
|
+
MatrixPortal S3) followed by a per-pixel write for every visible drop — far
|
|
16
|
+
cheaper than per-region bulk ops for sparse moving pixels. A drop's position is
|
|
17
|
+
a pure function of the frame number, so there is no per-frame allocation and no
|
|
18
|
+
clear/redraw bookkeeping.
|
|
19
|
+
|
|
20
|
+
The overlay's background (index 0) is **transparent**, so a drip composites over
|
|
21
|
+
whatever else is on screen (e.g. a scrolling label) rather than blacking it out.
|
|
22
|
+
Uses ``display.gfx`` (the same graphics context :class:`OverlayMask` uses) rather
|
|
23
|
+
than a bare ``import displayio``, so the simulator's displayio is used on desktop
|
|
24
|
+
and the hardware one on CircuitPython.
|
|
25
|
+
|
|
26
|
+
Two ways to use it:
|
|
27
|
+
|
|
28
|
+
* :func:`show_drip_splash` — a blocking convenience for setup-time splashes; it
|
|
29
|
+
runs the whole animation, holds, and removes the overlay.
|
|
30
|
+
* :class:`DripReveal` — a *frame-driven* core for use inside a running display
|
|
31
|
+
loop (call :meth:`DripReveal.step` once per frame). After it completes you can
|
|
32
|
+
leave the overlay in place so the assembled image stays on screen as the live
|
|
33
|
+
content, then update the value by starting a fresh ``DripReveal``.
|
|
34
|
+
|
|
35
|
+
Typical usage::
|
|
36
|
+
|
|
37
|
+
from scrollkit.effects.drip_splash import show_drip_splash
|
|
38
|
+
from scrollkit.effects.reveal_splash import pixels_from_text
|
|
39
|
+
|
|
40
|
+
px = pixels_from_text("PIXEL", x=17, y=8)
|
|
41
|
+
px += pixels_from_text("RAIN", x=20, y=20)
|
|
42
|
+
await show_drip_splash(display, px, color=0x00CCFF)
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
import asyncio
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
__all__ = ['DripReveal', 'show_drip_splash']
|
|
49
|
+
|
|
50
|
+
class DripReveal:
|
|
51
|
+
"""Frame-driven drip-in of a fixed set of target pixels from an edge.
|
|
52
|
+
|
|
53
|
+
Build it with the target ``pixels`` (e.g. from :func:`pixels_from_font_text`)
|
|
54
|
+
and a ``direction`` ("top" default — drops fall down; or "bottom"/"left"/"right"
|
|
55
|
+
to enter from that edge), call :meth:`start` once with the display, then
|
|
56
|
+
:meth:`step` once per frame.
|
|
57
|
+
:meth:`step` renders the current frame into a transparent overlay and returns
|
|
58
|
+
``True`` when the image is fully assembled. The overlay persists until
|
|
59
|
+
:meth:`detach` is called, so a completed reveal can stay on screen as the live
|
|
60
|
+
image (the assembled pixels are exactly the target pixels — there is nothing
|
|
61
|
+
to swap in).
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
_DIRECTIONS = ("top", "bottom", "left", "right")
|
|
65
|
+
|
|
66
|
+
def __init__(self, pixels, color=0xFFFF00, fall_speed=1, stagger=2, direction="top"):
|
|
67
|
+
self.pixels = pixels
|
|
68
|
+
self.color = color
|
|
69
|
+
self.fall_speed = fall_speed if fall_speed > 1 else 1
|
|
70
|
+
self.stagger = stagger if stagger > 0 else 0
|
|
71
|
+
# Edge the drops enter from: "top" (default, fall down), "bottom" (rise up),
|
|
72
|
+
# "left" (slide right), or "right" (slide left). Top keeps the original look.
|
|
73
|
+
self.direction = direction if direction in self._DIRECTIONS else "top"
|
|
74
|
+
self._display = None
|
|
75
|
+
self._bitmap = None
|
|
76
|
+
self._tile = None
|
|
77
|
+
self._drops = []
|
|
78
|
+
self._last_frame = 0
|
|
79
|
+
self._frame = 0
|
|
80
|
+
self._started = False
|
|
81
|
+
self._w = 0
|
|
82
|
+
self._h = 0
|
|
83
|
+
|
|
84
|
+
def start(self, display):
|
|
85
|
+
"""Create the transparent overlay layer and compute the drop schedule."""
|
|
86
|
+
gfx = display.gfx
|
|
87
|
+
w, h = display.width, display.height
|
|
88
|
+
self._w, self._h = w, h
|
|
89
|
+
self._bitmap = gfx.Bitmap(w, h, 2)
|
|
90
|
+
palette = gfx.Palette(2)
|
|
91
|
+
palette.make_transparent(0) # composite over content below
|
|
92
|
+
palette[1] = self.color
|
|
93
|
+
self._tile = gfx.TileGrid(self._bitmap, pixel_shader=palette)
|
|
94
|
+
display.add_layer(self._tile)
|
|
95
|
+
self._display = display
|
|
96
|
+
|
|
97
|
+
# Group pixels by the FIXED axis (the column for top/bottom, the row for
|
|
98
|
+
# left/right); each drop travels along the other axis from the entry edge to
|
|
99
|
+
# its target. The pixel that travels FARTHEST in a group launches first so a
|
|
100
|
+
# drop never has to pass a settled one (gaps preserved, no collision checks).
|
|
101
|
+
vertical = self.direction in ("top", "bottom")
|
|
102
|
+
from_low = self.direction in ("top", "left") # enters from coordinate 0
|
|
103
|
+
span = h if vertical else w
|
|
104
|
+
groups = {}
|
|
105
|
+
for (x, y) in self.pixels:
|
|
106
|
+
if 0 <= x < w and 0 <= y < h:
|
|
107
|
+
fixed = x if vertical else y
|
|
108
|
+
target = y if vertical else x
|
|
109
|
+
groups.setdefault(fixed, []).append(target)
|
|
110
|
+
drops = []
|
|
111
|
+
last_frame = 0
|
|
112
|
+
for fixed, targets in groups.items():
|
|
113
|
+
targets.sort(reverse=from_low) # farthest-travelling first
|
|
114
|
+
for k, t in enumerate(targets):
|
|
115
|
+
launch = k * self.stagger
|
|
116
|
+
drops.append((fixed, t, launch))
|
|
117
|
+
dist = t if from_low else (span - 1 - t)
|
|
118
|
+
settle = launch + (dist + self.fall_speed - 1) // self.fall_speed
|
|
119
|
+
if settle > last_frame:
|
|
120
|
+
last_frame = settle
|
|
121
|
+
self._drops = drops
|
|
122
|
+
self._last_frame = last_frame
|
|
123
|
+
self._frame = 0
|
|
124
|
+
self._started = True
|
|
125
|
+
|
|
126
|
+
@property
|
|
127
|
+
def has_pixels(self):
|
|
128
|
+
"""True if there is anything to drip (start() must run first)."""
|
|
129
|
+
return bool(self._drops)
|
|
130
|
+
|
|
131
|
+
@property
|
|
132
|
+
def is_complete(self):
|
|
133
|
+
"""True once every drop has reached its target."""
|
|
134
|
+
return self._started and self._frame > self._last_frame
|
|
135
|
+
|
|
136
|
+
def step(self):
|
|
137
|
+
"""Render the current frame into the overlay and advance one frame.
|
|
138
|
+
|
|
139
|
+
Does NOT call ``display.show()`` — the caller (or the display loop) owns
|
|
140
|
+
that. Returns ``True`` when the reveal is complete.
|
|
141
|
+
"""
|
|
142
|
+
if not self._started or self.is_complete:
|
|
143
|
+
return True
|
|
144
|
+
b = self._bitmap
|
|
145
|
+
f = self._frame
|
|
146
|
+
fs = self.fall_speed
|
|
147
|
+
try:
|
|
148
|
+
b.fill(0)
|
|
149
|
+
except (AttributeError, TypeError):
|
|
150
|
+
for xx in range(self._w):
|
|
151
|
+
for yy in range(self._h):
|
|
152
|
+
b[xx, yy] = 0
|
|
153
|
+
vertical = self.direction in ("top", "bottom")
|
|
154
|
+
from_low = self.direction in ("top", "left")
|
|
155
|
+
span = self._h if vertical else self._w
|
|
156
|
+
for (fixed, t, launch) in self._drops:
|
|
157
|
+
if f < launch:
|
|
158
|
+
continue # not released yet
|
|
159
|
+
moved = (f - launch) * fs
|
|
160
|
+
if from_low:
|
|
161
|
+
cur = moved if moved < t else t # 0 -> t, then settled
|
|
162
|
+
else:
|
|
163
|
+
cur = (span - 1) - moved
|
|
164
|
+
if cur < t:
|
|
165
|
+
cur = t # max -> t, then settled
|
|
166
|
+
if vertical:
|
|
167
|
+
b[fixed, cur] = 1
|
|
168
|
+
else:
|
|
169
|
+
b[cur, fixed] = 1
|
|
170
|
+
self._frame += 1
|
|
171
|
+
return self.is_complete
|
|
172
|
+
|
|
173
|
+
def detach(self):
|
|
174
|
+
"""Remove the overlay layer from the display (no-op if already gone)."""
|
|
175
|
+
if self._display is not None and self._tile is not None:
|
|
176
|
+
self._display.remove_layer(self._tile)
|
|
177
|
+
self._tile = None
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
async def show_drip_splash(
|
|
181
|
+
display,
|
|
182
|
+
pixels,
|
|
183
|
+
color=0xFFFF00,
|
|
184
|
+
fall_speed=1,
|
|
185
|
+
stagger=2,
|
|
186
|
+
hold_seconds=2.0,
|
|
187
|
+
direction="top",
|
|
188
|
+
):
|
|
189
|
+
"""Play a drip-in animation on ``display`` (blocking convenience wrapper).
|
|
190
|
+
|
|
191
|
+
Every pixel in ``pixels`` enters from one edge (the top by default — drops fall
|
|
192
|
+
down their column) and settles at its target position; the assembled image then
|
|
193
|
+
holds for ``hold_seconds`` before the overlay is removed. Built on
|
|
194
|
+
:class:`DripReveal`.
|
|
195
|
+
|
|
196
|
+
Args:
|
|
197
|
+
display: A ScrollKit display (``UnifiedDisplay``/``SimulatorDisplay``).
|
|
198
|
+
pixels: Iterable of ``(x, y)`` tuples — the target image. Build one
|
|
199
|
+
with :func:`pixels_from_text` / :func:`pixels_from_font_text`
|
|
200
|
+
or supply pixel art.
|
|
201
|
+
color: 24-bit RGB color of the drops (default: yellow 0xFFFF00).
|
|
202
|
+
fall_speed: Rows a drop descends per frame (>=1). Higher = faster fall.
|
|
203
|
+
stagger: Frames between successive drops launching in the same
|
|
204
|
+
column (>=0). Higher = a sparser, more deliberate drip;
|
|
205
|
+
0 launches a whole column at once.
|
|
206
|
+
hold_seconds: Seconds to hold the finished image before finishing.
|
|
207
|
+
direction: Edge the drops enter from — ``"top"`` (default, fall down),
|
|
208
|
+
``"bottom"`` (rise up), ``"left"`` (slide right), or ``"right"``
|
|
209
|
+
(slide left).
|
|
210
|
+
|
|
211
|
+
Returns:
|
|
212
|
+
``True`` when the animation finished normally; ``False`` if it was cut
|
|
213
|
+
short because the display reported a close (the simulator window was
|
|
214
|
+
closed). On hardware ``show()`` never reports a close, so this is
|
|
215
|
+
always ``True`` there. Callers that loop the effect can stop on
|
|
216
|
+
``False``.
|
|
217
|
+
"""
|
|
218
|
+
reveal = DripReveal(pixels, color=color, fall_speed=fall_speed, stagger=stagger,
|
|
219
|
+
direction=direction)
|
|
220
|
+
reveal.start(display)
|
|
221
|
+
if not reveal.has_pixels:
|
|
222
|
+
reveal.detach()
|
|
223
|
+
return True
|
|
224
|
+
|
|
225
|
+
while not reveal.is_complete:
|
|
226
|
+
reveal.step()
|
|
227
|
+
if await display.show() is False: # simulator window closed
|
|
228
|
+
reveal.detach()
|
|
229
|
+
return False
|
|
230
|
+
await asyncio.sleep(0.02)
|
|
231
|
+
|
|
232
|
+
await asyncio.sleep(hold_seconds)
|
|
233
|
+
reveal.detach()
|
|
234
|
+
return True
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
# --- advertised feasibility metadata (US7 / FR-026) -------------------------
|
|
238
|
+
# Per frame: one whole-bitmap C fill (~9 us) + one per-pixel write per launched
|
|
239
|
+
# drop (~7 us each). Total writes are bounded by the lit-pixel count of the
|
|
240
|
+
# image (a few hundred for typical text), so even a dense 512-px image is
|
|
241
|
+
# ~9 us + 512*7 us ~= 3.6 ms — well inside the 50 ms (20 fps) budget. No
|
|
242
|
+
# per-frame allocation (the drop list is built once; positions are recomputed).
|
|
243
|
+
# NOTE: FEASIBILITY is attached to the CLASS only. CircuitPython/MicroPython does
|
|
244
|
+
# not allow setting attributes on function objects, so the convenience wrapper
|
|
245
|
+
# does NOT carry it (callers read DripReveal.FEASIBILITY). Setting it on a
|
|
246
|
+
# function crashes `import scrollkit.effects` on-device.
|
|
247
|
+
_FEASIBILITY = {
|
|
248
|
+
"hardware_safe": True,
|
|
249
|
+
"allocates_per_frame": False,
|
|
250
|
+
"max_pixel_writes_per_frame": 512,
|
|
251
|
+
"modeled_frame_ms": 4.0,
|
|
252
|
+
}
|
|
253
|
+
DripReveal.FEASIBILITY = _FEASIBILITY
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# Copyright (c) 2024-2026 Michael Winslow Czeiszperger
|
|
2
|
+
"""Integer easing/tween lookup tables (CircuitPython-safe).
|
|
3
|
+
|
|
4
|
+
Each named curve is a length-256 ``bytes`` table mapping a 0..255 progress to a
|
|
5
|
+
0..255 eased value, built **once** at import. ``ease()`` is a pure table lookup
|
|
6
|
+
— no float math and no allocation on the hot path. Six curves of 256 bytes each
|
|
7
|
+
is ~1.5 KB of static RAM.
|
|
8
|
+
|
|
9
|
+
Table values are clamped to 0..255; a curve that conceptually overshoots past the
|
|
10
|
+
endpoints (``OVERSHOOT``/``ELASTIC``) is clamped in storage, and an effect that
|
|
11
|
+
wants the overshoot expresses it by scaling the eased value itself.
|
|
12
|
+
|
|
13
|
+
The float math below runs at import only (once); the device imports this once and
|
|
14
|
+
then only ever indexes the tables.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import math
|
|
18
|
+
|
|
19
|
+
# Curve ids (plain strings — no enum, CircuitPython-friendly).
|
|
20
|
+
LINEAR = "linear"
|
|
21
|
+
EASE_OUT_QUAD = "ease_out_quad"
|
|
22
|
+
EASE_IN_OUT = "ease_in_out"
|
|
23
|
+
OVERSHOOT = "overshoot"
|
|
24
|
+
BOUNCE = "bounce"
|
|
25
|
+
ELASTIC = "elastic"
|
|
26
|
+
|
|
27
|
+
CURVES = (LINEAR, EASE_OUT_QUAD, EASE_IN_OUT, OVERSHOOT, BOUNCE, ELASTIC)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
__all__ = ['ease', 'interp', 'CURVES', 'LINEAR', 'EASE_OUT_QUAD', 'EASE_IN_OUT', 'OVERSHOOT', 'BOUNCE', 'ELASTIC']
|
|
31
|
+
|
|
32
|
+
def _clamp_byte(v):
|
|
33
|
+
if v < 0:
|
|
34
|
+
return 0
|
|
35
|
+
if v > 255:
|
|
36
|
+
return 255
|
|
37
|
+
return int(round(v))
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _f_linear(t):
|
|
41
|
+
return t
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _f_ease_out_quad(t):
|
|
45
|
+
return 1.0 - (1.0 - t) * (1.0 - t)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _f_ease_in_out(t):
|
|
49
|
+
if t < 0.5:
|
|
50
|
+
return 2.0 * t * t
|
|
51
|
+
return 1.0 - 2.0 * (1.0 - t) * (1.0 - t)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _f_overshoot(t):
|
|
55
|
+
# Back ease-out: overshoots above 1.0 then settles (clamped in storage).
|
|
56
|
+
c1 = 1.70158
|
|
57
|
+
c3 = c1 + 1.0
|
|
58
|
+
u = t - 1.0
|
|
59
|
+
return 1.0 + c3 * u * u * u + c1 * u * u
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _f_bounce(t):
|
|
63
|
+
# Standard ease-out bounce.
|
|
64
|
+
n1 = 7.5625
|
|
65
|
+
d1 = 2.75
|
|
66
|
+
if t < 1.0 / d1:
|
|
67
|
+
return n1 * t * t
|
|
68
|
+
if t < 2.0 / d1:
|
|
69
|
+
t -= 1.5 / d1
|
|
70
|
+
return n1 * t * t + 0.75
|
|
71
|
+
if t < 2.5 / d1:
|
|
72
|
+
t -= 2.25 / d1
|
|
73
|
+
return n1 * t * t + 0.9375
|
|
74
|
+
t -= 2.625 / d1
|
|
75
|
+
return n1 * t * t + 0.984375
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _f_elastic(t):
|
|
79
|
+
# Ease-out elastic (lite). Endpoints pinned below.
|
|
80
|
+
if t <= 0.0:
|
|
81
|
+
return 0.0
|
|
82
|
+
if t >= 1.0:
|
|
83
|
+
return 1.0
|
|
84
|
+
c4 = (2.0 * math.pi) / 3.0
|
|
85
|
+
return math.pow(2.0, -10.0 * t) * math.sin((t * 10.0 - 0.75) * c4) + 1.0
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
_FUNCS = {
|
|
89
|
+
LINEAR: _f_linear,
|
|
90
|
+
EASE_OUT_QUAD: _f_ease_out_quad,
|
|
91
|
+
EASE_IN_OUT: _f_ease_in_out,
|
|
92
|
+
OVERSHOOT: _f_overshoot,
|
|
93
|
+
BOUNCE: _f_bounce,
|
|
94
|
+
ELASTIC: _f_elastic,
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _build(fn):
|
|
99
|
+
out = bytearray(256)
|
|
100
|
+
for p in range(256):
|
|
101
|
+
t = p / 255.0
|
|
102
|
+
out[p] = _clamp_byte(fn(t) * 255.0)
|
|
103
|
+
out[0] = 0 # pin endpoints
|
|
104
|
+
out[255] = 255
|
|
105
|
+
return bytes(out)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
_TABLES = {name: _build(fn) for name, fn in _FUNCS.items()}
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def ease(curve, progress_0_255):
|
|
112
|
+
"""Return the eased 0..255 value for ``progress`` (0..255) on ``curve``.
|
|
113
|
+
|
|
114
|
+
Pure ``bytes`` lookup: no floats, no allocation. Unknown curves fall back to
|
|
115
|
+
linear. ``progress`` is clamped to 0..255.
|
|
116
|
+
"""
|
|
117
|
+
table = _TABLES.get(curve)
|
|
118
|
+
if table is None:
|
|
119
|
+
table = _TABLES[LINEAR]
|
|
120
|
+
p = progress_0_255
|
|
121
|
+
if p < 0:
|
|
122
|
+
p = 0
|
|
123
|
+
elif p > 255:
|
|
124
|
+
p = 255
|
|
125
|
+
return table[p]
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def interp(curve, a, b, progress_0_255):
|
|
129
|
+
"""Integer interpolation from ``a`` to ``b`` along ``curve`` (no floats)."""
|
|
130
|
+
e = ease(curve, progress_0_255)
|
|
131
|
+
return a + ((b - a) * e) // 255
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# Copyright (c) 2024-2026 Michael Winslow Czeiszperger
|
|
2
|
+
"""Preallocated overlay-mask layer (Phase 3 primitive; substrate for transitions).
|
|
3
|
+
|
|
4
|
+
One reusable indexed Bitmap (transparent index 0) + Palette + TileGrid, added as a
|
|
5
|
+
display layer composited ABOVE content. A whole class of transitions becomes
|
|
6
|
+
"write a small pattern into the mask": cover regions with an opaque index, reveal
|
|
7
|
+
by setting them back to transparent. Allocated ONCE; every mutation touches only
|
|
8
|
+
dirty spans via the C bulk ops in ``display.gfx.bitmaptools`` — never a full-2048
|
|
9
|
+
Python loop, never a per-frame allocation. Runs unchanged on device and simulator.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
__all__ = ['OverlayMask']
|
|
14
|
+
|
|
15
|
+
class OverlayMask:
|
|
16
|
+
"""A reusable full-screen mask layer for cover -> swap -> reveal transitions.
|
|
17
|
+
|
|
18
|
+
Index 0 is transparent (underlying content shows through); indices 1..N are
|
|
19
|
+
opaque cover colors (index 1 defaults to black). Build once, reuse across
|
|
20
|
+
transitions (``clear()`` resets it); ``detach()`` removes the layer.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def __init__(self, display, value_count=4):
|
|
24
|
+
self._display = display
|
|
25
|
+
gfx = display.gfx
|
|
26
|
+
self._gfx = gfx
|
|
27
|
+
self._w = display.width
|
|
28
|
+
self._h = display.height
|
|
29
|
+
self.bitmap = gfx.Bitmap(self._w, self._h, value_count)
|
|
30
|
+
self.palette = gfx.Palette(value_count)
|
|
31
|
+
self.palette.make_transparent(0) # index 0 = transparent
|
|
32
|
+
if value_count > 1:
|
|
33
|
+
self.palette[1] = 0x000000 # default opaque cover = black
|
|
34
|
+
self.tilegrid = gfx.TileGrid(self.bitmap, pixel_shader=self.palette)
|
|
35
|
+
display.add_layer(self.tilegrid)
|
|
36
|
+
|
|
37
|
+
# --- palette --------------------------------------------------------------
|
|
38
|
+
def set_cover_color(self, index, color):
|
|
39
|
+
"""Set the color of an opaque cover index (1..N)."""
|
|
40
|
+
self.palette[index] = color
|
|
41
|
+
|
|
42
|
+
# --- bounded mutators (dirty-span only) -----------------------------------
|
|
43
|
+
def _clip(self, x, y, w, h):
|
|
44
|
+
x0 = x if x > 0 else 0
|
|
45
|
+
y0 = y if y > 0 else 0
|
|
46
|
+
x1 = x + w
|
|
47
|
+
y1 = y + h
|
|
48
|
+
if x1 > self._w:
|
|
49
|
+
x1 = self._w
|
|
50
|
+
if y1 > self._h:
|
|
51
|
+
y1 = self._h
|
|
52
|
+
if x1 < x0:
|
|
53
|
+
x1 = x0
|
|
54
|
+
if y1 < y0:
|
|
55
|
+
y1 = y0
|
|
56
|
+
return x0, y0, x1, y1
|
|
57
|
+
|
|
58
|
+
def _account(self, kind, px):
|
|
59
|
+
pm = getattr(self._display, "_perf", None)
|
|
60
|
+
if pm is not None:
|
|
61
|
+
pm.account_bulk_op(kind, px)
|
|
62
|
+
|
|
63
|
+
async def fill_rect(self, x, y, w, h, index=1):
|
|
64
|
+
"""Cover a clipped rectangle with an opaque ``index`` via a C bulk op."""
|
|
65
|
+
x0, y0, x1, y1 = self._clip(x, y, w, h)
|
|
66
|
+
if x1 > x0 and y1 > y0:
|
|
67
|
+
self._gfx.bitmaptools.fill_region(self.bitmap, x0, y0, x1, y1, index)
|
|
68
|
+
self._account("fill_region", (x1 - x0) * (y1 - y0))
|
|
69
|
+
|
|
70
|
+
async def fill_span(self, y, x0, x1, index=1):
|
|
71
|
+
"""Cover the single-row span ``[x0, x1)``."""
|
|
72
|
+
await self.fill_rect(x0, y, x1 - x0, 1, index)
|
|
73
|
+
|
|
74
|
+
async def clear_rect(self, x, y, w, h):
|
|
75
|
+
"""Reveal a clipped rectangle (set back to transparent index 0)."""
|
|
76
|
+
await self.fill_rect(x, y, w, h, 0)
|
|
77
|
+
|
|
78
|
+
async def clear(self):
|
|
79
|
+
"""Reset the whole mask to transparent (one bulk fill). Reusable."""
|
|
80
|
+
self.bitmap.fill(0)
|
|
81
|
+
self._account("fill_region", self._w * self._h)
|
|
82
|
+
|
|
83
|
+
async def blit_pattern(self, x, y, pattern_bitmap, *, skip_index=0):
|
|
84
|
+
"""Stamp a small pattern bitmap into the mask (transparent ``skip_index``)."""
|
|
85
|
+
self._gfx.bitmaptools.blit(self.bitmap, pattern_bitmap, x, y,
|
|
86
|
+
skip_index=skip_index)
|
|
87
|
+
self._account("blit", pattern_bitmap.width * pattern_bitmap.height)
|
|
88
|
+
|
|
89
|
+
# --- lifecycle ------------------------------------------------------------
|
|
90
|
+
def detach(self):
|
|
91
|
+
"""Remove the mask's layer from the display."""
|
|
92
|
+
self._display.remove_layer(self.tilegrid)
|