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,158 @@
1
+ # Copyright (c) 2024-2026 Michael Winslow Czeiszperger
2
+ """CircuitPython displayio.Bitmap equivalent."""
3
+
4
+ import numpy as np
5
+
6
+
7
+ class Bitmap:
8
+ """Bitmap for pixel data storage.
9
+
10
+ Stores pixel data as palette indices. Each pixel is an index
11
+ into a Palette object that defines the actual colors.
12
+ Compatible with CircuitPython's displayio.Bitmap API.
13
+ """
14
+
15
+ def __init__(self, width, height, value_count):
16
+ """Initialize bitmap with specified dimensions.
17
+
18
+ Args:
19
+ width: Bitmap width in pixels
20
+ height: Bitmap height in pixels
21
+ value_count: Number of different values each pixel can have
22
+ """
23
+ self.width = width
24
+ self.height = height
25
+ self.value_count = value_count
26
+
27
+ # Determine data type based on value count
28
+ if value_count <= 2:
29
+ self._bits_per_value = 1
30
+ dtype = np.uint8
31
+ elif value_count <= 4:
32
+ self._bits_per_value = 2
33
+ dtype = np.uint8
34
+ elif value_count <= 16:
35
+ self._bits_per_value = 4
36
+ dtype = np.uint8
37
+ elif value_count <= 256:
38
+ self._bits_per_value = 8
39
+ dtype = np.uint8
40
+ else:
41
+ self._bits_per_value = 16
42
+ dtype = np.uint16
43
+
44
+ # Create buffer to store pixel values
45
+ self._buffer = np.zeros((height, width), dtype=dtype)
46
+
47
+ def __setitem__(self, index, value):
48
+ """Set pixel value.
49
+
50
+ Args:
51
+ index: (x, y) tuple or flat index
52
+ value: Palette index value
53
+ """
54
+ if isinstance(index, tuple):
55
+ x, y = index
56
+ if not (0 <= x < self.width and 0 <= y < self.height):
57
+ raise IndexError(f"Pixel index ({x}, {y}) out of bounds")
58
+ else:
59
+ # Convert flat index to x, y
60
+ y = index // self.width
61
+ x = index % self.width
62
+ if not (0 <= index < self.width * self.height):
63
+ raise IndexError(f"Pixel index {index} out of bounds")
64
+
65
+ if not 0 <= value < self.value_count:
66
+ raise ValueError(f"Pixel value {value} out of range")
67
+
68
+ self._buffer[y, x] = value
69
+
70
+ def __getitem__(self, index):
71
+ """Get pixel value.
72
+
73
+ Args:
74
+ index: (x, y) tuple or flat index
75
+
76
+ Returns:
77
+ Palette index value
78
+ """
79
+ if isinstance(index, tuple):
80
+ x, y = index
81
+ if not (0 <= x < self.width and 0 <= y < self.height):
82
+ raise IndexError(f"Pixel index ({x}, {y}) out of bounds")
83
+ else:
84
+ # Convert flat index to x, y
85
+ y = index // self.width
86
+ x = index % self.width
87
+ if not (0 <= index < self.width * self.height):
88
+ raise IndexError(f"Pixel index {index} out of bounds")
89
+
90
+ return int(self._buffer[y, x])
91
+
92
+ def fill(self, value):
93
+ """Fill entire bitmap with a single value.
94
+
95
+ Args:
96
+ value: Palette index value to fill with
97
+ """
98
+ if not 0 <= value < self.value_count:
99
+ raise ValueError(f"Pixel value {value} out of range")
100
+
101
+ self._buffer.fill(value)
102
+
103
+ def blit(self, x, y, source_bitmap, *, x1=0, y1=0, x2=None, y2=None, skip_index=None):
104
+ """Copy pixels from another bitmap.
105
+
106
+ Args:
107
+ x: Destination X coordinate
108
+ y: Destination Y coordinate
109
+ source_bitmap: Source Bitmap to copy from
110
+ x1: Source left edge (default 0)
111
+ y1: Source top edge (default 0)
112
+ x2: Source right edge (default source width)
113
+ y2: Source bottom edge (default source height)
114
+ skip_index: Palette index to treat as transparent
115
+ """
116
+ # Set default source bounds
117
+ if x2 is None:
118
+ x2 = source_bitmap.width
119
+ if y2 is None:
120
+ y2 = source_bitmap.height
121
+
122
+ # Calculate copy region
123
+ src_width = x2 - x1
124
+ src_height = y2 - y1
125
+
126
+ # Clip to destination bounds
127
+ copy_width = min(src_width, self.width - x)
128
+ copy_height = min(src_height, self.height - y)
129
+
130
+ if copy_width <= 0 or copy_height <= 0:
131
+ return
132
+
133
+ # Clip source region
134
+ if x < 0:
135
+ x1 -= x
136
+ copy_width += x
137
+ x = 0
138
+ if y < 0:
139
+ y1 -= y
140
+ copy_height += y
141
+ y = 0
142
+
143
+ # Copy pixel data
144
+ src_data = source_bitmap._buffer[y1:y1 + copy_height, x1:x1 + copy_width]
145
+
146
+ if skip_index is not None:
147
+ # Copy only non-transparent pixels
148
+ mask = src_data != skip_index
149
+ dst_slice = self._buffer[y:y + copy_height, x:x + copy_width]
150
+ dst_slice[mask] = src_data[mask]
151
+ else:
152
+ # Copy all pixels
153
+ self._buffer[y:y + copy_height, x:x + copy_width] = src_data
154
+
155
+ @property
156
+ def bits_per_value(self):
157
+ """Get the number of bits per pixel value."""
158
+ return self._bits_per_value
@@ -0,0 +1,195 @@
1
+ # Copyright (c) 2024-2026 Michael Winslow Czeiszperger
2
+ """CircuitPython displayio.Display equivalent."""
3
+
4
+ from ..core.led_matrix import LEDMatrix
5
+ from ..core.color_utils import rgb565_to_rgb888
6
+
7
+
8
+ class Display:
9
+ """Display manages the screen output.
10
+
11
+ This class provides the main interface for showing groups and
12
+ managing display properties like brightness and rotation.
13
+ Compatible with CircuitPython's displayio.Display API.
14
+ """
15
+
16
+ def __init__(self, display_bus, *, width, height, rotation=0,
17
+ color_depth=16, auto_refresh=True, backlight_pin=None,
18
+ brightness=1.0, auto_brightness=False, backlight_on_high=True,
19
+ native_frames_per_second=60):
20
+ """Initialize Display.
21
+
22
+ Args:
23
+ display_bus: Display bus interface (e.g., FourWire)
24
+ width: Display width in pixels
25
+ height: Display height in pixels
26
+ rotation: Display rotation in degrees (0, 90, 180, 270)
27
+ color_depth: Color depth in bits (default 16)
28
+ auto_refresh: Whether to automatically refresh display
29
+ backlight_pin: Pin for backlight control (ignored in simulator)
30
+ brightness: Initial brightness (0.0 to 1.0)
31
+ auto_brightness: Whether to automatically adjust brightness
32
+ backlight_on_high: Whether backlight is on when pin is high
33
+ native_frames_per_second: Native refresh rate
34
+ """
35
+ self.display_bus = display_bus
36
+ self.width = width
37
+ self.height = height
38
+ self.rotation = rotation
39
+ self.color_depth = color_depth
40
+ self.auto_refresh = auto_refresh
41
+ self._brightness = brightness
42
+ self.auto_brightness = auto_brightness
43
+ self.native_frames_per_second = native_frames_per_second
44
+
45
+ # Current root group
46
+ self.root_group = None
47
+
48
+ # Create LED matrix for rendering
49
+ self._matrix = LEDMatrix(width, height)
50
+ self._matrix.set_brightness(brightness)
51
+
52
+ @property
53
+ def brightness(self):
54
+ """Get display brightness."""
55
+ return self._brightness
56
+
57
+ @brightness.setter
58
+ def brightness(self, value):
59
+ """Set display brightness.
60
+
61
+ Args:
62
+ value: Brightness from 0.0 to 1.0
63
+ """
64
+ self._brightness = max(0.0, min(1.0, value))
65
+ self._matrix.set_brightness(self._brightness)
66
+
67
+ def show(self, group_or_tilegrid):
68
+ """Show a Group or TileGrid on the display.
69
+
70
+ Args:
71
+ group_or_tilegrid: Group or TileGrid to display
72
+ """
73
+ self.root_group = group_or_tilegrid
74
+ if self.auto_refresh:
75
+ self.refresh()
76
+
77
+ def refresh(self, *, target_frames_per_second=None, minimum_frames_per_second=1):
78
+ """Manually refresh the display.
79
+
80
+ Args:
81
+ target_frames_per_second: Target refresh rate
82
+ minimum_frames_per_second: Minimum acceptable refresh rate
83
+ """
84
+ if self.root_group is None:
85
+ return
86
+
87
+ # Clear the matrix
88
+ self._matrix.clear()
89
+
90
+ # Render the root group
91
+ self._render_group(self.root_group, 0, 0, 1)
92
+
93
+ # Update the display
94
+ self._matrix.render()
95
+
96
+ def _render_group(self, group, offset_x, offset_y, scale):
97
+ """Recursively render a group and its contents.
98
+
99
+ Args:
100
+ group: Group to render
101
+ offset_x: X offset from parent
102
+ offset_y: Y offset from parent
103
+ scale: Cumulative scale factor
104
+ """
105
+ if hasattr(group, 'hidden') and group.hidden:
106
+ return
107
+
108
+ # Calculate group position
109
+ group_x = offset_x + (group.x if hasattr(group, 'x') else 0) * scale
110
+ group_y = offset_y + (group.y if hasattr(group, 'y') else 0) * scale
111
+ group_scale = scale * (group.scale if hasattr(group, 'scale') else 1)
112
+
113
+ # Check if this is a Group with items
114
+ if hasattr(group, '_items'):
115
+ # Render all items in the group
116
+ for item in group._items:
117
+ self._render_group(item, group_x, group_y, group_scale)
118
+ # Check if this is a TileGrid
119
+ elif hasattr(group, 'bitmap') and hasattr(group, 'pixel_shader'):
120
+ self._render_tilegrid(group, group_x, group_y, group_scale)
121
+ # Check if this is a Label (from adafruit_display_text)
122
+ elif hasattr(group, '_bitmap') and hasattr(group, 'color'):
123
+ self._render_label(group, group_x, group_y, group_scale)
124
+
125
+ def _render_tilegrid(self, tilegrid, x, y, scale):
126
+ """Render a TileGrid to the display.
127
+
128
+ Args:
129
+ tilegrid: TileGrid to render
130
+ x: X position
131
+ y: Y position
132
+ scale: Scale factor
133
+ """
134
+ if tilegrid.hidden:
135
+ return
136
+
137
+ # Get bitmap and palette
138
+ bitmap = tilegrid.bitmap
139
+ palette = tilegrid.pixel_shader
140
+
141
+ # Render each tile
142
+ for tile_y in range(tilegrid.height):
143
+ for tile_x in range(tilegrid.width):
144
+ tile_index = tilegrid[tile_x, tile_y]
145
+
146
+ # Calculate tile source position
147
+ src_tile_x = (tile_index % tilegrid._tiles_per_row) * tilegrid.tile_width
148
+ src_tile_y = (tile_index // tilegrid._tiles_per_row) * tilegrid.tile_height
149
+
150
+ # Calculate destination position
151
+ dst_x = x + tile_x * tilegrid.tile_width * scale
152
+ dst_y = y + tile_y * tilegrid.tile_height * scale
153
+
154
+ # Render tile pixels
155
+ for py in range(tilegrid.tile_height):
156
+ for px in range(tilegrid.tile_width):
157
+ # Get pixel from bitmap
158
+ pixel_value = bitmap[src_tile_x + px, src_tile_y + py]
159
+
160
+ # Skip transparent pixels
161
+ if palette.is_transparent(pixel_value):
162
+ continue
163
+
164
+ # Get color from palette
165
+ color = palette.get_rgb888(pixel_value)
166
+
167
+ # Apply scaling
168
+ for sy in range(scale):
169
+ for sx in range(scale):
170
+ pixel_x = int(dst_x + px * scale + sx)
171
+ pixel_y = int(dst_y + py * scale + sy)
172
+
173
+ # Set pixel if within bounds
174
+ if 0 <= pixel_x < self.width and 0 <= pixel_y < self.height:
175
+ self._matrix.set_pixel(pixel_x, pixel_y, color)
176
+
177
+ def _render_label(self, label, x, y, scale):
178
+ """Render a Label to the display.
179
+
180
+ Args:
181
+ label: Label to render
182
+ x: X position
183
+ y: Y position
184
+ scale: Scale factor
185
+ """
186
+ # Labels are Groups that contain a TileGrid, so render them as groups
187
+ self._render_group(label, x, y, scale)
188
+
189
+ def get_matrix(self):
190
+ """Get the underlying LED matrix.
191
+
192
+ Returns:
193
+ LEDMatrix instance
194
+ """
195
+ return self._matrix
@@ -0,0 +1,54 @@
1
+ # Copyright (c) 2024-2026 Michael Winslow Czeiszperger
2
+ """CircuitPython displayio.FourWire equivalent."""
3
+
4
+
5
+ class FourWire:
6
+ """FourWire interface for display communication.
7
+
8
+ This is a stub implementation for API compatibility.
9
+ The simulator doesn't need actual SPI communication.
10
+ """
11
+
12
+ def __init__(self, spi_bus, *, command=None, chip_select=None, reset=None,
13
+ baudrate=24000000, polarity=0, phase=0):
14
+ """Initialize FourWire interface.
15
+
16
+ Args:
17
+ spi_bus: SPI bus object (ignored in simulator)
18
+ command: Command pin (ignored in simulator)
19
+ chip_select: Chip select pin (ignored in simulator)
20
+ reset: Reset pin (ignored in simulator)
21
+ baudrate: SPI baudrate (ignored in simulator)
22
+ polarity: SPI polarity (ignored in simulator)
23
+ phase: SPI phase (ignored in simulator)
24
+ """
25
+ self.spi_bus = spi_bus
26
+ self.command = command
27
+ self.chip_select = chip_select
28
+ self.reset = reset
29
+ self.baudrate = baudrate
30
+ self.polarity = polarity
31
+ self.phase = phase
32
+ self._locked = False
33
+
34
+ def reset(self):
35
+ """Reset the display (no-op in simulator)."""
36
+ pass
37
+
38
+ def send(self, command, data):
39
+ """Send command and data (no-op in simulator).
40
+
41
+ Args:
42
+ command: Command byte
43
+ data: Data bytes
44
+ """
45
+ pass
46
+
47
+ def __enter__(self):
48
+ """Context manager entry."""
49
+ self._locked = True
50
+ return self
51
+
52
+ def __exit__(self, exc_type, exc_val, exc_tb):
53
+ """Context manager exit."""
54
+ self._locked = False
@@ -0,0 +1,125 @@
1
+ # Copyright (c) 2024-2026 Michael Winslow Czeiszperger
2
+ """CircuitPython displayio.Group equivalent."""
3
+
4
+
5
+ class Group:
6
+ """Group organizes display elements into a tree structure.
7
+
8
+ Groups can contain other groups, creating a display hierarchy.
9
+ Elements in a group can be positioned, scaled, and hidden.
10
+ Compatible with CircuitPython's displayio.Group API.
11
+ """
12
+
13
+ def __init__(self, *, scale=1, x=0, y=0):
14
+ """Initialize Group.
15
+
16
+ Args:
17
+ scale: Integer scale factor (default 1)
18
+ x: X position of group (default 0)
19
+ y: Y position of group (default 0)
20
+ """
21
+ self.scale = scale
22
+ self.x = x
23
+ self.y = y
24
+ self.hidden = False
25
+ self._items = []
26
+
27
+ def append(self, item):
28
+ """Add an item to the group.
29
+
30
+ Args:
31
+ item: Display object (Group, TileGrid, etc.) to add
32
+ """
33
+ if item in self._items:
34
+ raise ValueError("Item already in group")
35
+ self._items.append(item)
36
+
37
+ def insert(self, index, item):
38
+ """Insert an item at a specific position.
39
+
40
+ Args:
41
+ index: Position to insert at
42
+ item: Display object to insert
43
+ """
44
+ if item in self._items:
45
+ raise ValueError("Item already in group")
46
+ self._items.insert(index, item)
47
+
48
+ def remove(self, item):
49
+ """Remove an item from the group.
50
+
51
+ Args:
52
+ item: Display object to remove
53
+ """
54
+ self._items.remove(item)
55
+
56
+ def pop(self, index=-1):
57
+ """Remove and return an item.
58
+
59
+ Args:
60
+ index: Index of item to remove (default -1 for last item)
61
+
62
+ Returns:
63
+ Removed display object
64
+ """
65
+ return self._items.pop(index)
66
+
67
+ def index(self, item):
68
+ """Find the index of an item.
69
+
70
+ Args:
71
+ item: Display object to find
72
+
73
+ Returns:
74
+ Index of item in group
75
+ """
76
+ return self._items.index(item)
77
+
78
+ def __len__(self):
79
+ """Get number of items in group."""
80
+ return len(self._items)
81
+
82
+ def __getitem__(self, index):
83
+ """Get item at index.
84
+
85
+ Args:
86
+ index: Index of item to get
87
+
88
+ Returns:
89
+ Display object at index
90
+ """
91
+ return self._items[index]
92
+
93
+ def __setitem__(self, index, item):
94
+ """Replace item at index.
95
+
96
+ Args:
97
+ index: Index to replace at
98
+ item: New display object
99
+ """
100
+ # Remove old item if it exists elsewhere in group
101
+ if item in self._items:
102
+ self._items.remove(item)
103
+ self._items[index] = item
104
+
105
+ def __iter__(self):
106
+ """Iterate over items in group."""
107
+ return iter(self._items)
108
+
109
+ def sort(self, key=None):
110
+ """Sort items in group.
111
+
112
+ Args:
113
+ key: Optional key function for sorting
114
+ """
115
+ self._items.sort(key=key)
116
+
117
+ @property
118
+ def auto_write(self):
119
+ """Get auto_write setting (always True for compatibility)."""
120
+ return True
121
+
122
+ @auto_write.setter
123
+ def auto_write(self, value):
124
+ """Set auto_write (no-op for compatibility)."""
125
+ pass # No-op for API compatibility
@@ -0,0 +1,109 @@
1
+ # Copyright (c) 2024-2026 Michael Winslow Czeiszperger
2
+ """CircuitPython displayio.OnDiskBitmap equivalent."""
3
+
4
+ from PIL import Image
5
+ import numpy as np
6
+ from .bitmap import Bitmap
7
+ from .palette import Palette
8
+
9
+
10
+ class OnDiskBitmap:
11
+ """Load bitmap images from disk.
12
+
13
+ Supports loading BMP and other image formats from disk.
14
+ Creates a bitmap and palette from the loaded image.
15
+ Compatible with CircuitPython's displayio.OnDiskBitmap API.
16
+ """
17
+
18
+ def __init__(self, file_path):
19
+ """Initialize OnDiskBitmap from file.
20
+
21
+ Args:
22
+ file_path: Path to image file
23
+ """
24
+ self.file_path = file_path
25
+ self._load_image()
26
+
27
+ def _load_image(self):
28
+ """Load image from disk and create bitmap/palette."""
29
+ # Load image using PIL
30
+ img = Image.open(self.file_path)
31
+
32
+ # Convert to RGB if needed
33
+ if img.mode != 'RGB':
34
+ if img.mode == 'P':
35
+ # Palette mode - extract palette
36
+ img = img.convert('RGB')
37
+ elif img.mode == 'RGBA':
38
+ # Convert RGBA to RGB with white background
39
+ background = Image.new('RGB', img.size, (255, 255, 255))
40
+ background.paste(img, mask=img.split()[3])
41
+ img = background
42
+ else:
43
+ img = img.convert('RGB')
44
+
45
+ # Get image dimensions
46
+ self.width = img.width
47
+ self.height = img.height
48
+
49
+ # Convert to numpy array
50
+ img_array = np.array(img)
51
+
52
+ # Create palette with unique colors (up to 256)
53
+ unique_colors = {}
54
+ color_index = 0
55
+
56
+ # Create bitmap
57
+ self.bitmap = Bitmap(self.width, self.height, 256)
58
+
59
+ # Process pixels and build palette
60
+ for y in range(self.height):
61
+ for x in range(self.width):
62
+ color = tuple(img_array[y, x])
63
+
64
+ if color not in unique_colors:
65
+ if color_index < 256:
66
+ unique_colors[color] = color_index
67
+ color_index += 1
68
+ else:
69
+ # Find closest existing color
70
+ closest_idx = self._find_closest_color(color, unique_colors)
71
+ unique_colors[color] = closest_idx
72
+
73
+ self.bitmap[x, y] = unique_colors[color]
74
+
75
+ # Create palette. Hand Palette the plain-int RGB888 tuple and let
76
+ # Palette.__setitem__ do the single, correct RGB565 conversion. (Passing
77
+ # rgb888_to_rgb565() the raw numpy uint8 channels overflowed the dtype —
78
+ # (r & 0xF8) << 8 wrapped to 0 — zeroing red/green so every image went
79
+ # blue; it also double-converted, since __setitem__ re-converts ints.)
80
+ self.palette = Palette(len(unique_colors))
81
+ for color, idx in unique_colors.items():
82
+ self.palette[idx] = (int(color[0]), int(color[1]), int(color[2]))
83
+
84
+ def _find_closest_color(self, target_color, color_dict):
85
+ """Find closest color in palette.
86
+
87
+ Args:
88
+ target_color: Target RGB color tuple
89
+ color_dict: Dictionary of existing colors
90
+
91
+ Returns:
92
+ Index of closest color
93
+ """
94
+ min_distance = float('inf')
95
+ closest_idx = 0
96
+
97
+ for color, idx in color_dict.items():
98
+ # Calculate color distance (simple RGB distance)
99
+ distance = sum((a - b) ** 2 for a, b in zip(target_color, color))
100
+ if distance < min_distance:
101
+ min_distance = distance
102
+ closest_idx = idx
103
+
104
+ return closest_idx
105
+
106
+ @property
107
+ def pixel_shader(self):
108
+ """Get the palette (alias for CircuitPython compatibility)."""
109
+ return self.palette