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,273 @@
|
|
|
1
|
+
"""BDF font loading and rendering for bitmap displays."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from ..displayio import Bitmap, Palette
|
|
5
|
+
from .glyph_cache import GlyphCache
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class BitmapFont:
|
|
9
|
+
"""Bitmap font for rendering text on displays.
|
|
10
|
+
|
|
11
|
+
Loads and renders BDF (Bitmap Distribution Format) fonts.
|
|
12
|
+
Compatible with CircuitPython's adafruit_bitmap_font API.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
def __init__(self):
|
|
16
|
+
"""Initialize empty bitmap font."""
|
|
17
|
+
self.name = None
|
|
18
|
+
self.size = 0
|
|
19
|
+
self.height = 0
|
|
20
|
+
self.ascent = 0
|
|
21
|
+
self.descent = 0
|
|
22
|
+
# Global font metrics from BDF
|
|
23
|
+
self.font_bounding_box = None # (width, height, x_offset, y_offset)
|
|
24
|
+
self.font_ascent = None # From FONT_ASCENT property
|
|
25
|
+
self.font_descent = None # From FONT_DESCENT property
|
|
26
|
+
self.baseline_to_top = 0 # Distance from baseline to top of font bounding box
|
|
27
|
+
self.glyphs = {}
|
|
28
|
+
self.default_char = ord('?')
|
|
29
|
+
self._glyph_cache = GlyphCache()
|
|
30
|
+
|
|
31
|
+
def load_font(self, filename):
|
|
32
|
+
"""Load a BDF font file.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
filename: Path to BDF font file
|
|
36
|
+
"""
|
|
37
|
+
self.glyphs.clear()
|
|
38
|
+
self._glyph_cache.clear()
|
|
39
|
+
|
|
40
|
+
with open(filename, 'r', encoding='latin-1') as f:
|
|
41
|
+
self._parse_bdf(f)
|
|
42
|
+
|
|
43
|
+
def _parse_bdf(self, file):
|
|
44
|
+
"""Parse BDF font file.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
file: Open file object
|
|
48
|
+
"""
|
|
49
|
+
current_char = None
|
|
50
|
+
current_bitmap = []
|
|
51
|
+
current_dwidth = None
|
|
52
|
+
in_bitmap = False
|
|
53
|
+
in_properties = False
|
|
54
|
+
|
|
55
|
+
for line in file:
|
|
56
|
+
line = line.strip()
|
|
57
|
+
|
|
58
|
+
if line.startswith('FONT '):
|
|
59
|
+
self.name = line[5:].strip()
|
|
60
|
+
elif line.startswith('SIZE'):
|
|
61
|
+
parts = line.split()
|
|
62
|
+
if len(parts) >= 2:
|
|
63
|
+
self.size = int(parts[1])
|
|
64
|
+
elif line.startswith('FONTBOUNDINGBOX'):
|
|
65
|
+
parts = line.split()
|
|
66
|
+
if len(parts) >= 5:
|
|
67
|
+
bbox_width = int(parts[1])
|
|
68
|
+
bbox_height = int(parts[2])
|
|
69
|
+
bbox_x_offset = int(parts[3])
|
|
70
|
+
bbox_y_offset = int(parts[4])
|
|
71
|
+
self.font_bounding_box = (bbox_width, bbox_height, bbox_x_offset, bbox_y_offset)
|
|
72
|
+
# Set initial values (may be overridden by FONT_ASCENT/FONT_DESCENT)
|
|
73
|
+
self.height = bbox_height
|
|
74
|
+
self.ascent = bbox_height + bbox_y_offset # Distance from baseline to top
|
|
75
|
+
self.descent = -bbox_y_offset # Distance from baseline to bottom
|
|
76
|
+
self.baseline_to_top = self.ascent
|
|
77
|
+
elif line.startswith('STARTPROPERTIES'):
|
|
78
|
+
in_properties = True
|
|
79
|
+
elif line.startswith('ENDPROPERTIES'):
|
|
80
|
+
in_properties = False
|
|
81
|
+
elif in_properties and line.startswith('FONT_ASCENT'):
|
|
82
|
+
parts = line.split()
|
|
83
|
+
if len(parts) >= 2:
|
|
84
|
+
self.font_ascent = int(parts[1])
|
|
85
|
+
self.ascent = self.font_ascent
|
|
86
|
+
self.baseline_to_top = self.font_ascent
|
|
87
|
+
elif in_properties and line.startswith('FONT_DESCENT'):
|
|
88
|
+
parts = line.split()
|
|
89
|
+
if len(parts) >= 2:
|
|
90
|
+
self.font_descent = int(parts[1])
|
|
91
|
+
self.descent = self.font_descent
|
|
92
|
+
# Update total height if we have both ascent and descent
|
|
93
|
+
if self.font_ascent is not None:
|
|
94
|
+
self.height = self.font_ascent + self.font_descent
|
|
95
|
+
elif line.startswith('STARTCHAR'):
|
|
96
|
+
current_char = None
|
|
97
|
+
current_bitmap = []
|
|
98
|
+
current_dwidth = None
|
|
99
|
+
elif line.startswith('ENCODING'):
|
|
100
|
+
current_char = int(line.split()[1])
|
|
101
|
+
elif line.startswith('DWIDTH'):
|
|
102
|
+
# Parse device width (advance width for kerning)
|
|
103
|
+
parts = line.split()
|
|
104
|
+
if len(parts) >= 2:
|
|
105
|
+
current_dwidth = int(parts[1])
|
|
106
|
+
elif line.startswith('BBX'):
|
|
107
|
+
if current_char is not None:
|
|
108
|
+
parts = line.split()
|
|
109
|
+
if len(parts) >= 5:
|
|
110
|
+
char_width = int(parts[1])
|
|
111
|
+
char_height = int(parts[2])
|
|
112
|
+
x_offset = int(parts[3])
|
|
113
|
+
y_offset = int(parts[4])
|
|
114
|
+
|
|
115
|
+
if current_char not in self.glyphs:
|
|
116
|
+
self.glyphs[current_char] = {
|
|
117
|
+
'width': char_width,
|
|
118
|
+
'height': char_height,
|
|
119
|
+
'x_offset': x_offset,
|
|
120
|
+
'y_offset': y_offset,
|
|
121
|
+
'dwidth': current_dwidth if current_dwidth is not None else char_width, # Use DWIDTH or fallback to bitmap width
|
|
122
|
+
'bitmap': []
|
|
123
|
+
}
|
|
124
|
+
elif line.startswith('BITMAP'):
|
|
125
|
+
in_bitmap = True
|
|
126
|
+
current_bitmap = []
|
|
127
|
+
elif line.startswith('ENDCHAR'):
|
|
128
|
+
if current_char is not None and current_char in self.glyphs:
|
|
129
|
+
self.glyphs[current_char]['bitmap'] = current_bitmap
|
|
130
|
+
in_bitmap = False
|
|
131
|
+
current_char = None
|
|
132
|
+
elif in_bitmap and current_char is not None:
|
|
133
|
+
# Parse hex bitmap data
|
|
134
|
+
current_bitmap.append(line)
|
|
135
|
+
|
|
136
|
+
def get_glyph(self, char):
|
|
137
|
+
"""Get glyph information for a character.
|
|
138
|
+
|
|
139
|
+
Args:
|
|
140
|
+
char: Character or character code
|
|
141
|
+
|
|
142
|
+
Returns:
|
|
143
|
+
Dictionary with glyph information or None
|
|
144
|
+
"""
|
|
145
|
+
if isinstance(char, str):
|
|
146
|
+
char_code = ord(char)
|
|
147
|
+
else:
|
|
148
|
+
char_code = char
|
|
149
|
+
|
|
150
|
+
# Check cache first
|
|
151
|
+
cached = self._glyph_cache.get(char_code)
|
|
152
|
+
if cached:
|
|
153
|
+
return cached
|
|
154
|
+
|
|
155
|
+
# Get glyph data
|
|
156
|
+
if char_code not in self.glyphs:
|
|
157
|
+
# Use default character
|
|
158
|
+
char_code = self.default_char
|
|
159
|
+
if char_code not in self.glyphs:
|
|
160
|
+
return None
|
|
161
|
+
|
|
162
|
+
glyph_data = self.glyphs[char_code]
|
|
163
|
+
|
|
164
|
+
# Create bitmap for glyph
|
|
165
|
+
width = glyph_data['width']
|
|
166
|
+
height = glyph_data['height']
|
|
167
|
+
|
|
168
|
+
if width == 0 or height == 0:
|
|
169
|
+
# Handle zero-size glyphs (like spaces) - they still need DWIDTH for advancement
|
|
170
|
+
result = {
|
|
171
|
+
'bitmap': None, # No bitmap to render
|
|
172
|
+
'width': width,
|
|
173
|
+
'height': height,
|
|
174
|
+
'dx': glyph_data['dwidth'], # Still use DWIDTH for spacing
|
|
175
|
+
'dy': 0,
|
|
176
|
+
'x_offset': glyph_data['x_offset'],
|
|
177
|
+
'y_offset': glyph_data['y_offset']
|
|
178
|
+
}
|
|
179
|
+
# Cache the result
|
|
180
|
+
self._glyph_cache.put(char_code, None, result)
|
|
181
|
+
return result
|
|
182
|
+
|
|
183
|
+
# Create bitmap with 2 colors (0=background, 1=foreground)
|
|
184
|
+
bitmap = Bitmap(width, height, 2)
|
|
185
|
+
|
|
186
|
+
# Parse hex bitmap data
|
|
187
|
+
for y, hex_line in enumerate(glyph_data['bitmap']):
|
|
188
|
+
if y >= height:
|
|
189
|
+
break
|
|
190
|
+
|
|
191
|
+
# Convert hex to binary
|
|
192
|
+
byte_width = (width + 7) // 8
|
|
193
|
+
hex_line = hex_line.ljust(byte_width * 2, '0')
|
|
194
|
+
|
|
195
|
+
for byte_idx in range(0, len(hex_line), 2):
|
|
196
|
+
if byte_idx // 2 >= byte_width:
|
|
197
|
+
break
|
|
198
|
+
|
|
199
|
+
byte_val = int(hex_line[byte_idx:byte_idx + 2], 16)
|
|
200
|
+
|
|
201
|
+
for bit in range(8):
|
|
202
|
+
x = (byte_idx // 2) * 8 + bit
|
|
203
|
+
if x >= width:
|
|
204
|
+
break
|
|
205
|
+
|
|
206
|
+
if byte_val & (0x80 >> bit):
|
|
207
|
+
bitmap[x, y] = 1
|
|
208
|
+
else:
|
|
209
|
+
bitmap[x, y] = 0
|
|
210
|
+
|
|
211
|
+
# Create result dictionary
|
|
212
|
+
result = {
|
|
213
|
+
'bitmap': bitmap,
|
|
214
|
+
'width': width,
|
|
215
|
+
'height': height,
|
|
216
|
+
'dx': glyph_data['dwidth'], # Use DWIDTH for proper kerning/spacing
|
|
217
|
+
'dy': 0,
|
|
218
|
+
'x_offset': glyph_data['x_offset'],
|
|
219
|
+
'y_offset': glyph_data['y_offset']
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
# Cache the result
|
|
223
|
+
self._glyph_cache.put(char_code, bitmap, result)
|
|
224
|
+
|
|
225
|
+
return result
|
|
226
|
+
|
|
227
|
+
def get_bounding_box(self):
|
|
228
|
+
"""Get font bounding box.
|
|
229
|
+
|
|
230
|
+
Returns:
|
|
231
|
+
Tuple of (width, height, x_offset, y_offset)
|
|
232
|
+
"""
|
|
233
|
+
# Calculate from all glyphs
|
|
234
|
+
min_x = 0
|
|
235
|
+
max_x = 0
|
|
236
|
+
min_y = 0
|
|
237
|
+
max_y = 0
|
|
238
|
+
|
|
239
|
+
for glyph_data in self.glyphs.values():
|
|
240
|
+
x_offset = glyph_data['x_offset']
|
|
241
|
+
y_offset = glyph_data['y_offset']
|
|
242
|
+
width = glyph_data['width']
|
|
243
|
+
height = glyph_data['height']
|
|
244
|
+
|
|
245
|
+
min_x = min(min_x, x_offset)
|
|
246
|
+
max_x = max(max_x, x_offset + width)
|
|
247
|
+
min_y = min(min_y, y_offset)
|
|
248
|
+
max_y = max(max_y, y_offset + height)
|
|
249
|
+
|
|
250
|
+
return (max_x - min_x, max_y - min_y, min_x, min_y)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def load_font(filename, bitmap_class=Bitmap):
|
|
254
|
+
"""Load a font file.
|
|
255
|
+
|
|
256
|
+
Args:
|
|
257
|
+
filename: Path to font file
|
|
258
|
+
bitmap_class: Bitmap class to use (for compatibility)
|
|
259
|
+
|
|
260
|
+
Returns:
|
|
261
|
+
BitmapFont instance
|
|
262
|
+
"""
|
|
263
|
+
font = BitmapFont()
|
|
264
|
+
|
|
265
|
+
# Check if file exists, otherwise try to find it in package fonts
|
|
266
|
+
if not os.path.exists(filename):
|
|
267
|
+
# Try package fonts directory
|
|
268
|
+
package_dir = os.path.dirname(os.path.dirname(__file__))
|
|
269
|
+
fonts_dir = os.path.join(package_dir, 'fonts')
|
|
270
|
+
filename = os.path.join(fonts_dir, os.path.basename(filename))
|
|
271
|
+
|
|
272
|
+
font.load_font(filename)
|
|
273
|
+
return font
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Glyph caching for bitmap fonts."""
|
|
2
|
+
|
|
3
|
+
from ..displayio import Bitmap
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class GlyphCache:
|
|
7
|
+
"""Cache for rendered font glyphs.
|
|
8
|
+
|
|
9
|
+
Stores pre-rendered glyphs to improve text rendering performance.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
def __init__(self, max_glyphs=256):
|
|
13
|
+
"""Initialize glyph cache.
|
|
14
|
+
|
|
15
|
+
Args:
|
|
16
|
+
max_glyphs: Maximum number of glyphs to cache
|
|
17
|
+
"""
|
|
18
|
+
self.max_glyphs = max_glyphs
|
|
19
|
+
self._cache = {}
|
|
20
|
+
self._access_order = []
|
|
21
|
+
|
|
22
|
+
def get(self, char_code):
|
|
23
|
+
"""Get a cached glyph.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
char_code: Character code to get
|
|
27
|
+
|
|
28
|
+
Returns:
|
|
29
|
+
Cached glyph info dictionary or None if not cached
|
|
30
|
+
"""
|
|
31
|
+
if char_code in self._cache:
|
|
32
|
+
# Move to end of access order (most recently used)
|
|
33
|
+
self._access_order.remove(char_code)
|
|
34
|
+
self._access_order.append(char_code)
|
|
35
|
+
return self._cache[char_code]['info']
|
|
36
|
+
return None
|
|
37
|
+
|
|
38
|
+
def put(self, char_code, glyph_bitmap, glyph_info):
|
|
39
|
+
"""Add a glyph to the cache.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
char_code: Character code
|
|
43
|
+
glyph_bitmap: Bitmap containing glyph pixels
|
|
44
|
+
glyph_info: Dictionary with glyph metrics
|
|
45
|
+
"""
|
|
46
|
+
# Check if we need to evict old glyphs
|
|
47
|
+
if len(self._cache) >= self.max_glyphs and char_code not in self._cache:
|
|
48
|
+
# Evict least recently used
|
|
49
|
+
old_char = self._access_order.pop(0)
|
|
50
|
+
del self._cache[old_char]
|
|
51
|
+
|
|
52
|
+
# Add to cache
|
|
53
|
+
self._cache[char_code] = {
|
|
54
|
+
'bitmap': glyph_bitmap,
|
|
55
|
+
'info': glyph_info
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
# Update access order
|
|
59
|
+
if char_code in self._access_order:
|
|
60
|
+
self._access_order.remove(char_code)
|
|
61
|
+
self._access_order.append(char_code)
|
|
62
|
+
|
|
63
|
+
def clear(self):
|
|
64
|
+
"""Clear all cached glyphs."""
|
|
65
|
+
self._cache.clear()
|
|
66
|
+
self._access_order.clear()
|
|
67
|
+
|
|
68
|
+
def __len__(self):
|
|
69
|
+
"""Get number of cached glyphs."""
|
|
70
|
+
return len(self._cache)
|
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
"""Text label implementation for displayio."""
|
|
2
|
+
|
|
3
|
+
from ..displayio import Group, Bitmap, Palette, TileGrid
|
|
4
|
+
from ..core.color_utils import rgb888_to_rgb565
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Label(Group):
|
|
8
|
+
"""A label displaying a string of text.
|
|
9
|
+
|
|
10
|
+
Compatible with CircuitPython's adafruit_display_text.label.Label API.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
def __init__(self, font, *, text="", color=0xFFFFFF, background_color=None,
|
|
14
|
+
line_spacing=1.25, background_tight=False, padding_top=0,
|
|
15
|
+
padding_bottom=0, padding_left=0, padding_right=0,
|
|
16
|
+
anchor_point=None, anchored_position=None, scale=1,
|
|
17
|
+
base_alignment=False, tab_replacement=(4, " "), label_direction="LTR",
|
|
18
|
+
**kwargs):
|
|
19
|
+
"""Initialize Label.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
font: Font to use for text
|
|
23
|
+
text: Initial text to display
|
|
24
|
+
color: Text color (RGB565 or RGB888)
|
|
25
|
+
background_color: Background color (None for transparent)
|
|
26
|
+
line_spacing: Line spacing multiplier
|
|
27
|
+
background_tight: Whether background fits text tightly
|
|
28
|
+
padding_top: Top padding in pixels
|
|
29
|
+
padding_bottom: Bottom padding in pixels
|
|
30
|
+
padding_left: Left padding in pixels
|
|
31
|
+
padding_right: Right padding in pixels
|
|
32
|
+
anchor_point: Anchor point as (x, y) normalized values
|
|
33
|
+
anchored_position: Position for anchor point
|
|
34
|
+
scale: Scale factor for text
|
|
35
|
+
base_alignment: Whether to align text baselines
|
|
36
|
+
tab_replacement: Tuple of (spaces, character) for tab replacement
|
|
37
|
+
label_direction: Text direction ("LTR" or "RTL")
|
|
38
|
+
**kwargs: Additional keyword arguments
|
|
39
|
+
"""
|
|
40
|
+
# Store the font first
|
|
41
|
+
self.font = font
|
|
42
|
+
|
|
43
|
+
# Initialize parent with all parameters
|
|
44
|
+
super().__init__(scale=scale, **kwargs)
|
|
45
|
+
self._text = ""
|
|
46
|
+
if isinstance(color, int):
|
|
47
|
+
self._color = color
|
|
48
|
+
else:
|
|
49
|
+
# Convert tuple/list to RGB565
|
|
50
|
+
if hasattr(color, '__iter__'):
|
|
51
|
+
self._color = rgb888_to_rgb565(*color)
|
|
52
|
+
else:
|
|
53
|
+
self._color = color
|
|
54
|
+
self._background_color = background_color
|
|
55
|
+
self.line_spacing = line_spacing
|
|
56
|
+
self.background_tight = background_tight
|
|
57
|
+
self.padding_top = padding_top
|
|
58
|
+
self.padding_bottom = padding_bottom
|
|
59
|
+
self.padding_left = padding_left
|
|
60
|
+
self.padding_right = padding_right
|
|
61
|
+
self._anchor_point = anchor_point
|
|
62
|
+
self._anchored_position = anchored_position
|
|
63
|
+
self.base_alignment = base_alignment
|
|
64
|
+
self.tab_replacement = tab_replacement
|
|
65
|
+
self.label_direction = label_direction
|
|
66
|
+
|
|
67
|
+
# Internal display objects
|
|
68
|
+
self._bitmap = None
|
|
69
|
+
self._palette = None
|
|
70
|
+
self._tilegrid = None
|
|
71
|
+
|
|
72
|
+
# Set initial text
|
|
73
|
+
if text:
|
|
74
|
+
self.text = text
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def text(self):
|
|
78
|
+
"""Get the current text."""
|
|
79
|
+
return self._text
|
|
80
|
+
|
|
81
|
+
@text.setter
|
|
82
|
+
def text(self, new_text):
|
|
83
|
+
"""Set the text to display.
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
new_text: New text string
|
|
87
|
+
"""
|
|
88
|
+
if new_text == self._text:
|
|
89
|
+
return
|
|
90
|
+
|
|
91
|
+
self._text = str(new_text)
|
|
92
|
+
self._update_text()
|
|
93
|
+
|
|
94
|
+
@property
|
|
95
|
+
def color(self):
|
|
96
|
+
"""Get the text color."""
|
|
97
|
+
return self._color
|
|
98
|
+
|
|
99
|
+
@color.setter
|
|
100
|
+
def color(self, new_color):
|
|
101
|
+
"""Set the text color.
|
|
102
|
+
|
|
103
|
+
Args:
|
|
104
|
+
new_color: New color value
|
|
105
|
+
"""
|
|
106
|
+
if isinstance(new_color, int):
|
|
107
|
+
self._color = new_color
|
|
108
|
+
else:
|
|
109
|
+
if hasattr(new_color, '__iter__'):
|
|
110
|
+
self._color = rgb888_to_rgb565(*new_color)
|
|
111
|
+
else:
|
|
112
|
+
self._color = new_color
|
|
113
|
+
if self._palette:
|
|
114
|
+
self._palette[1] = self._color
|
|
115
|
+
|
|
116
|
+
@property
|
|
117
|
+
def background_color(self):
|
|
118
|
+
"""Get the background color."""
|
|
119
|
+
return self._background_color
|
|
120
|
+
|
|
121
|
+
@background_color.setter
|
|
122
|
+
def background_color(self, new_color):
|
|
123
|
+
"""Set the background color.
|
|
124
|
+
|
|
125
|
+
Args:
|
|
126
|
+
new_color: New color value or None for transparent
|
|
127
|
+
"""
|
|
128
|
+
self._background_color = new_color
|
|
129
|
+
if self._palette and new_color is not None:
|
|
130
|
+
if isinstance(new_color, int):
|
|
131
|
+
self._palette[0] = new_color
|
|
132
|
+
else:
|
|
133
|
+
self._palette[0] = rgb888_to_rgb565(*new_color)
|
|
134
|
+
self._update_text()
|
|
135
|
+
|
|
136
|
+
@property
|
|
137
|
+
def anchor_point(self):
|
|
138
|
+
"""Get the anchor point."""
|
|
139
|
+
return self._anchor_point
|
|
140
|
+
|
|
141
|
+
@anchor_point.setter
|
|
142
|
+
def anchor_point(self, new_anchor):
|
|
143
|
+
"""Set the anchor point.
|
|
144
|
+
|
|
145
|
+
Args:
|
|
146
|
+
new_anchor: (x, y) tuple with normalized coordinates (0.0-1.0)
|
|
147
|
+
"""
|
|
148
|
+
self._anchor_point = new_anchor
|
|
149
|
+
self._update_position()
|
|
150
|
+
|
|
151
|
+
@property
|
|
152
|
+
def anchored_position(self):
|
|
153
|
+
"""Get the anchored position."""
|
|
154
|
+
return self._anchored_position
|
|
155
|
+
|
|
156
|
+
@anchored_position.setter
|
|
157
|
+
def anchored_position(self, new_position):
|
|
158
|
+
"""Set the anchored position.
|
|
159
|
+
|
|
160
|
+
Args:
|
|
161
|
+
new_position: (x, y) position for the anchor point
|
|
162
|
+
"""
|
|
163
|
+
self._anchored_position = new_position
|
|
164
|
+
self._update_position()
|
|
165
|
+
|
|
166
|
+
def _update_text(self):
|
|
167
|
+
"""Update the text bitmap and display objects."""
|
|
168
|
+
# Replace tabs
|
|
169
|
+
display_text = self._text
|
|
170
|
+
if '\t' in display_text:
|
|
171
|
+
tab_spaces, tab_char = self.tab_replacement
|
|
172
|
+
display_text = display_text.replace('\t', tab_char * tab_spaces)
|
|
173
|
+
|
|
174
|
+
# Calculate text dimensions
|
|
175
|
+
lines = display_text.split('\n')
|
|
176
|
+
max_width = 0
|
|
177
|
+
total_height = 0
|
|
178
|
+
|
|
179
|
+
for i, line in enumerate(lines):
|
|
180
|
+
line_width = 0
|
|
181
|
+
line_height = 0
|
|
182
|
+
char_count = 0
|
|
183
|
+
|
|
184
|
+
for char in line:
|
|
185
|
+
glyph = self.font.get_glyph(char)
|
|
186
|
+
if glyph:
|
|
187
|
+
line_width += glyph['dx']
|
|
188
|
+
line_height = max(line_height, glyph['height'])
|
|
189
|
+
char_count += 1
|
|
190
|
+
|
|
191
|
+
# Character spacing is now handled by DWIDTH from BDF file
|
|
192
|
+
|
|
193
|
+
max_width = max(max_width, line_width)
|
|
194
|
+
if i == 0:
|
|
195
|
+
total_height = self.font.height # Use font height, not glyph height
|
|
196
|
+
else:
|
|
197
|
+
total_height += int(self.font.height * self.line_spacing)
|
|
198
|
+
|
|
199
|
+
# Add padding
|
|
200
|
+
bitmap_width = max_width + self.padding_left + self.padding_right
|
|
201
|
+
# Calculate bitmap height for proper baseline alignment
|
|
202
|
+
# Need space for: padding_top + font.ascent (above baseline) + font.descent (below baseline) + padding_bottom
|
|
203
|
+
# For multiline text, add space for additional lines
|
|
204
|
+
bitmap_height = self.padding_top + self.font.ascent + self.padding_bottom
|
|
205
|
+
if len(lines) > 1:
|
|
206
|
+
# Add space for additional lines
|
|
207
|
+
bitmap_height += (len(lines) - 1) * int(self.font.height * self.line_spacing)
|
|
208
|
+
# Add space for descenders (font.descent is the maximum depth below baseline)
|
|
209
|
+
bitmap_height += self.font.descent
|
|
210
|
+
|
|
211
|
+
# Ensure minimum size
|
|
212
|
+
bitmap_width = max(1, bitmap_width)
|
|
213
|
+
bitmap_height = max(1, bitmap_height)
|
|
214
|
+
|
|
215
|
+
# Hardware-realism hook: rebuilding the glyph bitmap is the dominant
|
|
216
|
+
# per-frame cost on CircuitPython. No-op unless a PerformanceManager is
|
|
217
|
+
# active (opt-in via SimulatorDisplay(hardware_timing=True)).
|
|
218
|
+
from ..core import performance_manager as _pm_mod
|
|
219
|
+
_pm = _pm_mod.get_active()
|
|
220
|
+
if _pm is not None and _pm.enabled:
|
|
221
|
+
_pm.account_bitmap_rebuild(bitmap_width, bitmap_height)
|
|
222
|
+
|
|
223
|
+
# Create new bitmap and palette
|
|
224
|
+
self._bitmap = Bitmap(bitmap_width, bitmap_height, 2)
|
|
225
|
+
self._palette = Palette(2)
|
|
226
|
+
|
|
227
|
+
# Set colors
|
|
228
|
+
if self._background_color is None:
|
|
229
|
+
self._palette.make_transparent(0)
|
|
230
|
+
else:
|
|
231
|
+
self._palette[0] = self._background_color
|
|
232
|
+
self._palette[1] = self._color
|
|
233
|
+
|
|
234
|
+
# Fill background
|
|
235
|
+
if self._background_color is not None:
|
|
236
|
+
self._bitmap.fill(0)
|
|
237
|
+
|
|
238
|
+
# Render text with proper baseline alignment
|
|
239
|
+
# The baseline should be positioned font.ascent pixels from the top of the text area
|
|
240
|
+
# This leaves room for ascenders above and descenders below the baseline
|
|
241
|
+
baseline_y = self.padding_top + self.font.ascent
|
|
242
|
+
|
|
243
|
+
for line_num, line in enumerate(lines):
|
|
244
|
+
x_offset = self.padding_left
|
|
245
|
+
|
|
246
|
+
# Handle RTL text direction
|
|
247
|
+
if self.label_direction == "RTL":
|
|
248
|
+
line = line[::-1]
|
|
249
|
+
|
|
250
|
+
for char in line:
|
|
251
|
+
glyph = self.font.get_glyph(char)
|
|
252
|
+
if not glyph:
|
|
253
|
+
continue
|
|
254
|
+
|
|
255
|
+
# Get glyph bitmap
|
|
256
|
+
glyph_bitmap = glyph['bitmap']
|
|
257
|
+
glyph_x_offset = glyph.get('x_offset', 0)
|
|
258
|
+
glyph_y_offset = glyph.get('y_offset', 0)
|
|
259
|
+
|
|
260
|
+
# Calculate position with proper baseline alignment
|
|
261
|
+
# All characters are positioned relative to the common baseline
|
|
262
|
+
draw_x = x_offset + glyph_x_offset
|
|
263
|
+
# Position character so its bottom edge aligns with baseline + y_offset
|
|
264
|
+
# For y_offset=0: character bottom is at baseline
|
|
265
|
+
# For y_offset=-2: character bottom is 2 pixels below baseline (descender)
|
|
266
|
+
draw_y = baseline_y - glyph['height'] - glyph_y_offset
|
|
267
|
+
|
|
268
|
+
# Copy glyph pixels to main bitmap (skip if no bitmap, e.g., spaces)
|
|
269
|
+
if glyph_bitmap is not None:
|
|
270
|
+
for gy in range(glyph_bitmap.height):
|
|
271
|
+
for gx in range(glyph_bitmap.width):
|
|
272
|
+
if glyph_bitmap[gx, gy] > 0:
|
|
273
|
+
bx = draw_x + gx
|
|
274
|
+
by = draw_y + gy
|
|
275
|
+
if 0 <= bx < bitmap_width and 0 <= by < bitmap_height:
|
|
276
|
+
self._bitmap[bx, by] = 1
|
|
277
|
+
|
|
278
|
+
# Use DWIDTH from BDF file for proper kerning
|
|
279
|
+
x_offset += glyph['dx']
|
|
280
|
+
|
|
281
|
+
# Move to next line
|
|
282
|
+
if line_num < len(lines) - 1:
|
|
283
|
+
baseline_y += int(self.font.height * self.line_spacing)
|
|
284
|
+
|
|
285
|
+
# Update or create tilegrid
|
|
286
|
+
if self._tilegrid:
|
|
287
|
+
self.remove(self._tilegrid)
|
|
288
|
+
|
|
289
|
+
self._tilegrid = TileGrid(self._bitmap, pixel_shader=self._palette)
|
|
290
|
+
|
|
291
|
+
# Apply baseline adjustment to TileGrid position
|
|
292
|
+
# In CircuitPython, label.y represents the baseline position
|
|
293
|
+
# We need to offset the TileGrid to account for this
|
|
294
|
+
# The adjustment needs to account for the actual character positioning
|
|
295
|
+
# For a font with ascent=9, setting y=15 should put the baseline at row 15
|
|
296
|
+
# This means the top of the bitmap (which includes ascent space) should be at y=15-9=6
|
|
297
|
+
# But we also need to account for the actual glyph positioning within the bitmap
|
|
298
|
+
baseline_offset = self.font.ascent + self.padding_top - 4 # Fine-tuned for CircuitPython compatibility
|
|
299
|
+
self._tilegrid.y = -baseline_offset
|
|
300
|
+
|
|
301
|
+
self.append(self._tilegrid)
|
|
302
|
+
|
|
303
|
+
# Update position if anchored
|
|
304
|
+
self._update_position()
|
|
305
|
+
|
|
306
|
+
def _update_position(self):
|
|
307
|
+
"""Update position based on anchor point."""
|
|
308
|
+
if self._anchor_point and self._anchored_position and self._bitmap:
|
|
309
|
+
anchor_x, anchor_y = self._anchor_point
|
|
310
|
+
pos_x, pos_y = self._anchored_position
|
|
311
|
+
|
|
312
|
+
# Calculate actual position
|
|
313
|
+
self.x = int(pos_x - self._bitmap.width * anchor_x)
|
|
314
|
+
self.y = int(pos_y - self._bitmap.height * anchor_y)
|
|
315
|
+
|
|
316
|
+
@property
|
|
317
|
+
def bounding_box(self):
|
|
318
|
+
"""Get the bounding box of the label.
|
|
319
|
+
|
|
320
|
+
Returns:
|
|
321
|
+
Tuple of (x, y, width, height)
|
|
322
|
+
"""
|
|
323
|
+
if self._bitmap:
|
|
324
|
+
return (self.x, self.y, self._bitmap.width, self._bitmap.height)
|
|
325
|
+
return (self.x, self.y, 0, 0)
|
|
326
|
+
|
|
327
|
+
@property
|
|
328
|
+
def width(self):
|
|
329
|
+
"""Get the width of the label."""
|
|
330
|
+
return self._bitmap.width if self._bitmap else 0
|
|
331
|
+
|
|
332
|
+
@property
|
|
333
|
+
def height(self):
|
|
334
|
+
"""Get the height of the label."""
|
|
335
|
+
return self._bitmap.height if self._bitmap else 0
|
|
336
|
+
|