1 # SPDX-FileCopyrightText: 2020 Melissa LeBlanc-Williams for Adafruit Industries
3 # SPDX-License-Identifier: MIT
7 ================================================================================
11 **Software and Dependencies:**
14 https://github.com/adafruit/Adafruit_Blinka/releases
16 * Author(s): Melissa LeBlanc-Williams
21 from typing import Union, Optional, Tuple
22 from circuitpython_typing import WriteableBuffer
23 from ._bitmap import Bitmap
24 from ._colorconverter import ColorConverter
25 from ._ondiskbitmap import OnDiskBitmap
26 from ._shape import Shape
27 from ._palette import Palette
28 from ._structs import TransformStruct, InputPixelStruct, OutputPixelStruct
29 from ._colorspace import Colorspace
30 from ._area import Area
32 __version__ = "0.0.0+auto.0"
33 __repo__ = "https://github.com/adafruit/Adafruit_Blinka_displayio.git"
37 # pylint: disable=too-many-instance-attributes
38 """Position a grid of tiles sourced from a bitmap and pixel_shader combination. Multiple
39 grids can share bitmaps and pixel shaders.
41 A single tile grid is also known as a Sprite.
46 bitmap: Union[Bitmap, OnDiskBitmap, Shape],
48 pixel_shader: Union[ColorConverter, Palette],
51 tile_width: Optional[int] = None,
52 tile_height: Optional[int] = None,
53 default_tile: int = 0,
57 """Create a TileGrid object. The bitmap is source for 2d pixels. The pixel_shader is
58 used to convert the value and its location to a display native pixel color. This may
59 be a simple color palette lookup, a gradient, a pattern or a color transformer.
61 tile_width and tile_height match the height of the bitmap by default.
63 if not isinstance(bitmap, (Bitmap, OnDiskBitmap, Shape)):
64 raise ValueError("Unsupported Bitmap type")
66 bitmap_width = bitmap.width
67 bitmap_height = bitmap.height
69 if pixel_shader is not None and not isinstance(
70 pixel_shader, (ColorConverter, Palette)
72 raise ValueError("Unsupported Pixel Shader type")
73 self._pixel_shader = pixel_shader
74 if isinstance(self._pixel_shader, ColorConverter):
75 self._pixel_shader._rgba = True # pylint: disable=protected-access
76 self._hidden_tilegrid = False
77 self._hidden_by_parent = False
78 self._rendered_hidden = False
81 self._width_in_tiles = width
82 self._height_in_tiles = height
83 self._transpose_xy = False
88 if tile_width is None or tile_width == 0:
89 tile_width = bitmap_width
90 if tile_height is None or tile_width == 0:
91 tile_height = bitmap_height
92 if tile_width < 1 or tile_height < 1:
93 raise ValueError("Tile width and height must be greater than 0")
94 if bitmap_width % tile_width != 0:
95 raise ValueError("Tile width must exactly divide bitmap width")
96 self._tile_width = tile_width
97 if bitmap_height % tile_height != 0:
98 raise ValueError("Tile height must exactly divide bitmap height")
99 self._tile_height = tile_height
100 if not 0 <= default_tile <= 255:
101 raise ValueError("Default Tile is out of range")
102 self._pixel_width = width * tile_width
103 self._pixel_height = height * tile_height
104 self._tiles = bytearray(
105 (self._width_in_tiles * self._height_in_tiles) * [default_tile]
107 self._in_group = False
108 self._absolute_transform = TransformStruct(0, 0, 1, 1, 1, False, False, False)
109 self._current_area = Area(0, 0, self._pixel_width, self._pixel_height)
110 self._dirty_area = Area(0, 0, 0, 0)
111 self._previous_area = Area(0xFFFF, 0xFFFF, 0xFFFF, 0xFFFF)
113 self._full_change = True
114 self._partial_change = True
115 self._bitmap_width_in_tiles = bitmap_width // tile_width
116 self._tiles_in_bitmap = self._bitmap_width_in_tiles * (
117 bitmap_height // tile_height
120 def _update_transform(self, absolute_transform):
121 """Update the parent transform and child transforms"""
122 self._absolute_transform = absolute_transform
123 if self._absolute_transform is not None:
124 self._update_current_x()
125 self._update_current_y()
127 def _update_current_x(self):
128 if self._transpose_xy:
129 width = self._pixel_height
131 width = self._pixel_width
133 if self._absolute_transform.transpose_xy:
134 self._current_area.y1 = (
135 self._absolute_transform.y + self._absolute_transform.dy * self._x
137 self._current_area.y2 = (
138 self._absolute_transform.y
139 + self._absolute_transform.dy * (self._x + width)
141 if self._current_area.y2 < self._current_area.y1:
142 self._current_area.y1, self._current_area.y2 = (
143 self._current_area.y2,
144 self._current_area.y1,
147 self._current_area.x1 = (
148 self._absolute_transform.x + self._absolute_transform.dx * self._x
150 self._current_area.x2 = (
151 self._absolute_transform.x
152 + self._absolute_transform.dx * (self._x + width)
154 if self._current_area.x2 < self._current_area.x1:
155 self._current_area.x1, self._current_area.x2 = (
156 self._current_area.x2,
157 self._current_area.x1,
160 def _update_current_y(self):
161 if self._transpose_xy:
162 height = self._pixel_width
164 height = self._pixel_height
166 if self._absolute_transform.transpose_xy:
167 self._current_area.x1 = (
168 self._absolute_transform.x + self._absolute_transform.dx * self._y
170 self._current_area.x2 = (
171 self._absolute_transform.x
172 + self._absolute_transform.dx * (self._y + height)
174 if self._current_area.x2 < self._current_area.x1:
175 self._current_area.x1, self._current_area.x2 = (
176 self._current_area.x2,
177 self._current_area.x1,
180 self._current_area.y1 = (
181 self._absolute_transform.y + self._absolute_transform.dy * self._y
183 self._current_area.y2 = (
184 self._absolute_transform.y
185 + self._absolute_transform.dy * (self._y + height)
187 if self._current_area.y2 < self._current_area.y1:
188 self._current_area.y1, self._current_area.y2 = (
189 self._current_area.y2,
190 self._current_area.y1,
193 def _shade(self, pixel_value):
194 if isinstance(self._pixel_shader, Palette):
195 return self._pixel_shader[pixel_value]
196 if isinstance(self._pixel_shader, ColorConverter):
197 return self._pixel_shader.convert(pixel_value)
200 def _apply_palette(self, image):
202 self._pixel_shader._get_palette() # pylint: disable=protected-access
205 def _add_alpha(self, image):
206 alpha = self._bitmap._image.copy().convert( # pylint: disable=protected-access
210 self._pixel_shader._get_alpha_palette() # pylint: disable=protected-access
212 image.putalpha(alpha.convert("L"))
216 colorspace: Colorspace,
218 mask: WriteableBuffer,
219 buffer: WriteableBuffer,
221 """Draw onto the image"""
222 # pylint: disable=too-many-locals,too-many-branches,too-many-statements
224 # If no tiles are present we have no impact
227 if self._hidden_tilegrid or self._hidden_by_parent:
231 if not self._current_area.compute_overlap(area, overlap):
234 if self._bitmap.width <= 0 or self._bitmap.height <= 0:
238 y_stride = area.width()
240 flip_x = self._flip_x
241 flip_y = self._flip_y
242 if self._transpose_xy != self._absolute_transform.transpose_xy:
243 flip_x, flip_y = flip_y, flip_x
246 if (self._absolute_transform.dx < 0) != flip_x:
247 start += (area.x2 - area.x1 - 1) * x_stride
249 if (self._absolute_transform.dy < 0) != flip_y:
250 start += (area.y2 - area.y1 - 1) * y_stride
253 full_coverage = area == overlap
256 area.transform_within(
257 flip_x != (self._absolute_transform.dx < 0),
258 flip_y != (self._absolute_transform.dy < 0),
259 self.transpose_xy != self._absolute_transform.transpose_xy,
265 start_x = transformed.x1 - self._current_area.x1
266 end_x = transformed.x2 - self._current_area.x1
267 start_y = transformed.y1 - self._current_area.y1
268 end_y = transformed.y2 - self._current_area.y1
270 if (self._absolute_transform.dx < 0) != flip_x:
271 x_shift = area.x2 - overlap.x2
273 x_shift = overlap.x1 - area.x1
274 if (self._absolute_transform.dy < 0) != flip_y:
275 y_shift = area.y2 - overlap.y2
277 y_shift = overlap.y1 - area.y1
279 if self._transpose_xy != self._absolute_transform.transpose_xy:
280 x_stride, y_stride = y_stride, x_stride
281 x_shift, y_shift = y_shift, x_shift
283 pixels_per_byte = 8 // colorspace.depth
285 input_pixel = InputPixelStruct()
286 output_pixel = OutputPixelStruct()
287 for input_pixel.y in range(start_y, end_y):
289 start + (input_pixel.y - start_y + y_shift) * y_stride
291 local_y = input_pixel.y // self._absolute_transform.scale
292 for input_pixel.x in range(start_x, end_x):
294 row_start + (input_pixel.x - start_x + x_shift) * x_stride
297 # Check the mask first to see if the pixel has already been set
298 if mask[offset // 32] & (1 << (offset % 32)):
300 local_x = input_pixel.x // self._absolute_transform.scale
302 (local_y // self._tile_height + self._top_left_y)
303 % self._height_in_tiles
304 ) * self._width_in_tiles + (
305 local_x // self._tile_width + self._top_left_x
306 ) % self._width_in_tiles
307 input_pixel.tile = tiles[tile_location]
308 input_pixel.tile_x = (
309 input_pixel.tile % self._bitmap_width_in_tiles
310 ) * self._tile_width + local_x % self._tile_width
311 input_pixel.tile_y = (
312 input_pixel.tile // self._bitmap_width_in_tiles
313 ) * self._tile_height + local_y % self._tile_height
315 input_pixel.pixel = (
316 self._bitmap._get_pixel( # pylint: disable=protected-access
317 input_pixel.tile_x, input_pixel.tile_y
320 output_pixel.opaque = True
322 if self._pixel_shader is None:
323 output_pixel.pixel = input_pixel.pixel
324 elif isinstance(self._pixel_shader, Palette):
325 self._pixel_shader._get_color( # pylint: disable=protected-access
326 colorspace, input_pixel, output_pixel
328 elif isinstance(self._pixel_shader, ColorConverter):
329 self._pixel_shader._convert( # pylint: disable=protected-access
330 colorspace, input_pixel, output_pixel
333 if not output_pixel.opaque:
334 full_coverage = False
336 mask[offset // 32] |= 1 << (offset % 32)
337 if colorspace.depth == 16:
344 elif colorspace.depth == 32:
351 elif colorspace.depth == 8:
352 buffer[offset] = output_pixel.pixel & 0xFF
353 elif colorspace.depth < 8:
354 # Reorder the offsets to pack multiple rows into
355 # a byte (meaning they share a column).
356 if not colorspace.pixels_in_byte_share_row:
358 row = offset // width
360 # Dividing by pixels_per_byte does truncated division
361 # even if we multiply it back out
363 col * pixels_per_byte
364 + (row // pixels_per_byte) * width
365 + (row % pixels_per_byte)
367 shift = (offset % pixels_per_byte) * colorspace.depth
368 if colorspace.reverse_pixels_in_byte:
369 # Reverse the shift by subtracting it from the leftmost shift
370 shift = (pixels_per_byte - 1) * colorspace.depth - shift
371 buffer[offset // pixels_per_byte] |= output_pixel.pixel << shift
374 def _finish_refresh(self):
375 first_draw = self._previous_area.x1 == self._previous_area.x2
376 hidden = self._hidden_tilegrid or self._hidden_by_parent
377 if not first_draw and hidden:
378 self._previous_area.x2 = self._previous_area.x1
379 elif self._moved or first_draw:
380 self._current_area.copy_into(self._previous_area)
383 self._full_change = False
384 self._partial_change = False
385 if isinstance(self._pixel_shader, (Palette, ColorConverter)):
386 self._pixel_shader._finish_refresh() # pylint: disable=protected-access
387 if isinstance(self._bitmap, (Bitmap, Shape)):
388 self._bitmap._finish_refresh() # pylint: disable=protected-access
390 def _get_refresh_areas(self, areas: list[Area]) -> None:
391 # pylint: disable=invalid-name, too-many-branches, too-many-statements
392 first_draw = self._previous_area.x1 == self._previous_area.x2
393 hidden = self._hidden_tilegrid or self._hidden_by_parent
395 # Check hidden first because it trumps all other changes
397 self._rendered_hidden = True
399 areas.append(self._previous_area)
401 if self._moved and not first_draw:
402 self._previous_area.union(self._current_area, self._dirty_area)
403 if self._dirty_area.size() < 2 * self._pixel_width * self._pixel_height:
404 areas.append(self._dirty_area)
406 areas.append(self._current_area)
407 areas.append(self._previous_area)
410 tail = areas[-1] if areas else None
411 # If we have an in-memory bitmap, then check it for modifications
412 if isinstance(self._bitmap, Bitmap):
413 self._bitmap._get_refresh_areas(areas) # pylint: disable=protected-access
414 refresh_area = areas[-1] if areas else None
415 if tail != refresh_area:
416 # Special case a TileGrid that shows a full bitmap and use its
417 # dirty area. Copy it to ours so we can transform it.
418 if self._tiles_in_bitmap == 1:
419 refresh_area.copy_into(self._dirty_area)
420 self._partial_change = True
422 self._full_change = True
423 elif isinstance(self._bitmap, Shape):
424 self._bitmap._get_refresh_areas(areas) # pylint: disable=protected-access
425 refresh_area = areas[-1] if areas else None
426 if refresh_area != tail:
427 refresh_area.copy_into(self._dirty_area)
428 self._partial_change = True
430 self._full_change = self._full_change or (
431 isinstance(self._pixel_shader, (Palette, ColorConverter))
432 and self._pixel_shader._needs_refresh # pylint: disable=protected-access
434 if self._full_change or first_draw:
435 areas.append(self._current_area)
438 if self._partial_change:
441 if self._absolute_transform.transpose_xy:
443 x1 = self._dirty_area.x1
444 x2 = self._dirty_area.x2
446 x1 = self._pixel_width - x1
447 x2 = self._pixel_width - x2
448 y1 = self._dirty_area.y1
449 y2 = self._dirty_area.y2
451 y1 = self._pixel_height - y1
452 y2 = self._pixel_height - y2
453 if self._transpose_xy != self._absolute_transform.transpose_xy:
456 self._dirty_area.x1 = (
457 self._absolute_transform.x + self._absolute_transform.dx * (x + x1)
459 self._dirty_area.y1 = (
460 self._absolute_transform.y + self._absolute_transform.dy * (y + y1)
462 self._dirty_area.x2 = (
463 self._absolute_transform.x + self._absolute_transform.dx * (x + x2)
465 self._dirty_area.y2 = (
466 self._absolute_transform.y + self._absolute_transform.dy * (y + y2)
468 if self._dirty_area.y2 < self._dirty_area.y1:
469 self._dirty_area.y1, self._dirty_area.y2 = (
473 if self._dirty_area.x2 < self._dirty_area.x1:
474 self._dirty_area.x1, self._dirty_area.x2 = (
478 areas.append(self._dirty_area)
480 def _set_hidden(self, hidden: bool) -> None:
481 self._hidden_tilegrid = hidden
482 self._rendered_hidden = False
484 self._full_change = True
486 def _set_hidden_by_parent(self, hidden: bool) -> None:
487 self._hidden_by_parent = hidden
488 self._rendered_hidden = False
490 self._full_change = True
492 def _get_rendered_hidden(self) -> bool:
493 return self._rendered_hidden
496 def hidden(self) -> bool:
497 """True when the TileGrid is hidden. This may be False even
498 when a part of a hidden Group."""
499 return self._hidden_tilegrid
502 def hidden(self, value: bool):
503 if not isinstance(value, (bool, int)):
504 raise ValueError("Expecting a boolean or integer value")
506 self._set_hidden(value)
510 """X position of the left edge in the parent."""
514 def x(self, value: int):
515 if not isinstance(value, int):
516 raise TypeError("X should be a integer type")
519 self._update_current_x()
523 """Y position of the top edge in the parent."""
527 def y(self, value: int):
528 if not isinstance(value, int):
529 raise TypeError("Y should be a integer type")
532 self._update_current_y()
535 def flip_x(self) -> bool:
536 """If true, the left edge rendered will be the right edge of the right-most tile."""
540 def flip_x(self, value: bool):
541 if not isinstance(value, bool):
542 raise TypeError("Flip X should be a boolean type")
543 if self._flip_x != value:
547 def flip_y(self) -> bool:
548 """If true, the top edge rendered will be the bottom edge of the bottom-most tile."""
552 def flip_y(self, value: bool):
553 if not isinstance(value, bool):
554 raise TypeError("Flip Y should be a boolean type")
555 if self._flip_y != value:
559 def transpose_xy(self) -> bool:
560 """If true, the TileGrid's axis will be swapped. When combined with mirroring, any 90
561 degree rotation can be achieved along with the corresponding mirrored version.
563 return self._transpose_xy
566 def transpose_xy(self, value: bool):
567 if not isinstance(value, bool):
568 raise TypeError("Transpose XY should be a boolean type")
569 if self._transpose_xy != value:
570 self._transpose_xy = value
571 self._update_current_x()
572 self._update_current_y()
575 def pixel_shader(self) -> Union[ColorConverter, Palette]:
576 """The pixel shader of the tilegrid."""
577 return self._pixel_shader
580 def pixel_shader(self, new_pixel_shader: Union[ColorConverter, Palette]) -> None:
581 if not isinstance(new_pixel_shader, ColorConverter) and not isinstance(
582 new_pixel_shader, Palette
585 "Unsupported Type: new_pixel_shader must be ColorConverter or Palette"
588 self._pixel_shader = new_pixel_shader
591 def bitmap(self) -> Union[Bitmap, OnDiskBitmap, Shape]:
592 """The Bitmap, OnDiskBitmap, or Shape that is assigned to this TileGrid"""
596 def bitmap(self, new_bitmap: Union[Bitmap, OnDiskBitmap, Shape]) -> None:
598 not isinstance(new_bitmap, Bitmap)
599 and not isinstance(new_bitmap, OnDiskBitmap)
600 and not isinstance(new_bitmap, Shape)
603 "Unsupported Type: new_bitmap must be Bitmap, OnDiskBitmap, or Shape"
607 new_bitmap.width != self.bitmap.width
608 or new_bitmap.height != self.bitmap.height
610 raise ValueError("New bitmap must be same size as old bitmap")
612 self._bitmap = new_bitmap
614 def _extract_and_check_index(self, index):
615 if isinstance(index, (tuple, list)):
618 index = y * self._width_in_tiles + x
619 elif isinstance(index, int):
620 x = index % self._width_in_tiles
621 y = index // self._width_in_tiles
623 x > self._width_in_tiles
624 or y > self._height_in_tiles
625 or index >= len(self._tiles)
627 raise ValueError("Tile index out of bounds")
630 def __getitem__(self, index: Union[Tuple[int, int], int]) -> int:
631 """Returns the tile index at the given index. The index can either be
632 an x,y tuple or an int equal to ``y * width + x``'.
634 index = self._extract_and_check_index(index)
635 return self._tiles[index]
637 def __setitem__(self, index: Union[Tuple[int, int], int], value: int) -> None:
638 """Sets the tile index at the given index. The index can either be
639 an x,y tuple or an int equal to ``y * width + x``.
641 index = self._extract_and_check_index(index)
642 if not 0 <= value <= 255:
643 raise ValueError("Tile value out of bounds")
644 self._tiles[index] = value
647 def width(self) -> int:
649 return self._width_in_tiles
652 def height(self) -> int:
653 """Height in tiles"""
654 return self._height_in_tiles
657 def tile_width(self) -> int:
658 """Width of each tile in pixels"""
659 return self._tile_width
662 def tile_height(self) -> int:
663 """Height of each tile in pixels"""
664 return self._tile_height