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