]> Repositories - hackapet/Adafruit_Blinka_Displayio.git/blob - displayio/_tilegrid.py
Remove more of PIL
[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,
342                             output_pixel.pixel,
343                         )
344                     elif colorspace.depth == 32:
345                         struct.pack_into(
346                             "I",
347                             buffer,
348                             offset,
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     @property
481     def hidden(self) -> bool:
482         """True when the TileGrid is hidden. This may be False even
483         when a part of a hidden Group."""
484         return self._hidden_tilegrid
485
486     @hidden.setter
487     def hidden(self, value: bool):
488         if not isinstance(value, (bool, int)):
489             raise ValueError("Expecting a boolean or integer value")
490         self._hidden_tilegrid = bool(value)
491
492     @property
493     def x(self) -> int:
494         """X position of the left edge in the parent."""
495         return self._x
496
497     @x.setter
498     def x(self, value: int):
499         if not isinstance(value, int):
500             raise TypeError("X should be a integer type")
501         if self._x != value:
502             self._x = value
503             self._update_current_x()
504
505     @property
506     def y(self) -> int:
507         """Y position of the top edge in the parent."""
508         return self._y
509
510     @y.setter
511     def y(self, value: int):
512         if not isinstance(value, int):
513             raise TypeError("Y should be a integer type")
514         if self._y != value:
515             self._y = value
516             self._update_current_y()
517
518     @property
519     def flip_x(self) -> bool:
520         """If true, the left edge rendered will be the right edge of the right-most tile."""
521         return self._flip_x
522
523     @flip_x.setter
524     def flip_x(self, value: bool):
525         if not isinstance(value, bool):
526             raise TypeError("Flip X should be a boolean type")
527         if self._flip_x != value:
528             self._flip_x = value
529
530     @property
531     def flip_y(self) -> bool:
532         """If true, the top edge rendered will be the bottom edge of the bottom-most tile."""
533         return self._flip_y
534
535     @flip_y.setter
536     def flip_y(self, value: bool):
537         if not isinstance(value, bool):
538             raise TypeError("Flip Y should be a boolean type")
539         if self._flip_y != value:
540             self._flip_y = value
541
542     @property
543     def transpose_xy(self) -> bool:
544         """If true, the TileGrid's axis will be swapped. When combined with mirroring, any 90
545         degree rotation can be achieved along with the corresponding mirrored version.
546         """
547         return self._transpose_xy
548
549     @transpose_xy.setter
550     def transpose_xy(self, value: bool):
551         if not isinstance(value, bool):
552             raise TypeError("Transpose XY should be a boolean type")
553         if self._transpose_xy != value:
554             self._transpose_xy = value
555             self._update_current_x()
556             self._update_current_y()
557
558     @property
559     def pixel_shader(self) -> Union[ColorConverter, Palette]:
560         """The pixel shader of the tilegrid."""
561         return self._pixel_shader
562
563     @pixel_shader.setter
564     def pixel_shader(self, new_pixel_shader: Union[ColorConverter, Palette]) -> None:
565         if not isinstance(new_pixel_shader, ColorConverter) and not isinstance(
566             new_pixel_shader, Palette
567         ):
568             raise TypeError(
569                 "Unsupported Type: new_pixel_shader must be ColorConverter or Palette"
570             )
571
572         self._pixel_shader = new_pixel_shader
573
574     @property
575     def bitmap(self) -> Union[Bitmap, OnDiskBitmap, Shape]:
576         """The Bitmap, OnDiskBitmap, or Shape that is assigned to this TileGrid"""
577         return self._bitmap
578
579     @bitmap.setter
580     def bitmap(self, new_bitmap: Union[Bitmap, OnDiskBitmap, Shape]) -> None:
581         if (
582             not isinstance(new_bitmap, Bitmap)
583             and not isinstance(new_bitmap, OnDiskBitmap)
584             and not isinstance(new_bitmap, Shape)
585         ):
586             raise TypeError(
587                 "Unsupported Type: new_bitmap must be Bitmap, OnDiskBitmap, or Shape"
588             )
589
590         if (
591             new_bitmap.width != self.bitmap.width
592             or new_bitmap.height != self.bitmap.height
593         ):
594             raise ValueError("New bitmap must be same size as old bitmap")
595
596         self._bitmap = new_bitmap
597
598     def _extract_and_check_index(self, index):
599         if isinstance(index, (tuple, list)):
600             x = index[0]
601             y = index[1]
602             index = y * self._width_in_tiles + x
603         elif isinstance(index, int):
604             x = index % self._width_in_tiles
605             y = index // self._width_in_tiles
606         if (
607             x > self._width_in_tiles
608             or y > self._height_in_tiles
609             or index >= len(self._tiles)
610         ):
611             raise ValueError("Tile index out of bounds")
612         return index
613
614     def __getitem__(self, index: Union[Tuple[int, int], int]) -> int:
615         """Returns the tile index at the given index. The index can either be
616         an x,y tuple or an int equal to ``y * width + x``'.
617         """
618         index = self._extract_and_check_index(index)
619         return self._tiles[index]
620
621     def __setitem__(self, index: Union[Tuple[int, int], int], value: int) -> None:
622         """Sets the tile index at the given index. The index can either be
623         an x,y tuple or an int equal to ``y * width + x``.
624         """
625         index = self._extract_and_check_index(index)
626         if not 0 <= value <= 255:
627             raise ValueError("Tile value out of bounds")
628         self._tiles[index] = value
629
630     @property
631     def width(self) -> int:
632         """Width in tiles"""
633         return self._width_in_tiles
634
635     @property
636     def height(self) -> int:
637         """Height in tiles"""
638         return self._height_in_tiles
639
640     @property
641     def tile_width(self) -> int:
642         """Width of each tile in pixels"""
643         return self._tile_width
644
645     @property
646     def tile_height(self) -> int:
647         """Height of each tile in pixels"""
648         return self._tile_height