]> Repositories - hackapet/Adafruit_Blinka_Displayio.git/blob - displayio/_tilegrid.py
More bug fixes
[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 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
31
32 __version__ = "0.0.0+auto.0"
33 __repo__ = "https://github.com/adafruit/Adafruit_Blinka_displayio.git"
34
35
36 class TileGrid:
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.
40
41     A single tile grid is also known as a Sprite.
42     """
43
44     def __init__(
45         self,
46         bitmap: Union[Bitmap, OnDiskBitmap, Shape],
47         *,
48         pixel_shader: Union[ColorConverter, Palette],
49         width: int = 1,
50         height: int = 1,
51         tile_width: Optional[int] = None,
52         tile_height: Optional[int] = None,
53         default_tile: int = 0,
54         x: int = 0,
55         y: int = 0,
56     ):
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.
60
61         tile_width and tile_height match the height of the bitmap by default.
62         """
63         if not isinstance(bitmap, (Bitmap, OnDiskBitmap, Shape)):
64             raise ValueError("Unsupported Bitmap type")
65         self._bitmap = bitmap
66         bitmap_width = bitmap.width
67         bitmap_height = bitmap.height
68
69         if pixel_shader is not None and not isinstance(
70             pixel_shader, (ColorConverter, Palette)
71         ):
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
79         self._x = x
80         self._y = y
81         self._width_in_tiles = width
82         self._height_in_tiles = height
83         self._transpose_xy = False
84         self._flip_x = False
85         self._flip_y = False
86         self._top_left_x = 0
87         self._top_left_y = 0
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]
106         )
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)
112         self._moved = False
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
118         )
119
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()
126
127     def _update_current_x(self):
128         if self._transpose_xy:
129             width = self._pixel_height
130         else:
131             width = self._pixel_width
132
133         if self._absolute_transform.transpose_xy:
134             self._current_area.y1 = (
135                 self._absolute_transform.y + self._absolute_transform.dy * self._x
136             )
137             self._current_area.y2 = (
138                 self._absolute_transform.y
139                 + self._absolute_transform.dy * (self._x + width)
140             )
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,
145                 )
146         else:
147             self._current_area.x1 = (
148                 self._absolute_transform.x + self._absolute_transform.dx * self._x
149             )
150             self._current_area.x2 = (
151                 self._absolute_transform.x
152                 + self._absolute_transform.dx * (self._x + width)
153             )
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,
158                 )
159
160     def _update_current_y(self):
161         if self._transpose_xy:
162             height = self._pixel_width
163         else:
164             height = self._pixel_height
165
166         if self._absolute_transform.transpose_xy:
167             self._current_area.x1 = (
168                 self._absolute_transform.x + self._absolute_transform.dx * self._y
169             )
170             self._current_area.x2 = (
171                 self._absolute_transform.x
172                 + self._absolute_transform.dx * (self._y + height)
173             )
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,
178                 )
179         else:
180             self._current_area.y1 = (
181                 self._absolute_transform.y + self._absolute_transform.dy * self._y
182             )
183             self._current_area.y2 = (
184                 self._absolute_transform.y
185                 + self._absolute_transform.dy * (self._y + height)
186             )
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,
191                 )
192
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)
198         return pixel_value
199
200     def _apply_palette(self, image):
201         image.putpalette(
202             self._pixel_shader._get_palette()  # pylint: disable=protected-access
203         )
204
205     def _add_alpha(self, image):
206         alpha = self._bitmap._image.copy().convert(  # pylint: disable=protected-access
207             "P"
208         )
209         alpha.putpalette(
210             self._pixel_shader._get_alpha_palette()  # pylint: disable=protected-access
211         )
212         image.putalpha(alpha.convert("L"))
213
214     def _fill_area(
215         self,
216         colorspace: Colorspace,
217         area: Area,
218         mask: WriteableBuffer,
219         buffer: WriteableBuffer,
220     ) -> bool:
221         """Draw onto the image"""
222         # pylint: disable=too-many-locals,too-many-branches,too-many-statements
223
224         # If no tiles are present we have no impact
225         tiles = self._tiles
226
227         if self._hidden_tilegrid or self._hidden_by_parent:
228             return False
229
230         overlap = Area()
231         if not self._current_area.compute_overlap(area, overlap):
232             return False
233
234         if self._bitmap.width <= 0 or self._bitmap.height <= 0:
235             return False
236
237         x_stride = 1
238         y_stride = area.width()
239
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
244
245         start = 0
246         if (self._absolute_transform.dx < 0) != flip_x:
247             start += (area.x2 - area.x1 - 1) * x_stride
248             x_stride *= -1
249         if (self._absolute_transform.dy < 0) != flip_y:
250             start += (area.y2 - area.y1 - 1) * y_stride
251             y_stride *= -1
252
253         full_coverage = area == overlap
254
255         transformed = Area()
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,
260             overlap,
261             self._current_area,
262             transformed,
263         )
264
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
269
270         if (self._absolute_transform.dx < 0) != flip_x:
271             x_shift = area.x2 - overlap.x2
272         else:
273             x_shift = overlap.x1 - area.x1
274         if (self._absolute_transform.dy < 0) != flip_y:
275             y_shift = area.y2 - overlap.y2
276         else:
277             y_shift = overlap.y1 - area.y1
278
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
282
283         pixels_per_byte = 8 // colorspace.depth
284
285         input_pixel = InputPixelStruct()
286         output_pixel = OutputPixelStruct()
287         for input_pixel.y in range(start_y, end_y):
288             row_start = (
289                 start + (input_pixel.y - start_y + y_shift) * y_stride
290             )  # In Pixels
291             local_y = input_pixel.y // self._absolute_transform.scale
292             for input_pixel.x in range(start_x, end_x):
293                 offset = (
294                     row_start + (input_pixel.x - start_x + x_shift) * x_stride
295                 )  # In Pixels
296
297                 # Check the mask first to see if the pixel has already been set
298                 if mask[offset // 32] & (1 << (offset % 32)):
299                     continue
300                 local_x = input_pixel.x // self._absolute_transform.scale
301                 tile_location = (
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
314
315                 input_pixel.pixel = (
316                     self._bitmap._get_pixel(  # pylint: disable=protected-access
317                         input_pixel.tile_x, input_pixel.tile_y
318                     )
319                 )
320                 output_pixel.opaque = True
321
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
327                     )
328                 elif isinstance(self._pixel_shader, ColorConverter):
329                     self._pixel_shader._convert(  # pylint: disable=protected-access
330                         colorspace, input_pixel, output_pixel
331                     )
332
333                 if not output_pixel.opaque:
334                     full_coverage = False
335                 else:
336                     mask[offset // 32] |= 1 << (offset % 32)
337                     if colorspace.depth == 16:
338                         struct.pack_into(
339                             "H",
340                             buffer,
341                             offset * 2,
342                             output_pixel.pixel,
343                         )
344                     elif colorspace.depth == 32:
345                         struct.pack_into(
346                             "I",
347                             buffer,
348                             offset * 4,
349                             output_pixel.pixel,
350                         )
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:
357                             width = area.width()
358                             row = offset // width
359                             col = offset % width
360                             # Dividing by pixels_per_byte does truncated division
361                             # even if we multiply it back out
362                             offset = (
363                                 col * pixels_per_byte
364                                 + (row // pixels_per_byte) * width
365                                 + (row % pixels_per_byte)
366                             )
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
372         return full_coverage
373
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)
381
382         self._moved = False
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
389
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
394
395         # Check hidden first because it trumps all other changes
396         if hidden:
397             self._rendered_hidden = True
398             if not first_draw:
399                 areas.append(self._previous_area)
400             return
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)
405                 return
406             areas.append(self._current_area)
407             areas.append(self._previous_area)
408             return
409
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
421                 else:
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
429
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
433         )
434         if self._full_change or first_draw:
435             areas.append(self._current_area)
436             return
437
438         if self._partial_change:
439             x = self._x
440             y = self._y
441             if self._absolute_transform.transpose_xy:
442                 x, y = y, x
443             x1 = self._dirty_area.x1
444             x2 = self._dirty_area.x2
445             if self._flip_x:
446                 x1 = self._pixel_width - x1
447                 x2 = self._pixel_width - x2
448             y1 = self._dirty_area.y1
449             y2 = self._dirty_area.y2
450             if self._flip_y:
451                 y1 = self._pixel_height - y1
452                 y2 = self._pixel_height - y2
453             if self._transpose_xy != self._absolute_transform.transpose_xy:
454                 x1, y1 = y1, x1
455                 x2, y2 = y2, x2
456             self._dirty_area.x1 = (
457                 self._absolute_transform.x + self._absolute_transform.dx * (x + x1)
458             )
459             self._dirty_area.y1 = (
460                 self._absolute_transform.y + self._absolute_transform.dy * (y + y1)
461             )
462             self._dirty_area.x2 = (
463                 self._absolute_transform.x + self._absolute_transform.dx * (x + x2)
464             )
465             self._dirty_area.y2 = (
466                 self._absolute_transform.y + self._absolute_transform.dy * (y + y2)
467             )
468             if self._dirty_area.y2 < self._dirty_area.y1:
469                 self._dirty_area.y1, self._dirty_area.y2 = (
470                     self._dirty_area.y2,
471                     self._dirty_area.y1,
472                 )
473             if self._dirty_area.x2 < self._dirty_area.x1:
474                 self._dirty_area.x1, self._dirty_area.x2 = (
475                     self._dirty_area.x2,
476                     self._dirty_area.x1,
477                 )
478             areas.append(self._dirty_area)
479
480     def _set_hidden(self, hidden: bool) -> None:
481         self._hidden_tilegrid = hidden
482         self._rendered_hidden = False
483         if not hidden:
484             self._full_change = True
485
486     def _set_hidden_by_parent(self, hidden: bool) -> None:
487         self._hidden_by_parent = hidden
488         self._rendered_hidden = False
489         if not hidden:
490             self._full_change = True
491
492     def _get_rendered_hidden(self) -> bool:
493         return self._rendered_hidden
494
495     @property
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
500
501     @hidden.setter
502     def hidden(self, value: bool):
503         if not isinstance(value, (bool, int)):
504             raise ValueError("Expecting a boolean or integer value")
505         value = bool(value)
506         self._set_hidden(value)
507
508     @property
509     def x(self) -> int:
510         """X position of the left edge in the parent."""
511         return self._x
512
513     @x.setter
514     def x(self, value: int):
515         if not isinstance(value, int):
516             raise TypeError("X should be a integer type")
517         if self._x != value:
518             self._x = value
519             self._update_current_x()
520
521     @property
522     def y(self) -> int:
523         """Y position of the top edge in the parent."""
524         return self._y
525
526     @y.setter
527     def y(self, value: int):
528         if not isinstance(value, int):
529             raise TypeError("Y should be a integer type")
530         if self._y != value:
531             self._y = value
532             self._update_current_y()
533
534     @property
535     def flip_x(self) -> bool:
536         """If true, the left edge rendered will be the right edge of the right-most tile."""
537         return self._flip_x
538
539     @flip_x.setter
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:
544             self._flip_x = value
545
546     @property
547     def flip_y(self) -> bool:
548         """If true, the top edge rendered will be the bottom edge of the bottom-most tile."""
549         return self._flip_y
550
551     @flip_y.setter
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:
556             self._flip_y = value
557
558     @property
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.
562         """
563         return self._transpose_xy
564
565     @transpose_xy.setter
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()
573
574     @property
575     def pixel_shader(self) -> Union[ColorConverter, Palette]:
576         """The pixel shader of the tilegrid."""
577         return self._pixel_shader
578
579     @pixel_shader.setter
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
583         ):
584             raise TypeError(
585                 "Unsupported Type: new_pixel_shader must be ColorConverter or Palette"
586             )
587
588         self._pixel_shader = new_pixel_shader
589
590     @property
591     def bitmap(self) -> Union[Bitmap, OnDiskBitmap, Shape]:
592         """The Bitmap, OnDiskBitmap, or Shape that is assigned to this TileGrid"""
593         return self._bitmap
594
595     @bitmap.setter
596     def bitmap(self, new_bitmap: Union[Bitmap, OnDiskBitmap, Shape]) -> None:
597         if (
598             not isinstance(new_bitmap, Bitmap)
599             and not isinstance(new_bitmap, OnDiskBitmap)
600             and not isinstance(new_bitmap, Shape)
601         ):
602             raise TypeError(
603                 "Unsupported Type: new_bitmap must be Bitmap, OnDiskBitmap, or Shape"
604             )
605
606         if (
607             new_bitmap.width != self.bitmap.width
608             or new_bitmap.height != self.bitmap.height
609         ):
610             raise ValueError("New bitmap must be same size as old bitmap")
611
612         self._bitmap = new_bitmap
613
614     def _extract_and_check_index(self, index):
615         if isinstance(index, (tuple, list)):
616             x = index[0]
617             y = index[1]
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
622         if (
623             x > self._width_in_tiles
624             or y > self._height_in_tiles
625             or index >= len(self._tiles)
626         ):
627             raise ValueError("Tile index out of bounds")
628         return index
629
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``'.
633         """
634         index = self._extract_and_check_index(index)
635         return self._tiles[index]
636
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``.
640         """
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
645
646     @property
647     def width(self) -> int:
648         """Width in tiles"""
649         return self._width_in_tiles
650
651     @property
652     def height(self) -> int:
653         """Height in tiles"""
654         return self._height_in_tiles
655
656     @property
657     def tile_width(self) -> int:
658         """Width of each tile in pixels"""
659         return self._tile_width
660
661     @property
662     def tile_height(self) -> int:
663         """Height of each tile in pixels"""
664         return self._tile_height