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,604 @@
1
+ # Copyright (c) 2024-2026 Michael Winslow Czeiszperger
2
+ """Display content classes for SLDK.
3
+
4
+ Provides base classes and implementations for displayable content.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ try:
10
+ from typing import Any, List, Optional
11
+ except ImportError: # CircuitPython has no 'typing' module
12
+ pass
13
+
14
+ from .text_fill import DEFAULT_PALETTE_STEPS, normalize_direction
15
+
16
+ import time
17
+ # Monotonic clock works in both sync and async contexts, on desktop and
18
+ # CircuitPython. Avoids asyncio.get_event_loop(), which raises outside a running
19
+ # loop on modern Python (and isn't available the same way on CircuitPython).
20
+ get_time = lambda: time.monotonic()
21
+
22
+ # The display loop runs at ~20 FPS (app loop sleeps 0.05 s; the feasibility budget
23
+ # is built on the same target). Scroll speed (px/sec) is converted to per-frame
24
+ # motion against this. Kept here so the scroll math and the budget stay in sync.
25
+ LOOP_FPS = 20
26
+
27
+ # Set by SLDKApp.__init__ so content objects can read library defaults without
28
+ # being tightly coupled to the app. None when running outside an app context
29
+ # (tests, standalone scripts) — helpers fall back to hardcoded values.
30
+ _settings = None
31
+
32
+
33
+ __all__ = ['Priority', 'DisplayContent', 'StaticText', 'ScrollingText', 'ContentQueue', 'LOOP_FPS']
34
+
35
+ def _resolve_color(color):
36
+ """Resolve a color argument to an int.
37
+
38
+ ``None`` → library default_color setting (or 0xFFFFFF if no app running)
39
+ ``str`` → a named settings key; reads the current value from _settings
40
+ ``int`` → used as-is
41
+ """
42
+ if color is None:
43
+ color = "default_color"
44
+ if isinstance(color, str):
45
+ if _settings is not None:
46
+ try:
47
+ return _settings.get(color, 0xFFFFFF)
48
+ except Exception:
49
+ pass
50
+ return 0xFFFFFF
51
+ return color
52
+
53
+
54
+ def _resolve_speed(speed):
55
+ """Resolve a speed argument to pixels/second.
56
+
57
+ ``None`` → library scroll_speed setting (or 25 if no app running)
58
+ ``int``/``float`` → used as-is
59
+ """
60
+ if speed is None:
61
+ if _settings is not None:
62
+ try:
63
+ return _settings.get_scroll_speed_px()
64
+ except Exception:
65
+ pass
66
+ return 25
67
+ return speed
68
+
69
+
70
+ class Priority:
71
+ """Priority levels for display content, highest wins ties in ContentQueue."""
72
+ IDLE: int = 0
73
+ LOW: int = 1
74
+ NORMAL: int = 2
75
+ HIGH: int = 3
76
+ URGENT: int = 4
77
+ SYSTEM: int = 5
78
+
79
+
80
+ class DisplayContent:
81
+ """Base class for displayable content."""
82
+
83
+ def __init__(self, duration: Optional[float] = None, priority: int = Priority.NORMAL):
84
+ """Initialize display content.
85
+
86
+ Args:
87
+ duration: How long to display in seconds (None = forever)
88
+ priority: Queue priority (default Priority.NORMAL). Higher
89
+ values are shown first by ContentQueue.
90
+ """
91
+ self.duration: Optional[float] = duration
92
+ self.priority: int = priority
93
+ # Stamp the clock at creation so duration-based completion works even
94
+ # when the content is used outside the async start()/render() loop
95
+ # (e.g. added straight to a ContentQueue). async start() re-stamps it.
96
+ self._start_time: Optional[float] = get_time()
97
+ self._is_complete: bool = False
98
+
99
+ def update(self) -> None:
100
+ """Synchronous tick hook.
101
+
102
+ Completion is derived from elapsed time (see ``is_complete``), so this
103
+ is a no-op placeholder that application loops may call each frame.
104
+ """
105
+ return None
106
+
107
+ async def start(self) -> None:
108
+ """Called when content starts displaying."""
109
+ self._start_time = get_time()
110
+ self._is_complete = False
111
+
112
+ async def stop(self) -> None:
113
+ """Called when content stops displaying."""
114
+ self._is_complete = True
115
+
116
+ async def render(self, display) -> None:
117
+ """Render content to display.
118
+
119
+ Args:
120
+ display: DisplayInterface instance
121
+ """
122
+ raise NotImplementedError("Subclass must implement render()")
123
+
124
+ @property
125
+ def elapsed(self) -> float:
126
+ """Time elapsed since content started displaying."""
127
+ if self._start_time is None:
128
+ return 0.0
129
+ return get_time() - self._start_time
130
+
131
+ @property
132
+ def is_complete(self) -> bool:
133
+ """Check if content display is complete."""
134
+ if self._is_complete:
135
+ return True
136
+ if self.duration is None:
137
+ return False
138
+ return self.elapsed >= self.duration
139
+
140
+ def describe(self) -> dict:
141
+ """A small, JSON-able summary of this content for verification.
142
+
143
+ Subclasses extend this with their own fields (text, position, ...).
144
+ Lets the dev harness read a supported summary rather than poking
145
+ private attributes like ``_position``.
146
+ """
147
+ return {
148
+ "type": type(self).__name__,
149
+ "priority": self.priority,
150
+ "duration": self.duration,
151
+ "elapsed": round(self.elapsed, 2),
152
+ "is_complete": self.is_complete,
153
+ }
154
+
155
+
156
+ class _GradientFillMixin:
157
+ """Shared gradient text-fill state for the Label-based text content classes.
158
+
159
+ When ``self.palette`` is set, the text is rendered through an indexed-bitmap
160
+ ``GradientTextLayer`` (built once, scrolled by moving its TileGrid) instead of
161
+ the flat single-colour displayio ``Label``. Mono content (``palette is None``)
162
+ never touches any of this — and the renderer module is imported lazily on the
163
+ gradient path only, so the boot/mono path pays no extra RAM.
164
+ """
165
+
166
+ def _init_gradient(self, palette, direction, palette_steps) -> None:
167
+ # None → flat single-colour Label path (unchanged). A sequence of >=2
168
+ # colours → gradient; a 1-element sequence → flat fill of that colour.
169
+ self.palette = tuple(palette) if palette else None
170
+ self.direction = normalize_direction(direction)
171
+ self.palette_steps = palette_steps
172
+ self._grad = None # lazily-built GradientTextLayer
173
+ self._grad_display = None
174
+ self._grad_key = None
175
+
176
+ def _grad_active(self, display) -> bool:
177
+ # Fall back to the flat path when there's no font (headless without one),
178
+ # rather than crashing in the rasteriser.
179
+ return self.palette is not None and getattr(display, "font", None) is not None
180
+
181
+ def _ensure_grad(self, display) -> None:
182
+ """Build (or rebuild on change) the gradient layer; idempotent per frame."""
183
+ from .gradient_text import GradientTextLayer # lazy: gradient path only
184
+ key = (self.text, self.palette, self.direction, self.palette_steps,
185
+ self.y, id(getattr(display, "font", None)))
186
+ if (self._grad is not None and self._grad_key == key
187
+ and self._grad_display is display):
188
+ return
189
+ self._detach_grad()
190
+ self._grad = GradientTextLayer(self.text, self.y, self.palette,
191
+ self.direction, self.palette_steps)
192
+ self._grad.build(display)
193
+ self._grad_display = display
194
+ self._grad_key = key
195
+
196
+ def _detach_grad(self) -> None:
197
+ """Remove the gradient layer and clear state (safe to call repeatedly)."""
198
+ if self._grad is not None and self._grad_display is not None:
199
+ try:
200
+ self._grad.detach(self._grad_display)
201
+ except Exception:
202
+ pass
203
+ self._grad = None
204
+ self._grad_display = None
205
+ self._grad_key = None
206
+
207
+
208
+ class StaticText(_GradientFillMixin, DisplayContent):
209
+ """Static text display content."""
210
+
211
+ def __init__(self, text: str, x: int = 0, y: int = 0, color=None,
212
+ duration: Optional[float] = None, priority: int = Priority.NORMAL,
213
+ palette=None, direction: str = "vertical",
214
+ palette_steps: int = DEFAULT_PALETTE_STEPS):
215
+ """Initialize static text.
216
+
217
+ Args:
218
+ text: Text to display
219
+ x: X coordinate
220
+ y: Y coordinate
221
+ color: Text color as 24-bit RGB int (None = use library default_color setting)
222
+ duration: Display duration in seconds
223
+ priority: Queue priority (default Priority.NORMAL)
224
+ palette: ``None`` for a flat ``color`` (the default). A sequence of two
225
+ 24-bit ``0xRRGGBB`` colours gives a gradient from the first to the
226
+ second; three or more gives a multi-stop gradient. Tip:
227
+ ``depth_palette(color)`` (``scrollkit.display.colors``) derives a
228
+ subtle close ramp from one base colour. When set, ``color`` is
229
+ ignored.
230
+ direction: Gradient axis — ``"vertical"`` (default, top→bottom; reads
231
+ as depth), ``"horizontal"`` (left→right) or ``"diagonal"``. Reverse
232
+ by reversing the ``palette``.
233
+ palette_steps: Number of ramp colours generated from the stops (clamped
234
+ to 2..15; the panel is RGB444, so a few steps is plenty).
235
+ """
236
+ super().__init__(duration, priority)
237
+ self.text: str = text
238
+ self.x: int = x
239
+ self.y: int = y
240
+ # None → "default_color"; str → named setting key; int → explicit value
241
+ self._color_setting = "default_color" if color is None else (color if isinstance(color, str) else None)
242
+ self.color: int = _resolve_color(color)
243
+ self._init_gradient(palette, direction, palette_steps)
244
+
245
+ async def start(self) -> None:
246
+ """Detach any prior gradient layer so a re-shown item rebuilds cleanly."""
247
+ await super().start()
248
+ self._detach_grad()
249
+
250
+ async def render(self, display) -> None:
251
+ """Render static text — flat ``Label`` path, or a gradient layer."""
252
+ if not self._grad_active(display):
253
+ await display.draw_text(self.text, self.x, self.y, self.color)
254
+ return
255
+ self._ensure_grad(display)
256
+ self._grad.x = self.x
257
+
258
+ async def stop(self) -> None:
259
+ await super().stop()
260
+ self._detach_grad()
261
+
262
+ def describe(self) -> dict:
263
+ info = super().describe()
264
+ info.update({"text": self.text, "x": self.x, "y": self.y,
265
+ "gradient": self.palette is not None})
266
+ return info
267
+
268
+
269
+ class ScrollingText(_GradientFillMixin, DisplayContent):
270
+ """Scrolling text display content.
271
+
272
+ When speed resolves to 0 (the library's "None" scroll speed setting),
273
+ the text is shown centred on-screen for ``static_duration`` seconds and
274
+ then completes — so the transition effect fires on every repetition without
275
+ any scrolling motion.
276
+ """
277
+
278
+ # How long static (speed=0) display lingers before completing.
279
+ DEFAULT_STATIC_DURATION = 5.0
280
+
281
+ def __init__(self, text: str, x: Optional[int] = None, y: int = 0,
282
+ color=None, speed=None, priority: int = Priority.NORMAL,
283
+ static_duration: float = DEFAULT_STATIC_DURATION,
284
+ palette=None, direction: str = "vertical",
285
+ palette_steps: int = DEFAULT_PALETTE_STEPS):
286
+ """Initialize scrolling text.
287
+
288
+ Args:
289
+ text: Text to display / scroll.
290
+ x: Starting X (None = right edge for scrolling, centred for static).
291
+ y: Y coordinate (baseline).
292
+ color: 24-bit RGB int (None = library default_color setting).
293
+ speed: px/sec (None = library scroll_speed setting; 0 = static mode).
294
+ priority: Queue priority (default Priority.NORMAL).
295
+ static_duration: Seconds to show text before completing in static mode.
296
+ palette: ``None`` for a flat ``color`` (the default). A sequence of two
297
+ 24-bit ``0xRRGGBB`` colours gives a gradient from the first to the
298
+ second; three or more gives a multi-stop gradient. Tip:
299
+ ``depth_palette(color)`` (``scrollkit.display.colors``) derives a
300
+ subtle close ramp from one base colour. When set, ``color`` is
301
+ ignored. The gradient is locked to the letters, so it scrolls with
302
+ the text.
303
+ direction: Gradient axis — ``"vertical"`` (default), ``"horizontal"``
304
+ or ``"diagonal"``. Reverse by reversing the ``palette``.
305
+ palette_steps: Number of ramp colours generated from the stops (clamped
306
+ to 2..15).
307
+ """
308
+ super().__init__(duration=None, priority=priority)
309
+ self.text: str = text
310
+ self.x: Optional[int] = x
311
+ self.y: int = y
312
+ self._color_setting = ("default_color" if color is None
313
+ else (color if isinstance(color, str) else None))
314
+ self.color: int = _resolve_color(color)
315
+ self._init_gradient(palette, direction, palette_steps)
316
+ self._speed_is_default = (speed is None)
317
+ self._static_duration: float = static_duration
318
+ # Internal speed storage; use the property setter so _static_mode and
319
+ # position state are always consistent.
320
+ self._speed: int = 0
321
+ self._static_mode: bool = False
322
+ self._pos_q: Optional[int] = None
323
+ self._measured_width: Optional[int] = None
324
+ self.speed = _resolve_speed(speed) # property setter
325
+
326
+ # --- speed property -------------------------------------------------------
327
+
328
+ @property
329
+ def speed(self) -> int:
330
+ return self._speed
331
+
332
+ @speed.setter
333
+ def speed(self, value: int) -> None:
334
+ old_static = self._static_mode
335
+ self._speed = value
336
+ self._static_mode = (value == 0)
337
+ if self._static_mode != old_static:
338
+ # Switching modes: reset position so render() re-initialises for
339
+ # the new mode, and clear any prior completion flag.
340
+ self._pos_q = None
341
+ self._measured_width = None
342
+ self._is_complete = False
343
+
344
+ # --- lifecycle ------------------------------------------------------------
345
+
346
+ async def start(self) -> None:
347
+ """Reset position so render() re-initialises on the first frame."""
348
+ await super().start()
349
+ self._pos_q = None
350
+ self._measured_width = None
351
+ # Detach any prior gradient layer so a re-shown item rebuilds + re-adds it.
352
+ self._detach_grad()
353
+
354
+ async def stop(self) -> None:
355
+ await super().stop()
356
+ self._detach_grad()
357
+
358
+ # --- rendering ------------------------------------------------------------
359
+
360
+ def _delta_q(self) -> int:
361
+ """Per-frame motion in 1/16-px units, derived from speed (px/sec)."""
362
+ return int(round(self._speed * 16 / LOOP_FPS))
363
+
364
+ async def render(self, display) -> None:
365
+ """Render text: scrolling when speed > 0, centred-static when speed == 0.
366
+
367
+ With a ``palette`` the glyphs are drawn through a gradient TileGrid that is
368
+ positioned each frame (``self._grad.x``); otherwise the flat-colour Label
369
+ path is used. The scroll bookkeeping (``_pos_q`` / completion) is identical.
370
+ """
371
+ use_grad = self._grad_active(display)
372
+ if use_grad:
373
+ self._ensure_grad(display)
374
+
375
+ if self._static_mode:
376
+ # First render in static mode: measure width and choose x position.
377
+ if self._pos_q is None:
378
+ self._measured_width = (self._grad.width if use_grad
379
+ else display.measure_text(self.text))
380
+ if self.x is not None:
381
+ x = self.x
382
+ else:
383
+ x = max(0, (display.width - self._measured_width) // 2)
384
+ self._pos_q = x << 4
385
+ if use_grad:
386
+ self._grad.x = self._pos_q >> 4
387
+ else:
388
+ await display.draw_text(self.text, self._pos_q >> 4, self.y, self.color)
389
+ return
390
+
391
+ # Scrolling mode: initialise position on first render (needs display.width).
392
+ if self._pos_q is None:
393
+ start_x = display.width if self.x is None else self.x
394
+ self._pos_q = int(start_x) << 4
395
+ self._measured_width = (self._grad.width if use_grad
396
+ else display.measure_text(self.text))
397
+
398
+ if use_grad:
399
+ self._grad.x = self._pos_q >> 4
400
+ else:
401
+ await display.draw_text(self.text, self._pos_q >> 4, self.y, self.color)
402
+ self._pos_q -= self._delta_q()
403
+
404
+ if (self._pos_q >> 4) < -self._measured_width:
405
+ self._is_complete = True
406
+
407
+ # --- completion -----------------------------------------------------------
408
+
409
+ @property
410
+ def is_complete(self) -> bool:
411
+ if self._is_complete:
412
+ return True
413
+ if self._static_mode:
414
+ return self.elapsed >= self._static_duration
415
+ return False
416
+
417
+ # --- introspection --------------------------------------------------------
418
+
419
+ def describe(self) -> dict:
420
+ info = super().describe()
421
+ info.update({
422
+ "text": self.text,
423
+ "y": self.y,
424
+ "speed": self._speed,
425
+ "static_mode": self._static_mode,
426
+ "position": None if self._pos_q is None else (self._pos_q >> 4),
427
+ "text_width": self._measured_width,
428
+ "started": self._pos_q is not None,
429
+ "gradient": self.palette is not None,
430
+ })
431
+ return info
432
+
433
+
434
+ # Advertised feasibility (read by run_headless(strict=True) and the capabilities
435
+ # catalog). Both the flat-colour Label path and the gradient path do ZERO per-frame
436
+ # pixel writes and no per-frame allocation: a flat Label is reused and only
437
+ # repositioned; a gradient rasterises ONCE and then scrolls by moving its
438
+ # TileGrid.x. Metadata lives on the CLASS (CircuitPython can't attach it to a
439
+ # function).
440
+ _TEXT_FILL_FEASIBILITY = {
441
+ "hardware_safe": True,
442
+ "allocates_per_frame": False,
443
+ "max_pixel_writes_per_frame": 0,
444
+ "modeled_frame_ms": 5.0,
445
+ "note": ("Flat colour reuses a pooled Label; a gradient palette rasterises the "
446
+ "text once and scrolls by moving its TileGrid — zero per-frame pixel "
447
+ "writes either way."),
448
+ }
449
+ StaticText.FEASIBILITY = dict(_TEXT_FILL_FEASIBILITY)
450
+ ScrollingText.FEASIBILITY = dict(_TEXT_FILL_FEASIBILITY)
451
+
452
+
453
+ class ContentQueue:
454
+ """Queue for managing display content."""
455
+
456
+ def __init__(self, loop: bool = True):
457
+ """Initialize content queue.
458
+
459
+ Args:
460
+ loop: Whether to loop back to start when the last item completes.
461
+ With ``loop=False`` the queue is exhausted after the final
462
+ item's stop(): get_current() returns None (the display keeps
463
+ its last frame) until new content is add()ed, which re-arms it.
464
+ """
465
+ self.loop: bool = loop
466
+ self._items: List[Any] = []
467
+ self._current_index: int = 0
468
+ self._current_content: Optional[Any] = None
469
+ # Monotonic insertion counter: the tie-breaker that keeps equal-priority
470
+ # items in insertion order without relying on sort stability (which
471
+ # MicroPython/CircuitPython does not guarantee).
472
+ self._add_seq: int = 0
473
+ # True when a loop=False queue has finished its last item.
474
+ self._exhausted: bool = False
475
+ # Content abandoned by clear() that still needs its async stop() run by the
476
+ # display loop (see clear()). A LIST, not a single slot: a settings change
477
+ # rebuilds the queue twice in quick succession (the synchronous web handler's
478
+ # immediate rebuild, then the scheduled data refresh's teardown), and a
479
+ # single slot would let the second rebuild clobber the first's pending stop —
480
+ # orphaning the on-screen content's overlay layer (e.g. a Swarm reveal's
481
+ # bird/number layers) on screen forever.
482
+ self._pending_stops: List[Any] = []
483
+ # Incremented every time a content item starts (first play counts as 1;
484
+ # each subsequent cycle increments by 1). The display loop detects
485
+ # changes to fire transitions between plays without relying on object
486
+ # identity (which stays constant when a single-item queue loops).
487
+ self._advance_count: int = 0
488
+
489
+ def add(self, content) -> None:
490
+ """Add content to queue.
491
+
492
+ Playback honors ``content.priority`` — higher values play first (the
493
+ contract DisplayContent documents); items of equal priority play in
494
+ insertion order. Adding to an exhausted loop=False queue re-arms it,
495
+ resuming with the newly added item. ``None`` is ignored.
496
+
497
+ Args:
498
+ content: DisplayContent instance
499
+ """
500
+ if content is None:
501
+ return
502
+ content._queue_seq = self._add_seq
503
+ self._add_seq += 1
504
+ self._items.append(content)
505
+ # Explicit (priority DESC, insertion ASC) key rather than relying on
506
+ # sort stability — MicroPython's sort doesn't guarantee it.
507
+ self._items.sort(key=lambda c: (-getattr(c, "priority", Priority.NORMAL),
508
+ getattr(c, "_queue_seq", 0)))
509
+ self._exhausted = False
510
+
511
+ def add_content(self, content) -> None:
512
+ """Add content to queue (alias for add).
513
+
514
+ Args:
515
+ content: DisplayContent instance
516
+ """
517
+ self.add(content)
518
+
519
+ def clear(self) -> None:
520
+ """Clear all content from queue."""
521
+ # Hand the in-flight content to the display loop to stop() on its next
522
+ # frame. stop() is a content's only chance to release external resources it
523
+ # holds — e.g. a persistent overlay layer added to the display's layer group,
524
+ # which the per-frame clear() never touches and so stays on screen until the
525
+ # content detaches it in stop(). A rebuild that drops the current content
526
+ # without stopping it would orphan such a resource forever. Deferring keeps
527
+ # the async stop() out of this synchronous method, so every rebuild path (a
528
+ # timed refresh and a synchronous settings handler alike) is covered with no
529
+ # race. Pending stops ACCUMULATE in a list: several rebuilds before the loop
530
+ # next runs (e.g. a settings handler's immediate rebuild + the scheduled
531
+ # refresh's teardown) each keep their overlay's stop(), so none is clobbered.
532
+ if self._current_content is not None and self._current_content not in self._pending_stops:
533
+ self._pending_stops.append(self._current_content)
534
+ self._items.clear()
535
+ self._current_index = 0
536
+ self._current_content = None
537
+ self._exhausted = False
538
+ # _advance_count is intentionally NOT reset: the display loop tracks it
539
+ # to detect advances; a reset would look like the first play and suppress
540
+ # the transition for the item that follows a rebuild.
541
+
542
+ def get_content_count(self) -> int:
543
+ """Get number of items in queue."""
544
+ return len(self._items)
545
+
546
+ def get_current_content(self) -> Optional[Any]:
547
+ """Get current content item (synchronous version)."""
548
+ if not self._items:
549
+ return None
550
+ if self._current_content is None:
551
+ self._current_content = self._items[0]
552
+ return self._current_content
553
+
554
+ async def get_current(self) -> Optional[Any]:
555
+ """Get current content to display."""
556
+ # Stop content abandoned by a clear()/rebuild before showing anything new,
557
+ # so it releases its overlay layer (see clear()). Drain the whole list (a
558
+ # rapid double-rebuild can leave more than one), popping each first so a
559
+ # concurrent clear() during stop() isn't lost; guarded so a misbehaving
560
+ # stop() can't wedge the loop. Runs even when the queue is now empty (a
561
+ # rebuild to no items must still detach the old overlay).
562
+ while self._pending_stops:
563
+ pending = self._pending_stops.pop(0)
564
+ try:
565
+ await pending.stop()
566
+ except Exception:
567
+ pass
568
+
569
+ if not self._items or self._exhausted:
570
+ return None
571
+
572
+ # Check if we need to advance
573
+ if self._current_content is None:
574
+ if self._current_index >= len(self._items):
575
+ self._current_index = 0 # defensive clamp (see exhaustion below)
576
+ self._current_content = self._items[self._current_index]
577
+ await self._current_content.start()
578
+ self._advance_count += 1
579
+ elif self._current_content.is_complete:
580
+ await self._current_content.stop()
581
+ next_index = self._current_index + 1
582
+ if next_index >= len(self._items) and not self.loop:
583
+ # loop=False: done after the last item. The display keeps its
584
+ # final frame; add() re-arms the queue. The index parks one
585
+ # past the end so playback resumes with whatever add() appends.
586
+ self._current_index = next_index
587
+ self._current_content = None
588
+ self._exhausted = True
589
+ return None
590
+ self._current_index = next_index % len(self._items)
591
+ self._current_content = self._items[self._current_index]
592
+ await self._current_content.start()
593
+ self._advance_count += 1
594
+
595
+ return self._current_content
596
+
597
+ def __iter__(self):
598
+ """Allow iteration over queue items."""
599
+ return iter(self._items)
600
+
601
+ @property
602
+ def is_empty(self) -> bool:
603
+ """Check if queue is empty."""
604
+ return len(self._items) == 0