scrollkit 0.8.4__tar.gz → 0.9.0__tar.gz

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 (121) hide show
  1. {scrollkit-0.8.4/src/scrollkit.egg-info → scrollkit-0.9.0}/PKG-INFO +15 -5
  2. {scrollkit-0.8.4 → scrollkit-0.9.0}/README.md +15 -5
  3. {scrollkit-0.8.4 → scrollkit-0.9.0}/pyproject.toml +1 -1
  4. {scrollkit-0.8.4 → scrollkit-0.9.0}/src/scrollkit/__init__.py +1 -1
  5. {scrollkit-0.8.4 → scrollkit-0.9.0}/src/scrollkit/dev/capabilities.py +32 -0
  6. {scrollkit-0.8.4 → scrollkit-0.9.0}/src/scrollkit/display/_graphics.py +26 -0
  7. {scrollkit-0.8.4 → scrollkit-0.9.0}/src/scrollkit/effects/__init__.py +8 -0
  8. {scrollkit-0.8.4 → scrollkit-0.9.0}/src/scrollkit/effects/image_animators.py +134 -10
  9. scrollkit-0.9.0/src/scrollkit/effects/palette_partition.py +294 -0
  10. scrollkit-0.9.0/src/scrollkit/effects/palette_treatments.py +705 -0
  11. {scrollkit-0.8.4 → scrollkit-0.9.0}/src/scrollkit/effects/swarm_reveal.py +50 -3
  12. scrollkit-0.9.0/src/scrollkit/effects/swirl_in.py +108 -0
  13. {scrollkit-0.8.4 → scrollkit-0.9.0}/src/scrollkit/network/http_client.py +34 -0
  14. {scrollkit-0.8.4 → scrollkit-0.9.0}/src/scrollkit/network/wifi_manager.py +14 -6
  15. {scrollkit-0.8.4 → scrollkit-0.9.0}/src/scrollkit/ota/client.py +103 -17
  16. {scrollkit-0.8.4 → scrollkit-0.9.0}/src/scrollkit/ota/display_progress.py +6 -0
  17. scrollkit-0.9.0/src/scrollkit/utils/scheduler.py +71 -0
  18. {scrollkit-0.8.4 → scrollkit-0.9.0}/src/scrollkit/web/wifi_setup.py +2 -1
  19. {scrollkit-0.8.4 → scrollkit-0.9.0/src/scrollkit.egg-info}/PKG-INFO +15 -5
  20. {scrollkit-0.8.4 → scrollkit-0.9.0}/src/scrollkit.egg-info/SOURCES.txt +4 -0
  21. {scrollkit-0.8.4 → scrollkit-0.9.0}/LICENSE +0 -0
  22. {scrollkit-0.8.4 → scrollkit-0.9.0}/setup.cfg +0 -0
  23. {scrollkit-0.8.4 → scrollkit-0.9.0}/src/scrollkit/app/__init__.py +0 -0
  24. {scrollkit-0.8.4 → scrollkit-0.9.0}/src/scrollkit/app/base.py +0 -0
  25. {scrollkit-0.8.4 → scrollkit-0.9.0}/src/scrollkit/app/memory.py +0 -0
  26. {scrollkit-0.8.4 → scrollkit-0.9.0}/src/scrollkit/config/__init__.py +0 -0
  27. {scrollkit-0.8.4 → scrollkit-0.9.0}/src/scrollkit/config/settings_manager.py +0 -0
  28. {scrollkit-0.8.4 → scrollkit-0.9.0}/src/scrollkit/config/transition_names.py +0 -0
  29. {scrollkit-0.8.4 → scrollkit-0.9.0}/src/scrollkit/dev/__init__.py +0 -0
  30. {scrollkit-0.8.4 → scrollkit-0.9.0}/src/scrollkit/dev/harness.py +0 -0
  31. {scrollkit-0.8.4 → scrollkit-0.9.0}/src/scrollkit/dev/metrics.py +0 -0
  32. {scrollkit-0.8.4 → scrollkit-0.9.0}/src/scrollkit/dev/performance.py +0 -0
  33. {scrollkit-0.8.4 → scrollkit-0.9.0}/src/scrollkit/dev/validation.py +0 -0
  34. {scrollkit-0.8.4 → scrollkit-0.9.0}/src/scrollkit/display/__init__.py +0 -0
  35. {scrollkit-0.8.4 → scrollkit-0.9.0}/src/scrollkit/display/_recording.py +0 -0
  36. {scrollkit-0.8.4 → scrollkit-0.9.0}/src/scrollkit/display/_sim_backend.py +0 -0
  37. {scrollkit-0.8.4 → scrollkit-0.9.0}/src/scrollkit/display/bitmap_text.py +0 -0
  38. {scrollkit-0.8.4 → scrollkit-0.9.0}/src/scrollkit/display/boards.py +0 -0
  39. {scrollkit-0.8.4 → scrollkit-0.9.0}/src/scrollkit/display/colors.py +0 -0
  40. {scrollkit-0.8.4 → scrollkit-0.9.0}/src/scrollkit/display/content.py +0 -0
  41. {scrollkit-0.8.4 → scrollkit-0.9.0}/src/scrollkit/display/gradient_text.py +0 -0
  42. {scrollkit-0.8.4 → scrollkit-0.9.0}/src/scrollkit/display/interface.py +0 -0
  43. {scrollkit-0.8.4 → scrollkit-0.9.0}/src/scrollkit/display/simulator.py +0 -0
  44. {scrollkit-0.8.4 → scrollkit-0.9.0}/src/scrollkit/display/text_fill.py +0 -0
  45. {scrollkit-0.8.4 → scrollkit-0.9.0}/src/scrollkit/display/text_pixels.py +0 -0
  46. {scrollkit-0.8.4 → scrollkit-0.9.0}/src/scrollkit/display/unified.py +0 -0
  47. {scrollkit-0.8.4 → scrollkit-0.9.0}/src/scrollkit/effects/drip_splash.py +0 -0
  48. {scrollkit-0.8.4 → scrollkit-0.9.0}/src/scrollkit/effects/easing.py +0 -0
  49. {scrollkit-0.8.4 → scrollkit-0.9.0}/src/scrollkit/effects/overlay.py +0 -0
  50. {scrollkit-0.8.4 → scrollkit-0.9.0}/src/scrollkit/effects/particles.py +0 -0
  51. {scrollkit-0.8.4 → scrollkit-0.9.0}/src/scrollkit/effects/reveal_splash.py +0 -0
  52. {scrollkit-0.8.4 → scrollkit-0.9.0}/src/scrollkit/effects/scrolling.py +0 -0
  53. {scrollkit-0.8.4 → scrollkit-0.9.0}/src/scrollkit/effects/text_render.py +0 -0
  54. {scrollkit-0.8.4 → scrollkit-0.9.0}/src/scrollkit/effects/transitions.py +0 -0
  55. {scrollkit-0.8.4 → scrollkit-0.9.0}/src/scrollkit/exceptions.py +0 -0
  56. {scrollkit-0.8.4 → scrollkit-0.9.0}/src/scrollkit/network/__init__.py +0 -0
  57. {scrollkit-0.8.4 → scrollkit-0.9.0}/src/scrollkit/network/mdns.py +0 -0
  58. {scrollkit-0.8.4 → scrollkit-0.9.0}/src/scrollkit/ota/__init__.py +0 -0
  59. {scrollkit-0.8.4 → scrollkit-0.9.0}/src/scrollkit/ota/manifest.py +0 -0
  60. {scrollkit-0.8.4 → scrollkit-0.9.0}/src/scrollkit/ota/publish.py +0 -0
  61. {scrollkit-0.8.4 → scrollkit-0.9.0}/src/scrollkit/simulator/ATTRIBUTION.md +0 -0
  62. {scrollkit-0.8.4 → scrollkit-0.9.0}/src/scrollkit/simulator/CIRCUITPYTHON_COMPATIBILITY.md +0 -0
  63. {scrollkit-0.8.4 → scrollkit-0.9.0}/src/scrollkit/simulator/LICENSE +0 -0
  64. {scrollkit-0.8.4 → scrollkit-0.9.0}/src/scrollkit/simulator/README.md +0 -0
  65. {scrollkit-0.8.4 → scrollkit-0.9.0}/src/scrollkit/simulator/__init__.py +0 -0
  66. {scrollkit-0.8.4 → scrollkit-0.9.0}/src/scrollkit/simulator/adafruit_bitmap_font/__init__.py +0 -0
  67. {scrollkit-0.8.4 → scrollkit-0.9.0}/src/scrollkit/simulator/adafruit_bitmap_font/bitmap_font.py +0 -0
  68. {scrollkit-0.8.4 → scrollkit-0.9.0}/src/scrollkit/simulator/adafruit_bitmap_font/glyph_cache.py +0 -0
  69. {scrollkit-0.8.4 → scrollkit-0.9.0}/src/scrollkit/simulator/adafruit_display_text/__init__.py +0 -0
  70. {scrollkit-0.8.4 → scrollkit-0.9.0}/src/scrollkit/simulator/adafruit_display_text/label.py +0 -0
  71. {scrollkit-0.8.4 → scrollkit-0.9.0}/src/scrollkit/simulator/bitmaptools.py +0 -0
  72. {scrollkit-0.8.4 → scrollkit-0.9.0}/src/scrollkit/simulator/core/__init__.py +0 -0
  73. {scrollkit-0.8.4 → scrollkit-0.9.0}/src/scrollkit/simulator/core/color_utils.py +0 -0
  74. {scrollkit-0.8.4 → scrollkit-0.9.0}/src/scrollkit/simulator/core/device_benchmarks.json +0 -0
  75. {scrollkit-0.8.4 → scrollkit-0.9.0}/src/scrollkit/simulator/core/feasibility.py +0 -0
  76. {scrollkit-0.8.4 → scrollkit-0.9.0}/src/scrollkit/simulator/core/hardware_profile.py +0 -0
  77. {scrollkit-0.8.4 → scrollkit-0.9.0}/src/scrollkit/simulator/core/led_matrix.py +0 -0
  78. {scrollkit-0.8.4 → scrollkit-0.9.0}/src/scrollkit/simulator/core/matrixportal_s3_baseline.json +0 -0
  79. {scrollkit-0.8.4 → scrollkit-0.9.0}/src/scrollkit/simulator/core/performance_manager.py +0 -0
  80. {scrollkit-0.8.4 → scrollkit-0.9.0}/src/scrollkit/simulator/core/pixel_buffer.py +0 -0
  81. {scrollkit-0.8.4 → scrollkit-0.9.0}/src/scrollkit/simulator/devices/__init__.py +0 -0
  82. {scrollkit-0.8.4 → scrollkit-0.9.0}/src/scrollkit/simulator/devices/base_device.py +0 -0
  83. {scrollkit-0.8.4 → scrollkit-0.9.0}/src/scrollkit/simulator/devices/matrixportal_s3.py +0 -0
  84. {scrollkit-0.8.4 → scrollkit-0.9.0}/src/scrollkit/simulator/displayio/__init__.py +0 -0
  85. {scrollkit-0.8.4 → scrollkit-0.9.0}/src/scrollkit/simulator/displayio/bitmap.py +0 -0
  86. {scrollkit-0.8.4 → scrollkit-0.9.0}/src/scrollkit/simulator/displayio/display.py +0 -0
  87. {scrollkit-0.8.4 → scrollkit-0.9.0}/src/scrollkit/simulator/displayio/fourwire.py +0 -0
  88. {scrollkit-0.8.4 → scrollkit-0.9.0}/src/scrollkit/simulator/displayio/group.py +0 -0
  89. {scrollkit-0.8.4 → scrollkit-0.9.0}/src/scrollkit/simulator/displayio/ondiskbitmap.py +0 -0
  90. {scrollkit-0.8.4 → scrollkit-0.9.0}/src/scrollkit/simulator/displayio/palette.py +0 -0
  91. {scrollkit-0.8.4 → scrollkit-0.9.0}/src/scrollkit/simulator/displayio/tilegrid.py +0 -0
  92. {scrollkit-0.8.4 → scrollkit-0.9.0}/src/scrollkit/simulator/fonts/3x5.bdf +0 -0
  93. {scrollkit-0.8.4 → scrollkit-0.9.0}/src/scrollkit/simulator/fonts/Arial_16.bdf +0 -0
  94. {scrollkit-0.8.4 → scrollkit-0.9.0}/src/scrollkit/simulator/fonts/Arial_16.bdf.license +0 -0
  95. {scrollkit-0.8.4 → scrollkit-0.9.0}/src/scrollkit/simulator/fonts/Arial_Bold_12.bdf +0 -0
  96. {scrollkit-0.8.4 → scrollkit-0.9.0}/src/scrollkit/simulator/fonts/Arial_Bold_12.bdf.license +0 -0
  97. {scrollkit-0.8.4 → scrollkit-0.9.0}/src/scrollkit/simulator/fonts/Arial_Bold_18.bdf +0 -0
  98. {scrollkit-0.8.4 → scrollkit-0.9.0}/src/scrollkit/simulator/fonts/Arial_Bold_18.bdf.license +0 -0
  99. {scrollkit-0.8.4 → scrollkit-0.9.0}/src/scrollkit/simulator/fonts/Junction_regular_24.bdf +0 -0
  100. {scrollkit-0.8.4 → scrollkit-0.9.0}/src/scrollkit/simulator/fonts/Junction_regular_24.bdf.license +0 -0
  101. {scrollkit-0.8.4 → scrollkit-0.9.0}/src/scrollkit/simulator/fonts/LeagueSpartan-Bold-16.bdf +0 -0
  102. {scrollkit-0.8.4 → scrollkit-0.9.0}/src/scrollkit/simulator/fonts/LeagueSpartan-Bold-16.bdf.license +0 -0
  103. {scrollkit-0.8.4 → scrollkit-0.9.0}/src/scrollkit/simulator/fonts/LeagueSpartan_Bold_16.bdf +0 -0
  104. {scrollkit-0.8.4 → scrollkit-0.9.0}/src/scrollkit/simulator/fonts/LeagueSpartan_Bold_16.bdf.license +0 -0
  105. {scrollkit-0.8.4 → scrollkit-0.9.0}/src/scrollkit/simulator/fonts/LibreBodoniv2002-Bold-27.bdf +0 -0
  106. {scrollkit-0.8.4 → scrollkit-0.9.0}/src/scrollkit/simulator/fonts/LibreBodoniv2002-Bold-27.bdf.license +0 -0
  107. {scrollkit-0.8.4 → scrollkit-0.9.0}/src/scrollkit/simulator/fonts/tom-thumb.bdf +0 -0
  108. {scrollkit-0.8.4 → scrollkit-0.9.0}/src/scrollkit/simulator/fonts/viii-bold.bdf +0 -0
  109. {scrollkit-0.8.4 → scrollkit-0.9.0}/src/scrollkit/simulator/fonts/viii.bdf +0 -0
  110. {scrollkit-0.8.4 → scrollkit-0.9.0}/src/scrollkit/simulator/terminalio/__init__.py +0 -0
  111. {scrollkit-0.8.4 → scrollkit-0.9.0}/src/scrollkit/utils/__init__.py +0 -0
  112. {scrollkit-0.8.4 → scrollkit-0.9.0}/src/scrollkit/utils/color_utils.py +0 -0
  113. {scrollkit-0.8.4 → scrollkit-0.9.0}/src/scrollkit/utils/diagnostics.py +0 -0
  114. {scrollkit-0.8.4 → scrollkit-0.9.0}/src/scrollkit/utils/error_handler.py +0 -0
  115. {scrollkit-0.8.4 → scrollkit-0.9.0}/src/scrollkit/utils/system_utils.py +0 -0
  116. {scrollkit-0.8.4 → scrollkit-0.9.0}/src/scrollkit/utils/url_utils.py +0 -0
  117. {scrollkit-0.8.4 → scrollkit-0.9.0}/src/scrollkit/web/__init__.py +0 -0
  118. {scrollkit-0.8.4 → scrollkit-0.9.0}/src/scrollkit/web/settings_server.py +0 -0
  119. {scrollkit-0.8.4 → scrollkit-0.9.0}/src/scrollkit.egg-info/dependency_links.txt +0 -0
  120. {scrollkit-0.8.4 → scrollkit-0.9.0}/src/scrollkit.egg-info/requires.txt +0 -0
  121. {scrollkit-0.8.4 → scrollkit-0.9.0}/src/scrollkit.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: scrollkit
