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,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,5 @@
1
+ """Adafruit display text library compatibility."""
2
+
3
+ from .label import Label
4
+
5
+ __all__ = ['Label']
@@ -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
+