scrollkit 0.8.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. scrollkit/__init__.py +16 -0
  2. scrollkit/app/__init__.py +6 -0
  3. scrollkit/app/base.py +918 -0
  4. scrollkit/app/memory.py +70 -0
  5. scrollkit/config/__init__.py +1 -0
  6. scrollkit/config/settings_manager.py +215 -0
  7. scrollkit/config/transition_names.py +31 -0
  8. scrollkit/dev/__init__.py +41 -0
  9. scrollkit/dev/capabilities.py +374 -0
  10. scrollkit/dev/harness.py +383 -0
  11. scrollkit/dev/metrics.py +91 -0
  12. scrollkit/dev/performance.py +174 -0
  13. scrollkit/dev/validation.py +245 -0
  14. scrollkit/display/__init__.py +14 -0
  15. scrollkit/display/_graphics.py +299 -0
  16. scrollkit/display/_recording.py +165 -0
  17. scrollkit/display/_sim_backend.py +99 -0
  18. scrollkit/display/bitmap_text.py +408 -0
  19. scrollkit/display/boards.py +186 -0
  20. scrollkit/display/colors.py +176 -0
  21. scrollkit/display/content.py +604 -0
  22. scrollkit/display/gradient_text.py +167 -0
  23. scrollkit/display/interface.py +126 -0
  24. scrollkit/display/simulator.py +80 -0
  25. scrollkit/display/text_fill.py +59 -0
  26. scrollkit/display/text_pixels.py +250 -0
  27. scrollkit/display/unified.py +595 -0
  28. scrollkit/effects/__init__.py +28 -0
  29. scrollkit/effects/drip_splash.py +253 -0
  30. scrollkit/effects/easing.py +131 -0
  31. scrollkit/effects/overlay.py +92 -0
  32. scrollkit/effects/particles.py +355 -0
  33. scrollkit/effects/reveal_splash.py +132 -0
  34. scrollkit/effects/scrolling.py +363 -0
  35. scrollkit/effects/swarm_reveal.py +512 -0
  36. scrollkit/effects/text_render.py +25 -0
  37. scrollkit/effects/transitions.py +873 -0
  38. scrollkit/exceptions.py +55 -0
  39. scrollkit/network/__init__.py +1 -0
  40. scrollkit/network/http_client.py +505 -0
  41. scrollkit/network/mdns.py +44 -0
  42. scrollkit/network/wifi_manager.py +382 -0
  43. scrollkit/ota/__init__.py +13 -0
  44. scrollkit/ota/client.py +528 -0
  45. scrollkit/ota/display_progress.py +125 -0
  46. scrollkit/ota/manifest.py +206 -0
  47. scrollkit/ota/publish.py +379 -0
  48. scrollkit/simulator/ATTRIBUTION.md +20 -0
  49. scrollkit/simulator/CIRCUITPYTHON_COMPATIBILITY.md +364 -0
  50. scrollkit/simulator/LICENSE +176 -0
  51. scrollkit/simulator/README.md +87 -0
  52. scrollkit/simulator/__init__.py +11 -0
  53. scrollkit/simulator/adafruit_bitmap_font/__init__.py +5 -0
  54. scrollkit/simulator/adafruit_bitmap_font/bitmap_font.py +273 -0
  55. scrollkit/simulator/adafruit_bitmap_font/glyph_cache.py +70 -0
  56. scrollkit/simulator/adafruit_display_text/__init__.py +5 -0
  57. scrollkit/simulator/adafruit_display_text/label.py +336 -0
  58. scrollkit/simulator/bitmaptools.py +50 -0
  59. scrollkit/simulator/core/__init__.py +8 -0
  60. scrollkit/simulator/core/color_utils.py +62 -0
  61. scrollkit/simulator/core/device_benchmarks.json +254 -0
  62. scrollkit/simulator/core/feasibility.py +160 -0
  63. scrollkit/simulator/core/hardware_profile.py +191 -0
  64. scrollkit/simulator/core/led_matrix.py +307 -0
  65. scrollkit/simulator/core/matrixportal_s3_baseline.json +12 -0
  66. scrollkit/simulator/core/performance_manager.py +253 -0
  67. scrollkit/simulator/core/pixel_buffer.py +188 -0
  68. scrollkit/simulator/devices/__init__.py +7 -0
  69. scrollkit/simulator/devices/base_device.py +79 -0
  70. scrollkit/simulator/devices/matrixportal_s3.py +87 -0
  71. scrollkit/simulator/displayio/__init__.py +12 -0
  72. scrollkit/simulator/displayio/bitmap.py +158 -0
  73. scrollkit/simulator/displayio/display.py +195 -0
  74. scrollkit/simulator/displayio/fourwire.py +54 -0
  75. scrollkit/simulator/displayio/group.py +125 -0
  76. scrollkit/simulator/displayio/ondiskbitmap.py +109 -0
  77. scrollkit/simulator/displayio/palette.py +115 -0
  78. scrollkit/simulator/displayio/tilegrid.py +155 -0
  79. scrollkit/simulator/fonts/3x5.bdf +2474 -0
  80. scrollkit/simulator/fonts/Arial_16.bdf +7366 -0
  81. scrollkit/simulator/fonts/Arial_16.bdf.license +3 -0
  82. scrollkit/simulator/fonts/Arial_Bold_12.bdf +6131 -0
  83. scrollkit/simulator/fonts/Arial_Bold_12.bdf.license +2 -0
  84. scrollkit/simulator/fonts/Arial_Bold_18.bdf +32653 -0
  85. scrollkit/simulator/fonts/Arial_Bold_18.bdf.license +2 -0
  86. scrollkit/simulator/fonts/Junction_regular_24.bdf +8676 -0
  87. scrollkit/simulator/fonts/Junction_regular_24.bdf.license +3 -0
  88. scrollkit/simulator/fonts/LeagueSpartan-Bold-16.bdf +12458 -0
  89. scrollkit/simulator/fonts/LeagueSpartan-Bold-16.bdf.license +1921 -0
  90. scrollkit/simulator/fonts/LeagueSpartan_Bold_16.bdf +12458 -0
  91. scrollkit/simulator/fonts/LeagueSpartan_Bold_16.bdf.license +4 -0
  92. scrollkit/simulator/fonts/LibreBodoniv2002-Bold-27.bdf +16818 -0
  93. scrollkit/simulator/fonts/LibreBodoniv2002-Bold-27.bdf.license +1921 -0
  94. scrollkit/simulator/fonts/tom-thumb.bdf +2353 -0
  95. scrollkit/simulator/fonts/viii-bold.bdf +2673 -0
  96. scrollkit/simulator/fonts/viii.bdf +2659 -0
  97. scrollkit/simulator/terminalio/__init__.py +20 -0
  98. scrollkit/utils/__init__.py +1 -0
  99. scrollkit/utils/color_utils.py +54 -0
  100. scrollkit/utils/diagnostics.py +227 -0
  101. scrollkit/utils/error_handler.py +347 -0
  102. scrollkit/utils/system_utils.py +245 -0
  103. scrollkit/utils/url_utils.py +46 -0
  104. scrollkit/web/__init__.py +6 -0
  105. scrollkit/web/settings_server.py +328 -0
  106. scrollkit/web/wifi_setup.py +331 -0
  107. scrollkit-0.8.3.dist-info/METADATA +248 -0
  108. scrollkit-0.8.3.dist-info/RECORD +111 -0
  109. scrollkit-0.8.3.dist-info/WHEEL +5 -0
  110. scrollkit-0.8.3.dist-info/licenses/LICENSE +31 -0
  111. scrollkit-0.8.3.dist-info/top_level.txt +1 -0
@@ -0,0 +1,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}