3
- Version: 0.8.4
3
+ Version: 0.9.0
4
4
  Summary: LED Matrix Display Framework for CircuitPython and Desktop
5
5
  Author-email: Michael Czeiszperger <michael@czei.org>
6
6
  License: MIT
@@ -57,17 +57,28 @@ Most LED-matrix libraries get you a scrolling "Hello, World" and stop. I built S
57
57
 
58
58
  ## Installation
59
59
 
60
+ For a stable desktop install, use the version published on PyPI. A Git tag such
61
+ as `v0.8.4` is a released PyPI build; `master` is the development branch and can
62
+ contain unreleased work. Pin an exact version in a project that needs reproducible
63
+ builds.
64
+
60
65
  ```bash
61
- # Desktop development with simulator
66
+ # Stable desktop install with simulator support
62
67
  pip install "scrollkit[simulator]"
68
+ # Or pin a tested release: pip install "scrollkit[simulator]==X.Y.Z"
63
69
 
64
- # To modify ScrollKit itself (or run the demos): clone and install editable
70
+ # To contribute, test unreleased work, or run the bundled demos: clone master
71
+ # and install it editable.
65
72
  git clone https://github.com/czei/scrollkit.git
66
73
  cd scrollkit && pip install -e ".[simulator]"
67
74
 
68
- # CircuitPython — copy scrollkit/ to your device's lib/ alongside your source
75
+ # CircuitPython — deploy a tested copy (or matching .mpy build) of scrollkit/
76
+ # inside your app payload; a device should not track Git master directly.
69
77
  ```
