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 ._palette import Palette
27 from ._structs import (
32 from ._colorspace import Colorspace
33 from ._area import Area
35 __version__ = "0.0.0+auto.0"
36 __repo__ = "https://github.com/adafruit/Adafruit_Blinka_displayio.git"
40 # pylint: disable=too-many-instance-attributes, too-many-statements
41 """Position a grid of tiles sourced from a bitmap and pixel_shader combination. Multiple
42 grids can share bitmaps and pixel shaders.
44 A single tile grid is also known as a Sprite.
49 bitmap: Union[Bitmap, OnDiskBitmap],
51 pixel_shader: Union[ColorConverter, Palette],
54 tile_width: Optional[int] = None,
55 tile_height: Optional[int] = None,
56 default_tile: int = 0,
60 """Create a TileGrid object. The bitmap is source for 2d pixels. The pixel_shader is
61 used to convert the value and its location to a display native pixel color. This may
62 be a simple color palette lookup, a gradient, a pattern or a color transformer.
64 tile_width and tile_height match the height of the bitmap by default.
66 if not isinstance(bitmap, (Bitmap, OnDiskBitmap)):
67 raise ValueError("Unsupported Bitmap type")
69 bitmap_width = bitmap.width
70 bitmap_height = bitmap.height
72 if pixel_shader is not None and not isinstance(
73 pixel_shader, (ColorConverter, Palette)
75 raise ValueError("Unsupported Pixel Shader type")
76 self._pixel_shader = pixel_shader
77 if isinstance(self._pixel_shader, ColorConverter):
78 self._pixel_shader._rgba = True # pylint: disable=protected-access
79 self._hidden_tilegrid = False
80 self._hidden_by_parent = False
81 self._rendered_hidden = False
82 self._name = "Tilegrid"
85 self._width_in_tiles = width
86 self._height_in_tiles = height
87 self._transpose_xy = False
92 if tile_width is None or tile_width == 0:
93 tile_width = bitmap_width
94 if tile_height is None or tile_width == 0:
95 tile_height = bitmap_height
96 if tile_width < 1 or tile_height < 1:
97 raise ValueError("Tile width and height must be greater than 0")
98 if bitmap_width % tile_width != 0:
99 raise ValueError("Tile width must exactly divide bitmap width")
100 self._tile_width = tile_width
101 if bitmap_height % tile_height != 0:
102 raise ValueError("Tile height must exactly divide bitmap height")
103 self._tile_height = tile_height
104 if not 0 <= default_tile <= 255:
105 raise ValueError("Default Tile is out of range")
106 self._pixel_width = width * tile_width
107 self._pixel_height = height * tile_height
108 self._tiles = bytearray(
109 (self._width_in_tiles * self._height_in_tiles) * [default_tile]
111 self._in_group = False
112 self._absolute_transform = None
113 self._current_area = Area(0, 0, self._pixel_width, self._pixel_height)
114 self._dirty_area = Area(0, 0, 0, 0)
115 self._previous_area = Area(0xFFFF, 0xFFFF, 0xFFFF, 0xFFFF)
117 self._full_change = True
118 self._partial_change = True
119 self._bitmap_width_in_tiles = bitmap_width // tile_width
120 self._tiles_in_bitmap = self._bitmap_width_in_tiles * (
121 bitmap_height // tile_height
124 def _update_transform(self, absolute_transform):
125 """Update the parent transform and child transforms"""
126 self._in_group = absolute_transform is not None
127 self._absolute_transform = absolute_transform
128 if self._absolute_transform is not None:
130 self._update_current_x()
131 self._update_current_y()
133 def _update_current_x(self):
134 if self._transpose_xy:
135 width = self._pixel_height
137 width = self._pixel_width
139 absolute_transform = (
141 if self._absolute_transform is None
142 else self._absolute_transform
145 if absolute_transform.transpose_xy:
146 self._current_area.y1 = (
147 absolute_transform.y + absolute_transform.dy * self._x
149 self._current_area.y2 = absolute_transform.y + absolute_transform.dy * (
152 if self._current_area.y2 < self._current_area.y1:
153 self._current_area.y1, self._current_area.y2 = (
154 self._current_area.y2,
155 self._current_area.y1,
158 self._current_area.x1 = (
159 absolute_transform.x + absolute_transform.dx * self._x
161 self._current_area.x2 = absolute_transform.x + absolute_transform.dx * (
164 if self._current_area.x2 < self._current_area.x1:
165 self._current_area.x1, self._current_area.x2 = (
166 self._current_area.x2,
167 self._current_area.x1,
170 def _update_current_y(self):
171 if self._transpose_xy:
172 height = self._pixel_width
174 height = self._pixel_height
176 absolute_transform = (
178 if self._absolute_transform is None
179 else self._absolute_transform
182 if absolute_transform.transpose_xy:
183 self._current_area.x1 = (
184 absolute_transform.x + absolute_transform.dx * self._y
186 self._current_area.x2 = absolute_transform.x + absolute_transform.dx * (
189 if self._current_area.x2 < self._current_area.x1:
190 self._current_area.x1, self._current_area.x2 = (
191 self._current_area.x2,
192 self._current_area.x1,
195 self._current_area.y1 = (
196 absolute_transform.y + absolute_transform.dy * self._y
198 self._current_area.y2 = absolute_transform.y + absolute_transform.dy * (
201 if self._current_area.y2 < self._current_area.y1:
202 self._current_area.y1, self._current_area.y2 = (
203 self._current_area.y2,
204 self._current_area.y1,
207 def _shade(self, pixel_value):
208 if isinstance(self._pixel_shader, Palette):
209 return self._pixel_shader[pixel_value]
210 if isinstance(self._pixel_shader, ColorConverter):
211 return self._pixel_shader.convert(pixel_value)
214 def _apply_palette(self, image):
216 self._pixel_shader._get_palette() # pylint: disable=protected-access
219 def _add_alpha(self, image):
220 alpha = self._bitmap._image.copy().convert( # pylint: disable=protected-access
224 self._pixel_shader._get_alpha_palette() # pylint: disable=protected-access
226 image.putalpha(alpha.convert("L"))
230 colorspace: Colorspace,
232 mask: WriteableBuffer,
233 buffer: WriteableBuffer,
235 """Draw onto the image"""
236 # pylint: disable=too-many-locals,too-many-branches,too-many-statements
238 # If no tiles are present we have no impact
241 if tiles is None or len(tiles) == 0:
244 if self._hidden_tilegrid or self._hidden_by_parent:
246 overlap = Area() # area, current_area, overlap
247 if not area.compute_overlap(self._current_area, overlap):
250 # print("Checking", area.x1, area.y1, area.x2, area.y2)
251 # print("Overlap", overlap.x1, overlap.y1, overlap.x2, overlap.y2)
253 if self._bitmap.width <= 0 or self._bitmap.height <= 0:
257 y_stride = area.width()
259 flip_x = self._flip_x
260 flip_y = self._flip_y
261 if self._transpose_xy != self._absolute_transform.transpose_xy:
262 flip_x, flip_y = flip_y, flip_x
265 if (self._absolute_transform.dx < 0) != flip_x:
266 start += (area.x2 - area.x1 - 1) * x_stride
268 if (self._absolute_transform.dy < 0) != flip_y:
269 start += (area.y2 - area.y1 - 1) * y_stride
272 # Track if this layer finishes filling in the given area. We can ignore any remaining
273 # layers at that point.
274 full_coverage = area == overlap
277 area.transform_within(
278 flip_x != (self._absolute_transform.dx < 0),
279 flip_y != (self._absolute_transform.dy < 0),
280 self.transpose_xy != self._absolute_transform.transpose_xy,
286 start_x = transformed.x1 - self._current_area.x1
287 end_x = transformed.x2 - self._current_area.x1
288 start_y = transformed.y1 - self._current_area.y1
289 end_y = transformed.y2 - self._current_area.y1
291 if (self._absolute_transform.dx < 0) != flip_x:
292 x_shift = area.x2 - overlap.x2
294 x_shift = overlap.x1 - area.x1
295 if (self._absolute_transform.dy < 0) != flip_y:
296 y_shift = area.y2 - overlap.y2
298 y_shift = overlap.y1 - area.y1
300 # This untransposes x and y so it aligns with bitmap rows
301 if self._transpose_xy != self._absolute_transform.transpose_xy:
302 x_stride, y_stride = y_stride, x_stride
303 x_shift, y_shift = y_shift, x_shift
305 pixels_per_byte = 8 // colorspace.depth
307 input_pixel = InputPixelStruct()
308 output_pixel = OutputPixelStruct()
309 for input_pixel.y in range(start_y, end_y):
311 start + (input_pixel.y - start_y + y_shift) * y_stride
313 local_y = input_pixel.y // self._absolute_transform.scale
314 for input_pixel.x in range(start_x, end_x):
315 # Compute the destination pixel in the buffer and mask based on the transformations
317 row_start + (input_pixel.x - start_x + x_shift) * x_stride
320 # Check the mask first to see if the pixel has already been set
321 if mask[offset // 32] & (1 << (offset % 32)):
323 local_x = input_pixel.x // self._absolute_transform.scale
325 (local_y // self._tile_height + self._top_left_y)
326 % self._height_in_tiles
327 ) * self._width_in_tiles + (
328 local_x // self._tile_width + self._top_left_x
329 ) % self._width_in_tiles
330 input_pixel.tile = tiles[tile_location]
331 input_pixel.tile_x = (
332 input_pixel.tile % self._bitmap_width_in_tiles
333 ) * self._tile_width + local_x % self._tile_width
334 input_pixel.tile_y = (
335 input_pixel.tile // self._bitmap_width_in_tiles
336 ) * self._tile_height + local_y % self._tile_height
338 output_pixel.pixel = 0
339 input_pixel.pixel = 0
341 # We always want to read bitmap pixels by row first and then transpose into
342 # the destination buffer because most bitmaps are row associated.
343 if isinstance(self._bitmap, (Bitmap, OnDiskBitmap)):
344 input_pixel.pixel = (
345 self._bitmap._get_pixel( # pylint: disable=protected-access
346 input_pixel.tile_x, input_pixel.tile_y
350 output_pixel.opaque = True
351 if self._pixel_shader is None:
352 output_pixel.pixel = input_pixel.pixel
353 elif isinstance(self._pixel_shader, Palette):
354 self._pixel_shader._get_color( # pylint: disable=protected-access
355 colorspace, input_pixel, output_pixel
357 elif isinstance(self._pixel_shader, ColorConverter):
358 self._pixel_shader._convert( # pylint: disable=protected-access
359 colorspace, input_pixel, output_pixel
362 if not output_pixel.opaque:
363 full_coverage = False
365 mask[offset // 32] |= 1 << (offset % 32)
366 if colorspace.depth == 16:
373 elif colorspace.depth == 32:
380 elif colorspace.depth == 8:
381 buffer.cast("B")[offset] = output_pixel.pixel & 0xFF
382 elif colorspace.depth < 8:
383 # Reorder the offsets to pack multiple rows into
384 # a byte (meaning they share a column).
385 if not colorspace.pixels_in_byte_share_row:
387 row = offset // width
389 # Dividing by pixels_per_byte does truncated division
390 # even if we multiply it back out
392 col * pixels_per_byte
393 + (row // pixels_per_byte) * pixels_per_byte * width
394 + (row % pixels_per_byte)
396 shift = (offset % pixels_per_byte) * colorspace.depth
397 if colorspace.reverse_pixels_in_byte:
398 # Reverse the shift by subtracting it from the leftmost shift
399 shift = (pixels_per_byte - 1) * colorspace.depth - shift
400 buffer.cast("B")[offset // pixels_per_byte] |= (
401 output_pixel.pixel << shift
406 def _finish_refresh(self):
407 first_draw = self._previous_area.x1 == self._previous_area.x2
408 hidden = self._hidden_tilegrid or self._hidden_by_parent
409 if not first_draw and hidden:
410 self._previous_area.x2 = self._previous_area.x1
411 elif self._moved or first_draw:
412 self._current_area.copy_into(self._previous_area)
415 self._full_change = False
416 self._partial_change = False
417 if isinstance(self._pixel_shader, (Palette, ColorConverter)):
418 self._pixel_shader._finish_refresh() # pylint: disable=protected-access
419 if isinstance(self._bitmap, Bitmap):
420 self._bitmap._finish_refresh() # pylint: disable=protected-access
422 def _get_refresh_areas(self, areas: list[Area]) -> None:
423 # pylint: disable=invalid-name, too-many-branches, too-many-statements
424 first_draw = self._previous_area.x1 == self._previous_area.x2
425 hidden = self._hidden_tilegrid or self._hidden_by_parent
427 # Check hidden first because it trumps all other changes
429 self._rendered_hidden = True
431 areas.append(self._previous_area)
433 if self._moved and not first_draw:
434 self._previous_area.union(self._current_area, self._dirty_area)
435 if self._dirty_area.size() < 2 * self._pixel_width * self._pixel_height:
436 areas.append(self._dirty_area)
438 areas.append(self._current_area)
439 areas.append(self._previous_area)
442 tail = areas[-1] if areas else None
443 # If we have an in-memory bitmap, then check it for modifications
444 if isinstance(self._bitmap, Bitmap):
445 self._bitmap._get_refresh_areas(areas) # pylint: disable=protected-access
446 refresh_area = areas[-1] if areas else None
447 if refresh_area != tail:
448 # Special case a TileGrid that shows a full bitmap and use its
449 # dirty area. Copy it to ours so we can transform it.
450 if self._tiles_in_bitmap == 1:
451 refresh_area.copy_into(self._dirty_area)
452 self._partial_change = True
454 self._full_change = True
456 self._full_change = self._full_change or (
457 isinstance(self._pixel_shader, (Palette, ColorConverter))
458 and self._pixel_shader._needs_refresh # pylint: disable=protected-access
460 if self._full_change or first_draw:
461 areas.append(self._current_area)
464 if self._partial_change:
467 if self._absolute_transform.transpose_xy:
469 x1 = self._dirty_area.x1
470 x2 = self._dirty_area.x2
472 x1 = self._pixel_width - x1
473 x2 = self._pixel_width - x2
474 y1 = self._dirty_area.y1
475 y2 = self._dirty_area.y2
477 y1 = self._pixel_height - y1
478 y2 = self._pixel_height - y2
479 if self._transpose_xy != self._absolute_transform.transpose_xy:
482 self._dirty_area.x1 = (
483 self._absolute_transform.x + self._absolute_transform.dx * (x + x1)
485 self._dirty_area.y1 = (
486 self._absolute_transform.y + self._absolute_transform.dy * (y + y1)
488 self._dirty_area.x2 = (
489 self._absolute_transform.x + self._absolute_transform.dx * (x + x2)
491 self._dirty_area.y2 = (
492 self._absolute_transform.y + self._absolute_transform.dy * (y + y2)
494 if self._dirty_area.y2 < self._dirty_area.y1:
495 self._dirty_area.y1, self._dirty_area.y2 = (
499 if self._dirty_area.x2 < self._dirty_area.x1:
500 self._dirty_area.x1, self._dirty_area.x2 = (
504 areas.append(self._dirty_area)
506 def _set_hidden(self, hidden: bool) -> None:
507 self._hidden_tilegrid = hidden
508 self._rendered_hidden = False
510 self._full_change = True
512 def _set_hidden_by_parent(self, hidden: bool) -> None:
513 self._hidden_by_parent = hidden
514 self._rendered_hidden = False
516 self._full_change = True
518 def _get_rendered_hidden(self) -> bool:
519 return self._rendered_hidden
521 def _set_all_tiles(self, tile_index: int) -> None:
522 """Set all tiles to the given tile index"""
523 if tile_index >= self._tiles_in_bitmap:
524 raise ValueError("Tile index out of bounds")
525 self._tiles = bytearray(
526 (self._width_in_tiles * self._height_in_tiles) * [tile_index]
528 self._full_change = True
530 def _set_tile(self, x: int, y: int, tile_index: int) -> None:
531 self._tiles[y * self._width_in_tiles + x] = tile_index
533 if not self._partial_change:
534 tile_area = self._dirty_area
536 tile_area = temp_area
537 top_x = (x - self._top_left_x) % self._width_in_tiles
539 top_x += self._width_in_tiles
540 tile_area.x1 = top_x * self._tile_width
541 tile_area.x2 = tile_area.x1 + self._tile_width
542 top_y = (y - self._top_left_y) % self._height_in_tiles
544 top_y += self._height_in_tiles
545 tile_area.y1 = top_y * self._tile_height
546 tile_area.y2 = tile_area.y1 + self._tile_height
548 if self._partial_change:
549 self._dirty_area.union(temp_area, self._dirty_area)
551 self._partial_change = True
553 def _set_top_left(self, x: int, y: int) -> None:
556 self._full_change = True
559 def hidden(self) -> bool:
560 """True when the TileGrid is hidden. This may be False even
561 when a part of a hidden Group."""
562 return self._hidden_tilegrid
565 def hidden(self, value: bool):
566 if not isinstance(value, (bool, int)):
567 raise ValueError("Expecting a boolean or integer value")
569 self._set_hidden(value)
573 """X position of the left edge in the parent."""
577 def x(self, value: int):
578 if not isinstance(value, int):
579 raise TypeError("X should be a integer type")
583 if self._absolute_transform is not None:
584 self._update_current_x()
588 """Y position of the top edge in the parent."""
592 def y(self, value: int):
593 if not isinstance(value, int):
594 raise TypeError("Y should be a integer type")
598 if self._absolute_transform is not None:
599 self._update_current_y()
602 def flip_x(self) -> bool:
603 """If true, the left edge rendered will be the right edge of the right-most tile."""
607 def flip_x(self, value: bool):
608 if not isinstance(value, bool):
609 raise TypeError("Flip X should be a boolean type")
610 if self._flip_x != value:
612 self._full_change = True
615 def flip_y(self) -> bool:
616 """If true, the top edge rendered will be the bottom edge of the bottom-most tile."""
620 def flip_y(self, value: bool):
621 if not isinstance(value, bool):
622 raise TypeError("Flip Y should be a boolean type")
623 if self._flip_y != value:
625 self._full_change = True
628 def transpose_xy(self) -> bool:
629 """If true, the TileGrid's axis will be swapped. When combined with mirroring, any 90
630 degree rotation can be achieved along with the corresponding mirrored version.
632 return self._transpose_xy
635 def transpose_xy(self, value: bool) -> None:
636 if not isinstance(value, bool):
637 raise TypeError("Transpose XY should be a boolean type")
638 if self._transpose_xy != value:
639 self._transpose_xy = value
640 if self._pixel_width == self._pixel_height:
641 self._full_change = True
643 self._update_current_x()
644 self._update_current_y()
648 def pixel_shader(self) -> Union[ColorConverter, Palette]:
649 """The pixel shader of the tilegrid."""
650 return self._pixel_shader
653 def pixel_shader(self, new_pixel_shader: Union[ColorConverter, Palette]) -> None:
654 if not isinstance(new_pixel_shader, ColorConverter) and not isinstance(
655 new_pixel_shader, Palette
658 "Unsupported Type: new_pixel_shader must be ColorConverter or Palette"
661 self._pixel_shader = new_pixel_shader
662 self._full_change = True
665 def bitmap(self) -> Union[Bitmap, OnDiskBitmap]:
666 """The Bitmap or OnDiskBitmap that is assigned to this TileGrid"""
670 def bitmap(self, new_bitmap: Union[Bitmap, OnDiskBitmap]) -> None:
671 if not isinstance(new_bitmap, Bitmap) and not isinstance(
672 new_bitmap, OnDiskBitmap
675 "Unsupported Type: new_bitmap must be Bitmap or OnDiskBitmap"
679 new_bitmap.width != self.bitmap.width
680 or new_bitmap.height != self.bitmap.height
682 raise ValueError("New bitmap must be same size as old bitmap")
684 self._bitmap = new_bitmap
685 self._full_change = True
687 def _extract_and_check_index(self, index):
688 if isinstance(index, (tuple, list)):
691 index = y * self._width_in_tiles + x
692 elif isinstance(index, int):
693 x = index % self._width_in_tiles
694 y = index // self._width_in_tiles
696 x > self._width_in_tiles
697 or y > self._height_in_tiles
698 or index >= len(self._tiles)
700 raise ValueError("Tile index out of bounds")
703 def __getitem__(self, index: Union[Tuple[int, int], int]) -> int:
704 """Returns the tile index at the given index. The index can either be
705 an x,y tuple or an int equal to ``y * width + x``'.
707 x, y = self._extract_and_check_index(index)
708 return self._tiles[y * self._width_in_tiles + x]
710 def __setitem__(self, index: Union[Tuple[int, int], int], value: int) -> None:
711 """Sets the tile index at the given index. The index can either be
712 an x,y tuple or an int equal to ``y * width + x``.
714 x, y = self._extract_and_check_index(index)
715 if not 0 <= value <= 255:
716 raise ValueError("Tile value out of bounds")
717 self._set_tile(x, y, value)
720 def width(self) -> int:
722 return self._width_in_tiles
725 def height(self) -> int:
726 """Height in tiles"""
727 return self._height_in_tiles
730 def tile_width(self) -> int:
731 """Width of each tile in pixels"""
732 return self._tile_width
735 def tile_height(self) -> int:
736 """Height of each tile in pixels"""
737 return self._tile_height