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