70
78
 
79
+ The `release-*` branches and the optional `live` OTA branch are deployment
80
+ channels for application payloads, not alternate `pip` installation channels.
81
+
71
82
  ## Quick Start
72
83
 
73
84
  ```python
@@ -228,7 +239,6 @@ if is_dev_mode():
228
239
  | Platform | Backend | Status |
229
240
  |---|---|---|
230
241
  | Adafruit MatrixPortal S3 | CircuitPython + displayio | ✅ Calibrated from device |
231
- | Pimoroni Interstate 75 W (RP2350) | CircuitPython + rgbmatrix | ✅ Supported (perf profile uncalibrated) |
232
242
  | Desktop (macOS/Linux/Windows) | SLDK Simulator | ✅ |
233
243
  | Custom CircuitPython boards | displayio / rgbmatrix | 🔌 Extensible (see [Adding New Hardware](https://scrollkit.dev/guide/hardware/)) |
234
244
 
@@ -13,17 +13,28 @@ Most LED-matrix libraries get you a scrolling "Hello, World" and stop. I built S
13
13
 
14
14
  ## Installation
15
15
 
16
+ For a stable desktop install, use the version published on PyPI. A Git tag such
17
+ as `v0.8.4` is a released PyPI build; `master` is the development branch and can
18
+ contain unreleased work. Pin an exact version in a project that needs reproducible
19
+ builds.
20
+
16
21
  ```bash
