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