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,245 @@
1
+ # Copyright (c) 2024-2026 Michael Winslow Czeiszperger
2
+ """Pre-flight validation for a ScrollKit app — structured issues with fixes.
3
+
4
+ ``validate(app)`` runs the app headless once and combines *static* checks on its
5
+ content (colors, text width, on-panel positions) with *dynamic* findings from the
6
+ run (did it render anything, plus the hardware feasibility warnings — stutter,
7
+ RAM). It returns a ``ValidationReport`` of ``Issue``s, each carrying a severity, a
8
+ stable code, a human message, and a concrete fix — so an AI agent gets actionable
9
+ feedback instead of a stack trace, and can decide whether the app is ready.
10
+
11
+ Design note: this reports the *real* behavior of the imperative content classes.
12
+ A color name string, for example, is flagged as an error (it would crash
13
+ ``draw_text``, which expects an int) rather than being quietly "fixed". Honest
14
+ root-cause feedback over papering over the problem.
15
+
16
+ Desktop-only (imported via ``scrollkit.dev``).
17
+ """
18
+
19
+ PANEL_WIDTH = 64
20
+ PANEL_HEIGHT = 32
21
+ CHAR_WIDTH_PX = 6 # ScrollingText's own width estimate; good enough for a heuristic
22
+
23
+
24
+ class Issue:
25
+ """One validation finding: severity + stable code + message + concrete fix."""
26
+
27
+ __slots__ = ("severity", "code", "message", "fix")
28
+
29
+ def __init__(self, severity, code, message, fix):
30
+ self.severity = severity # "error" | "warning" | "info"
31
+ self.code = code
32
+ self.message = message
33
+ self.fix = fix
34
+
35
+ def as_dict(self):
36
+ return {"severity": self.severity, "code": self.code,
37
+ "message": self.message, "fix": self.fix}
38
+
39
+ def __repr__(self):
40
+ return "Issue(%s, %s)" % (self.severity, self.code)
41
+
42
+
43
+ class ValidationReport:
44
+ """Aggregate of validation issues, plus the underlying RunResult (if any)."""
45
+
46
+ def __init__(self, issues, run=None):
47
+ self.issues = issues
48
+ self.run = run # the RunResult from the headless render, or None
49
+
50
+ @property
51
+ def errors(self):
52
+ return [i for i in self.issues if i.severity == "error"]
53
+
54
+ @property
55
+ def warnings(self):
56
+ return [i for i in self.issues if i.severity == "warning"]
57
+
58
+ @property
59
+ def infos(self):
60
+ return [i for i in self.issues if i.severity == "info"]
61
+
62
+ @property
63
+ def ok(self):
64
+ """True when there are no errors (warnings are allowed)."""
65
+ return not self.errors
66
+
67
+ def as_dict(self):
68
+ return {
69
+ "ok": self.ok,
70
+ "counts": {"error": len(self.errors), "warning": len(self.warnings),
71
+ "info": len(self.infos)},
72
+ "issues": [i.as_dict() for i in self.issues],
73
+ }
74
+
75
+ def as_text(self):
76
+ lines = ["=== validate: %s ===" % ("OK" if self.ok else "ISSUES FOUND")]
77
+ if not self.issues:
78
+ lines.append(" No issues.")
79
+ for i in self.issues:
80
+ lines.append(" [%s] %s: %s" % (i.severity.upper(), i.code, i.message))
81
+ if i.fix:
82
+ lines.append(" fix: %s" % i.fix)
83
+ return "\n".join(lines)
84
+
85
+ def __repr__(self):
86
+ return "ValidationReport(ok=%s, errors=%d, warnings=%d)" % (
87
+ self.ok, len(self.errors), len(self.warnings))
88
+
89
+
90
+ # --- checks -------------------------------------------------------------------
91
+
92
+ def _check_color(color, label):
93
+ if isinstance(color, bool): # bool is an int subclass — almost certainly a bug
94
+ return [Issue("error", "color_type",
95
+ "%s: color is a bool (%r)." % (label, color),
96
+ "Use an int 0xRRGGBB or an (r, g, b) tuple.")]
97
+ if isinstance(color, int):
98
+ if color < 0 or color > 0xFFFFFF:
99
+ return [Issue("warning", "color_out_of_range",
100
+ "%s: color 0x%X is outside 0x000000..0xFFFFFF."
101
+ % (label, color),
102
+ "Use a 24-bit value, e.g. 0xFF8800.")]
103
+ return []
104
+ if isinstance(color, (tuple, list)):
105
+ ok = (len(color) == 3 and all(
106
+ isinstance(c, int) and not isinstance(c, bool) and 0 <= c <= 255
107
+ for c in color))
108
+ if not ok:
109
+ return [Issue("warning", "color_out_of_range",
110
+ "%s: RGB %r must be three ints in 0..255." % (label, color),
111
+ "e.g. (255, 136, 0).")]
112
+ return []
113
+ if isinstance(color, str):
114
+ return [Issue("error", "color_string",
115
+ "%s: color is the string %r — pass an int 0xRRGGBB, a tuple "
116
+ "(r, g, b), or a settings key defined via "
117
+ "self.settings.define(key, default, type='color')." % (label, color),
118
+ "Use 0xRRGGBB or (r, g, b). For a named setting: "
119
+ "self.settings.define('highlight', 0xFF8800, type='color').")]
120
+ return [Issue("error", "color_type",
121
+ "%s: color has unsupported type %s." % (label, type(color).__name__),
122
+ "Use an int 0xRRGGBB or an (r, g, b) tuple.")]
123
+
124
+
125
+ def _static_checks(items, panel_width, panel_height, app=None):
126
+ issues = []
127
+ if not items:
128
+ issues.append(Issue(
129
+ "info", "empty_queue",
130
+ "content_queue is empty.",
131
+ "If you render via a prepare_display_content() override this is fine; "
132
+ "otherwise add content in setup(), e.g. "
133
+ "self.content_queue.add(ScrollingText('HELLO'))."))
134
+ return issues
135
+
136
+ # Build the set of defined settings keys so we can validate color references.
137
+ defined_keys = set()
138
+ if app is not None:
139
+ try:
140
+ schema = getattr(getattr(app, "settings", None), "_schema", [])
141
+ defined_keys = {e["key"] for e in schema}
142
+ except Exception:
143
+ pass
144
+
145
+ for idx, item in enumerate(items):
146
+ label = "%s#%d" % (type(item).__name__, idx)
147
+
148
+ color_setting = getattr(item, "_color_setting", None)
149
+ if color_setting is not None:
150
+ # color was None or a string key — validate the key is defined
151
+ if color_setting not in defined_keys:
152
+ issues.extend(_check_color(color_setting, label))
153
+ else:
154
+ color = getattr(item, "color", None)
155
+ if color is not None:
156
+ issues.extend(_check_color(color, label))
157
+
158
+ text = getattr(item, "text", None)
159
+ scrolling = hasattr(item, "speed") # ScrollingText has speed; StaticText doesn't
160
+ if isinstance(text, str) and not scrolling:
161
+ width = len(text) * CHAR_WIDTH_PX
162
+ if width > panel_width:
163
+ issues.append(Issue(
164
+ "warning", "text_clipped",
165
+ "%s: text ~%dpx wide exceeds the %dpx panel and will be clipped."
166
+ % (label, width, panel_width),
167
+ "Use ScrollingText so it scrolls, or shorten the text."))
168
+
169
+ y = getattr(item, "y", None)
170
+ if isinstance(y, int) and (y < 0 or y >= panel_height):
171
+ issues.append(Issue(
172
+ "warning", "offscreen_y",
173
+ "%s: y=%d is outside the 0..%d panel." % (label, y, panel_height - 1),
174
+ "y is the top-down baseline (origin top-left, y grows down); "
175
+ "pick a y within the panel. y=12 vertically centers an 8px font; "
176
+ "low y like 0 clips glyphs off the top."))
177
+
178
+ return issues
179
+
180
+
181
+ def _dynamic_checks(result):
182
+ issues = []
183
+ for e in result.errors:
184
+ issues.append(Issue(
185
+ "error", "runtime_error", e,
186
+ "Fix the exception; scrollkit.dev.run_headless(app) shows full detail."))
187
+ if result.is_blank and not result.errors:
188
+ if result.frames == 0:
189
+ msg = ("Nothing rendered (0 frames shown) — no content reached the "
190
+ "display.")
191
+ else:
192
+ msg = "Ran %d frames but nothing was lit." % result.frames
193
+ issues.append(Issue(
194
+ "error", "blank_render", msg,
195
+ "Check content was added to the queue (or drawn), colors aren't "
196
+ "black, and positions are on-panel."))
197
+ # Hardware estimates from a crashed or empty run are meaningless, so skip
198
+ # them then. The feasibility warnings already carry their own fix guidance,
199
+ # so no separate fix line is needed.
200
+ if result.hardware and not result.errors and result.frames > 0:
201
+ for w in result.hardware.get("warnings", []):
202
+ issues.append(Issue("warning", "hardware", w, None))
203
+ return issues
204
+
205
+
206
+ def validate(app, panel_width=PANEL_WIDTH, panel_height=PANEL_HEIGHT,
207
+ run=True, frames=60):
208
+ """Validate ``app`` and return a :class:`ValidationReport`.
209
+
210
+ Args:
211
+ app: a ``ScrollKitApp`` instance (its ``setup()`` populates content).
212
+ panel_width/panel_height: target panel size (MatrixPortal S3 = 64x32).
213
+ run: render the app headless to add dynamic checks + hardware warnings
214
+ (default True). With ``run=False`` only static checks run.
215
+ frames: frames to render when ``run`` is True.
216
+
217
+ The app's ``setup()`` runs as part of this call (via the headless render, or
218
+ directly when ``run=False``), so pass a fresh, un-run app instance.
219
+ """
220
+ issues = []
221
+ result = None
222
+
223
+ if run:
224
+ from .harness import run_headless
225
+ result = run_headless(app, frames=frames, hardware=True)
226
+ else:
227
+ import asyncio
228
+ try:
229
+ asyncio.run(app.setup())
230
+ except Exception as e:
231
+ issues.append(Issue(
232
+ "warning", "setup_failed",
233
+ "Could not run setup() for static checks: %r" % (e,),
234
+ "Ensure setup() runs without a live display, or use run=True."))
235
+
236
+ try:
237
+ items = list(app.content_queue)
238
+ except Exception:
239
+ items = []
240
+ issues.extend(_static_checks(items, panel_width, panel_height, app=app))
241
+
242
+ if result is not None:
243
+ issues.extend(_dynamic_checks(result))
244
+
245
+ return ValidationReport(issues, result)
@@ -0,0 +1,14 @@
1
+ # Copyright (c) 2024-2026 Michael Winslow Czeiszperger
2
+ """ScrollKit Display module.
3
+
4
+ Kept lean on purpose: importing this package pulls in only the core content
5
+ model (``DisplayContent``/``ContentQueue``/text classes) and ``UnifiedDisplay``,
6
+ so a basic display app stays small on the RAM-constrained device. Heavier,
7
+ optional pieces (effects, the dev/simulator toolkits) are imported directly
8
+ from their own modules when needed.
9
+ """
10
+
11
+ from .content import ContentQueue, DisplayContent, ScrollingText, StaticText
12
+ from .unified import UnifiedDisplay
13
+
14
+ __all__ = ['ContentQueue', 'DisplayContent', 'ScrollingText', 'StaticText', 'UnifiedDisplay']
@@ -0,0 +1,299 @@
1
+ # Copyright (c) 2024-2026 Michael Winslow Czeiszperger
2
+ """Shared display-graphics primitives (mixed into Simulator + Unified displays).
3
+
4
+ One code path, both platforms. Provides:
5
+ - ``GraphicsContext`` (``display.gfx``): the platform-resolved Bitmap / Palette /
6
+ TileGrid / Group / bitmaptools, built once and cached.
7
+ - The content/layer group split (D11): ``_content_group`` (Labels + fill, emptied
8
+ per frame) sits BELOW ``_layer_group`` (overlay-mask / bitmap-text / paint
9
+ canvas, persistent across frames), so an effect layer can never be hidden by
10
+ per-frame label churn.
11
+ - Bounded span/rect painters (``fill_rect`` / ``fill_span`` / ``clear_rect``) that
12
+ write a persistent full-screen paint canvas through ``bitmaptools.fill_region``
13
+ (a C bulk op) — never a full-2048 Python loop — and account the cost.
14
+ - The shared per-frame surface: ``clear()`` (label-slot reset + paint wipe),
15
+ ``set_pixel()`` (a 1x1 ``fill_rect`` into the paint canvas, so pixels survive
16
+ the displayio re-render on BOTH platforms — this is what the particle system
17
+ renders through) and ``fill()`` (a cached full-screen background TileGrid at
18
+ the bottom of the content group). One implementation, hardware == simulator.
19
+ - ``measure_text`` via real font glyph advances (not ``len(text) * 6``).
20
+
21
+ CircuitPython-safe: no runtime ``typing``, no desktop-only imports here.
22
+ """
23
+
24
+
25
+ class GraphicsContext:
26
+ """Platform-resolved graphics primitives, held by reference (no allocation).
27
+
28
+ Built once per display in ``initialize()`` and returned by ``display.gfx`` so
29
+ effects build bitmaps/palettes/tilegrids and run C bulk ops through ONE code
30
+ path. On CircuitPython these are the real built-ins; on desktop they are the
31
+ ``scrollkit.simulator`` equivalents (including the ``bitmaptools`` shim).
32
+ """
33
+
34
+ __slots__ = ("Bitmap", "Palette", "TileGrid", "Group", "bitmaptools")
35
+
36
+ def __init__(self, Bitmap, Palette, TileGrid, Group, bitmaptools):
37
+ self.Bitmap = Bitmap
38
+ self.Palette = Palette
39
+ self.TileGrid = TileGrid
40
+ self.Group = Group
41
+ self.bitmaptools = bitmaptools
42
+
43
+
44
+ def _glyph_advance(glyph):
45
+ """Pull the horizontal advance from a glyph (dict or object), or None."""
46
+ if glyph is None:
47
+ return None
48
+ if isinstance(glyph, dict):
49
+ for key in ("dx", "shift_x", "width"):
50
+ if key in glyph and glyph[key] is not None:
51
+ return glyph[key]
52
+ return None
53
+ for key in ("dx", "shift_x", "width"):
54
+ val = getattr(glyph, key, None)
55
+ if val is not None:
56
+ return val
57
+ return None
58
+
59
+
60
+ class GraphicsMixin:
61
+ """Mixed into the display implementations. Expects the subclass to provide
62
+ ``self._width``, ``self._height``, ``self.font``, ``self._perf`` and to call
63
+ ``_init_graphics()`` from ``initialize()`` once ``self.main_group`` exists."""
64
+
65
+ PAINT_VALUE_COUNT = 256
66
+
67
+ # --- setup ----------------------------------------------------------------
68
+ def _init_graphics(self, displayio_mod, bitmaptools_mod):
69
+ """Create the content/layer sub-groups inside main_group and cache gfx.
70
+
71
+ Call from ``initialize()`` after ``self.main_group`` is created and set as
72
+ the display's root group.
73
+ """
74
+ self._content_group = displayio_mod.Group()
75
+ self._layer_group = displayio_mod.Group()
76
+ self.main_group.append(self._content_group) # below
77
+ self.main_group.append(self._layer_group) # above
78
+ self._gfx = GraphicsContext(
79
+ displayio_mod.Bitmap, displayio_mod.Palette, displayio_mod.TileGrid,
80
+ displayio_mod.Group, bitmaptools_mod)
81
+ # The full-screen paint canvas is created lazily on the first painter call
82
+ # so apps that never paint pay nothing.
83
+ self._paint_bitmap = None
84
+ self._paint_palette = None
85
+ self._paint_tile = None
86
+ self._paint_colors = None
87
+ # Full-screen background for fill(), also lazy (one Bitmap/Palette/
88
+ # TileGrid ever — the palette color is mutated per call, never realloc'd).
89
+ self._bg_bitmap = None
90
+ self._bg_palette = None
91
+ self._bg_tile = None
92
+
93
+ # --- gfx ------------------------------------------------------------------
94
+ @property
95
+ def gfx(self):
96
+ """The cached :class:`GraphicsContext` (identity-stable; no per-access alloc)."""
97
+ gfx = getattr(self, "_gfx", None)
98
+ if gfx is None:
99
+ raise RuntimeError("display.gfx is unavailable until initialize() runs")
100
+ return gfx
101
+
102
+ # --- layers (D11) ---------------------------------------------------------
103
+ def add_layer(self, tilegrid):
104
+ """Composite ``tilegrid`` above all content (idempotent)."""
105
+ lg = getattr(self, "_layer_group", None)
106
+ if lg is None:
107
+ return
108
+ for i in range(len(lg)):
109
+ if lg[i] is tilegrid:
110
+ return
111
+ lg.append(tilegrid)
112
+
113
+ def remove_layer(self, tilegrid):
114
+ """Remove a layer added via :meth:`add_layer` (idempotent)."""
115
+ lg = getattr(self, "_layer_group", None)
116
+ if lg is None:
117
+ return
118
+ for i in range(len(lg)):
119
+ if lg[i] is tilegrid:
120
+ lg.pop(i)
121
+ return
122
+
123
+ # --- bounded painters -----------------------------------------------------
124
+ def _ensure_paint(self):
125
+ if self._paint_bitmap is not None:
126
+ return
127
+ gfx = self.gfx
128
+ bm = gfx.Bitmap(self._width, self._height, self.PAINT_VALUE_COUNT)
129
+ pal = gfx.Palette(self.PAINT_VALUE_COUNT)
130
+ if hasattr(pal, "make_transparent"):
131
+ pal.make_transparent(0) # unpainted shows underlying content
132
+ tile = gfx.TileGrid(bm, pixel_shader=pal)
133
+ self._paint_bitmap = bm
134
+ self._paint_palette = pal
135
+ self._paint_tile = tile
136
+ self._paint_colors = {} # 0xRRGGBB -> palette index (0 reserved)
137
+ self.add_layer(tile)
138
+
139
+ def _paint_index(self, color):
140
+ idx = self._paint_colors.get(color)
141
+ if idx is not None:
142
+ return idx
143
+ idx = len(self._paint_colors) + 1
144
+ if idx >= self.PAINT_VALUE_COUNT:
145
+ # Palette saturated: reuse the last slot for this color WITHOUT caching
146
+ # it, so the most recently painted color is always the correct one
147
+ # (caching here would alias several colors to one mutable slot).
148
+ idx = self.PAINT_VALUE_COUNT - 1
149
+ self._paint_palette[idx] = color
150
+ return idx
151
+ self._paint_palette[idx] = color
152
+ self._paint_colors[color] = idx
153
+ return idx
154
+
155
+ def _clip_rect(self, x, y, w, h):
156
+ x0 = x if x > 0 else 0
157
+ y0 = y if y > 0 else 0
158
+ x1 = x + w
159
+ y1 = y + h
160
+ if x1 > self._width:
161
+ x1 = self._width
162
+ if y1 > self._height:
163
+ y1 = self._height
164
+ if x1 < x0:
165
+ x1 = x0
166
+ if y1 < y0:
167
+ y1 = y0
168
+ return x0, y0, x1, y1
169
+
170
+ def _account_bulk(self, x0, y0, x1, y1):
171
+ pm = getattr(self, "_perf", None)
172
+ if pm is not None:
173
+ pm.account_bulk_op("fill_region", (x1 - x0) * (y1 - y0))
174
+
175
+ async def fill_rect(self, x, y, w, h, color):
176
+ """Fill the clipped rectangle with ``color`` via a C bulk op."""
177
+ self._ensure_paint()
178
+ idx = self._paint_index(color)
179
+ x0, y0, x1, y1 = self._clip_rect(x, y, w, h)
180
+ if x1 > x0 and y1 > y0:
181
+ self.gfx.bitmaptools.fill_region(self._paint_bitmap, x0, y0, x1, y1, idx)
182
+ self._account_bulk(x0, y0, x1, y1)
183
+
184
+ async def fill_span(self, y, x0, x1, color):
185
+ """Fill the single-row span ``[x0, x1)`` with ``color``."""
186
+ await self.fill_rect(x0, y, x1 - x0, 1, color)
187
+
188
+ async def clear_rect(self, x, y, w, h):
189
+ """Clear the clipped rectangle back to transparent via a C bulk op."""
190
+ self._ensure_paint()
191
+ x0, y0, x1, y1 = self._clip_rect(x, y, w, h)
192
+ if x1 > x0 and y1 > y0:
193
+ self.gfx.bitmaptools.fill_region(self._paint_bitmap, x0, y0, x1, y1, 0)
194
+ self._account_bulk(x0, y0, x1, y1)
195
+
196
+ # --- the shared per-frame surface (one code path, both platforms) ---------
197
+ def _hide_unused_labels(self):
198
+ """Hide pooled Labels that weren't drawn this frame (frame drew fewer)."""
199
+ pool = getattr(self, "_label_pool", None) or ()
200
+ for i in range(getattr(self, "_label_idx", 0), len(pool)):
201
+ lbl = pool[i]
202
+ if hasattr(lbl, "hidden"):
203
+ lbl.hidden = True
204
+ pool = getattr(self, "_scaled_pool", None) or ()
205
+ for i in range(getattr(self, "_scaled_idx", 0), len(pool)):
206
+ lbl = pool[i]
207
+ if hasattr(lbl, "hidden"):
208
+ lbl.hidden = True
209
+
210
+ async def clear(self):
211
+ """Clear the display (start a new frame).
212
+
213
+ Resets the per-frame Label slot indices so draw_text() reuses pooled
214
+ Labels instead of allocating (labels not redrawn this frame are hidden
215
+ in show()), wipes the bounded-painter canvas so fill_rect()/set_pixel()
216
+ drawings don't ghost across frames (immediate-mode, one C bulk fill),
217
+ and hides the fill() background. Persistent effect layers live in
218
+ _layer_group and are deliberately NOT touched. No per-pixel loops, no
219
+ raw-matrix writes: the displayio refresh in show() re-renders the whole
220
+ group each frame on both platforms.
221
+ """
222
+ self._label_idx = 0
223
+ self._scaled_idx = 0
224
+ if getattr(self, "_paint_bitmap", None) is not None:
225
+ self._paint_bitmap.fill(0)
226
+ if getattr(self, "_bg_tile", None) is not None:
227
+ self._bg_tile.hidden = True
228
+
229
+ async def set_pixel(self, x, y, color):
230
+ """Set a single foreground pixel (drawn on top of other content).
231
+
232
+ A 1x1 fill_rect into the persistent paint-canvas LAYER, so the pixel is
233
+ part of the displayio tree and survives show()'s re-render on hardware
234
+ AND desktop (a raw matrix write would be wiped by refresh, and the
235
+ hardware matrix has no pixel API at all). This is the primitive the
236
+ particle system renders through; each write is a bounded C bulk op that
237
+ the hardware feasibility model accounts for.
238
+ """
239
+ if getattr(self, "_gfx", None) is None:
240
+ return # not initialized yet — mirror the old silent no-op
241
+ if 0 <= x < self._width and 0 <= y < self._height:
242
+ await self.fill_rect(x, y, 1, 1, color)
243
+
244
+ def _ensure_bg(self):
245
+ gfx = self.gfx
246
+ if self._bg_bitmap is None:
247
+ self._bg_bitmap = gfx.Bitmap(self._width, self._height, 1)
248
+ self._bg_palette = gfx.Palette(1)
249
+ self._bg_tile = gfx.TileGrid(self._bg_bitmap,
250
+ pixel_shader=self._bg_palette)
251
+ # Bottom of the CONTENT group: behind every label, below the
252
+ # persistent effect layers. Inserted once; fill()/clear() toggle
253
+ # .hidden and mutate the palette — never another allocation.
254
+ self._content_group.insert(0, self._bg_tile)
255
+
256
+ async def fill(self, color):
257
+ """Fill the display background with a solid color (behind labels).
258
+
259
+ Immediate-mode like draw_text(): lasts until the next clear(). Backed
260
+ by one cached full-screen TileGrid whose 1-entry palette is mutated per
261
+ call — no per-frame allocation, works identically on hardware and
262
+ desktop (the old paths wrote to the raw matrix, which the hardware
263
+ wrapper doesn't expose and the desktop refresh wiped).
264
+ """
265
+ if getattr(self, "_gfx", None) is None or self._content_group is None:
266
+ return
267
+ self._ensure_bg()
268
+ self._bg_palette[0] = color
269
+ self._bg_tile.hidden = False
270
+
271
+ # --- measurement ----------------------------------------------------------
272
+ def measure_text(self, text, font=None):
273
+ """Rendered pixel width from summed font glyph advances (not ``len*6``).
274
+
275
+ Empty string is 0; a missing/advance-less glyph contributes the font's
276
+ space advance (else a documented 6). A font with no ``get_glyph`` API
277
+ falls back to a coarse ``len*6`` estimate (last resort).
278
+ """
279
+ if not text:
280
+ return 0
281
+ if font is None:
282
+ font = getattr(self, "font", None)
283
+ get = getattr(font, "get_glyph", None) if font is not None else None
284
+ if get is None:
285
+ return len(text) * 6
286
+ try:
287
+ repl = _glyph_advance(get(" "))
288
+ except Exception:
289
+ repl = None
290
+ if repl is None:
291
+ repl = 6
292
+ total = 0
293
+ for ch in text:
294
+ try:
295
+ adv = _glyph_advance(get(ch))
296
+ except Exception:
297
+ adv = None
298
+ total += adv if adv is not None else repl
299
+ return total