]> Repositories - hackapet/Adafruit_Blinka_Displayio.git/blob - displayio/_tilegrid.py
Fewer bugs, more code, shape done
[hackapet/Adafruit_Blinka_Displayio.git] / displayio / _tilegrid.py
1 # SPDX-FileCopyrightText: 2020 Melissa LeBlanc-Williams for Adafruit Industries
2 #
3 # SPDX-License-Identifier: MIT
4
5 """
6 `displayio.tilegrid`
7 ================================================================================
8
9 displayio for Blinka
10
11 **Software and Dependencies:**
12
13 * Adafruit Blinka:
14   https://github.com/adafruit/Adafruit_Blinka/releases
15
16 * Author(s): Melissa LeBlanc-Williams
17
18 """
19
20 import struct
21 from typing import Union, Optional, Tuple
22 from ._bitmap import Bitmap
23 from ._colorconverter import ColorConverter
24 from ._ondiskbitmap import OnDiskBitmap
25 from ._shape import Shape
26 from ._palette import Palette
27 from ._structs import TransformStruct, InputPixelStruct, OutputPixelStruct
28 from ._colorspace import Colorspace
29 from ._area import Area
30
31 __version__ = "0.0.0+auto.0"
32 __repo__ = "https://github.com/adafruit/Adafruit_Blinka_displayio.git"
33
34
35 class TileGrid:
36     # pylint: disable=too-many-instance-attributes
37     """Position a grid of tiles sourced from a bitmap and pixel_shader combination. Multiple
38     grids can share bitmaps and pixel shaders.
39
40     A single tile grid is also known as a Sprite.
41     """
42
43     def __init__(
44         self,
45         bitmap: Union[Bitmap, OnDiskBitmap, Shape],
46         *,
47         pixel_shader: Union[ColorConverter, Palette],
48         width: int = 1,
49         height: int = 1,
50         tile_width: Optional[int] = None,
51         tile_height: Optional[int] = None,
52         default_tile: int = 0,
53         x: int = 0,
54         y: int = 0,
55     ):
56         """Create a TileGrid object. The bitmap is source for 2d pixels. The pixel_shader is
57         used to convert the value and its location to a display native pixel color. This may
58         be a simple color palette lookup, a gradient, a pattern or a color transformer.
59
60         tile_width and tile_height match the height of the bitmap by default.
61         """
62         if not isinstance(bitmap, (Bitmap, OnDiskBitmap, Shape)):
63             raise ValueError("Unsupported Bitmap type")
64         self._bitmap = bitmap
65         bitmap_width = bitmap.width
66         bitmap_height = bitmap.height
67
68         if pixel_shader is not None and not isinstance(
69             pixel_shader, (ColorConverter, Palette)
70         ):
71             raise ValueError("Unsupported Pixel Shader type")
72         self._pixel_shader = pixel_shader
73         if isinstance(self._pixel_shader, ColorConverter):
74             self._pixel_shader._rgba = True  # pylint: disable=protected-access
75         self._hidden_tilegrid = False
76         self._hidden_by_parent = False
77         self._rendered_hidden = False
78         self._x = x
79         self._y = y
80         self._width_in_tiles = width
81         self._height_in_tiles = height
82         self._transpose_xy = False
83         self._flip_x = False
84         self._flip_y = False
85         self._top_left_x = 0
86         self._top_left_y = 0
87         if tile_width is None or tile_width == 0:
88             tile_width = bitmap_width
89         if tile_height is None or tile_width == 0:
90             tile_height = bitmap_height
91         if tile_width < 1 or tile_height < 1:
92             raise ValueError("Tile width and height must be greater than 0")
93         if bitmap_width % tile_width != 0:
94             raise ValueError("Tile width must exactly divide bitmap width")
95         self._tile_width = tile_width
96         if bitmap_height % tile_height != 0:
97             raise ValueError("Tile height must exactly divide bitmap height")
98         self._tile_height = tile_height
99         if not 0 <= default_tile <= 255:
100             raise ValueError("Default Tile is out of range")
101         self._pixel_width = width * tile_width
102         self._pixel_height = height * tile_height
103         self._tiles = bytearray(
104             (self._width_in_tiles * self._height_in_tiles) * [default_tile]
105         )
106         self._in_group = False
107         self._absolute_transform = TransformStruct(0, 0, 1, 1, 1, False, False, False)
108         self._current_area = Area(0, 0, self._pixel_width, self._pixel_height)
109         self._dirty_area = Area(0, 0, 0, 0)
110         self._previous_area = Area(0xFFFF, 0xFFFF, 0xFFFF, 0xFFFF)
111         self._moved = False
112         self._full_change = True
113         self._partial_change = True
114         self._bitmap_width_in_tiles = bitmap_width // tile_width
115         self._tiles_in_bitmap = self._bitmap_width_in_tiles * (
116             bitmap_height // tile_height
117         )
118
119     def _update_transform(self, absolute_transform):
120         """Update the parent transform and child transforms"""
121         self._absolute_transform = absolute_transform
122         if self._absolute_transform is not None:
123             self._update_current_x()
124             self._update_current_y()
125
126     def _update_current_x(self):
127         if self._transpose_xy:
128             width = self._pixel_height
129         else:
130             width = self._pixel_width
131
132         if self._absolute_transform.transpose_xy:
133             self._current_area.y1 = (
134                 self._absolute_transform.y + self._absolute_transform.dy * self._x
135             )
136             self._current_area.y2 = (
137                 self._absolute_transform.y
138                 + self._absolute_transform.dy * (self._x + width)
139             )
140             if self._current_area.y2 < self._current_area.y1:
141                 self._current_area.y1, self._current_area.y2 = (
142                     self._current_area.y2,
143                     self._current_area.y1,
144                 )
145         else:
146             self._current_area.x1 = (
147                 self._absolute_transform.x + self._absolute_transform.dx * self._x
148             )
149             self._current_area.x2 = (
150                 self._absolute_transform.x
151                 + self._absolute_transform.dx * (self._x + width)
152             )
153             if self._current_area.x2 < self._current_area.x1:
154                 self._current_area.x1, self._current_area.x2 = (
155                     self._current_area.x2,
156                     self._current_area.x1,
157                 )
158
159     def _update_current_y(self):
160         if self._transpose_xy:
161             height = self._pixel_width
162         else:
163             height = self._pixel_height
164
165         if self._absolute_transform.transpose_xy:
166             self._current_area.x1 = (
167                 self._absolute_transform.x + self._absolute_transform.dx * self._y
168             )
169             self._current_area.x2 = (
170                 self._absolute_transform.x
171                 + self._absolute_transform.dx * (self._y + height)
172             )
173             if self._current_area.x2 < self._current_area.x1:
174                 self._current_area.x1, self._current_area.x2 = (
175                     self._current_area.x2,
176                     self._current_area.x1,
177                 )
178         else:
179             self._current_area.y1 = (
180                 self._absolute_transform.y + self._absolute_transform.dy * self._y
181             )
182             self._current_area.y2 = (
183                 self._absolute_transform.y
184                 + self._absolute_transform.dy * (self._y + height)
185             )
186             if self._current_area.y2 < self._current_area.y1:
187                 self._current_area.y1, self._current_area.y2 = (
188                     self._current_area.y2,
189                     self._current_area.y1,
190                 )
191
192     def _shade(self, pixel_value):
193         if isinstance(self._pixel_shader, Palette):
194             return self._pixel_shader[pixel_value]
195         if isinstance(self._pixel_shader, ColorConverter):
196             return self._pixel_shader.convert(pixel_value)
197         return pixel_value
198
199     def _apply_palette(self, image):
200         image.putpalette(
201             self._pixel_shader._get_palette()  # pylint: disable=protected-access
202         )
203
204     def _add_alpha(self, image):
205         alpha = self._bitmap._image.copy().convert(  # pylint: disable=protected-access
206             "P"
207         )
208         alpha.putpalette(
209             self._pixel_shader._get_alpha_palette()  # pylint: disable=protected-access
210         )
211         image.putalpha(alpha.convert("L"))
212
213     def _fill_area(
214         self, colorspace: Colorspace, area: Area, mask: bytearray, buffer: bytearray
215     ) -> bool:
216         """Draw onto the image"""
217         # pylint: disable=too-many-locals,too-many-branches,too-many-statements
218
219         # If no tiles are present we have no impact
220         tiles = self._tiles
221
222         if self._hidden_tilegrid or self._hidden_by_parent:
223             return False
224
225         overlap = Area()
226         if not self._current_area.compute_overlap(area, overlap):
227             return False
228
229         if self._bitmap.width <= 0 or self._bitmap.height <= 0:
230             return False
231
232         x_stride = 1
233         y_stride = area.width()
234
235         flip_x = self._flip_x
236         flip_y = self._flip_y
237         if self._transpose_xy != self._absolute_transform.transpose_xy:
238             flip_x, flip_y = flip_y, flip_x
239
240         start = 0
241         if (self._absolute_transform.dx < 0) != flip_x:
242             start += (area.x2 - area.x1 - 1) * x_stride
243             x_stride *= -1
244         if (self._absolute_transform.dy < 0) != flip_y:
245             start += (area.y2 - area.y1 - 1) * y_stride
246             y_stride *= -1
247
248         full_coverage = area == overlap
249
250         transformed = Area()
251         area.transform_within(
252             flip_x != (self._absolute_transform.dx < 0),
253             flip_y != (self._absolute_transform.dy < 0),
254             self.transpose_xy != self._absolute_transform.transpose_xy,
255             overlap,
256             self._current_area,
257             transformed,
258         )
259
260         start_x = transformed.x1 - self._current_area.x1
261         end_x = transformed.x2 - self._current_area.x1
262         start_y = transformed.y1 - self._current_area.y1
263         end_y = transformed.y2 - self._current_area.y1
264
265         if (self._absolute_transform.dx < 0) != flip_x:
266             x_shift = area.x2 - overlap.x2
267         else:
268             x_shift = overlap.x1 - area.x1
269         if (self._absolute_transform.dy < 0) != flip_y:
270             y_shift = area.y2 - overlap.y2
271         else:
272             y_shift = overlap.y1 - area.y1
273
274         if self._transpose_xy != self._absolute_transform.transpose_xy:
275             x_stride, y_stride = y_stride, x_stride
276             x_shift, y_shift = y_shift, x_shift
277
278         pixels_per_byte = 8 // colorspace.depth
279
280         input_pixel = InputPixelStruct()
281         output_pixel = OutputPixelStruct()
282         for input_pixel.y in range(start_y, end_y):
283             row_start = (
284                 start + (input_pixel.y - start_y + y_shift) * y_stride
285             )  # In Pixels
286             local_y = input_pixel.y // self._absolute_transform.scale
287             for input_pixel.x in range(start_x, end_x):
288                 offset = (
289                     row_start + (input_pixel.x - start_x + x_shift) * x_stride
290                 )  # In Pixels
291
292                 # Check the mask first to see if the pixel has already been set
293                 if mask[offset // 32] & (1 << (offset % 32)):
294                     continue
295                 local_x = input_pixel.x // self._absolute_transform.scale
296                 tile_location = (
297                     (local_y // self._tile_height + self._top_left_y)
298                     % self._height_in_tiles
299                 ) * self._width_in_tiles + (
300                     local_x // self._tile_width + self._top_left_x
301                 ) % self._width_in_tiles
302                 input_pixel.tile = tiles[tile_location]
303                 input_pixel.tile_x = (
304                     input_pixel.tile % self._bitmap_width_in_tiles
305                 ) * self._tile_width + local_x % self._tile_width
306                 input_pixel.tile_y = (
307                     input_pixel.tile // self._bitmap_width_in_tiles
308                 ) * self._tile_height + local_y % self._tile_height
309
310                 input_pixel.pixel = (
311                     self._bitmap._get_pixel(  # pylint: disable=protected-access
312                         input_pixel.tile_x, input_pixel.tile_y
313                     )
314                 )
315                 output_pixel.opaque = True
316
317                 if self._pixel_shader is None:
318                     output_pixel.pixel = input_pixel.pixel
319                 elif isinstance(self._pixel_shader, Palette):
320                     self._pixel_shader._get_color(  # pylint: disable=protected-access
321                         colorspace, input_pixel, output_pixel
322                     )
323                 elif isinstance(self._pixel_shader, ColorConverter):
324                     self._pixel_shader._convert(  # pylint: disable=protected-access
325                         colorspace, input_pixel, output_pixel
326                     )
327
328                 if not output_pixel.opaque:
329                     full_coverage = False
330                 else:
331                     mask[offset // 32] |= 1 << (offset % 32)
332                     if colorspace.depth == 16:
333                         buffer = (
334                             buffer[:offset]
335                             + struct.pack("H", output_pixel.pixel)
336                             + buffer[offset + 2 :]
337                         )
338                     elif colorspace.depth == 32:
339                         buffer = (
340                             buffer[:offset]
341                             + struct.pack("I", output_pixel.pixel)
342                             + buffer[offset + 4 :]
343                         )
344                     elif colorspace.depth == 8:
345                         buffer[offset] = output_pixel.pixel & 0xFF
346                     elif colorspace.depth < 8:
347                         # Reorder the offsets to pack multiple rows into
348                         # a byte (meaning they share a column).
349                         if not colorspace.pixels_in_byte_share_row:
350                             width = area.width()
351                             row = offset // width
352                             col = offset % width
353                             # Dividing by pixels_per_byte does truncated division
354                             # even if we multiply it back out
355                             offset = (
356                                 col * pixels_per_byte
357                                 + (row // pixels_per_byte) * width
358                                 + (row % pixels_per_byte)
359                             )
360                         shift = (offset % pixels_per_byte) * colorspace.depth
361                         if colorspace.reverse_pixels_in_byte:
362                             # Reverse the shift by subtracting it from the leftmost shift
363                             shift = (pixels_per_byte - 1) * colorspace.depth - shift
364                         buffer[offset // pixels_per_byte] |= output_pixel.pixel << shift
365         return full_coverage
366
367     def _finish_refresh(self):
368         first_draw = self._previous_area.x1 == self._previous_area.x2
369         hidden = self._hidden_tilegrid or self._hidden_by_parent
370         if not first_draw and hidden:
371             self._previous_area.x2 = self._previous_area.x1
372         elif self._moved or first_draw:
373             self._current_area.copy_into(self._previous_area)
374
375         self._moved = False
376         self._full_change = False
377         self._partial_change = False
378         if isinstance(self._pixel_shader, (Palette, ColorConverter)):
379             self._pixel_shader._finish_refresh()  # pylint: disable=protected-access
380         if isinstance(self._bitmap, (Bitmap, Shape)):
381             self._bitmap._finish_refresh()  # pylint: disable=protected-access
382
383     def _get_refresh_areas(self, areas: list[Area]) -> None:
384         # pylint: disable=invalid-name, too-many-branches, too-many-statements
385         first_draw = self._previous_area.x1 == self._previous_area.x2
386         hidden = self._hidden_tilegrid or self._hidden_by_parent
387
388         # Check hidden first because it trumps all other changes
389         if hidden:
390             self._rendered_hidden = True
391             if not first_draw:
392                 areas.append(self._previous_area)
393             return
394         if self._moved and not first_draw:
395             self._previous_area.union(self._current_area, self._dirty_area)
396             if self._dirty_area.size() < 2 * self._pixel_width * self._pixel_height:
397                 areas.append(self._dirty_area)
398                 return
399             areas.append(self._current_area)
400             areas.append(self._previous_area)
401             return
402
403         tail = areas[-1]
404         # If we have an in-memory bitmap, then check it for modifications
405         if isinstance(self._bitmap, Bitmap):
406             self._bitmap._get_refresh_areas(areas)  # pylint: disable=protected-access
407             if tail != areas[-1]:
408                 # Special case a TileGrid that shows a full bitmap and use its
409                 # dirty area. Copy it to ours so we can transform it.
410                 if self._tiles_in_bitmap == 1:
411                     areas[-1].copy_into(self._dirty_area)
412                     self._partial_change = True
413                 else:
414                     self._full_change = True
415         elif isinstance(self._bitmap, Shape):
416             self._bitmap._get_refresh_areas(areas)  # pylint: disable=protected-access
417             if areas[-1] != tail:
418                 areas[-1].copy_into(self._dirty_area)
419                 self._partial_change = True
420
421         self._full_change = self._full_change or (
422             isinstance(self._pixel_shader, (Palette, ColorConverter))
423             and self._pixel_shader._needs_refresh  # pylint: disable=protected-access
424         )
425         if self._full_change or first_draw:
426             areas.append(self._current_area)
427             return
428
429         if self._partial_change:
430             x = self._x
431             y = self._y
432             if self._absolute_transform.transpose_xy:
433                 x, y = y, x
434             x1 = self._dirty_area.x1
435             x2 = self._dirty_area.x2
436             if self._flip_x:
437                 x1 = self._pixel_width - x1
438                 x2 = self._pixel_width - x2
439             y1 = self._dirty_area.y1
440             y2 = self._dirty_area.y2
441             if self._flip_y:
442                 y1 = self._pixel_height - y1
443                 y2 = self._pixel_height - y2
444             if self._transpose_xy != self._absolute_transform.transpose_xy:
445                 x1, y1 = y1, x1
446                 x2, y2 = y2, x2
447             self._dirty_area.x1 = (
448                 self._absolute_transform.x + self._absolute_transform.dx * (x + x1)
449             )
450             self._dirty_area.y1 = (
451                 self._absolute_transform.y + self._absolute_transform.dy * (y + y1)
452             )
453             self._dirty_area.x2 = (
454                 self._absolute_transform.x + self._absolute_transform.dx * (x + x2)
455             )
456             self._dirty_area.y2 = (
457                 self._absolute_transform.y + self._absolute_transform.dy * (y + y2)
458             )
459             if self._dirty_area.y2 < self._dirty_area.y1:
460                 self._dirty_area.y1, self._dirty_area.y2 = (
461                     self._dirty_area.y2,
462                     self._dirty_area.y1,
463                 )
464             if self._dirty_area.x2 < self._dirty_area.x1:
465                 self._dirty_area.x1, self._dirty_area.x2 = (
466                     self._dirty_area.x2,
467                     self._dirty_area.x1,
468                 )
469             areas.append(self._dirty_area)
470
471     @property
472     def hidden(self) -> bool:
473         """True when the TileGrid is hidden. This may be False even
474         when a part of a hidden Group."""
475         return self._hidden_tilegrid
476
477     @hidden.setter
478     def hidden(self, value: bool):
479         if not isinstance(value, (bool, int)):
480             raise ValueError("Expecting a boolean or integer value")
481         self._hidden_tilegrid = bool(value)
482
483     @property
484     def x(self) -> int:
485         """X position of the left edge in the parent."""
486         return self._x
487
488     @x.setter
489     def x(self, value: int):
490         if not isinstance(value, int):
491             raise TypeError("X should be a integer type")
492         if self._x != value:
493             self._x = value
494             self._update_current_x()
495
496     @property
497     def y(self) -> int:
498         """Y position of the top edge in the parent."""
499         return self._y
500
501     @y.setter
502     def y(self, value: int):
503         if not isinstance(value, int):
504             raise TypeError("Y should be a integer type")
505         if self._y != value:
506             self._y = value
507             self._update_current_y()
508
509     @property
510     def flip_x(self) -> bool:
511         """If true, the left edge rendered will be the right edge of the right-most tile."""
512         return self._flip_x
513
514     @flip_x.setter
515     def flip_x(self, value: bool):
516         if not isinstance(value, bool):
517             raise TypeError("Flip X should be a boolean type")
518         if self._flip_x != value:
519             self._flip_x = value
520
521     @property
522     def flip_y(self) -> bool:
523         """If true, the top edge rendered will be the bottom edge of the bottom-most tile."""
524         return self._flip_y
525
526     @flip_y.setter
527     def flip_y(self, value: bool):
528         if not isinstance(value, bool):
529             raise TypeError("Flip Y should be a boolean type")
530         if self._flip_y != value:
531             self._flip_y = value
532
533     @property
534     def transpose_xy(self) -> bool:
535         """If true, the TileGrid's axis will be swapped. When combined with mirroring, any 90
536         degree rotation can be achieved along with the corresponding mirrored version.
537         """
538         return self._transpose_xy
539
540     @transpose_xy.setter
541     def transpose_xy(self, value: bool):
542         if not isinstance(value, bool):
543             raise TypeError("Transpose XY should be a boolean type")
544         if self._transpose_xy != value:
545             self._transpose_xy = value
546             self._update_current_x()
547             self._update_current_y()
548
549     @property
550     def pixel_shader(self) -> Union[ColorConverter, Palette]:
551         """The pixel shader of the tilegrid."""
552         return self._pixel_shader
553
554     @pixel_shader.setter
555     def pixel_shader(self, new_pixel_shader: Union[ColorConverter, Palette]) -> None:
556         if not isinstance(new_pixel_shader, ColorConverter) and not isinstance(
557             new_pixel_shader, Palette
558         ):
559             raise TypeError(
560                 "Unsupported Type: new_pixel_shader must be ColorConverter or Palette"
561             )
562
563         self._pixel_shader = new_pixel_shader
564
565     @property
566     def bitmap(self) -> Union[Bitmap, OnDiskBitmap, Shape]:
567         """The Bitmap, OnDiskBitmap, or Shape that is assigned to this TileGrid"""
568         return self._bitmap
569
570     @bitmap.setter
571     def bitmap(self, new_bitmap: Union[Bitmap, OnDiskBitmap, Shape]) -> None:
572         if (
573             not isinstance(new_bitmap, Bitmap)
574             and not isinstance(new_bitmap, OnDiskBitmap)
575             and not isinstance(new_bitmap, Shape)
576         ):
577             raise TypeError(
578                 "Unsupported Type: new_bitmap must be Bitmap, OnDiskBitmap, or Shape"
579             )
580
581         if (
582             new_bitmap.width != self.bitmap.width
583             or new_bitmap.height != self.bitmap.height
584         ):
585             raise ValueError("New bitmap must be same size as old bitmap")
586
587         self._bitmap = new_bitmap
588
589     def _extract_and_check_index(self, index):
590         if isinstance(index, (tuple, list)):
591             x = index[0]
592             y = index[1]
593             index = y * self._width_in_tiles + x
594         elif isinstance(index, int):
595             x = index % self._width_in_tiles
596             y = index // self._width_in_tiles
597         if (
598             x > self._width_in_tiles
599             or y > self._height_in_tiles
600             or index >= len(self._tiles)
601         ):
602             raise ValueError("Tile index out of bounds")
603         return index
604
605     def __getitem__(self, index: Union[Tuple[int, int], int]) -> int:
606         """Returns the tile index at the given index. The index can either be
607         an x,y tuple or an int equal to ``y * width + x``'.
608         """
609         index = self._extract_and_check_index(index)
610         return self._tiles[index]
611
612     def __setitem__(self, index: Union[Tuple[int, int], int], value: int) -> None:
613         """Sets the tile index at the given index. The index can either be
614         an x,y tuple or an int equal to ``y * width + x``.
615         """
616         index = self._extract_and_check_index(index)
617         if not 0 <= value <= 255:
618             raise ValueError("Tile value out of bounds")
619         self._tiles[index] = value
620
621     @property
622     def width(self) -> int:
623         """Width in tiles"""
624         return self._width_in_tiles
625
626     @property
627     def height(self) -> int:
628         """Height in tiles"""
629         return self._height_in_tiles
630
631     @property
632     def tile_width(self) -> int:
633         """Width of each tile in pixels"""
634         return self._tile_width
635
636     @property
637     def tile_height(self) -> int:
638         """Height of each tile in pixels"""
639         return self._tile_height