17
- # Desktop development with simulator
22
+ # Stable desktop install with simulator support
18
23
  pip install "scrollkit[simulator]"
24
+ # Or pin a tested release: pip install "scrollkit[simulator]==X.Y.Z"
19
25
 
20
- # To modify ScrollKit itself (or run the demos): clone and install editable
26
+ # To contribute, test unreleased work, or run the bundled demos: clone master
27
+ # and install it editable.
21
28
  git clone https://github.com/czei/scrollkit.git
22
29
  cd scrollkit && pip install -e ".[simulator]"
23
30
 
24
- # CircuitPython — copy scrollkit/ to your device's lib/ alongside your source
31
+ # CircuitPython — deploy a tested copy (or matching .mpy build) of scrollkit/
32
+ # inside your app payload; a device should not track Git master directly.
25
33
  ```
26
34
 
35
+ The `release-*` branches and the optional `live` OTA branch are deployment
36
+ channels for application payloads, not alternate `pip` installation channels.
37
+
27
38
  ## Quick Start
28
39
 
29
40
  ```python
@@ -184,7 +195,6 @@ if is_dev_mode():
184
195
  | Platform | Backend | Status |
185
196
  |---|---|---|
186
197
  | Adafruit MatrixPortal S3 | CircuitPython + displayio | ✅ Calibrated from device |
