scrollkit 0.8.3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- scrollkit/__init__.py +16 -0
- scrollkit/app/__init__.py +6 -0
- scrollkit/app/base.py +918 -0
- scrollkit/app/memory.py +70 -0
- scrollkit/config/__init__.py +1 -0
- scrollkit/config/settings_manager.py +215 -0
- scrollkit/config/transition_names.py +31 -0
- scrollkit/dev/__init__.py +41 -0
- scrollkit/dev/capabilities.py +374 -0
- scrollkit/dev/harness.py +383 -0
- scrollkit/dev/metrics.py +91 -0
- scrollkit/dev/performance.py +174 -0
- scrollkit/dev/validation.py +245 -0
- scrollkit/display/__init__.py +14 -0
- scrollkit/display/_graphics.py +299 -0
- scrollkit/display/_recording.py +165 -0
- scrollkit/display/_sim_backend.py +99 -0
- scrollkit/display/bitmap_text.py +408 -0
- scrollkit/display/boards.py +186 -0
- scrollkit/display/colors.py +176 -0
- scrollkit/display/content.py +604 -0
- scrollkit/display/gradient_text.py +167 -0
- scrollkit/display/interface.py +126 -0
- scrollkit/display/simulator.py +80 -0
- scrollkit/display/text_fill.py +59 -0
- scrollkit/display/text_pixels.py +250 -0
- scrollkit/display/unified.py +595 -0
- scrollkit/effects/__init__.py +28 -0
- scrollkit/effects/drip_splash.py +253 -0
- scrollkit/effects/easing.py +131 -0
- scrollkit/effects/overlay.py +92 -0
- scrollkit/effects/particles.py +355 -0
- scrollkit/effects/reveal_splash.py +132 -0
- scrollkit/effects/scrolling.py +363 -0
- scrollkit/effects/swarm_reveal.py +512 -0
- scrollkit/effects/text_render.py +25 -0
- scrollkit/effects/transitions.py +873 -0
- scrollkit/exceptions.py +55 -0
- scrollkit/network/__init__.py +1 -0
- scrollkit/network/http_client.py +505 -0
- scrollkit/network/mdns.py +44 -0
- scrollkit/network/wifi_manager.py +382 -0
- scrollkit/ota/__init__.py +13 -0
- scrollkit/ota/client.py +528 -0
- scrollkit/ota/display_progress.py +125 -0
- scrollkit/ota/manifest.py +206 -0
- scrollkit/ota/publish.py +379 -0
- scrollkit/simulator/ATTRIBUTION.md +20 -0
- scrollkit/simulator/CIRCUITPYTHON_COMPATIBILITY.md +364 -0
- scrollkit/simulator/LICENSE +176 -0
- scrollkit/simulator/README.md +87 -0
- scrollkit/simulator/__init__.py +11 -0
- scrollkit/simulator/adafruit_bitmap_font/__init__.py +5 -0
- scrollkit/simulator/adafruit_bitmap_font/bitmap_font.py +273 -0
- scrollkit/simulator/adafruit_bitmap_font/glyph_cache.py +70 -0
- scrollkit/simulator/adafruit_display_text/__init__.py +5 -0
- scrollkit/simulator/adafruit_display_text/label.py +336 -0
- scrollkit/simulator/bitmaptools.py +50 -0
- scrollkit/simulator/core/__init__.py +8 -0
- scrollkit/simulator/core/color_utils.py +62 -0
- scrollkit/simulator/core/device_benchmarks.json +254 -0
- scrollkit/simulator/core/feasibility.py +160 -0
- scrollkit/simulator/core/hardware_profile.py +191 -0
- scrollkit/simulator/core/led_matrix.py +307 -0
- scrollkit/simulator/core/matrixportal_s3_baseline.json +12 -0
- scrollkit/simulator/core/performance_manager.py +253 -0
- scrollkit/simulator/core/pixel_buffer.py +188 -0
- scrollkit/simulator/devices/__init__.py +7 -0
- scrollkit/simulator/devices/base_device.py +79 -0
- scrollkit/simulator/devices/matrixportal_s3.py +87 -0
- scrollkit/simulator/displayio/__init__.py +12 -0
- scrollkit/simulator/displayio/bitmap.py +158 -0
- scrollkit/simulator/displayio/display.py +195 -0
- scrollkit/simulator/displayio/fourwire.py +54 -0
- scrollkit/simulator/displayio/group.py +125 -0
- scrollkit/simulator/displayio/ondiskbitmap.py +109 -0
- scrollkit/simulator/displayio/palette.py +115 -0
- scrollkit/simulator/displayio/tilegrid.py +155 -0
- scrollkit/simulator/fonts/3x5.bdf +2474 -0
- scrollkit/simulator/fonts/Arial_16.bdf +7366 -0
- scrollkit/simulator/fonts/Arial_16.bdf.license +3 -0
- scrollkit/simulator/fonts/Arial_Bold_12.bdf +6131 -0
- scrollkit/simulator/fonts/Arial_Bold_12.bdf.license +2 -0
- scrollkit/simulator/fonts/Arial_Bold_18.bdf +32653 -0
- scrollkit/simulator/fonts/Arial_Bold_18.bdf.license +2 -0
- scrollkit/simulator/fonts/Junction_regular_24.bdf +8676 -0
- scrollkit/simulator/fonts/Junction_regular_24.bdf.license +3 -0
- scrollkit/simulator/fonts/LeagueSpartan-Bold-16.bdf +12458 -0
- scrollkit/simulator/fonts/LeagueSpartan-Bold-16.bdf.license +1921 -0
- scrollkit/simulator/fonts/LeagueSpartan_Bold_16.bdf +12458 -0
- scrollkit/simulator/fonts/LeagueSpartan_Bold_16.bdf.license +4 -0
- scrollkit/simulator/fonts/LibreBodoniv2002-Bold-27.bdf +16818 -0
- scrollkit/simulator/fonts/LibreBodoniv2002-Bold-27.bdf.license +1921 -0
- scrollkit/simulator/fonts/tom-thumb.bdf +2353 -0
- scrollkit/simulator/fonts/viii-bold.bdf +2673 -0
- scrollkit/simulator/fonts/viii.bdf +2659 -0
- scrollkit/simulator/terminalio/__init__.py +20 -0
- scrollkit/utils/__init__.py +1 -0
- scrollkit/utils/color_utils.py +54 -0
- scrollkit/utils/diagnostics.py +227 -0
- scrollkit/utils/error_handler.py +347 -0
- scrollkit/utils/system_utils.py +245 -0
- scrollkit/utils/url_utils.py +46 -0
- scrollkit/web/__init__.py +6 -0
- scrollkit/web/settings_server.py +328 -0
- scrollkit/web/wifi_setup.py +331 -0
- scrollkit-0.8.3.dist-info/METADATA +248 -0
- scrollkit-0.8.3.dist-info/RECORD +111 -0
- scrollkit-0.8.3.dist-info/WHEEL +5 -0
- scrollkit-0.8.3.dist-info/licenses/LICENSE +31 -0
- scrollkit-0.8.3.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,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
|