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 (
33 from ._colorspace import Colorspace
34 from ._area import Area
36 __version__ = "0.0.0+auto.0"
37 __repo__ = "https://github.com/adafruit/Adafruit_Blinka_displayio.git"
41 # pylint: disable=too-many-instance-attributes, too-many-statements
42 """Position a grid of tiles sourced from a bitmap and pixel_shader combination. Multiple
43 grids can share bitmaps and pixel shaders.
45 A single tile grid is also known as a Sprite.
50 bitmap: Union[Bitmap, OnDiskBitmap, Shape],
52 pixel_shader: Union[ColorConverter, Palette],
55 tile_width: Optional[int] = None,
56 tile_height: Optional[int] = None,
57 default_tile: int = 0,
61 """Create a TileGrid object. The bitmap is source for 2d pixels. The pixel_shader is
62 used to convert the value and its location to a display native pixel color. This may
63 be a simple color palette lookup, a gradient, a pattern or a color transformer.
65 tile_width and tile_height match the height of the bitmap by default.
67 if not isinstance(bitmap, (Bitmap, OnDiskBitmap, Shape)):
68 raise ValueError("Unsupported Bitmap type")
70 bitmap_width = bitmap.width
71 bitmap_height = bitmap.height
73 if pixel_shader is not None and not isinstance(
74 pixel_shader, (ColorConverter, Palette)
76 raise ValueError("Unsupported Pixel Shader type")
77 self._pixel_shader = pixel_shader
78 if isinstance(self._pixel_shader, ColorConverter):
79 self._pixel_shader._rgba = True # pylint: disable=protected-access
80 self._hidden_tilegrid = False
81 self._hidden_by_parent = False
82 self._rendered_hidden = False
83 self._name = "Tilegrid"
86 self._width_in_tiles = width
87 self._height_in_tiles = height
88 self._transpose_xy = False
93 if tile_width is None or tile_width == 0:
94 tile_width = bitmap_width
95 if tile_height is None or tile_width == 0:
96 tile_height = bitmap_height
97 if tile_width < 1 or tile_height < 1:
98 raise ValueError("Tile width and height must be greater than 0")
99 if bitmap_width % tile_width != 0:
100 raise ValueError("Tile width must exactly divide bitmap width")
101 self._tile_width = tile_width
102 if bitmap_height % tile_height != 0:
103 raise ValueError("Tile height must exactly divide bitmap height")
104 self._tile_height = tile_height
105 if not 0 <= default_tile <= 255:
106 raise ValueError("Default Tile is out of range")
107 self._pixel_width = width * tile_width
108 self._pixel_height = height * tile_height
109 self._tiles = bytearray(
110 (self._width_in_tiles * self._height_in_tiles) * [default_tile]
112 self._in_group = False
113 self._absolute_transform = None
114 self._current_area = Area(0, 0, self._pixel_width, self._pixel_height)
115 self._dirty_area = Area(0, 0, 0, 0)
116 self._previous_area = Area(0xFFFF, 0xFFFF, 0xFFFF, 0xFFFF)
118 self._full_change = True
119 self._partial_change = True
120 self._bitmap_width_in_tiles = bitmap_width // tile_width
121 self._tiles_in_bitmap = self._bitmap_width_in_tiles * (
122 bitmap_height // tile_height
125 def _update_transform(self, absolute_transform):
126 """Update the parent transform and child transforms"""
127 self._in_group = absolute_transform is not None
128 self._absolute_transform = absolute_transform
129 if self._absolute_transform is not None:
131 self._update_current_x()
132 self._update_current_y()
134 def _update_current_x(self):
135 if self._transpose_xy:
136 width = self._pixel_height
138 width = self._pixel_width
140 absolute_transform = (
142 if self._absolute_transform is None
143 else self._absolute_transform
146 if absolute_transform.transpose_xy:
147 self._current_area.y1 = (
148 absolute_transform.y + absolute_transform.dy * self._x
150 self._current_area.y2 = absolute_transform.y + absolute_transform.dy * (
153 if self._current_area.y2 < self._current_area.y1:
154 self._current_area.y1, self._current_area.y2 = (
155 self._current_area.y2,
156 self._current_area.y1,
159 self._current_area.x1 = (
160 absolute_transform.x + absolute_transform.dx * self._x
162 self._current_area.x2 = absolute_transform.x + absolute_transform.dx * (
165 if self._current_area.x2 < self._current_area.x1:
166 self._current_area.x1, self._current_area.x2 = (
167 self._current_area.x2,
168 self._current_area.x1,
171 def _update_current_y(self):
172 if self._transpose_xy:
173 height = self._pixel_width
175 height = self._pixel_height
177 absolute_transform = (
179 if self._absolute_transform is None
180 else self._absolute_transform
183 if absolute_transform.transpose_xy:
184 self._current_area.x1 = (
185 absolute_transform.x + absolute_transform.dx * self._y
187 self._current_area.x2 = absolute_transform.x + absolute_transform.dx * (
190 if self._current_area.x2 < self._current_area.x1:
191 self._current_area.x1, self._current_area.x2 = (
192 self._current_area.x2,
193 self._current_area.x1,
196 self._current_area.y1 = (
197 absolute_transform.y + absolute_transform.dy * self._y
199 self._current_area.y2 = absolute_transform.y + absolute_transform.dy * (
202 if self._current_area.y2 < self._current_area.y1:
203 self._current_area.y1, self._current_area.y2 = (
204 self._current_area.y2,
205 self._current_area.y1,
208 def _shade(self, pixel_value):
209 if isinstance(self._pixel_shader, Palette):
210 return self._pixel_shader[pixel_value]
211 if isinstance(self._pixel_shader, ColorConverter):
212 return self._pixel_shader.convert(pixel_value)
215 def _apply_palette(self, image):
217 self._pixel_shader._get_palette() # pylint: disable=protected-access
220 def _add_alpha(self, image):
221 alpha = self._bitmap._image.copy().convert( # pylint: disable=protected-access
225 self._pixel_shader._get_alpha_palette() # pylint: disable=protected-access
227 image.putalpha(alpha.convert("L"))
231 colorspace: Colorspace,
233 mask: WriteableBuffer,
234 buffer: WriteableBuffer,
236 """Draw onto the image"""
237 # pylint: disable=too-many-locals,too-many-branches,too-many-statements
239 # If no tiles are present we have no impact
242 if tiles is None or len(tiles) == 0:
245 if self._hidden_tilegrid or self._hidden_by_parent:
247 overlap = Area() # area, current_area, overlap
248 if not area.compute_overlap(self._current_area, overlap):
251 # print("Checking", area.x1, area.y1, area.x2, area.y2)
252 # print("Overlap", overlap.x1, overlap.y1, overlap.x2, overlap.y2)
254 if self._bitmap.width <= 0 or self._bitmap.height <= 0:
258 y_stride = area.width()
260 flip_x = self._flip_x
261 flip_y = self._flip_y
262 if self._transpose_xy != self._absolute_transform.transpose_xy:
263 flip_x, flip_y = flip_y, flip_x
266 if (self._absolute_transform.dx < 0) != flip_x:
267 start += (area.x2 - area.x1 - 1) * x_stride
269 if (self._absolute_transform.dy < 0) != flip_y:
270 start += (area.y2 - area.y1 - 1) * y_stride
273 # Track if this layer finishes filling in the given area. We can ignore any remaining
274 # layers at that point.
275 full_coverage = area == overlap
278 area.transform_within(
279 flip_x != (self._absolute_transform.dx < 0),
280 flip_y != (self._absolute_transform.dy < 0),
281 self.transpose_xy != self._absolute_transform.transpose_xy,
287 start_x = transformed.x1 - self._current_area.x1
288 end_x = transformed.x2 - self._current_area.x1
289 start_y = transformed.y1 - self._current_area.y1
290 end_y = transformed.y2 - self._current_area.y1
292 if (self._absolute_transform.dx < 0) != flip_x:
293 x_shift = area.x2 - overlap.x2
295 x_shift = overlap.x1 - area.x1
296 if (self._absolute_transform.dy < 0) != flip_y:
297 y_shift = area.y2 - overlap.y2
299 y_shift = overlap.y1 - area.y1
301 # This untransposes x and y so it aligns with bitmap rows
302 if self._transpose_xy != self._absolute_transform.transpose_xy:
303 x_stride, y_stride = y_stride, x_stride
304 x_shift, y_shift = y_shift, x_shift
306 pixels_per_byte = 8 // colorspace.depth
308 input_pixel = InputPixelStruct()
309 output_pixel = OutputPixelStruct()
310 for input_pixel.y in range(start_y, end_y):
312 start + (input_pixel.y - start_y + y_shift) * y_stride
314 local_y = input_pixel.y // self._absolute_transform.scale
315 for input_pixel.x in range(start_x, end_x):
316 # Compute the destination pixel in the buffer and mask based on the transformations
318 row_start + (input_pixel.x - start_x + x_shift) * x_stride
321 # Check the mask first to see if the pixel has already been set
322 if mask[offset // 32] & (1 << (offset % 32)):
324 local_x = input_pixel.x // self._absolute_transform.scale
326 (local_y // self._tile_height + self._top_left_y)
327 % self._height_in_tiles
328 ) * self._width_in_tiles + (
329 local_x // self._tile_width + self._top_left_x
330 ) % self._width_in_tiles
331 input_pixel.tile = tiles[tile_location]
332 input_pixel.tile_x = (
333 input_pixel.tile % self._bitmap_width_in_tiles
334 ) * self._tile_width + local_x % self._tile_width
335 input_pixel.tile_y = (
336 input_pixel.tile // self._bitmap_width_in_tiles
337 ) * self._tile_height + local_y % self._tile_height
339 output_pixel.pixel = 0
340 input_pixel.pixel = 0
342 # We always want to read bitmap pixels by row first and then transpose into
343 # the destination buffer because most bitmaps are row associated.
344 if isinstance(self._bitmap, (Bitmap, Shape, OnDiskBitmap)):
345 input_pixel.pixel = (
346 self._bitmap._get_pixel( # pylint: disable=protected-access
347 input_pixel.tile_x, input_pixel.tile_y
351 output_pixel.opaque = True
352 if self._pixel_shader is None:
353 output_pixel.pixel = input_pixel.pixel
354 elif isinstance(self._pixel_shader, Palette):
355 self._pixel_shader._get_color( # pylint: disable=protected-access
356 colorspace, input_pixel, output_pixel
358 elif isinstance(self._pixel_shader, ColorConverter):
359 self._pixel_shader._convert( # pylint: disable=protected-access
360 colorspace, input_pixel, output_pixel
363 if not output_pixel.opaque:
364 full_coverage = False
366 mask[offset // 32] |= 1 << (offset % 32)
367 if colorspace.depth == 16:
374 elif colorspace.depth == 32:
381 elif colorspace.depth == 8:
382 buffer.cast("B")[offset] = output_pixel.pixel & 0xFF
383 elif colorspace.depth < 8:
384 # Reorder the offsets to pack multiple rows into
385 # a byte (meaning they share a column).
386 if not colorspace.pixels_in_byte_share_row:
388 row = offset // width
390 # Dividing by pixels_per_byte does truncated division
391 # even if we multiply it back out
393 col * pixels_per_byte
394 + (row // pixels_per_byte) * pixels_per_byte * width
395 + (row % pixels_per_byte)
397 shift = (offset % pixels_per_byte) * colorspace.depth
398 if colorspace.reverse_pixels_in_byte:
399 # Reverse the shift by subtracting it from the leftmost shift
400 shift = (pixels_per_byte - 1) * colorspace.depth - shift
401 buffer.cast("B")[offset // pixels_per_byte] |= (
402 output_pixel.pixel << shift
407 def _finish_refresh(self):
408 first_draw = self._previous_area.x1 == self._previous_area.x2
409 hidden = self._hidden_tilegrid or self._hidden_by_parent
410 if not first_draw and hidden:
411 self._previous_area.x2 = self._previous_area.x1
412 elif self._moved or first_draw:
413 self._current_area.copy_into(self._previous_area)
416 self._full_change = False
417 self._partial_change = False
418 if isinstance(self._pixel_shader, (Palette, ColorConverter)):
419 self._pixel_shader._finish_refresh() # pylint: disable=protected-access
420 if isinstance(self._bitmap, (Bitmap, Shape)):
421 self._bitmap._finish_refresh() # pylint: disable=protected-access
423 def _get_refresh_areas(self, areas: list[Area]) -> None:
424 # pylint: disable=invalid-name, too-many-branches, too-many-statements
425 first_draw = self._previous_area.x1 == self._previous_area.x2
426 hidden = self._hidden_tilegrid or self._hidden_by_parent
428 # Check hidden first because it trumps all other changes
430 self._rendered_hidden = True
432 areas.append(self._previous_area)
434 if self._moved and not first_draw:
435 self._previous_area.union(self._current_area, self._dirty_area)
436 if self._dirty_area.size() < 2 * self._pixel_width * self._pixel_height:
437 areas.append(self._dirty_area)
439 areas.append(self._current_area)
440 areas.append(self._previous_area)
443 tail = areas[-1] if areas else None
444 # If we have an in-memory bitmap, then check it for modifications
445 if isinstance(self._bitmap, Bitmap):
446 self._bitmap._get_refresh_areas(areas) # pylint: disable=protected-access
447 refresh_area = areas[-1] if areas else None
448 if refresh_area != tail:
449 # Special case a TileGrid that shows a full bitmap and use its
450 # dirty area. Copy it to ours so we can transform it.
451 if self._tiles_in_bitmap == 1:
452 refresh_area.copy_into(self._dirty_area)
453 self._partial_change = True
455 self._full_change = True
456 elif isinstance(self._bitmap, Shape):
457 self._bitmap._get_refresh_areas(areas) # pylint: disable=protected-access
458 refresh_area = areas[-1] if areas else None
459 if refresh_area != tail:
460 refresh_area.copy_into(self._dirty_area)
461 self._partial_change = True
463 self._full_change = self._full_change or (
464 isinstance(self._pixel_shader, (Palette, ColorConverter))
465 and self._pixel_shader._needs_refresh # pylint: disable=protected-access
467 if self._full_change or first_draw:
468 areas.append(self._current_area)
471 if self._partial_change:
474 if self._absolute_transform.transpose_xy:
476 x1 = self._dirty_area.x1
477 x2 = self._dirty_area.x2
479 x1 = self._pixel_width - x1
480 x2 = self._pixel_width - x2
481 y1 = self._dirty_area.y1
482 y2 = self._dirty_area.y2
484 y1 = self._pixel_height - y1
485 y2 = self._pixel_height - y2
486 if self._transpose_xy != self._absolute_transform.transpose_xy:
489 self._dirty_area.x1 = (
490 self._absolute_transform.x + self._absolute_transform.dx * (x + x1)
492 self._dirty_area.y1 = (
493 self._absolute_transform.y + self._absolute_transform.dy * (y + y1)
495 self._dirty_area.x2 = (
496 self._absolute_transform.x + self._absolute_transform.dx * (x + x2)
498 self._dirty_area.y2 = (
499 self._absolute_transform.y + self._absolute_transform.dy * (y + y2)
501 if self._dirty_area.y2 < self._dirty_area.y1:
502 self._dirty_area.y1, self._dirty_area.y2 = (
506 if self._dirty_area.x2 < self._dirty_area.x1:
507 self._dirty_area.x1, self._dirty_area.x2 = (
511 areas.append(self._dirty_area)
513 def _set_hidden(self, hidden: bool) -> None:
514 self._hidden_tilegrid = hidden
515 self._rendered_hidden = False
517 self._full_change = True
519 def _set_hidden_by_parent(self, hidden: bool) -> None:
520 self._hidden_by_parent = hidden
521 self._rendered_hidden = False
523 self._full_change = True
525 def _get_rendered_hidden(self) -> bool:
526 return self._rendered_hidden
528 def _set_all_tiles(self, tile_index: int) -> None:
529 """Set all tiles to the given tile index"""
530 if tile_index >= self._tiles_in_bitmap:
531 raise ValueError("Tile index out of bounds")
532 self._tiles = bytearray(
533 (self._width_in_tiles * self._height_in_tiles) * [tile_index]
535 self._full_change = True
537 def _set_tile(self, x: int, y: int, tile_index: int) -> None:
538 self._tiles[y * self._width_in_tiles + x] = tile_index
540 if not self._partial_change:
541 tile_area = self._dirty_area
543 tile_area = temp_area
544 top_x = (x - self._top_left_x) % self._width_in_tiles
546 top_x += self._width_in_tiles
547 tile_area.x1 = top_x * self._tile_width
548 tile_area.x2 = tile_area.x1 + self._tile_width
549 top_y = (y - self._top_left_y) % self._height_in_tiles
551 top_y += self._height_in_tiles
552 tile_area.y1 = top_y * self._tile_height
553 tile_area.y2 = tile_area.y1 + self._tile_height
555 if self._partial_change:
556 self._dirty_area.union(temp_area, self._dirty_area)
558 self._partial_change = True
560 def _set_top_left(self, x: int, y: int) -> None:
563 self._full_change = True
566 def hidden(self) -> bool:
567 """True when the TileGrid is hidden. This may be False even
568 when a part of a hidden Group."""
569 return self._hidden_tilegrid
572 def hidden(self, value: bool):
573 if not isinstance(value, (bool, int)):
574 raise ValueError("Expecting a boolean or integer value")
576 self._set_hidden(value)
580 """X position of the left edge in the parent."""
584 def x(self, value: int):
585 if not isinstance(value, int):
586 raise TypeError("X should be a integer type")
590 if self._absolute_transform is not None:
591 self._update_current_x()
595 """Y position of the top edge in the parent."""
599 def y(self, value: int):
600 if not isinstance(value, int):
601 raise TypeError("Y should be a integer type")
605 if self._absolute_transform is not None:
606 self._update_current_y()
609 def flip_x(self) -> bool:
610 """If true, the left edge rendered will be the right edge of the right-most tile."""
614 def flip_x(self, value: bool):
615 if not isinstance(value, bool):
616 raise TypeError("Flip X should be a boolean type")
617 if self._flip_x != value:
619 self._full_change = True
622 def flip_y(self) -> bool:
623 """If true, the top edge rendered will be the bottom edge of the bottom-most tile."""
627 def flip_y(self, value: bool):
628 if not isinstance(value, bool):
629 raise TypeError("Flip Y should be a boolean type")
630 if self._flip_y != value:
632 self._full_change = True
635 def transpose_xy(self) -> bool:
636 """If true, the TileGrid's axis will be swapped. When combined with mirroring, any 90
637 degree rotation can be achieved along with the corresponding mirrored version.
639 return self._transpose_xy
642 def transpose_xy(self, value: bool) -> None:
643 if not isinstance(value, bool):
644 raise TypeError("Transpose XY should be a boolean type")
645 if self._transpose_xy != value:
646 self._transpose_xy = value
647 if self._pixel_width == self._pixel_height:
648 self._full_change = True
650 self._update_current_x()
651 self._update_current_y()
655 def pixel_shader(self) -> Union[ColorConverter, Palette]:
656 """The pixel shader of the tilegrid."""
657 return self._pixel_shader
660 def pixel_shader(self, new_pixel_shader: Union[ColorConverter, Palette]) -> None:
661 if not isinstance(new_pixel_shader, ColorConverter) and not isinstance(
662 new_pixel_shader, Palette
665 "Unsupported Type: new_pixel_shader must be ColorConverter or Palette"
668 self._pixel_shader = new_pixel_shader
669 self._full_change = True
672 def bitmap(self) -> Union[Bitmap, OnDiskBitmap, Shape]:
673 """The Bitmap, OnDiskBitmap, or Shape that is assigned to this TileGrid"""
677 def bitmap(self, new_bitmap: Union[Bitmap, OnDiskBitmap, Shape]) -> None:
679 not isinstance(new_bitmap, Bitmap)
680 and not isinstance(new_bitmap, OnDiskBitmap)
681 and not isinstance(new_bitmap, Shape)
684 "Unsupported Type: new_bitmap must be Bitmap, OnDiskBitmap, or Shape"
688 new_bitmap.width != self.bitmap.width
689 or new_bitmap.height != self.bitmap.height
691 raise ValueError("New bitmap must be same size as old bitmap")
693 self._bitmap = new_bitmap
694 self._full_change = True
696 def _extract_and_check_index(self, index):
697 if isinstance(index, (tuple, list)):
700 index = y * self._width_in_tiles + x
701 elif isinstance(index, int):
702 x = index % self._width_in_tiles
703 y = index // self._width_in_tiles
705 x > self._width_in_tiles
706 or y > self._height_in_tiles
707 or index >= len(self._tiles)
709 raise ValueError("Tile index out of bounds")
712 def __getitem__(self, index: Union[Tuple[int, int], int]) -> int:
713 """Returns the tile index at the given index. The index can either be
714 an x,y tuple or an int equal to ``y * width + x``'.
716 x, y = self._extract_and_check_index(index)
717 return self._tiles[y * self._width_in_tiles + x]
719 def __setitem__(self, index: Union[Tuple[int, int], int], value: int) -> None:
720 """Sets the tile index at the given index. The index can either be
721 an x,y tuple or an int equal to ``y * width + x``.
723 x, y = self._extract_and_check_index(index)
724 if not 0 <= value <= 255:
725 raise ValueError("Tile value out of bounds")
726 self._set_tile(x, y, value)
729 def width(self) -> int:
731 return self._width_in_tiles
734 def height(self) -> int:
735 """Height in tiles"""
736 return self._height_in_tiles
739 def tile_width(self) -> int:
740 """Width of each tile in pixels"""
741 return self._tile_width
744 def tile_height(self) -> int:
745 """Height of each tile in pixels"""
746 return self._tile_height