187
- | Pimoroni Interstate 75 W (RP2350) | CircuitPython + rgbmatrix | ✅ Supported (perf profile uncalibrated) |
188
198
  | Desktop (macOS/Linux/Windows) | SLDK Simulator | ✅ |
189
199
  | Custom CircuitPython boards | displayio / rgbmatrix | 🔌 Extensible (see [Adding New Hardware](https://scrollkit.dev/guide/hardware/)) |
190
200
 
@@ -208,4 +218,4 @@ of his code — but the idea came from his project, and it deserves the shout-ou
208
218
 
209
219
  ## License
210
220
 
211
- MIT
221
+ MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "scrollkit"
7
- version = "0.8.4"
7
+ version = "0.9.0"
8
8
  description = "LED Matrix Display Framework for CircuitPython and Desktop"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.8"
@@ -13,4 +13,4 @@ need from submodules, e.g.::
13
13
  from scrollkit.display.unified import UnifiedDisplay
14
14
  """
15
15
 
16
- __version__ = "0.8.4"
16
+ __version__ = "0.9.0"
@@ -146,6 +146,17 @@ def _effects():
146
146
  out.append({"name": "SwarmReveal", "doc": _first_line(SwarmReveal)})
147
147
  except ImportError:
148
148
  pass
149
+ try:
150
+ from ..effects.palette_partition import PalettePartition
151
+ out.append({"name": "PalettePartition",
152
+ "doc": _first_line(PalettePartition)})
153
+ except ImportError:
154
+ pass
155
+ try:
156
+ from ..effects.swirl_in import SwirlIn
157
+ out.append({"name": "SwirlIn", "doc": _first_line(SwirlIn)})
158
+ except ImportError:
159
+ pass
149
160
  return out
150
161
 
151
162
 
@@ -228,6 +239,26 @@ def _palette_effects():
228
239
  return out
229
240
 
230
241
 
242
+ def _palette_treatments():
243
+ """``[{name, doc, partition, feasibility}]`` for the partition dwell
244
+ treatments (see :mod:`scrollkit.effects.palette_treatments`).
245
+
246
+ Frame-driven classes animating a :class:`PalettePartition` purely with
247
+ palette writes; ``partition`` names the recommended partition builder.
248
+ Enumerated via the module's explicit ``TREATMENT_CLASSES`` catalog.
249
+ """
250
+ out = []
251
+ try:
252
+ from ..effects.palette_treatments import TREATMENT_CLASSES
253
+ except ImportError:
254
+ return out
255
+ for cls in TREATMENT_CLASSES:
256
+ out.append({"name": cls.__name__, "doc": _first_line(cls),
257
+ "partition": cls.PARTITION,
258
+ "feasibility": getattr(cls, "FEASIBILITY", None)})
259
+ return out
260
+
261
+
231
262
  def _named_colors():
232
263
  """name -> 0xRRGGBB int, from the tooling color table (deduped)."""
233
264
  from ..utils.color_utils import NAMED_COLORS
@@ -322,6 +353,7 @@ def capabilities():
322
353
  for key, fn in (("content_types", _content_types), ("priorities", _priorities),
323
354
  ("effects", _effects), ("transitions", _transitions),
324
355
  ("scrolling", _scrolling), ("palette_effects", _palette_effects),
356
+ ("palette_treatments", _palette_treatments),
325
357
  ("image_animators", _image_animators),
326
358
  ("text_fills", _text_fills), ("color_utilities", _color_utilities),
327
359
  ("named_colors", _named_colors),
@@ -120,6 +120,32 @@ class GraphicsMixin:
120
120
  lg.pop(i)
121
121
  return
122
122
 
123
+ def clear_layers(self):
124
+ """Strip EVERY persistent layer — the takeover blank.
125
+
126
+ ``clear()`` deliberately leaves ``_layer_group`` alone (per-frame label
127
+ reset must not disturb long-lived effect layers), so a full-screen
128
+ takeover message needs this instead: without it, an interrupted ride
129
+ screen's bitmap layers stay composited and the takeover text paints ON
130
+ TOP of them (the "Updating — DO NOT UNPLUG over the ride list" bug,
131
+ 2026-07-12). Content objects whose layers vanish are unaffected later:
132
+ ``remove_layer`` is idempotent.
133
+
134
+ The bounded painter's canvas lives in ``_layer_group`` too, so its refs
135
+ are reset here — ``_ensure_paint`` rebuilds a composited canvas on the
136
+ next ``set_pixel``/``fill_rect`` (stale refs would silently draw into a
137
+ bitmap no longer in the displayio tree).
138
+ """
139
+ lg = getattr(self, "_layer_group", None)
140
+ if lg is None:
141
+ return
142
+ while len(lg):
143
+ lg.pop()
144
+ self._paint_bitmap = None
145
+ self._paint_palette = None
146
+ self._paint_tile = None
147
+ self._paint_colors = {}
148
+
123
149
  # --- bounded painters -----------------------------------------------------
124
150
  def _ensure_paint(self):
125
151
  if self._paint_bitmap is not None:
@@ -22,6 +22,14 @@ into RAM. Import each submodule directly instead:
22
22
  screen — start/step/detach, not the ``Transition`` contract) —
23
23
  ``from scrollkit.effects.image_animators import TwinkleAnimator, MotionAnimator,
24
24
  ComboAnimator``
25
+ - **Palette partitions** (indexed layers animated purely by palette writes) —
26
+ ``from scrollkit.effects.palette_partition import PalettePartition,
27
+ map_diagonal, map_radial, map_route``
28
+ - **Palette treatments** (dwell animations on a partition — the gallery) —
29
+ ``from scrollkit.effects.palette_treatments import VelvetSweep, HaloPulse,
30
+ TREATMENT_CLASSES, treatments_for``
31
+ - **Swirl entrance** (sprites spiral in onto their targets) —
32
+ ``from scrollkit.effects.swirl_in import SwirlIn``
25
33
  - **Text-rendering helpers** —
26
34
  ``from scrollkit.effects.text_render import pixels_from_font_text, font_text_width``
27
35
 
@@ -579,7 +579,9 @@ class RegionRotateAnimator(IntroAnimator):
579
579
  ci = bitmap[x, y]
580
580
  if ci != 0 and not self._in_excl(x, y): # body pixels stay put
581
581
  src[(x, y)] = ci
582
- r = math.hypot(x - self._px, y - self._py)
582
+ dx = x - self._px
583
+ dy = y - self._py
584
+ r = math.sqrt(dx * dx + dy * dy) # math.hypot is absent on CircuitPython
583
585
  if r > maxr:
584
586
  maxr = r
585
587
  if not src or len(src) > 320:
@@ -598,16 +600,18 @@ class RegionRotateAnimator(IntroAnimator):
598
600
  self._hidden = False
599
601
 
600
602
  def _stamp(self, ang):
601
- bmp = self.bitmap
602
- for x, y in self._stamped: # erase only what we last drew
603
- bmp[x, y] = 0
604
603
  # forward is dest = R(ang)*(p-pivot)+pivot, so the pre-image of a dest pixel is
605
604
  # src = R(-ang)*(dest-pivot)+pivot. R(-ang) = [[cos, sin], [-sin, cos]].
605
+ # Compute the whole new pose FIRST (no bitmap writes), then apply it as a
606
+ # DIFF against the previous pose. The device panel refreshes continuously
607
+ # from the bitmap, so the old erase-everything-then-slowly-redraw left a
608
+ # visible "head blinks out" window on every restamp (the desktop simulator
609
+ # only presents between steps, which is why it never showed there).
606
610
  ca = math.cos(ang)
607
611
  sa = math.sin(ang)
608
612
  px, py = self._px, self._py
609
613
  src = self._src
610
- stamped = []
614
+ new = {}
611
615
  excl = self._excl
612
616
  for Y in range(self._sy0, self._sy1 + 1):
613
617
  dY = Y - py
@@ -619,9 +623,15 @@ class RegionRotateAnimator(IntroAnimator):
619
623
  sy = int(round(py - dX * sa + dY * ca))
620
624
  ci = src.get((sx, sy))
621
625
  if ci:
622
- bmp[X, Y] = ci
623
- stamped.append((X, Y))
624
- self._stamped = stamped
626
+ new[(X, Y)] = ci
627
+ bmp = self.bitmap
628
+ for xy in self._stamped: # erase only pixels LEAVING the pose
629
+ if xy not in new:
630
+ bmp[xy[0], xy[1]] = 0
631
+ for xy, ci in new.items(): # write only pixels that changed
632
+ if bmp[xy[0], xy[1]] != ci:
633
+ bmp[xy[0], xy[1]] = ci
634
+ self._stamped = list(new)
625
635
  self._last_ang = ang
626
636
  self._hidden = False
627
637
 
@@ -1074,14 +1084,36 @@ class CelWalkAnimator(IntroAnimator):
1074
1084
  HOLD_FRAMES = 104 # a full off-screen-to-off-screen crossing plus several strides
1075
1085
 
1076
1086
  def __init__(self, sheet_suffix="_walk", period=6, bob=0, path="traverse_lr",
1077
- tile_w=None, tile_h=None):
1087
+ tile_w=None, tile_h=None, head_box=None, head_pivot=None,
1088
+ head_amp_deg=0, head_period=None, head_steps=5):
1089
+ """Create an authored cel walk, optionally with a procedurally nodding head.
1090
+
1091
+ ``head_box`` is an inclusive ``(x0, y0, x1, y1)`` box in each cel and
1092
+ ``head_pivot`` is the fixed neck joint. When both are supplied with a
1093
+ non-zero ``head_amp_deg``, the head pixels are split from every authored
1094
+ cel and a small set of rotated head poses is baked at :meth:`start`.
1095
+ The walk still costs only tile-index and position writes per displayed
1096
+ frame; the one-time bake avoids rotating pixels on the MatrixPortal.
1097
+
1098
+ The box must include only the head/neck and the transparent space its arc
1099
+ crosses. The default ``head_period`` is one complete authored gait cycle
1100
+ (``period * number_of_cels``), and ``head_steps`` is the number of baked
1101
+ angles across ``-head_amp_deg`` through ``+head_amp_deg``.
1102
+ """
1078
1103
  self._suffix = sheet_suffix
1079
1104
  self._period = max(1, period) # display frames per pose (the gait clock)
1080
1105
  self._bob = bob
1081
1106
  self._path = path
1082
1107
  self._tw = tile_w
1083
1108
  self._th = tile_h
1109
+ self._head_box = tuple(head_box) if head_box is not None else None
1110
+ self._head_pivot = tuple(head_pivot) if head_pivot is not None else None
1111
+ self._head_amp = head_amp_deg * 0.017453292519943295
1112
+ self._head_period = head_period
1113
+ steps = max(3, head_steps)
1114
+ self._head_steps = steps if steps & 1 else steps + 1
1084
1115
  self._tile = None
1116
+ self._head_tile = None
1085
1117
  self._odb = None
1086
1118
 
1087
1119
  def _sheet_path(self):
@@ -1108,17 +1140,98 @@ class CelWalkAnimator(IntroAnimator):
1108
1140
  n = sheet.width // tw
1109
1141
  if n < 1:
1110
1142
  raise ValueError("cel_walk: sheet narrower than one tile")
1111
- gtile = display.gfx.TileGrid(sheet, pixel_shader=pal, tile_width=tw, tile_height=th)
1143
+ head_enabled = self._head_box is not None or self._head_pivot is not None
1144
+ if head_enabled:
1145
+ if self._head_box is None or self._head_pivot is None or not self._head_amp:
1146
+ raise ValueError("cel_walk: head rotation needs box, pivot, and non-zero amplitude")
1147
+ sheet, head_sheet = self._bake_head_poses(display.gfx, self._sheet_path(),
1148
+ n, tw, th, len(pal))
1149
+ gtile = display.gfx.TileGrid(sheet, pixel_shader=pal,
1150
+ tile_width=tw, tile_height=th)
1151
+ head_tile = display.gfx.TileGrid(head_sheet, pixel_shader=pal,
1152
+ tile_width=tw, tile_height=th)
1153
+ else:
1154
+ gtile = display.gfx.TileGrid(sheet, pixel_shader=pal, tile_width=tw, tile_height=th)
1155
+ head_tile = None
1112
1156
  # Commit: blank the static base sprite, then composite the walking strip above it.
1113
1157
  self._odb = odb
1114
1158
  self._tile = gtile
1159
+ self._head_tile = head_tile
1115
1160
  self._n = n
1116
1161
  self._apply(0)
1117
1162
  bitmap.fill(0)
1118
1163
  display.add_layer(self._tile)
1164
+ if self._head_tile is not None:
1165
+ display.add_layer(self._head_tile)
1166
+
1167
+ def _bake_head_poses(self, gfx, sheet_path, n, tw, th, ncolors):
1168
+ """Return a headless cel sheet plus a sheet of pre-rotated head poses.
1169
+
1170
+ A sheet read from ``OnDiskBitmap`` cannot be inspected or changed on
1171
+ CircuitPython. Decode it once only when this opt-in feature is used;
1172
+ the base sheet retains the authored body/leg poses while the sparse head
1173
+ sheet supplies ``n * head_steps`` rotated alternatives.
1174
+ """
1175
+ x0, y0, x1, y1 = self._head_box
1176
+ px, py = self._head_pivot
1177
+ if x0 < 0 or y0 < 0 or x1 < x0 or y1 < y0 or x1 >= tw or y1 >= th:
1178
+ raise ValueError("cel_walk: head box outside a cel")
1179
+ if not (0 <= px < tw and 0 <= py < th):
1180
+ raise ValueError("cel_walk: head pivot outside a cel")
1181
+
1182
+ base = read_indexed_bmp(gfx, sheet_path)
1183
+ head_sheet = gfx.Bitmap(tw * n * self._head_steps, th, ncolors)
1184
+ max_radius = 1.0
1185
+ head_pixels = []
1186
+ for pose in range(n):
1187
+ pixels = {}
1188
+ offset = pose * tw
1189
+ for y in range(y0, y1 + 1):
1190
+ for x in range(x0, x1 + 1):
1191
+ ci = base[offset + x, y]
1192
+ if ci:
1193
+ pixels[(x, y)] = ci
1194
+ base[offset + x, y] = 0
1195
+ dx, dy = x - px, y - py
1196
+ radius = math.sqrt(dx * dx + dy * dy)
1197
+ if radius > max_radius:
1198
+ max_radius = radius
1199
+ if not pixels:
1200
+ raise ValueError("cel_walk: head box has no pixels in pose %d" % pose)
1201
+ head_pixels.append(pixels)
1202
+
1203
+ margin = int(max_radius * abs(math.sin(self._head_amp))) + 1
1204
+ sx0, sx1 = max(0, x0 - margin), min(tw - 1, x1 + margin)
1205
+ sy0, sy1 = max(0, y0 - margin), min(th - 1, y1 + margin)
1206
+ for pose, pixels in enumerate(head_pixels):
1207
+ for step in range(self._head_steps):
1208
+ # Equally-spaced angles include the upright rest pose because
1209
+ # head_steps is always odd.
1210
+ angle = self._head_amp * ((2.0 * step / (self._head_steps - 1)) - 1.0)
1211
+ ca, sa = math.cos(angle), math.sin(angle)
1212
+ cell_offset = (pose * self._head_steps + step) * tw
1213
+ # Inverse-map the bounded destination rectangle so the rotated
1214
+ # head stays solid instead of developing forward-map holes.
1215
+ for Y in range(sy0, sy1 + 1):
1216
+ dy = Y - py
1217
+ for X in range(sx0, sx1 + 1):
1218
+ dx = X - px
1219
+ sx = int(round(px + dx * ca + dy * sa))
1220
+ sy = int(round(py - dx * sa + dy * ca))
1221
+ ci = pixels.get((sx, sy))
1222
+ if ci:
1223
+ head_sheet[cell_offset + X, Y] = ci
1224
+ return base, head_sheet
1119
1225
 
1120
1226
  def _apply(self, frame):
1121
1227
  self._tile[0, 0] = (frame // self._period) % self._n # gait clock -> which pose
1228
+ head_tile = self._head_tile
1229
+ if head_tile is not None:
1230
+ cycle = self._head_period or (self._period * self._n)
1231
+ amount = (math.sin(6.2832 * frame / float(max(1, cycle))) + 1.0) * 0.5
1232
+ head_step = int(round(amount * (self._head_steps - 1)))
1233
+ pose = (frame // self._period) % self._n
1234
+ head_tile[0, 0] = pose * self._head_steps + head_step
1122
1235
  span = self.HOLD_FRAMES - 1 if self.HOLD_FRAMES > 1 else 1
1123
1236
  t = frame / span
1124
1237
  if t > 1.0:
@@ -1126,13 +1239,23 @@ class CelWalkAnimator(IntroAnimator):
1126
1239
  x0, x1 = ((self._off, -self._off) if self._path == "traverse_rl"
1127
1240
  else (-self._off, self._off))
1128
1241
  self._tile.x = int(round(x0 + (x1 - x0) * t))
1242
+ if head_tile is not None:
1243
+ head_tile.x = self._tile.x
1129
1244
  if self._bob:
1130
1245
  self._tile.y = int(round(self._bob * math.sin(frame * 0.3)))
1246
+ if head_tile is not None:
1247
+ head_tile.y = self._tile.y
1131
1248
 
1132
1249
  def step(self, frame):
1133
1250
  self._apply(frame)
1134
1251
 
1135
1252
  def detach(self):
1253
+ head = getattr(self, "_head_tile", None)
1254
+ if head is not None and getattr(self, "display", None) is not None:
1255
+ try:
1256
+ self.display.remove_layer(head)
1257
+ except Exception:
1258
+ pass
1136
1259
  t = getattr(self, "_tile", None)
1137
1260
  if t is not None and getattr(self, "display", None) is not None:
1138
1261
  try:
@@ -1143,6 +1266,7 @@ class CelWalkAnimator(IntroAnimator):
1143
1266
  # (CircuitPython has a small open-file limit). No base-restore — the sprite walked off,
1144
1267
  # the base is blank, the fade shows empty sky (matches MotionAnimator traverse).
1145
1268
  self._tile = None
1269
+ self._head_tile = None
1146
1270
  self._odb = None
1147
1271
 
1148
1272