]> Repositories - hackapet/Adafruit_Blinka_Displayio.git/blob - displayio/_tilegrid.py
Fixed buffer and get_refresh_areas
[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 import circuitpython_typing
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: circuitpython_typing.WriteableBuffer,
219         buffer: circuitpython_typing.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                         buffer = (
339                             buffer[:offset]
340                             + struct.pack("H", output_pixel.pixel)
341                             + buffer[offset + 2 :]
342                         )
343                     elif colorspace.depth == 32:
344                         buffer = (
345                             buffer[:offset]
346                             + struct.pack("I", output_pixel.pixel)
347                             + buffer[offset + 4 :]
348                         )
349                     elif colorspace.depth == 8:
350                         buffer[offset] = output_pixel.pixel & 0xFF
351                     elif colorspace.depth < 8:
352                         # Reorder the offsets to pack multiple rows into
353                         # a byte (meaning they share a column).
354                         if not colorspace.pixels_in_byte_share_row:
355                             width = area.width()
356                             row = offset // width
357                             col = offset % width
358                             # Dividing by pixels_per_byte does truncated division
359                             # even if we multiply it back out
360                             offset = (
361                                 col * pixels_per_byte
362                                 + (row // pixels_per_byte) * width
363                                 + (row % pixels_per_byte)
364                             )
365                         shift = (offset % pixels_per_byte) * colorspace.depth
366                         if colorspace.reverse_pixels_in_byte:
367                             # Reverse the shift by subtracting it from the leftmost shift
368                             shift = (pixels_per_byte - 1) * colorspace.depth - shift
369                         buffer[offset // pixels_per_byte] |= output_pixel.pixel << shift
370         return full_coverage
371
372     def _finish_refresh(self):
373         first_draw = self._previous_area.x1 == self._previous_area.x2
374         hidden = self._hidden_tilegrid or self._hidden_by_parent
375         if not first_draw and hidden:
376             self._previous_area.x2 = self._previous_area.x1
377         elif self._moved or first_draw:
378             self._current_area.copy_into(self._previous_area)
379
380         self._moved = False
381         self._full_change = False
382         self._partial_change = False
383         if isinstance(self._pixel_shader, (Palette, ColorConverter)):
384             self._pixel_shader._finish_refresh()  # pylint: disable=protected-access
385         if isinstance(self._bitmap, (Bitmap, Shape)):
386             self._bitmap._finish_refresh()  # pylint: disable=protected-access
387
388     def _get_refresh_areas(self, areas: list[Area]) -> None:
389         # pylint: disable=invalid-name, too-many-branches, too-many-statements
390         first_draw = self._previous_area.x1 == self._previous_area.x2
391         hidden = self._hidden_tilegrid or self._hidden_by_parent
392
393         # Check hidden first because it trumps all other changes
394         if hidden:
395             self._rendered_hidden = True
396             if not first_draw:
397                 areas.append(self._previous_area)
398             return
399         if self._moved and not first_draw:
400             self._previous_area.union(self._current_area, self._dirty_area)
401             if self._dirty_area.size() < 2 * self._pixel_width * self._pixel_height:
402                 areas.append(self._dirty_area)
403                 return
404             areas.append(self._current_area)
405             areas.append(self._previous_area)
406             return
407
408         tail = areas[-1] if areas else None
409         # If we have an in-memory bitmap, then check it for modifications
410         if isinstance(self._bitmap, Bitmap):
411             self._bitmap._get_refresh_areas(areas)  # pylint: disable=protected-access
412             refresh_area = areas[-1] if areas else None
413             if tail != refresh_area:
414                 # Special case a TileGrid that shows a full bitmap and use its
415                 # dirty area. Copy it to ours so we can transform it.
416                 if self._tiles_in_bitmap == 1:
417                     refresh_area.copy_into(self._dirty_area)
418                     self._partial_change = True
419                 else:
420                     self._full_change = True
421         elif isinstance(self._bitmap, Shape):
422             self._bitmap._get_refresh_areas(areas)  # pylint: disable=protected-access
423             refresh_area = areas[-1] if areas else None
424             if refresh_area != tail:
425                 refresh_area.copy_into(self._dirty_area)
426                 self._partial_change = True
427
428         self._full_change = self._full_change or (
429             isinstance(self._pixel_shader, (Palette, ColorConverter))
430             and self._pixel_shader._needs_refresh  # pylint: disable=protected-access
431         )
432         if self._full_change or first_draw:
433             areas.append(self._current_area)
434             return
435
436         if self._partial_change:
437             x = self._x
438             y = self._y
439             if self._absolute_transform.transpose_xy:
440                 x, y = y, x
441             x1 = self._dirty_area.x1
442             x2 = self._dirty_area.x2
443             if self._flip_x:
444                 x1 = self._pixel_width - x1
445                 x2 = self._pixel_width - x2
446             y1 = self._dirty_area.y1
447             y2 = self._dirty_area.y2
448             if self._flip_y:
449                 y1 = self._pixel_height - y1
450                 y2 = self._pixel_height - y2
451             if self._transpose_xy != self._absolute_transform.transpose_xy:
452                 x1, y1 = y1, x1
453                 x2, y2 = y2, x2
454             self._dirty_area.x1 = (
455                 self._absolute_transform.x + self._absolute_transform.dx * (x + x1)
456             )
457             self._dirty_area.y1 = (
458                 self._absolute_transform.y + self._absolute_transform.dy * (y + y1)
459             )
460             self._dirty_area.x2 = (
461                 self._absolute_transform.x + self._absolute_transform.dx * (x + x2)
462             )
463             self._dirty_area.y2 = (
464                 self._absolute_transform.y + self._absolute_transform.dy * (y + y2)
465             )
466             if self._dirty_area.y2 < self._dirty_area.y1:
467                 self._dirty_area.y1, self._dirty_area.y2 = (
468                     self._dirty_area.y2,
469                     self._dirty_area.y1,
470                 )
471             if self._dirty_area.x2 < self._dirty_area.x1:
472                 self._dirty_area.x1, self._dirty_area.x2 = (
473                     self._dirty_area.x2,
474                     self._dirty_area.x1,
475                 )
476             areas.append(self._dirty_area)
477
478     @property
479     def hidden(self) -> bool:
480         """True when the TileGrid is hidden. This may be False even
481         when a part of a hidden Group."""
482         return self._hidden_tilegrid
483
484     @hidden.setter
485     def hidden(self, value: bool):
486         if not isinstance(value, (bool, int)):
487             raise ValueError("Expecting a boolean or integer value")
488         self._hidden_tilegrid = bool(value)
489
490     @property
491     def x(self) -> int:
492         """X position of the left edge in the parent."""
493         return self._x
494
495     @x.setter
496     def x(self, value: int):
497         if not isinstance(value, int):
498             raise TypeError("X should be a integer type")
499         if self._x != value:
500             self._x = value
501             self._update_current_x()
502
503     @property
504     def y(self) -> int:
505         """Y position of the top edge in the parent."""
506         return self._y
507
508     @y.setter
509     def y(self, value: int):
510         if not isinstance(value, int):
511             raise TypeError("Y should be a integer type")
512         if self._y != value:
513             self._y = value
514             self._update_current_y()
515
516     @property
517     def flip_x(self) -> bool:
518         """If true, the left edge rendered will be the right edge of the right-most tile."""
519         return self._flip_x
520
521     @flip_x.setter
522     def flip_x(self, value: bool):
523         if not isinstance(value, bool):
524             raise TypeError("Flip X should be a boolean type")
525         if self._flip_x != value:
526             self._flip_x = value
527
528     @property
529     def flip_y(self) -> bool:
530         """If true, the top edge rendered will be the bottom edge of the bottom-most tile."""
531         return self._flip_y
532
533     @flip_y.setter
534     def flip_y(self, value: bool):
535         if not isinstance(value, bool):
536             raise TypeError("Flip Y should be a boolean type")
537         if self._flip_y != value:
538             self._flip_y = value
539
540     @property
541     def transpose_xy(self) -> bool:
542         """If true, the TileGrid's axis will be swapped. When combined with mirroring, any 90
543         degree rotation can be achieved along with the corresponding mirrored version.
544         """
545         return self._transpose_xy
546
547     @transpose_xy.setter
548     def transpose_xy(self, value: bool):
549         if not isinstance(value, bool):
550             raise TypeError("Transpose XY should be a boolean type")
551         if self._transpose_xy != value:
552             self._transpose_xy = value
553             self._update_current_x()
554             self._update_current_y()
555
556     @property
557     def pixel_shader(self) -> Union[ColorConverter, Palette]:
558         """The pixel shader of the tilegrid."""
559         return self._pixel_shader
560
561     @pixel_shader.setter
562     def pixel_shader(self, new_pixel_shader: Union[ColorConverter, Palette]) -> None:
563         if not isinstance(new_pixel_shader, ColorConverter) and not isinstance(
564             new_pixel_shader, Palette
565         ):
566             raise TypeError(
567                 "Unsupported Type: new_pixel_shader must be ColorConverter or Palette"
568             )
569
570         self._pixel_shader = new_pixel_shader
571
572     @property
573     def bitmap(self) -> Union[Bitmap, OnDiskBitmap, Shape]:
574         """The Bitmap, OnDiskBitmap, or Shape that is assigned to this TileGrid"""
575         return self._bitmap
576
577     @bitmap.setter
578     def bitmap(self, new_bitmap: Union[Bitmap, OnDiskBitmap, Shape]) -> None:
579         if (
580             not isinstance(new_bitmap, Bitmap)
581             and not isinstance(new_bitmap, OnDiskBitmap)
582             and not isinstance(new_bitmap, Shape)
583         ):
584             raise TypeError(
585                 "Unsupported Type: new_bitmap must be Bitmap, OnDiskBitmap, or Shape"
586             )
587
588         if (
589             new_bitmap.width != self.bitmap.width
590             or new_bitmap.height != self.bitmap.height
591         ):
592             raise ValueError("New bitmap must be same size as old bitmap")
593
594         self._bitmap = new_bitmap
595
596     def _extract_and_check_index(self, index):
597         if isinstance(index, (tuple, list)):
598             x = index[0]
599             y = index[1]
600             index = y * self._width_in_tiles + x
601         elif isinstance(index, int):
602             x = index % self._width_in_tiles
603             y = index // self._width_in_tiles
604         if (
605             x > self._width_in_tiles
606             or y > self._height_in_tiles
607             or index >= len(self._tiles)
608         ):
609             raise ValueError("Tile index out of bounds")
610         return index
611
612     def __getitem__(self, index: Union[Tuple[int, int], int]) -> int:
613         """Returns 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         return self._tiles[index]
618
619     def __setitem__(self, index: Union[Tuple[int, int], int], value: int) -> None:
620         """Sets the tile index at the given index. The index can either be
621         an x,y tuple or an int equal to ``y * width + x``.
622         """
623         index = self._extract_and_check_index(index)
624         if not 0 <= value <= 255:
625             raise ValueError("Tile value out of bounds")
626         self._tiles[index] = value
627
628     @property
629     def width(self) -> int:
630         """Width in tiles"""
631         return self._width_in_tiles
632
633     @property
634     def height(self) -> int:
635         """Height in tiles"""
636         return self._height_in_tiles
637
638     @property
639     def tile_width(self) -> int:
640         """Width of each tile in pixels"""
641         return self._tile_width
642
643     @property
644     def tile_height(self) -> int:
645         """Height of each tile in pixels"""
646         return self._tile_height