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,165 @@
|
|
|
1
|
+
# Copyright (c) 2024-2026 Michael Winslow Czeiszperger
|
|
2
|
+
"""Desktop-only frame capture + encoding for the display recording API.
|
|
3
|
+
|
|
4
|
+
The user-facing methods (``start_recording`` / ``save_gif`` / ``save_video`` /
|
|
5
|
+
``screenshot``) live on ``UnifiedDisplay`` (and are therefore inherited by
|
|
6
|
+
``SimulatorDisplay``); the heavy pygame/Pillow/ffmpeg work lives here so the
|
|
7
|
+
RAM-constrained device never loads a byte of it — this module is only ever
|
|
8
|
+
imported lazily from inside those methods, on desktop.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def capture_frame(matrix):
|
|
15
|
+
"""Return the matrix's current LED-panel surface as an (H, W, 3) uint8 array,
|
|
16
|
+
or None when pygame/the surface isn't available."""
|
|
17
|
+
try:
|
|
18
|
+
import pygame
|
|
19
|
+
except ImportError:
|
|
20
|
+
return None
|
|
21
|
+
surface = (matrix.get_surface()
|
|
22
|
+
if matrix is not None and hasattr(matrix, "get_surface")
|
|
23
|
+
else None)
|
|
24
|
+
if surface is None:
|
|
25
|
+
return None
|
|
26
|
+
# array3d gives (W, H, 3); transpose to image orientation (H, W, 3).
|
|
27
|
+
return pygame.surfarray.array3d(surface).transpose(1, 0, 2).copy()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def save_surface_png(matrix, path):
|
|
31
|
+
"""Save the current LED-panel surface (or the window surface) to ``path``.
|
|
32
|
+
|
|
33
|
+
Returns the path on success or None when pygame/no surface is available.
|
|
34
|
+
"""
|
|
35
|
+
try:
|
|
36
|
+
import pygame
|
|
37
|
+
except ImportError:
|
|
38
|
+
return None
|
|
39
|
+
surface = None
|
|
40
|
+
if matrix is not None and hasattr(matrix, "get_surface"):
|
|
41
|
+
surface = matrix.get_surface()
|
|
42
|
+
if surface is None and pygame.get_init():
|
|
43
|
+
surface = pygame.display.get_surface()
|
|
44
|
+
if surface is None:
|
|
45
|
+
return None
|
|
46
|
+
try:
|
|
47
|
+
pygame.image.save(surface, path)
|
|
48
|
+
return path
|
|
49
|
+
except (pygame.error, OSError) as e:
|
|
50
|
+
print(f"screenshot failed: {e}")
|
|
51
|
+
return None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def encode_gif(frames, path, *, fps=20, target_width=360, max_colors=48,
|
|
55
|
+
loop=0, frame_step=1, disposal=1):
|
|
56
|
+
"""Encode recorded frames to an animated GIF (see ``save_gif`` docstring)."""
|
|
57
|
+
if not frames:
|
|
58
|
+
return None
|
|
59
|
+
try:
|
|
60
|
+
from PIL import Image
|
|
61
|
+
except ImportError:
|
|
62
|
+
print("save_gif failed: Pillow not installed (pip install Pillow)")
|
|
63
|
+
return None
|
|
64
|
+
|
|
65
|
+
kept = frames[::max(1, int(frame_step))]
|
|
66
|
+
rgb = []
|
|
67
|
+
for arr in kept:
|
|
68
|
+
img = Image.fromarray(arr, "RGB")
|
|
69
|
+
if target_width and img.width != target_width:
|
|
70
|
+
h = max(1, round(img.height * target_width / img.width))
|
|
71
|
+
img = img.resize((target_width, h), Image.LANCZOS)
|
|
72
|
+
rgb.append(img)
|
|
73
|
+
|
|
74
|
+
# One shared palette from a handful of evenly-sampled frames: stable
|
|
75
|
+
# colors across the loop (no per-frame flicker) and a much smaller file.
|
|
76
|
+
sample = rgb[::max(1, len(rgb) // 16)]
|
|
77
|
+
montage = Image.new("RGB", (rgb[0].width, rgb[0].height * len(sample)))
|
|
78
|
+
for i, frame_img in enumerate(sample):
|
|
79
|
+
montage.paste(frame_img, (0, i * rgb[0].height))
|
|
80
|
+
palette = montage.quantize(colors=max_colors, method=Image.MEDIANCUT)
|
|
81
|
+
paletted = [im.quantize(palette=palette) for im in rgb]
|
|
82
|
+
|
|
83
|
+
duration = int(round(1000.0 / fps)) * max(1, int(frame_step))
|
|
84
|
+
try:
|
|
85
|
+
# disposal=1 ("do not dispose") leaves the prior frame in place so
|
|
86
|
+
# Pillow can crop each frame to just its changed region — the static
|
|
87
|
+
# LED-panel background is written once, shrinking the file several-fold
|
|
88
|
+
# versus disposal=2 (which restores to background and forces full frames).
|
|
89
|
+
paletted[0].save(path, save_all=True, append_images=paletted[1:],
|
|
90
|
+
duration=duration, loop=loop, optimize=True,
|
|
91
|
+
disposal=disposal)
|
|
92
|
+
return path
|
|
93
|
+
except (OSError, ValueError) as e:
|
|
94
|
+
print(f"save_gif failed: {e}")
|
|
95
|
+
return None
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def encode_video(frames, path, *, fps=24, target_width=None, crf=20,
|
|
99
|
+
preset="medium", border=0, border_color=(10, 10, 13)):
|
|
100
|
+
"""Encode recorded frames to MP4/H.264 via ffmpeg (see ``save_video``)."""
|
|
101
|
+
import shutil
|
|
102
|
+
import subprocess
|
|
103
|
+
|
|
104
|
+
if not frames:
|
|
105
|
+
return None
|
|
106
|
+
ffmpeg = shutil.which("ffmpeg")
|
|
107
|
+
if ffmpeg is None:
|
|
108
|
+
print("save_video failed: ffmpeg not found (e.g. `brew install ffmpeg`)")
|
|
109
|
+
return None
|
|
110
|
+
|
|
111
|
+
h0, w0 = int(frames[0].shape[0]), int(frames[0].shape[1])
|
|
112
|
+
if target_width and int(target_width) != w0:
|
|
113
|
+
out_w = int(target_width)
|
|
114
|
+
out_h = max(1, round(h0 * out_w / w0))
|
|
115
|
+
else:
|
|
116
|
+
out_w, out_h = w0, h0
|
|
117
|
+
out_w -= out_w % 2 # yuv420p needs even dimensions
|
|
118
|
+
out_h -= out_h % 2
|
|
119
|
+
if out_w < 2 or out_h < 2:
|
|
120
|
+
return None
|
|
121
|
+
resize = (out_w, out_h) != (w0, h0)
|
|
122
|
+
if resize:
|
|
123
|
+
try:
|
|
124
|
+
from PIL import Image
|
|
125
|
+
except ImportError:
|
|
126
|
+
print("save_video failed: Pillow needed to resize (pip install Pillow)")
|
|
127
|
+
return None
|
|
128
|
+
|
|
129
|
+
vf = []
|
|
130
|
+
if border and int(border) > 0:
|
|
131
|
+
b = int(border) - int(border) % 2 # keep padded dims even for yuv420p
|
|
132
|
+
if b > 0:
|
|
133
|
+
bc = border_color
|
|
134
|
+
vf = ["-vf", "pad=iw+%d:ih+%d:%d:%d:color=0x%02X%02X%02X"
|
|
135
|
+
% (2 * b, 2 * b, b, b, int(bc[0]), int(bc[1]), int(bc[2]))]
|
|
136
|
+
|
|
137
|
+
cmd = ([ffmpeg, "-y", "-loglevel", "error",
|
|
138
|
+
"-f", "rawvideo", "-pix_fmt", "rgb24",
|
|
139
|
+
"-s", "%dx%d" % (out_w, out_h), "-r", str(int(fps)), "-i", "-",
|
|
140
|
+
"-an"] + vf
|
|
141
|
+
+ ["-c:v", "libx264", "-pix_fmt", "yuv420p",
|
|
142
|
+
"-crf", str(int(crf)), "-preset", str(preset),
|
|
143
|
+
"-movflags", "+faststart", path])
|
|
144
|
+
try:
|
|
145
|
+
proc = subprocess.Popen(cmd, stdin=subprocess.PIPE)
|
|
146
|
+
except OSError as e:
|
|
147
|
+
print("save_video failed: could not launch ffmpeg (%r)" % (e,))
|
|
148
|
+
return None
|
|
149
|
+
try:
|
|
150
|
+
for arr in frames:
|
|
151
|
+
if resize:
|
|
152
|
+
arr_bytes = Image.fromarray(arr, "RGB").resize(
|
|
153
|
+
(out_w, out_h), Image.LANCZOS).tobytes()
|
|
154
|
+
else:
|
|
155
|
+
arr_bytes = arr.tobytes()
|
|
156
|
+
proc.stdin.write(arr_bytes)
|
|
157
|
+
proc.stdin.close()
|
|
158
|
+
rc = proc.wait()
|
|
159
|
+
except (OSError, ValueError) as e:
|
|
160
|
+
print("save_video failed: %r" % (e,))
|
|
161
|
+
return None
|
|
162
|
+
if rc != 0:
|
|
163
|
+
print("save_video failed: ffmpeg exited %s" % rc)
|
|
164
|
+
return None
|
|
165
|
+
return path
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# Copyright (c) 2024-2026 Michael Winslow Czeiszperger
|
|
2
|
+
"""Shared desktop-simulator backend setup for the two display classes.
|
|
3
|
+
|
|
4
|
+
``UnifiedDisplay`` (the production entry point, which auto-selects hardware vs
|
|
5
|
+
simulator) and ``SimulatorDisplay`` (the desktop recording/verification display)
|
|
6
|
+
both stand up an identical simulator device: build a ``MatrixPortalS3``, wire the
|
|
7
|
+
optional hardware-timing model onto it *before* ``initialize()`` (the LEDMatrix
|
|
8
|
+
reads ``device.performance_manager`` there), initialize it, and prepare the
|
|
9
|
+
render surface. That setup lived duplicated in both classes; it lives here now so
|
|
10
|
+
one edit updates both.
|
|
11
|
+
|
|
12
|
+
Desktop-only by design: every simulator import is inside a function, so importing
|
|
13
|
+
this module costs nothing and it is never reached on the CircuitPython path (the
|
|
14
|
+
hardware branch of ``UnifiedDisplay`` doesn't call it, and ``SimulatorDisplay``
|
|
15
|
+
itself raises ``ImportError`` on CircuitPython).
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import os
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def create_sim_device(width, height, board_id, *, pitch=None,
|
|
24
|
+
hardware_timing=False, throttle=False, strict=False):
|
|
25
|
+
"""Build and initialize a simulator ``MatrixPortalS3``.
|
|
26
|
+
|
|
27
|
+
Wires an optional ``PerformanceManager`` (hardware-timing model) onto the
|
|
28
|
+
device before ``initialize()`` when timing is requested — via the
|
|
29
|
+
``hardware_timing``/``throttle``/``strict`` flags or the
|
|
30
|
+
``SCROLLKIT_HW_SIM`` / ``SCROLLKIT_HW_THROTTLE`` / ``SCROLLKIT_HW_STRICT``
|
|
31
|
+
env vars. ``strict`` (and throttle) imply timing: you can't gate/crawl a
|
|
32
|
+
model you aren't running.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
width, height: Panel geometry in pixels.
|
|
36
|
+
board_id: Which board's timing profile to model.
|
|
37
|
+
pitch: LED pitch (mm) -> on-screen size; ``None`` uses the device default
|
|
38
|
+
(``UnifiedDisplay`` doesn't set it; ``SimulatorDisplay`` does, for
|
|
39
|
+
higher-resolution recordings).
|
|
40
|
+
hardware_timing, throttle, strict: Constructor-level opt-ins for the
|
|
41
|
+
timing model (env vars are honored regardless).
|
|
42
|
+
|
|
43
|
+
Returns:
|
|
44
|
+
``(device, matrix, display, perf)`` where ``perf`` is the
|
|
45
|
+
``PerformanceManager`` (or ``None`` when hardware timing is off).
|
|
46
|
+
"""
|
|
47
|
+
from scrollkit.simulator.devices.matrixportal_s3 import MatrixPortalS3
|
|
48
|
+
|
|
49
|
+
env_sim = os.environ.get("SCROLLKIT_HW_SIM") == "1"
|
|
50
|
+
env_throttle = os.environ.get("SCROLLKIT_HW_THROTTLE") == "1"
|
|
51
|
+
env_strict = os.environ.get("SCROLLKIT_HW_STRICT") == "1"
|
|
52
|
+
want_throttle = throttle or env_throttle
|
|
53
|
+
want_strict = strict or env_strict
|
|
54
|
+
# strict implies hardware timing — you can't gate a model you aren't running.
|
|
55
|
+
want_timing = hardware_timing or env_sim or want_throttle or want_strict
|
|
56
|
+
|
|
57
|
+
if pitch is None:
|
|
58
|
+
device = MatrixPortalS3(width=width, height=height)
|
|
59
|
+
else:
|
|
60
|
+
device = MatrixPortalS3(width=width, height=height, pitch=pitch)
|
|
61
|
+
|
|
62
|
+
perf = None
|
|
63
|
+
if want_timing:
|
|
64
|
+
try:
|
|
65
|
+
from scrollkit.simulator.core.hardware_profile import profile_for
|
|
66
|
+
from scrollkit.simulator.core.performance_manager import (
|
|
67
|
+
PerformanceManager, set_active)
|
|
68
|
+
except ImportError:
|
|
69
|
+
perf = None
|
|
70
|
+
else:
|
|
71
|
+
perf = PerformanceManager(profile_for(board_id), enabled=True,
|
|
72
|
+
throttle=want_throttle, strict=want_strict)
|
|
73
|
+
# Wired BEFORE initialize() (LEDMatrix reads it there); set_active
|
|
74
|
+
# feeds the Label glyph-rebuild hook.
|
|
75
|
+
device.performance_manager = perf
|
|
76
|
+
set_active(perf)
|
|
77
|
+
|
|
78
|
+
device.initialize()
|
|
79
|
+
|
|
80
|
+
matrix = device.matrix
|
|
81
|
+
display = device.display
|
|
82
|
+
if hasattr(matrix, "initialize_surface"):
|
|
83
|
+
matrix.initialize_surface()
|
|
84
|
+
|
|
85
|
+
return device, matrix, display, perf
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def disabled_feasibility_report(hint):
|
|
89
|
+
"""The shared "hardware timing is off" stub report.
|
|
90
|
+
|
|
91
|
+
``hint`` is the caller-specific way to turn timing on (the env var alone for
|
|
92
|
+
``UnifiedDisplay``; the constructor flag for ``SimulatorDisplay``).
|
|
93
|
+
"""
|
|
94
|
+
from scrollkit.simulator.core.feasibility import FeasibilityReport
|
|
95
|
+
return FeasibilityReport(
|
|
96
|
+
"hardware timing disabled", "DISABLED",
|
|
97
|
+
hint,
|
|
98
|
+
False, None, 0.0, 0.0, {}, 0, 0,
|
|
99
|
+
["Hardware timing is off — no feasibility data. " + hint])
|
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
# Copyright (c) 2024-2026 Michael Winslow Czeiszperger
|
|
2
|
+
"""Palette-animated bitmap text (Class 3 — the proving spike for the foundation).
|
|
3
|
+
|
|
4
|
+
A ScrollKit-native fixed-cell 5x7 font is rendered ONCE into an indexed Bitmap
|
|
5
|
+
whose lit pixels carry palette *indices* (a colour-ramp position). The text scrolls
|
|
6
|
+
by moving a TileGrid; the animation comes from rewriting a few palette entries each
|
|
7
|
+
frame — near-zero per-frame pixel work and NO glyph rebuild. Runs unchanged on
|
|
8
|
+
device and simulator via ``display.gfx``.
|
|
9
|
+
|
|
10
|
+
This ships ``BitmapText`` plus the full palette-animation set (rainbow chase, neon
|
|
11
|
+
tube crawl, chrome sheen, hazard stripes) and the complete printable-ASCII 5x7 font.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from .content import DisplayContent, LOOP_FPS
|
|
15
|
+
from .colors import scale as _scale, spectrum, gradient
|
|
16
|
+
|
|
17
|
+
# Full printable-ASCII 5x7 font (table-driven; no BDF). Each glyph is 7 rows of a
|
|
18
|
+
# 5-char mask ('#' = lit). Missing chars render blank. Lookup folds to upper-case.
|
|
19
|
+
FONT_5x7 = {
|
|
20
|
+
" ": [" ", " ", " ", " ", " ", " ", " "],
|
|
21
|
+
"A": [" ### ", "# #", "# #", "#####", "# #", "# #", "# #"],
|
|
22
|
+
"B": ["#### ", "# #", "# #", "#### ", "# #", "# #", "#### "],
|
|
23
|
+
"C": [" ### ", "# #", "# ", "# ", "# ", "# #", " ### "],
|
|
24
|
+
"D": ["#### ", "# #", "# #", "# #", "# #", "# #", "#### "],
|
|
25
|
+
"E": ["#####", "# ", "# ", "#### ", "# ", "# ", "#####"],
|
|
26
|
+
"F": ["#####", "# ", "# ", "#### ", "# ", "# ", "# "],
|
|
27
|
+
"G": [" ### ", "# #", "# ", "# ###", "# #", "# #", " ### "],
|
|
28
|
+
"H": ["# #", "# #", "# #", "#####", "# #", "# #", "# #"],
|
|
29
|
+
"I": ["#####", " # ", " # ", " # ", " # ", " # ", "#####"],
|
|
30
|
+
"J": [" ###", " # ", " # ", " # ", "# # ", "# # ", " ## "],
|
|
31
|
+
"K": ["# #", "# # ", "# # ", "## ", "# # ", "# # ", "# #"],
|
|
32
|
+
"L": ["# ", "# ", "# ", "# ", "# ", "# ", "#####"],
|
|
33
|
+
"M": ["# #", "## ##", "# # #", "# #", "# #", "# #", "# #"],
|
|
34
|
+
"N": ["# #", "## #", "# # #", "# ##", "# #", "# #", "# #"],
|
|
35
|
+
"O": [" ### ", "# #", "# #", "# #", "# #", "# #", " ### "],
|
|
36
|
+
"P": ["#### ", "# #", "# #", "#### ", "# ", "# ", "# "],
|
|
37
|
+
"Q": [" ### ", "# #", "# #", "# #", "# # #", "# # ", " ## #"],
|
|
38
|
+
"R": ["#### ", "# #", "# #", "#### ", "# # ", "# # ", "# #"],
|
|
39
|
+
"S": [" ####", "# ", "# ", " ### ", " #", " #", "#### "],
|
|
40
|
+
"T": ["#####", " # ", " # ", " # ", " # ", " # ", " # "],
|
|
41
|
+
"U": ["# #", "# #", "# #", "# #", "# #", "# #", " ### "],
|
|
42
|
+
"V": ["# #", "# #", "# #", "# #", "# #", " # # ", " # "],
|
|
43
|
+
"W": ["# #", "# #", "# #", "# # #", "# # #", "## ##", "# #"],
|
|
44
|
+
"X": ["# #", "# #", " # # ", " # ", " # # ", "# #", "# #"],
|
|
45
|
+
"Y": ["# #", "# #", " # # ", " # ", " # ", " # ", " # "],
|
|
46
|
+
"Z": ["#####", " #", " # ", " # ", " # ", "# ", "#####"],
|
|
47
|
+
"0": [" ### ", "# #", "# ##", "# # #", "## #", "# #", " ### "],
|
|
48
|
+
"1": [" # ", " ## ", " # ", " # ", " # ", " # ", "#####"],
|
|
49
|
+
"2": [" ### ", "# #", " #", " # ", " # ", " # ", "#####"],
|
|
50
|
+
"3": ["#####", " # ", " # ", " # ", " #", "# #", " ### "],
|
|
51
|
+
"4": [" # ", " ## ", " # # ", "# # ", "#####", " # ", " # "],
|
|
52
|
+
"5": ["#####", "# ", "#### ", " #", " #", "# #", " ### "],
|
|
53
|
+
"6": [" ### ", "# ", "# ", "#### ", "# #", "# #", " ### "],
|
|
54
|
+
"7": ["#####", " #", " # ", " # ", " # ", " # ", " # "],
|
|
55
|
+
"8": [" ### ", "# #", "# #", " ### ", "# #", "# #", " ### "],
|
|
56
|
+
"9": [" ### ", "# #", "# #", " ####", " #", " #", " ### "],
|
|
57
|
+
"!": [" # ", " # ", " # ", " # ", " # ", " ", " # "],
|
|
58
|
+
"?": [" ### ", "# #", " #", " # ", " # ", " ", " # "],
|
|
59
|
+
".": [" ", " ", " ", " ", " ", " ## ", " ## "],
|
|
60
|
+
",": [" ", " ", " ", " ", " ", " # ", " # "],
|
|
61
|
+
":": [" ", " # ", " # ", " ", " # ", " # ", " "],
|
|
62
|
+
";": [" ", " # ", " # ", " ", " # ", " # ", " # "],
|
|
63
|
+
"'": [" # ", " # ", " ", " ", " ", " ", " "],
|
|
64
|
+
'"': [" # # ", " # # ", " ", " ", " ", " ", " "],
|
|
65
|
+
"-": [" ", " ", " ", "#####", " ", " ", " "],
|
|
66
|
+
"+": [" ", " # ", " # ", "#####", " # ", " # ", " "],
|
|
67
|
+
"=": [" ", " ", "#####", " ", "#####", " ", " "],
|
|
68
|
+
"_": [" ", " ", " ", " ", " ", " ", "#####"],
|
|
69
|
+
"/": [" #", " #", " # ", " # ", " # ", "# ", "# "],
|
|
70
|
+
"\\": ["# ", "# ", " # ", " # ", " # ", " #", " #"],
|
|
71
|
+
"*": [" ", " # # ", " # ", "#####", " # ", " # # ", " "],
|
|
72
|
+
"#": [" # # ", " # # ", "#####", " # # ", "#####", " # # ", " # # "],
|
|
73
|
+
"%": ["## #", "## #", " # ", " # ", " # ", "# ##", "# ##"],
|
|
74
|
+
"&": [" ## ", "# # ", "# # ", " ## ", "# # #", "# # ", " ## #"],
|
|
75
|
+
"@": [" ### ", "# #", "# ###", "# # #", "# ###", "# ", " ### "],
|
|
76
|
+
"$": [" # ", " ####", "# # ", " ### ", " # #", "#### ", " # "],
|
|
77
|
+
"(": [" # ", " # ", "# ", "# ", "# ", " # ", " # "],
|
|
78
|
+
")": [" # ", " # ", " #", " #", " #", " # ", " # "],
|
|
79
|
+
"<": [" # ", " # ", " # ", "# ", " # ", " # ", " # "],
|
|
80
|
+
">": [" # ", " # ", " # ", " #", " # ", " # ", " # "],
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
# Backwards-compatible alias for the table.
|
|
84
|
+
_GLYPHS = FONT_5x7
|
|
85
|
+
|
|
86
|
+
GLYPH_W = 5
|
|
87
|
+
GLYPH_H = 7
|
|
88
|
+
CELL_W = GLYPH_W + 1 # one column of spacing between glyphs
|
|
89
|
+
|
|
90
|
+
# Colour resolution of the palette effects. RAMP is how many palette slots BitmapText
|
|
91
|
+
# animates, so it sets how smoothly a gradient spreads across the letters — wider ==
|
|
92
|
+
# smoother (still pure palette rewrites: no per-frame allocation and zero PIXEL writes,
|
|
93
|
+
# so feasibility is unchanged). 16 is a smooth spread that stays well inside the panel's
|
|
94
|
+
# 4-bit-per-channel (4096-colour) reality. (Index 0 is the transparent background.)
|
|
95
|
+
RAMP = 16
|
|
96
|
+
# A smooth full-spectrum hue wheel of RAMP stops, built ONCE at import (zero per-frame
|
|
97
|
+
# cost). Replaces the old six fixed primaries, so rainbow text sweeps the whole spectrum
|
|
98
|
+
# instead of stepping through six blocks.
|
|
99
|
+
_RAINBOW = spectrum(RAMP)
|
|
100
|
+
|
|
101
|
+
# Public ramp + a tiny chase helper, so content can use the SAME flowing rainbow as
|
|
102
|
+
# the palette effects without the legacy EffectsEngine.get_rainbow_color().
|
|
103
|
+
RAINBOW = _RAINBOW
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
__all__ = ['BitmapText', 'RainbowChase', 'NeonTubeCrawl', 'ChromeSheen', 'HazardStripes', 'MonoChase', 'palette_effects_for', 'rainbow_color', 'FONT_5x7']
|
|
107
|
+
|
|
108
|
+
def rainbow_color(step):
|
|
109
|
+
"""A flowing rainbow colour for integer ``step`` (wraps around the ramp)."""
|
|
110
|
+
return _RAINBOW[step % RAMP]
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _glyph(ch):
|
|
114
|
+
return FONT_5x7.get(ch.upper())
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
# Brightness curves for the single-hue palette effects, GENERATED at RAMP length so
|
|
118
|
+
# they scale with the palette resolution (no hard-coded 6-tuples to fall out of sync
|
|
119
|
+
# when RAMP changes). Built once when the effect is constructed.
|
|
120
|
+
def _ramp_profile(n):
|
|
121
|
+
"""A dark -> full brightness curve of length ``n`` (sheen: dim base to highlight)."""
|
|
122
|
+
if n <= 1:
|
|
123
|
+
return (1.0,)
|
|
124
|
+
lo = 0.12
|
|
125
|
+
return tuple(lo + (1.0 - lo) * i / (n - 1) for i in range(n))
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _peak_profile(n):
|
|
129
|
+
"""A dim -> bright -> dim brightness curve of length ``n`` (mono-chase highlight)."""
|
|
130
|
+
if n <= 1:
|
|
131
|
+
return (1.0,)
|
|
132
|
+
lo = 0.12
|
|
133
|
+
return tuple(lo + (1.0 - lo) * (1.0 - abs(2.0 * i / (n - 1) - 1.0))
|
|
134
|
+
for i in range(n))
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class RainbowChase:
|
|
138
|
+
"""Palette effect: rotate the colour ramp so a rainbow travels through the
|
|
139
|
+
letters. Pure palette rewrites — no glyph rebuild. ``period`` advances the
|
|
140
|
+
phase every N frames (>1 = a calmer, slower chase)."""
|
|
141
|
+
|
|
142
|
+
def __init__(self, step=1, period=1):
|
|
143
|
+
self.step = step
|
|
144
|
+
self.period = period if period > 1 else 1
|
|
145
|
+
self._phase = 0
|
|
146
|
+
self._tick = 0
|
|
147
|
+
|
|
148
|
+
def apply(self, palette):
|
|
149
|
+
for i in range(RAMP):
|
|
150
|
+
palette[1 + i] = _RAINBOW[(i + self._phase) % RAMP]
|
|
151
|
+
self._tick += 1
|
|
152
|
+
if self._tick >= self.period:
|
|
153
|
+
self._tick = 0
|
|
154
|
+
self._phase = (self._phase + self.step) % RAMP
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
class NeonTubeCrawl:
|
|
158
|
+
"""A bright pulse crawls along an otherwise-dim neon tube of one colour: one ramp
|
|
159
|
+
slot glows at full ``color`` while the rest hold a dimmed version of it. Pass
|
|
160
|
+
``glow``/``base`` to set the two shades directly instead. Pure palette rewrites —
|
|
161
|
+
no glyph rebuild. ``period`` advances the pulse every N frames."""
|
|
162
|
+
|
|
163
|
+
def __init__(self, color=0x66FFCC, glow=None, base=None, period=2):
|
|
164
|
+
self.glow = glow if glow is not None else color
|
|
165
|
+
self.base = base if base is not None else _scale(color, 0.18)
|
|
166
|
+
self.period = period if period > 1 else 1
|
|
167
|
+
self._phase = 0
|
|
168
|
+
self._tick = 0
|
|
169
|
+
|
|
170
|
+
def apply(self, palette):
|
|
171
|
+
for i in range(RAMP):
|
|
172
|
+
palette[1 + i] = self.glow if i == self._phase else self.base
|
|
173
|
+
self._tick += 1
|
|
174
|
+
if self._tick >= self.period:
|
|
175
|
+
self._tick = 0
|
|
176
|
+
self._phase = (self._phase + 1) % RAMP
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
class ChromeSheen:
|
|
180
|
+
"""A metallic sheen: a dark→bright ramp of ``color`` (default silver/white) with a
|
|
181
|
+
highlight band that sweeps across the letters as the ramp rotates. Pass
|
|
182
|
+
``highlight`` to make the sheen a smooth TWO-colour gradient (dim ``color`` → full
|
|
183
|
+
``highlight``, e.g. steel-blue → white) instead of a single-hue brightness ramp.
|
|
184
|
+
Pure palette rewrites — no glyph rebuild."""
|
|
185
|
+
|
|
186
|
+
# brightness profile, dark -> full; the highlight sits at the bright end. Generated
|
|
187
|
+
# at RAMP length so the gradient stays smooth as the palette resolution widens.
|
|
188
|
+
_PROFILE = _ramp_profile(RAMP)
|
|
189
|
+
|
|
190
|
+
def __init__(self, color=0xFFFFFF, highlight=None, period=1):
|
|
191
|
+
self.color = color
|
|
192
|
+
self.highlight = highlight
|
|
193
|
+
self.period = period if period > 1 else 1
|
|
194
|
+
if highlight is None:
|
|
195
|
+
self._ramp = tuple(_scale(color, f) for f in self._PROFILE)
|
|
196
|
+
else:
|
|
197
|
+
# dim base of `color` -> full `highlight`: a smooth two-colour sheen ramp.
|
|
198
|
+
self._ramp = gradient(_scale(color, self._PROFILE[0]), highlight, RAMP)
|
|
199
|
+
self._phase = 0
|
|
200
|
+
self._tick = 0
|
|
201
|
+
|
|
202
|
+
def apply(self, palette):
|
|
203
|
+
for i in range(RAMP):
|
|
204
|
+
palette[1 + i] = self._ramp[(i + self._phase) % RAMP]
|
|
205
|
+
self._tick += 1
|
|
206
|
+
if self._tick >= self.period:
|
|
207
|
+
self._tick = 0
|
|
208
|
+
self._phase = (self._phase + 1) % RAMP
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
class HazardStripes:
|
|
212
|
+
"""Marching hazard stripes: an accent ``color`` alternating with a ``dark`` ground,
|
|
213
|
+
shifting one slot each step. Pass ``a``/``b`` to set the two colours directly.
|
|
214
|
+
Pure palette rewrites — no glyph rebuild."""
|
|
215
|
+
|
|
216
|
+
def __init__(self, color=0xFFCC00, dark=0x101010, a=None, b=None, period=2):
|
|
217
|
+
self.a = a if a is not None else color
|
|
218
|
+
self.b = b if b is not None else dark
|
|
219
|
+
self.period = period if period > 1 else 1
|
|
220
|
+
self._phase = 0
|
|
221
|
+
self._tick = 0
|
|
222
|
+
|
|
223
|
+
def apply(self, palette):
|
|
224
|
+
for i in range(RAMP):
|
|
225
|
+
palette[1 + i] = self.a if ((i + self._phase) % 2 == 0) else self.b
|
|
226
|
+
self._tick += 1
|
|
227
|
+
if self._tick >= self.period:
|
|
228
|
+
self._tick = 0
|
|
229
|
+
self._phase = (self._phase + 1) % 2
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
class MonoChase:
|
|
233
|
+
"""A single bright band of one ``color`` chases through the letters — RainbowChase,
|
|
234
|
+
but monochrome (one hue at varying brightness). Pure palette rewrites — no glyph
|
|
235
|
+
rebuild. ``period`` advances the chase every N frames."""
|
|
236
|
+
|
|
237
|
+
# a brightness peak (dim -> bright -> dim) that sweeps around the ramp. Generated at
|
|
238
|
+
# RAMP length so the sweep stays smooth as the palette resolution widens.
|
|
239
|
+
_PROFILE = _peak_profile(RAMP)
|
|
240
|
+
|
|
241
|
+
def __init__(self, color=0xFFFFFF, period=1):
|
|
242
|
+
self.color = color
|
|
243
|
+
self.period = period if period > 1 else 1
|
|
244
|
+
self._ramp = tuple(_scale(color, f) for f in self._PROFILE)
|
|
245
|
+
self._phase = 0
|
|
246
|
+
self._tick = 0
|
|
247
|
+
|
|
248
|
+
def apply(self, palette):
|
|
249
|
+
for i in range(RAMP):
|
|
250
|
+
palette[1 + i] = self._ramp[(i + self._phase) % RAMP]
|
|
251
|
+
self._tick += 1
|
|
252
|
+
if self._tick >= self.period:
|
|
253
|
+
self._tick = 0
|
|
254
|
+
self._phase = (self._phase + 1) % RAMP
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
# Content pairing: palette effects animate the colours of bitmap text, so they read
|
|
258
|
+
# well whether the text is held static or scrolling (surfaced in capabilities()/docs).
|
|
259
|
+
RainbowChase.PAIRS_WITH = ("static", "scrolling")
|
|
260
|
+
NeonTubeCrawl.PAIRS_WITH = ("static", "scrolling")
|
|
261
|
+
ChromeSheen.PAIRS_WITH = ("static", "scrolling")
|
|
262
|
+
HazardStripes.PAIRS_WITH = ("static", "scrolling")
|
|
263
|
+
MonoChase.PAIRS_WITH = ("static", "scrolling")
|
|
264
|
+
|
|
265
|
+
_PALETTE_EFFECTS = (RainbowChase, NeonTubeCrawl, ChromeSheen, HazardStripes, MonoChase)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def palette_effects_for(presentation):
|
|
269
|
+
"""The palette-effect CLASSES suited to `presentation` ('static' | 'scrolling').
|
|
270
|
+
|
|
271
|
+
Each returned class is used as ``BitmapText(text, palette_effect=cls())``. These
|
|
272
|
+
animate colour over bitmap text and read well static or scrolling. Reads the live
|
|
273
|
+
PAIRS_WITH tags, so it stays current as palette effects are added or retagged.
|
|
274
|
+
|
|
275
|
+
cls = random.choice(palette_effects_for("scrolling"))
|
|
276
|
+
app.content_queue.add(BitmapText("OPEN", palette_effect=cls()))
|
|
277
|
+
"""
|
|
278
|
+
return tuple(cls for cls in _PALETTE_EFFECTS
|
|
279
|
+
if presentation in getattr(cls, "PAIRS_WITH", ()))
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
class BitmapText(DisplayContent):
|
|
283
|
+
"""A message rendered once into an indexed bitmap, scrolled via a TileGrid,
|
|
284
|
+
with an optional per-frame palette effect."""
|
|
285
|
+
|
|
286
|
+
def __init__(self, text, y=0, palette_effect=None, scroll_speed=30,
|
|
287
|
+
max_width_px=192, priority=2, complete_after_passes=None):
|
|
288
|
+
"""Args (beyond the obvious):
|
|
289
|
+
|
|
290
|
+
complete_after_passes: if set (e.g. 1), ``is_complete`` becomes True
|
|
291
|
+
after the text has fully scrolled across that many times, so the
|
|
292
|
+
banner can advance a ContentQueue instead of looping forever.
|
|
293
|
+
Default None keeps the persistent-banner behaviour (never
|
|
294
|
+
completes on its own). Completion is keyed on SCROLL POSITION, not
|
|
295
|
+
wall-clock, so a heavy concurrent effect that drops the frame rate
|
|
296
|
+
can't end a pass early (cutting the text off mid-scroll).
|
|
297
|
+
"""
|
|
298
|
+
super().__init__(duration=None, priority=priority)
|
|
299
|
+
self.text = text
|
|
300
|
+
self.y = y
|
|
301
|
+
self.palette_effect = palette_effect if palette_effect is not None else RainbowChase()
|
|
302
|
+
self.scroll_speed = scroll_speed
|
|
303
|
+
self.max_width_px = max_width_px
|
|
304
|
+
self.complete_after_passes = complete_after_passes
|
|
305
|
+
self._passes = 0 # full scroll passes completed (frame-based)
|
|
306
|
+
self._built = False
|
|
307
|
+
self._display = None
|
|
308
|
+
self._bitmap = None
|
|
309
|
+
self._palette = None
|
|
310
|
+
self._tile = None
|
|
311
|
+
self._pos_q = 0 # 1/16-px scroll accumulator
|
|
312
|
+
self._width = 0
|
|
313
|
+
|
|
314
|
+
def _delta_q(self):
|
|
315
|
+
return int(round(self.scroll_speed * 16 / LOOP_FPS))
|
|
316
|
+
|
|
317
|
+
def _build(self, display):
|
|
318
|
+
gfx = display.gfx
|
|
319
|
+
width = min(self.max_width_px, max(1, len(self.text) * CELL_W))
|
|
320
|
+
self._width = width
|
|
321
|
+
bitmap = gfx.Bitmap(width, GLYPH_H, RAMP + 1)
|
|
322
|
+
palette = gfx.Palette(RAMP + 1)
|
|
323
|
+
palette.make_transparent(0) # background transparent
|
|
324
|
+
for i in range(RAMP):
|
|
325
|
+
palette[1 + i] = _RAINBOW[i]
|
|
326
|
+
# Render every glyph ONCE; each lit pixel's palette index is its column
|
|
327
|
+
# position in the ramp, so rotating the palette makes the colour chase.
|
|
328
|
+
cx = 0
|
|
329
|
+
for ch in self.text:
|
|
330
|
+
rows = _glyph(ch)
|
|
331
|
+
if rows is not None:
|
|
332
|
+
for ry in range(GLYPH_H):
|
|
333
|
+
row = rows[ry]
|
|
334
|
+
for rx in range(GLYPH_W):
|
|
335
|
+
ax = cx + rx
|
|
336
|
+
if ax < width and row[rx] != " ":
|
|
337
|
+
bitmap[ax, ry] = (ax % RAMP) + 1
|
|
338
|
+
cx += CELL_W
|
|
339
|
+
if cx >= width:
|
|
340
|
+
break
|
|
341
|
+
tile = gfx.TileGrid(bitmap, pixel_shader=palette)
|
|
342
|
+
tile.x = display.width # start off the right edge
|
|
343
|
+
tile.y = self.y
|
|
344
|
+
display.add_layer(tile)
|
|
345
|
+
self._bitmap = bitmap
|
|
346
|
+
self._palette = palette
|
|
347
|
+
self._tile = tile
|
|
348
|
+
self._pos_q = display.width << 4
|
|
349
|
+
self._built = True
|
|
350
|
+
|
|
351
|
+
async def start(self):
|
|
352
|
+
"""Reset so the layer is rebuilt and pass-completion replays when this
|
|
353
|
+
content cycles back through a ContentQueue.
|
|
354
|
+
|
|
355
|
+
The library's previous BitmapText never reset ``_built``, so a banner that
|
|
356
|
+
was ``stop()``-ed (which detaches its TileGrid) would not re-add the layer
|
|
357
|
+
when shown again — it went invisible on the second cycle. Rebuilding on
|
|
358
|
+
each start() makes it queue-safe.
|
|
359
|
+
"""
|
|
360
|
+
await super().start()
|
|
361
|
+
self._built = False
|
|
362
|
+
self._passes = 0
|
|
363
|
+
|
|
364
|
+
async def render(self, display):
|
|
365
|
+
self._display = display # remembered so stop() can detach
|
|
366
|
+
was_built = self._built
|
|
367
|
+
if not self._built:
|
|
368
|
+
self._build(display) # one-time (frame 0)
|
|
369
|
+
# Animate by rewriting palette entries — NO glyph rebuild, ~zero pixel work.
|
|
370
|
+
if self.palette_effect is not None:
|
|
371
|
+
self.palette_effect.apply(self._palette)
|
|
372
|
+
# Scroll by moving the TileGrid.
|
|
373
|
+
self._pos_q -= self._delta_q()
|
|
374
|
+
self._tile.x = self._pos_q >> 4
|
|
375
|
+
if (self._pos_q >> 4) < -self._width:
|
|
376
|
+
self._pos_q = display.width << 4 # loop the scroll
|
|
377
|
+
# The wrap (position jumping back to the right edge) marks one full
|
|
378
|
+
# pass. Counted from scroll POSITION (frame-based), not wall-clock, so
|
|
379
|
+
# a low frame rate never ends a pass early. Skip the build frame.
|
|
380
|
+
if was_built:
|
|
381
|
+
self._passes += 1
|
|
382
|
+
|
|
383
|
+
@property
|
|
384
|
+
def is_complete(self):
|
|
385
|
+
if self._is_complete:
|
|
386
|
+
return True
|
|
387
|
+
if self.complete_after_passes is not None:
|
|
388
|
+
return self._passes >= self.complete_after_passes
|
|
389
|
+
return False
|
|
390
|
+
|
|
391
|
+
async def stop(self):
|
|
392
|
+
"""Remove the bitmap-text layer when the content is taken off the queue."""
|
|
393
|
+
await super().stop()
|
|
394
|
+
if getattr(self, "_display", None) is not None:
|
|
395
|
+
self.detach(self._display)
|
|
396
|
+
self._display = None
|
|
397
|
+
|
|
398
|
+
def detach(self, display):
|
|
399
|
+
if self._tile is not None:
|
|
400
|
+
display.remove_layer(self._tile)
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
# --- advertised feasibility metadata (US7 / FR-026) -------------------------
|
|
404
|
+
# The glyph bitmap is rendered ONCE; animation is pure palette rewrites (<= RAMP
|
|
405
|
+
# entries) and scrolling moves the TileGrid.x — so per-frame PIXEL writes are zero
|
|
406
|
+
# and there is no per-frame allocation. Strict-feasible at 20 fps.
|
|
407
|
+
BitmapText.FEASIBILITY = {"hardware_safe": True, "allocates_per_frame": False,
|
|
408
|
+
"max_pixel_writes_per_frame": 0, "modeled_frame_ms": 5.0}
|