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,167 @@
1
+ # Copyright (c) 2024-2026 Michael Winslow Czeiszperger
2
+ """Static directional gradient / palette text fill (indexed-bitmap renderer).
3
+
4
+ When ``StaticText`` / ``ScrollingText`` are given a ``palette``, their glyphs are
5
+ rasterised ONCE — through the shared :func:`~scrollkit.display.text_pixels.\
6
+ pixels_from_font_text` helper, so the *real* display font (terminalio by default)
7
+ is preserved — into an indexed ``Bitmap`` whose every lit pixel carries a palette
8
+ *index* equal to its position along the chosen axis. The text then shows / scrolls
9
+ by moving a ``TileGrid``: no glyph rebuild and **zero per-frame pixel writes**,
10
+ exactly like ``BitmapText``. The difference is that the palette here is a FIXED
11
+ ramp (a static gradient), not a per-frame animation — animated colour stays the
12
+ distinct concern of ``BitmapText`` + ``palette_effect``.
13
+
14
+ A displayio ``Label`` is physically one colour (a 2-entry indexed bitmap), so this
15
+ indexed-bitmap path is the only way to colour the normal font per-pixel. Runs
16
+ unchanged on the simulator and on CircuitPython via ``display.gfx`` (integer-only;
17
+ no per-frame allocation).
18
+ """
19
+
20
+ from .colors import gradient, multi_gradient
21
+ from .text_fill import clamp_palette_steps, normalize_direction
22
+ from .text_pixels import font_text_ascent, font_text_width, pixels_from_font_text
23
+
24
+ __all__ = ["GradientTextLayer"]
25
+
26
+ # The Label path positions a line's baseline at screen row ``y + 4`` (the
27
+ # simulator's adafruit_display_text.Label applies a device-compat offset so the
28
+ # sim matches hardware). pixels_from_font_text rasterises with the run's baseline
29
+ # `ascent` rows below the top, so placing tile.y = y + 4 - ascent puts that
30
+ # baseline on the Label's baseline. The `ascent` term then cancels: every glyph
31
+ # pixel lands at `y + 4 - height - y_offset + gy`, identical to the flat Label
32
+ # path per glyph (descenders included). Pinned by test_gradient_text.py.
33
+ _BASELINE_DROP = 4
34
+
35
+
36
+ def _build_ramp(palette, steps):
37
+ """A ``steps``-long tuple of ``0xRRGGBB`` from the user's palette stops.
38
+
39
+ Two stops -> a straight :func:`gradient`; three or more -> a
40
+ :func:`multi_gradient` across them; one (or none) -> a flat fill.
41
+ """
42
+ stops = tuple(palette) if palette else ()
43
+ if len(stops) <= 1:
44
+ c = stops[0] if stops else 0xFFFFFF
45
+ return tuple(c for _ in range(max(1, steps)))
46
+ if len(stops) == 2:
47
+ return gradient(stops[0], stops[1], steps)
48
+ return multi_gradient(stops, steps)
49
+
50
+
51
+ class GradientTextLayer:
52
+ """Owns the indexed bitmap, palette and TileGrid for one gradient string.
53
+
54
+ Build once (rasterise + positional index map, paid on the first frame), then
55
+ reposition by setting :attr:`x`; :meth:`detach` removes the layer.
56
+ ``StaticText`` / ``ScrollingText`` drive it via their ``palette=`` argument for
57
+ the common case; use this class directly for custom positioning/animation.
58
+
59
+ Args:
60
+ text: The string to rasterise.
61
+ y: Baseline row (screen coordinates; matches ``StaticText``/``ScrollingText``).
62
+ palette: A tuple of ``0xRRGGBB`` gradient stops (1 = flat fill, 2 = a
63
+ straight gradient, 3+ = a multi-stop gradient across the run).
64
+ direction: ``"vertical"`` (default), ``"horizontal"``, or ``"diagonal"``.
65
+ palette_steps: Ramp resolution (2..``MAX_PALETTE_STEPS``); ignored for a
66
+ single-colour palette.
67
+ """
68
+
69
+ def __init__(self, text, y, palette, direction="vertical", palette_steps=8):
70
+ self.text = text
71
+ self.y = y
72
+ self.palette = tuple(palette) if palette else ()
73
+ self.direction = normalize_direction(direction)
74
+ # A single-colour palette is a degenerate (flat) gradient: ramp length 1.
75
+ # Otherwise clamp to 2..MAX_PALETTE_STEPS so steps + transparent(0) <= 16
76
+ # keeps the bitmap at 4 bits/pixel.
77
+ self.steps = 1 if len(self.palette) <= 1 else clamp_palette_steps(palette_steps)
78
+ self.width = 0
79
+ self._bitmap = None
80
+ self._palette = None
81
+ self._tile = None
82
+
83
+ def build(self, display):
84
+ """Rasterise the text into an indexed bitmap and add it as a layer."""
85
+ gfx = display.gfx
86
+ font = getattr(display, "font", None)
87
+ # Top-origin rasterisation; the layer is positioned vertically via tile.y.
88
+ pixels = pixels_from_font_text(font, self.text, x=0, y=0)
89
+ advance = font_text_width(font, self.text)
90
+ ascent = font_text_ascent(font, self.text) # baseline row within the raster
91
+ if pixels:
92
+ max_x = max(p[0] for p in pixels)
93
+ ys = [p[1] for p in pixels]
94
+ min_y, max_y = min(ys), max(ys)
95
+ else:
96
+ max_x = min_y = max_y = 0
97
+ # Full advance width (includes trailing spaces) so a horizontal ramp is
98
+ # stable across the whole phrase; widen if any glyph overhangs its advance.
99
+ width = max(1, advance, max_x + 1)
100
+ height = max_y + 1
101
+
102
+ ramp = _build_ramp(self.palette, self.steps)
103
+ n = len(ramp)
104
+ bitmap = gfx.Bitmap(width, height, n + 1)
105
+ palette = gfx.Palette(n + 1)
106
+ palette.make_transparent(0) # index 0 = transparent ground
107
+ for i in range(n):
108
+ palette[1 + i] = ramp[i]
109
+
110
+ # Each lit pixel's palette index is its position along the axis (NO modulo,
111
+ # so the ramp spans the whole word end-to-end rather than tiling). One O(1)
112
+ # lookup per lit pixel, paid once at build.
113
+ span_x = width - 1
114
+ span_y = max_y - min_y
115
+ last = n - 1
116
+ for (px, py) in pixels:
117
+ bitmap[px, py] = self._ramp_index(px, py, min_y, span_x, span_y, last) + 1
118
+
119
+ tile = gfx.TileGrid(bitmap, pixel_shader=palette)
120
+ tile.x = display.width # start off the right edge
121
+ tile.y = self.y + _BASELINE_DROP - ascent # baseline-align to the Label
122
+ display.add_layer(tile)
123
+
124
+ self._bitmap = bitmap
125
+ self._palette = palette
126
+ self._tile = tile
127
+ self.width = width
128
+
129
+ def _ramp_index(self, x, y, min_y, span_x, span_y, last):
130
+ """Position -> ramp index 0..last for the configured direction."""
131
+ if last <= 0:
132
+ return 0
133
+ d = self.direction
134
+ if d == "horizontal":
135
+ num, den = x, span_x
136
+ elif d == "diagonal":
137
+ num, den = x + (y - min_y), span_x + span_y
138
+ else: # vertical (default)
139
+ num, den = (y - min_y), span_y
140
+ if den <= 0:
141
+ return 0
142
+ ramp_i = num * last // den
143
+ if ramp_i < 0:
144
+ return 0
145
+ if ramp_i > last:
146
+ return last
147
+ return ramp_i
148
+
149
+ @property
150
+ def x(self):
151
+ return self._tile.x if self._tile is not None else 0
152
+
153
+ @x.setter
154
+ def x(self, value):
155
+ if self._tile is not None:
156
+ self._tile.x = value
157
+
158
+ def detach(self, display):
159
+ """Remove the layer (idempotent — remove_layer ignores an absent tile)."""
160
+ if self._tile is not None:
161
+ display.remove_layer(self._tile)
162
+ self._tile = None
163
+
164
+
165
+ # 0.8.x compatibility alias: themeparkwaits (and any other pre-0.8.2 caller)
166
+ # imports the old private name directly. Kept until a future 0.9.0.
167
+ _GradientTextLayer = GradientTextLayer
@@ -0,0 +1,126 @@
1
+ # Copyright (c) 2024-2026 Michael Winslow Czeiszperger
2
+ """Display interface for SLDK.
3
+
4
+ This provides the abstract interface that all display implementations must follow.
5
+ Compatible with both CircuitPython and desktop Python.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ try:
11
+ from typing import Any, Optional
12
+ except ImportError: # CircuitPython has no 'typing' module
13
+ pass
14
+
15
+
16
+ __all__ = ['DisplayInterface']
17
+
18
+ class DisplayInterface:
19
+ """Base interface for all display implementations."""
20
+
21
+ @property
22
+ def width(self) -> int:
23
+ """Display width in pixels."""
24
+ raise NotImplementedError("Subclass must implement width property")
25
+
26
+ @property
27
+ def height(self) -> int:
28
+ """Display height in pixels."""
29
+ raise NotImplementedError("Subclass must implement height property")
30
+
31
+ async def initialize(self) -> None:
32
+ """Initialize the display hardware or simulator."""
33
+ raise NotImplementedError("Subclass must implement initialize()")
34
+
35
+ async def clear(self) -> None:
36
+ """Clear the display."""
37
+ raise NotImplementedError("Subclass must implement clear()")
38
+
39
+ async def show(self) -> bool:
40
+ """Update the physical display.
41
+
42
+ Returns False when the user closed the simulator window (so the app loop
43
+ can shut down); True otherwise.
44
+ """
45
+ raise NotImplementedError("Subclass must implement show()")
46
+
47
+ async def set_pixel(self, x: int, y: int, color: int) -> None:
48
+ """Set a single pixel color.
49
+
50
+ Args:
51
+ x: X coordinate
52
+ y: Y coordinate
53
+ color: Color as 24-bit RGB integer (0xRRGGBB)
54
+ """
55
+ raise NotImplementedError("Subclass must implement set_pixel()")
56
+
57
+ async def fill(self, color: int) -> None:
58
+ """Fill entire display with color.
59
+
60
+ Args:
61
+ color: Color as 24-bit RGB integer (0xRRGGBB)
62
+ """
63
+ # Default implementation using set_pixel
64
+ for y in range(self.height):
65
+ for x in range(self.width):
66
+ await self.set_pixel(x, y, color)
67
+
68
+ async def set_brightness(self, brightness: float) -> None:
69
+ """Set display brightness.
70
+
71
+ Args:
72
+ brightness: Float between 0.0 and 1.0
73
+ """
74
+ raise NotImplementedError("Subclass must implement set_brightness()")
75
+
76
+ # Higher-level convenience methods with default implementations
77
+
78
+ async def draw_text(self, text: str, x: int = 0, y: int = 0, color: int = 0xFFFFFF, font: Any = None) -> None:
79
+ """Draw text on display.
80
+
81
+ Args:
82
+ text: Text to display
83
+ x: Starting X coordinate
84
+ y: Starting Y coordinate
85
+ color: Text color as 24-bit RGB
86
+ font: Font to use (implementation specific)
87
+ """
88
+ # Not a silent no-op: a DisplayInterface that can't draw text is a bug,
89
+ # and the concrete displays (Unified/Simulator via GraphicsMixin) all
90
+ # override this.
91
+ raise NotImplementedError("Subclass must implement draw_text()")
92
+
93
+ # --- bounded painters + graphics bridge (see display/_graphics.py) --------
94
+ # Real displays mix in GraphicsMixin, which provides concrete implementations
95
+ # backed by C bulk ops (bitmaptools). These stubs document the contract for
96
+ # any other DisplayInterface.
97
+
98
+ async def fill_rect(self, x: int, y: int, w: int, h: int, color: int) -> None:
99
+ """Fill a bounded rectangle with ``color`` via a C bulk op (no full loop)."""
100
+ raise NotImplementedError("Subclass must implement fill_rect()")
101
+
102
+ async def fill_span(self, y: int, x0: int, x1: int, color: int) -> None:
103
+ """Fill the single-row span ``[x0, x1)`` with ``color``."""
104
+ raise NotImplementedError("Subclass must implement fill_span()")
105
+
106
+ async def clear_rect(self, x: int, y: int, w: int, h: int) -> None:
107
+ """Clear a bounded rectangle back to the background."""
108
+ raise NotImplementedError("Subclass must implement clear_rect()")
109
+
110
+ def measure_text(self, text: str, font: Any = None) -> int:
111
+ """Rendered pixel width of ``text`` (summed glyph advances; not len*6)."""
112
+ return len(text) * 6 # coarse fallback; real displays override
113
+
114
+ @property
115
+ def gfx(self) -> Any:
116
+ """Platform-resolved graphics namespace (Bitmap/Palette/TileGrid/Group/
117
+ bitmaptools), cached per display."""
118
+ raise NotImplementedError("Subclass must implement gfx")
119
+
120
+ def add_layer(self, tilegrid: Any) -> None:
121
+ """Composite a TileGrid above content (persistent across frames)."""
122
+ raise NotImplementedError("Subclass must implement add_layer()")
123
+
124
+ def remove_layer(self, tilegrid: Any) -> None:
125
+ """Remove a layer added via add_layer()."""
126
+ raise NotImplementedError("Subclass must implement remove_layer()")
@@ -0,0 +1,80 @@
1
+ # Copyright (c) 2024-2026 Michael Winslow Czeiszperger
2
+ """Simulator display for SLDK — the interactive desktop entry point.
3
+
4
+ ``SimulatorDisplay`` IS ``UnifiedDisplay`` (one per-frame pipeline: the same
5
+ clear/draw_text/set_pixel/fill/show code that runs on hardware, over the
6
+ emulated ``displayio`` backend). What this subclass adds is purely the desktop
7
+ developer ergonomics:
8
+
9
+ - opens the pygame window automatically on the first ``show()``
10
+ - constructor knobs for the window (``scale``) and recording quality (``pitch``)
11
+ - the hardware-timing flags as first-class constructor args (they exist on
12
+ ``UnifiedDisplay`` too)
13
+
14
+ Everything else — recording (``start_recording`` / ``save_gif`` /
15
+ ``save_video``), ``screenshot``, the painters, the label pools, feasibility —
16
+ is inherited. If simulator output ever disagrees with hardware, fix the
17
+ simulator backend (``scrollkit.simulator``), never this class: there is no
18
+ per-class render logic left to diverge.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import sys
24
+
25
+ # Verify we're NOT on CircuitPython
26
+ if hasattr(sys, 'implementation') and sys.implementation.name == 'circuitpython':
27
+ raise ImportError("Simulator display cannot be used on CircuitPython")
28
+
29
+ from .unified import UnifiedDisplay, LED_SIMULATOR_AVAILABLE
30
+
31
+ if not LED_SIMULATOR_AVAILABLE:
32
+ raise ImportError(
33
+ "SLDK simulator not available. "
34
+ "Ensure all SLDK components are properly installed."
35
+ )
36
+
37
+
38
+ __all__ = ['SimulatorDisplay']
39
+
40
+
41
+ class SimulatorDisplay(UnifiedDisplay):
42
+ """Interactive desktop display: UnifiedDisplay + an auto-created window."""
43
+
44
+ _WINDOW_TITLE = "SLDK Simulator"
45
+ _AUTO_WINDOW = True
46
+
47
+ def __init__(self, width: int = 64, height: int = 32, scale: int = 10,
48
+ *, hardware_timing: bool = False, throttle: bool = False,
49
+ strict: bool = False, board=None, pitch: float = 3.0):
50
+ """Initialize the simulator display.
51
+
52
+ Args:
53
+ width: Display width in pixels
54
+ height: Display height in pixels
55
+ scale: Window scale factor (fallback when the LED renderer doesn't
56
+ publish its own surface size)
57
+ hardware_timing: Model how slow the real CircuitPython device would
58
+ run (read the estimate via feasibility_report()). Off by default;
59
+ also enabled by the env var SCROLLKIT_HW_SIM=1.
60
+ throttle: When hardware_timing is on, also sleep so the window crawls
61
+ at the modeled hardware speed (off by default; tests never use it).
62
+ strict: Enforce the feasibility gate — a sustained over-budget run (or
63
+ a catastrophic single frame, or a RAM breach) raises
64
+ FeasibilityError instead of just warning. Implies hardware_timing.
65
+ Also enabled by SCROLLKIT_HW_STRICT=1. Off by default.
66
+ board: Which board's performance profile to model for hardware
67
+ timing/feasibility (e.g. ``"pimoroni_interstate75_w"``). ``None``
68
+ honors ``SCROLLKIT_HW_BOARD`` and defaults to the MatrixPortal S3.
69
+ pitch: LED pitch (mm) -> on-screen LED size. Raise it (e.g. 6.0) to
70
+ render the panel at a higher resolution for crisp recordings and
71
+ screenshots; changes only the visual scale, not the pixel grid.
72
+ """
73
+ super().__init__(width=width, height=height, board=board,
74
+ hardware_timing=hardware_timing, throttle=throttle,
75
+ strict=strict, pitch=pitch)
76
+ self._scale: int = scale
77
+
78
+ async def initialize(self) -> None:
79
+ await super().initialize()
80
+ print("Simulator display initialized")
@@ -0,0 +1,59 @@
1
+ # Copyright (c) 2024-2026 Michael Winslow Czeiszperger
2
+ """Shared vocabulary for gradient text fills (the live source of truth).
3
+
4
+ Kept deliberately tiny and dependency-free (pure constants + functions, no
5
+ displayio, no colour math) so the RAM-constrained device pays almost nothing to
6
+ import it, and so three callers can share ONE definition instead of hardcoding
7
+ the strings in each place:
8
+
9
+ * ``display.content`` — validates the ``direction`` argument at construction;
10
+ * ``display.gradient_text`` — maps a pixel's position to a ramp index per axis;
11
+ * ``dev.capabilities`` — advertises the directions to the AI-authoring catalog by
12
+ calling :func:`gradient_directions` (never a copy-pasted tuple), the same
13
+ live-introspection discipline ``transition_names`` / ``palette_effects_for``
14
+ already use.
15
+ """
16
+
17
+ # The three gradient axes, matching ``SwarmReveal``'s colour-axis vocabulary so a
18
+ # user learns one set of names. "vertical" is the default because a top-light →
19
+ # bottom-dark shade reads as depth (the common "subtle" case). Reverse any axis by
20
+ # reversing the palette tuple — there is intentionally no ``*_reverse`` name.
21
+ GRADIENT_DIRECTIONS = ("vertical", "horizontal", "diagonal")
22
+
23
+ # Default number of palette ramp steps generated from the stops. Small on purpose:
24
+ # the panel is RGB444 (4 bits/channel) at the default bit_depth=4, so more steps
25
+ # rarely survive as distinct colours.
26
+ DEFAULT_PALETTE_STEPS = 8
27
+
28
+ # Hard cap. ``steps`` colours plus the transparent background (palette index 0)
29
+ # must fit in 16 values to keep the indexed bitmap at 4 bits/pixel (RAM + speed).
30
+ MAX_PALETTE_STEPS = 15
31
+
32
+
33
+ __all__ = ['gradient_directions', 'normalize_direction', 'clamp_palette_steps', 'GRADIENT_DIRECTIONS', 'DEFAULT_PALETTE_STEPS', 'MAX_PALETTE_STEPS']
34
+
35
+ def gradient_directions():
36
+ """The supported gradient ``direction`` values, as a tuple of strings."""
37
+ return GRADIENT_DIRECTIONS
38
+
39
+
40
+ def normalize_direction(direction):
41
+ """Return ``direction`` if valid, else fall back to ``"vertical"``.
42
+
43
+ Mirrors ``SwarmReveal``'s lenient handling: an unknown axis name degrades to
44
+ the sensible default rather than raising on the device.
45
+ """
46
+ return direction if direction in GRADIENT_DIRECTIONS else "vertical"
47
+
48
+
49
+ def clamp_palette_steps(steps):
50
+ """Clamp a requested ramp length into the 2..``MAX_PALETTE_STEPS`` range."""
51
+ try:
52
+ steps = int(steps)
53
+ except (TypeError, ValueError):
54
+ return DEFAULT_PALETTE_STEPS
55
+ if steps < 2:
56
+ return 2
57
+ if steps > MAX_PALETTE_STEPS:
58
+ return MAX_PALETTE_STEPS
59
+ return steps
@@ -0,0 +1,250 @@
1
+ # Copyright (c) 2024-2026 Michael Winslow Czeiszperger
2
+ """Font/scale-aware text → lit-pixel composition (single source of truth).
3
+
4
+ ``pixels_from_font_text`` renders a string with *any* loaded font at an integer
5
+ scale and returns the list of lit ``(x, y)`` pixels — the same ``[(x, y), ...]``
6
+ format the splash effects and the gradient text-fill renderer consume. Because
7
+ the drip animation, the live on-screen image, *and* gradient text are all built
8
+ from this one function, the pixels are identical **by construction** — there is
9
+ nothing to "match" and no chance of desktop/device drift.
10
+
11
+ It is a pure function over the font's glyph bitmaps (no displayio Bitmap is
12
+ allocated, no ``gfx`` needed), so it runs unchanged on the simulator and on
13
+ CircuitPython. The one real platform difference it papers over is the glyph
14
+ object shape:
15
+
16
+ * simulator ``font.get_glyph`` returns a **dict** (``'dx'`` is the advance,
17
+ ``'x_offset'`` the left bearing, ``'y_offset'`` the baseline-relative vertical
18
+ offset, ``'bitmap'`` the glyph bitmap);
19
+ * CircuitPython ``adafruit_bitmap_font`` returns an **object** (``.shift_x`` is
20
+ the advance, ``.dx`` the left bearing, ``.dy`` the vertical offset, ``.bitmap``
21
+ the glyph bitmap).
22
+
23
+ Glyphs are laid out left-to-right by advance and **baseline-aligned** (each glyph
24
+ sits at ``baseline - height - y_offset``, so descenders drop below the line and
25
+ short glyphs rest on it), matching ``adafruit_display_text.Label`` — so mixed-case
26
+ prose with descenders renders correctly, not just digits and ALL-CAPS runs. The
27
+ shared baseline sits ``font_text_ascent`` pixels below ``y`` (``y`` is the top of
28
+ the tallest glyph in the run).
29
+
30
+ This module lives in ``display/`` (not ``effects/``) on purpose: it is depended
31
+ on by both ``display.gradient_text`` and the ``effects`` splash animations, and
32
+ ``effects`` already imports from ``display``. Homing it here keeps the
33
+ ``display → effects`` direction from ever forming a cycle and stops the
34
+ RAM-heavy ``effects`` package (particles/splashes) from loading just to render
35
+ text. ``effects/text_render.py`` re-exports these names for backwards
36
+ compatibility.
37
+ """
38
+
39
+ # Advance used when a glyph is missing entirely (keeps spacing sane).
40
+ _MISSING_ADVANCE = 4
41
+
42
+
43
+ __all__ = ['pixels_from_font_text', 'font_text_width', 'font_text_ascent']
44
+
45
+ def _glyph_fields(glyph):
46
+ """Normalise a glyph (dict on simulator, object on device) to a tuple.
47
+
48
+ Returns ``(bitmap, width, height, x_bearing, y_offset, advance, sheet_x,
49
+ sheet_y)`` or ``None`` when the glyph is absent. ``bitmap`` may be ``None``
50
+ (e.g. a space) — the caller still advances by ``advance``.
51
+
52
+ ``y_offset`` is the BDF ``BBX`` vertical offset: the bitmap's bottom edge sits
53
+ that many pixels above the baseline (so it is **negative for descenders**).
54
+ The top of the glyph relative to the baseline is therefore
55
+ ``baseline - height - y_offset`` — exactly what ``adafruit_display_text.Label``
56
+ uses, so callers can baseline-align mixed-height text instead of top-aligning
57
+ it. On the simulator the glyph dict carries it as ``'y_offset'``; on
58
+ CircuitPython the ``adafruit_bitmap_font`` glyph object carries it as ``.dy``
59
+ (same sign convention — verify on hardware if in doubt).
60
+
61
+ ``sheet_x/sheet_y`` are the glyph's pixel origin *within* ``bitmap``: 0,0 for
62
+ per-glyph bitmaps (most BDF loads + the simulator), but non-zero for
63
+ built-in/packed fonts whose glyphs all share one sprite-sheet bitmap addressed
64
+ by ``tile_index`` (e.g. the device's ``terminalio`` font, where ``bitmap`` is
65
+ the whole 570x12 sheet).
66
+ """
67
+ if glyph is None:
68
+ return None
69
+ if isinstance(glyph, dict):
70
+ bmp = glyph.get("bitmap")
71
+ gw = glyph.get("width", 0) or 0
72
+ gh = glyph.get("height", 0) or 0
73
+ xoff = glyph.get("x_offset", 0) or 0
74
+ yoff = glyph.get("y_offset", 0) or 0 # simulator: BDF y-offset (baseline-rel)
75
+ adv = glyph.get("dx") # simulator: 'dx' is the advance
76
+ if adv is None:
77
+ adv = glyph.get("shift_x", gw)
78
+ return (bmp, gw, gh, xoff, yoff, adv if adv is not None else gw, 0, 0)
79
+ bmp = getattr(glyph, "bitmap", None)
80
+ gw = getattr(glyph, "width", 0) or 0
81
+ gh = getattr(glyph, "height", 0) or 0
82
+ xoff = getattr(glyph, "dx", 0) or 0 # device: '.dx' is the left bearing
83
+ yoff = getattr(glyph, "dy", 0) or 0 # device: '.dy' is the BDF y-offset
84
+ adv = getattr(glyph, "shift_x", None) # device: '.shift_x' is the advance
85
+ if adv is None:
86
+ adv = gw
87
+ # Packed sprite-sheet fonts (terminalio built-in): every glyph's '.bitmap' is
88
+ # the same wide sheet; the glyph lives at tile '.tile_index'. Detect by the
89
+ # sheet being wider than one glyph and resolve the tile's top-left origin.
90
+ sheet_x = sheet_y = 0
91
+ if bmp is not None and gw:
92
+ try:
93
+ bw = bmp.width
94
+ except (AttributeError, TypeError):
95
+ bw = gw
96
+ if bw > gw:
97
+ tile_index = getattr(glyph, "tile_index", 0) or 0
98
+ tiles_per_row = bw // gw if gw else 1
99
+ if tiles_per_row < 1:
100
+ tiles_per_row = 1
101
+ sheet_x = (tile_index % tiles_per_row) * gw
102
+ sheet_y = (tile_index // tiles_per_row) * gh
103
+ return (bmp, gw, gh, xoff, yoff, adv, sheet_x, sheet_y)
104
+
105
+
106
+ def _run_ascent(fields_list):
107
+ """Rows above the baseline for the TALLEST glyph in a resolved run (unscaled).
108
+
109
+ ``height + y_offset`` is a glyph's extent above the baseline; the run's ascent
110
+ is the max of that. It is the baseline's row offset from the top of the run,
111
+ and the smallest value that keeps every glyph (placed at
112
+ ``ascent - height - y_offset``) at a non-negative row.
113
+ """
114
+ run = 0
115
+ for fields in fields_list:
116
+ if fields is not None:
117
+ _b, _gw, gh, _x, yoff, _a, _sx, _sy = fields
118
+ if gh and (gh + yoff) > run:
119
+ run = gh + yoff
120
+ return run
121
+
122
+
123
+ def pixels_from_font_text(font, text, x=0, y=0, scale=1):
124
+ """Return ``[(x, y), ...]`` lit pixels for ``text`` in ``font`` at ``scale``.
125
+
126
+ Glyphs are **baseline-aligned**: the run's shared baseline sits
127
+ ``font_text_ascent(font, text, scale)`` pixels below ``y``, and each glyph is
128
+ placed at ``baseline - height - y_offset`` (descenders drop below the line),
129
+ matching ``adafruit_display_text.Label``. For an equal-height run (digits, an
130
+ ALL-CAPS word) this is identical to plain top-alignment; for mixed-case prose
131
+ with descenders it renders correctly instead of floating the short letters up.
132
+
133
+ Args:
134
+ font: A loaded font exposing ``get_glyph(codepoint)`` (the display's
135
+ ``self.font`` — terminalio by default).
136
+ text: String to render.
137
+ x: Left edge of the first glyph, in display pixels.
138
+ y: Top edge of the text (the top of the tallest glyph), in display px.
139
+ scale: Integer magnification (>=1); each lit cell becomes a
140
+ ``scale`` x ``scale`` block, matching ``draw_text_scaled``.
141
+
142
+ Returns:
143
+ List of ``(x, y)`` integer tuples for every lit pixel.
144
+
145
+ Example — a 2x wait-time number, centred on a 64-wide display::
146
+
147
+ from scrollkit.display.text_pixels import pixels_from_font_text
148
+ # width is len*advance*scale; centre it, then drip these pixels in.
149
+ px = pixels_from_font_text(display.font, "45", x=20, y=8, scale=2)
150
+ """
151
+ if scale is None or scale < 1:
152
+ scale = 1
153
+ scale = int(scale)
154
+
155
+ # Resolve every glyph once, then find the run's baseline (tallest glyph above
156
+ # it) so shorter glyphs and descenders can be placed relative to it.
157
+ resolved = [_glyph_fields(font.get_glyph(ord(ch)) if font is not None else None)
158
+ for ch in text]
159
+ ascent = _run_ascent(resolved)
160
+
161
+ pixels = []
162
+ pen_x = x
163
+ for fields in resolved:
164
+ if fields is None:
165
+ pen_x += _MISSING_ADVANCE * scale
166
+ continue
167
+ bmp, gw, gh, xoff, yoff, adv, sheet_x, sheet_y = fields
168
+ if bmp is not None and gw and gh:
169
+ base_x = pen_x + xoff * scale
170
+ # Top of this glyph on a baseline shared by the whole run.
171
+ glyph_top = y + (ascent - gh - yoff) * scale
172
+ for gx in range(gw):
173
+ for gy in range(gh):
174
+ if bmp[sheet_x + gx, sheet_y + gy]:
175
+ px0 = base_x + gx * scale
176
+ py0 = glyph_top + gy * scale
177
+ for sx in range(scale):
178
+ for sy in range(scale):
179
+ pixels.append((px0 + sx, py0 + sy))
180
+ pen_x += (adv if adv else gw) * scale
181
+ return pixels
182
+
183
+
184
+ def font_text_ascent(font, text, scale=1):
185
+ """Baseline row offset (px below ``y``) for ``text`` — how far below the top of
186
+ the rendered run its shared baseline sits, so a caller positioning the pixels
187
+ itself (the gradient text layer) can baseline-align to the ``Label`` path.
188
+
189
+ Derived from the glyphs' real **ink bottoms** (the most common bottom row is the
190
+ baseline), NOT from the reported glyph heights. That distinction is the whole
191
+ fix for the device: its built-in ``terminalio`` font is a FULL-CELL font — every
192
+ glyph is a 6x12 tile with ``height=12, dy=0`` and the true baseline buried inside
193
+ the cell — so ``max(height + y_offset)`` returns the cell bottom (12), which
194
+ pushes the text up and clips the cap tops on mixed-case names. Ink-bottoms give
195
+ the real baseline (~10) on that font and reduce to ``run_ascent`` on the
196
+ simulator's trimmed BDF font, so one rule serves both. Falls back to the metric
197
+ ascent when no ink is found (all spaces / empty).
198
+ """
199
+ if scale is None or scale < 1:
200
+ scale = 1
201
+ scale = int(scale)
202
+ resolved = [_glyph_fields(font.get_glyph(ord(ch)) if font is not None else None)
203
+ for ch in text]
204
+ # Same placement reference pixels_from_font_text uses, so ink rows land in the
205
+ # same coordinate space as the rasterised pixels.
206
+ ascent = _run_ascent(resolved)
207
+ counts = {}
208
+ for fields in resolved:
209
+ if fields is None:
210
+ continue
211
+ bmp, gw, gh, _xoff, yoff, _adv, sheet_x, sheet_y = fields
212
+ if bmp is None or not gw or not gh:
213
+ continue
214
+ glyph_top = ascent - gh - yoff
215
+ for ry in range(gh - 1, -1, -1): # this glyph's lowest ink row
216
+ if any(bmp[sheet_x + rx, sheet_y + ry] for rx in range(gw)):
217
+ row = glyph_top + ry
218
+ counts[row] = counts.get(row, 0) + 1
219
+ break
220
+ if not counts:
221
+ return ascent * scale # no ink -> metric fallback
222
+ # baseline-1 == the most common ink bottom. Ties resolve to the HIGHER row
223
+ # (smaller value) — the body baseline rather than a descender's bottom — which
224
+ # is also the smaller ascent, so the text shifts DOWN, never up into a top clip.
225
+ best_row, best_n = None, -1
226
+ for row, n in counts.items():
227
+ if n > best_n or (n == best_n and row < best_row):
228
+ best_row, best_n = row, n
229
+ return (best_row + 1) * scale
230
+
231
+
232
+ def font_text_width(font, text, scale=1):
233
+ """Total advance width (px) of ``text`` in ``font`` at ``scale``.
234
+
235
+ Mirrors the layout ``pixels_from_font_text`` uses, so callers can centre the
236
+ text (``x = (display.width - font_text_width(...)) // 2``) and get pixels
237
+ that line up with a later ``draw_text_scaled`` of the same string.
238
+ """
239
+ if scale is None or scale < 1:
240
+ scale = 1
241
+ scale = int(scale)
242
+ total = 0
243
+ for ch in text:
244
+ fields = _glyph_fields(font.get_glyph(ord(ch)) if font is not None else None)
245
+ if fields is None:
246
+ total += _MISSING_ADVANCE
247
+ continue
248
+ _bmp, gw, _gh, _xoff, _yoff, adv, _sx, _sy = fields
249
+ total += adv if adv else gw
250
+ return total * scale