]> Repositories - hackapet/Adafruit_Blinka_Displayio.git/blob - displayio/_tilegrid.py
Fix TileGrid flip_x, flip_y and transpose_xy
[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 from typing import Union, Optional, Tuple
21 from PIL import Image
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 RectangleStruct, TransformStruct
28
29 __version__ = "0.0.0+auto.0"
30 __repo__ = "https://github.com/adafruit/Adafruit_Blinka_displayio.git"
31
32
33 class TileGrid:
34     # pylint: disable=too-many-instance-attributes
35     """Position a grid of tiles sourced from a bitmap and pixel_shader combination. Multiple
36     grids can share bitmaps and pixel shaders.
37
38     A single tile grid is also known as a Sprite.
39     """
40
41     def __init__(
42         self,
43         bitmap: Union[Bitmap, OnDiskBitmap, Shape],
44         *,
45         pixel_shader: Union[ColorConverter, Palette],
46         width: int = 1,
47         height: int = 1,
48         tile_width: Optional[int] = None,
49         tile_height: Optional[int] = None,
50         default_tile: int = 0,
51         x: int = 0,
52         y: int = 0,
53     ):
54         """Create a TileGrid object. The bitmap is source for 2d pixels. The pixel_shader is
55         used to convert the value and its location to a display native pixel color. This may
56         be a simple color palette lookup, a gradient, a pattern or a color transformer.
57
58         tile_width and tile_height match the height of the bitmap by default.
59         """
60         if not isinstance(bitmap, (Bitmap, OnDiskBitmap, Shape)):
61             raise ValueError("Unsupported Bitmap type")
62         self._bitmap = bitmap
63         bitmap_width = bitmap.width
64         bitmap_height = bitmap.height
65
66         if pixel_shader is not None and not isinstance(
67             pixel_shader, (ColorConverter, Palette)
68         ):
69             raise ValueError("Unsupported Pixel Shader type")
70         self._pixel_shader = pixel_shader
71         if isinstance(self._pixel_shader, ColorConverter):
72             self._pixel_shader._rgba = True  # pylint: disable=protected-access
73         self._hidden_tilegrid = False
74         self._x = x
75         self._y = y
76         self._width = width  # Number of Tiles Wide
77         self._height = height  # Number of Tiles High
78         self._transpose_xy = False
79         self._flip_x = False
80         self._flip_y = False
81         self._top_left_x = 0
82         self._top_left_y = 0
83         if tile_width is None or tile_width == 0:
84             tile_width = bitmap_width
85         if tile_height is None or tile_width == 0:
86             tile_height = bitmap_height
87         if tile_width < 1 or tile_height < 1:
88             raise ValueError("Tile width and height must be greater than 0")
89         if bitmap_width % tile_width != 0:
90             raise ValueError("Tile width must exactly divide bitmap width")
91         self._tile_width = tile_width
92         if bitmap_height % tile_height != 0:
93             raise ValueError("Tile height must exactly divide bitmap height")
94         self._tile_height = tile_height
95         if not 0 <= default_tile <= 255:
96             raise ValueError("Default Tile is out of range")
97         self._pixel_width = width * tile_width
98         self._pixel_height = height * tile_height
99         self._tiles = (self._width * self._height) * [default_tile]
100         self._in_group = False
101         self._absolute_transform = TransformStruct(0, 0, 1, 1, 1, False, False, False)
102         self._current_area = RectangleStruct(
103             0, 0, self._pixel_width, self._pixel_height
104         )
105         self._moved = False
106
107     def _update_transform(self, absolute_transform):
108         """Update the parent transform and child transforms"""
109         self._absolute_transform = absolute_transform
110         if self._absolute_transform is not None:
111             self._update_current_x()
112             self._update_current_y()
113
114     def _update_current_x(self):
115         if self._transpose_xy:
116             width = self._pixel_height
117         else:
118             width = self._pixel_width
119         if self._absolute_transform.transpose_xy:
120             self._current_area.y1 = (
121                 self._absolute_transform.y + self._absolute_transform.dy * self._x
122             )
123             self._current_area.y2 = (
124                 self._absolute_transform.y
125                 + self._absolute_transform.dy * (self._x + width)
126             )
127             if self._current_area.y2 < self._current_area.y1:
128                 self._current_area.y1, self._current_area.y2 = (
129                     self._current_area.y2,
130                     self._current_area.y1,
131                 )
132         else:
133             self._current_area.x1 = (
134                 self._absolute_transform.x + self._absolute_transform.dx * self._x
135             )
136             self._current_area.x2 = (
137                 self._absolute_transform.x
138                 + self._absolute_transform.dx * (self._x + width)
139             )
140             if self._current_area.x2 < self._current_area.x1:
141                 self._current_area.x1, self._current_area.x2 = (
142                     self._current_area.x2,
143                     self._current_area.x1,
144                 )
145
146     def _update_current_y(self):
147         if self._transpose_xy:
148             height = self._pixel_width
149         else:
150             height = self._pixel_height
151         if self._absolute_transform.transpose_xy:
152             self._current_area.x1 = (
153                 self._absolute_transform.x + self._absolute_transform.dx * self._y
154             )
155             self._current_area.x2 = (
156                 self._absolute_transform.x
157                 + self._absolute_transform.dx * (self._y + height)
158             )
159             if self._current_area.x2 < self._current_area.x1:
160                 self._current_area.x1, self._current_area.x2 = (
161                     self._current_area.x2,
162                     self._current_area.x1,
163                 )
164         else:
165             self._current_area.y1 = (
166                 self._absolute_transform.y + self._absolute_transform.dy * self._y
167             )
168             self._current_area.y2 = (
169                 self._absolute_transform.y
170                 + self._absolute_transform.dy * (self._y + height)
171             )
172             if self._current_area.y2 < self._current_area.y1:
173                 self._current_area.y1, self._current_area.y2 = (
174                     self._current_area.y2,
175                     self._current_area.y1,
176                 )
177
178     def _shade(self, pixel_value):
179         if isinstance(self._pixel_shader, Palette):
180             return self._pixel_shader[pixel_value]
181         if isinstance(self._pixel_shader, ColorConverter):
182             return self._pixel_shader.convert(pixel_value)
183         return pixel_value
184
185     def _apply_palette(self, image):
186         image.putpalette(
187             self._pixel_shader._get_palette()  # pylint: disable=protected-access
188         )
189
190     def _add_alpha(self, image):
191         alpha = self._bitmap._image.copy().convert(  # pylint: disable=protected-access
192             "P"
193         )
194         alpha.putpalette(
195             self._pixel_shader._get_alpha_palette()  # pylint: disable=protected-access
196         )
197         image.putalpha(alpha.convert("L"))
198
199     def _fill_area(self, buffer):
200         # pylint: disable=too-many-locals,too-many-branches,too-many-statements
201         """Draw onto the image"""
202         if self._hidden_tilegrid:
203             return
204
205         if self._bitmap.width <= 0 or self._bitmap.height <= 0:
206             return
207
208         # Copy class variables to local variables in case something changes
209         x = self._x
210         y = self._y
211         width = self._width
212         height = self._height
213         tile_width = self._tile_width
214         tile_height = self._tile_height
215         bitmap_width = self._bitmap.width
216         pixel_width = self._pixel_width
217         pixel_height = self._pixel_height
218         tiles = self._tiles
219         absolute_transform = self._absolute_transform
220         pixel_shader = self._pixel_shader
221         bitmap = self._bitmap
222         tiles = self._tiles
223
224         tile_count_x = bitmap_width // tile_width
225
226         image = Image.new(
227             "RGBA",
228             (width * tile_width, height * tile_height),
229             (0, 0, 0, 0),
230         )
231
232         for tile_x in range(width):
233             for tile_y in range(height):
234                 tile_index = tiles[tile_y * width + tile_x]
235                 tile_index_x = tile_index % tile_count_x
236                 tile_index_y = tile_index // tile_count_x
237                 tile_image = bitmap._image  # pylint: disable=protected-access
238                 if isinstance(pixel_shader, Palette):
239                     tile_image = tile_image.copy().convert("P")
240                     self._apply_palette(tile_image)
241                     tile_image = tile_image.convert("RGBA")
242                     self._add_alpha(tile_image)
243                 elif isinstance(pixel_shader, ColorConverter):
244                     # This will be needed for eInks, grayscale, and monochrome displays
245                     pass
246                 image.alpha_composite(
247                     tile_image,
248                     dest=(tile_x * tile_width, tile_y * tile_height),
249                     source=(
250                         tile_index_x * tile_width,
251                         tile_index_y * tile_height,
252                         tile_index_x * tile_width + tile_width,
253                         tile_index_y * tile_height + tile_height,
254                     ),
255                 )
256
257         if absolute_transform is not None:
258             if absolute_transform.scale > 1:
259                 image = image.resize(
260                     (
261                         int(pixel_width * absolute_transform.scale),
262                         int(
263                             pixel_height * absolute_transform.scale,
264                         ),
265                     ),
266                     resample=Image.NEAREST,
267                 )
268             if absolute_transform.mirror_x != self._flip_x:
269                 image = image.transpose(Image.FLIP_LEFT_RIGHT)
270             if absolute_transform.mirror_y != self._flip_y:
271                 image = image.transpose(Image.FLIP_TOP_BOTTOM)
272             if absolute_transform.transpose_xy != self._transpose_xy:
273                 image = image.transpose(Image.TRANSPOSE)
274             x *= absolute_transform.dx
275             y *= absolute_transform.dy
276             x += absolute_transform.x
277             y += absolute_transform.y
278
279         source_x = source_y = 0
280         if x < 0:
281             source_x = round(0 - x)
282             x = 0
283         if y < 0:
284             source_y = round(0 - y)
285             y = 0
286
287         x = round(x)
288         y = round(y)
289
290         if (
291             x <= buffer.width
292             and y <= buffer.height
293             and source_x <= image.width
294             and source_y <= image.height
295         ):
296             buffer.alpha_composite(image, (x, y), source=(source_x, source_y))
297
298     def _finish_refresh(self):
299         pass
300
301     @property
302     def hidden(self) -> bool:
303         """True when the TileGrid is hidden. This may be False even
304         when a part of a hidden Group."""
305         return self._hidden_tilegrid
306
307     @hidden.setter
308     def hidden(self, value: bool):
309         if not isinstance(value, (bool, int)):
310             raise ValueError("Expecting a boolean or integer value")
311         self._hidden_tilegrid = bool(value)
312
313     @property
314     def x(self) -> int:
315         """X position of the left edge in the parent."""
316         return self._x
317
318     @x.setter
319     def x(self, value: int):
320         if not isinstance(value, int):
321             raise TypeError("X should be a integer type")
322         if self._x != value:
323             self._x = value
324             self._update_current_x()
325
326     @property
327     def y(self) -> int:
328         """Y position of the top edge in the parent."""
329         return self._y
330
331     @y.setter
332     def y(self, value: int):
333         if not isinstance(value, int):
334             raise TypeError("Y should be a integer type")
335         if self._y != value:
336             self._y = value
337             self._update_current_y()
338
339     @property
340     def flip_x(self) -> bool:
341         """If true, the left edge rendered will be the right edge of the right-most tile."""
342         return self._flip_x
343
344     @flip_x.setter
345     def flip_x(self, value: bool):
346         if not isinstance(value, bool):
347             raise TypeError("Flip X should be a boolean type")
348         if self._flip_x != value:
349             self._flip_x = value
350
351     @property
352     def flip_y(self) -> bool:
353         """If true, the top edge rendered will be the bottom edge of the bottom-most tile."""
354         return self._flip_y
355
356     @flip_y.setter
357     def flip_y(self, value: bool):
358         if not isinstance(value, bool):
359             raise TypeError("Flip Y should be a boolean type")
360         if self._flip_y != value:
361             self._flip_y = value
362
363     @property
364     def transpose_xy(self) -> bool:
365         """If true, the TileGrid's axis will be swapped. When combined with mirroring, any 90
366         degree rotation can be achieved along with the corresponding mirrored version.
367         """
368         return self._transpose_xy
369
370     @transpose_xy.setter
371     def transpose_xy(self, value: bool):
372         if not isinstance(value, bool):
373             raise TypeError("Transpose XY should be a boolean type")
374         if self._transpose_xy != value:
375             self._transpose_xy = value
376             self._update_current_x()
377             self._update_current_y()
378
379     @property
380     def pixel_shader(self) -> Union[ColorConverter, Palette]:
381         """The pixel shader of the tilegrid."""
382         return self._pixel_shader
383
384     @pixel_shader.setter
385     def pixel_shader(self, new_pixel_shader: Union[ColorConverter, Palette]) -> None:
386         if not isinstance(new_pixel_shader, ColorConverter) and not isinstance(
387             new_pixel_shader, Palette
388         ):
389             raise TypeError(
390                 "Unsupported Type: new_pixel_shader must be ColorConverter or Palette"
391             )
392
393         self._pixel_shader = new_pixel_shader
394
395     @property
396     def bitmap(self) -> Union[Bitmap, OnDiskBitmap, Shape]:
397         """The Bitmap, OnDiskBitmap, or Shape that is assigned to this TileGrid"""
398         return self._bitmap
399
400     @bitmap.setter
401     def bitmap(self, new_bitmap: Union[Bitmap, OnDiskBitmap, Shape]) -> None:
402
403         if (
404             not isinstance(new_bitmap, Bitmap)
405             and not isinstance(new_bitmap, OnDiskBitmap)
406             and not isinstance(new_bitmap, Shape)
407         ):
408             raise TypeError(
409                 "Unsupported Type: new_bitmap must be Bitmap, OnDiskBitmap, or Shape"
410             )
411
412         if (
413             new_bitmap.width != self.bitmap.width
414             or new_bitmap.height != self.bitmap.height
415         ):
416             raise ValueError("New bitmap must be same size as old bitmap")
417
418         self._bitmap = new_bitmap
419
420     def _extract_and_check_index(self, index):
421         if isinstance(index, (tuple, list)):
422             x = index[0]
423             y = index[1]
424             index = y * self._width + x
425         elif isinstance(index, int):
426             x = index % self._width
427             y = index // self._width
428         if x > self._width or y > self._height or index >= len(self._tiles):
429             raise ValueError("Tile index out of bounds")
430         return index
431
432     def __getitem__(self, index: Union[Tuple[int, int], int]) -> int:
433         """Returns the tile index at the given index. The index can either be
434         an x,y tuple or an int equal to ``y * width + x``'.
435         """
436         index = self._extract_and_check_index(index)
437         return self._tiles[index]
438
439     def __setitem__(self, index: Union[Tuple[int, int], int], value: int) -> None:
440         """Sets the tile index at the given index. The index can either be
441         an x,y tuple or an int equal to ``y * width + x``.
442         """
443         index = self._extract_and_check_index(index)
444         if not 0 <= value <= 255:
445             raise ValueError("Tile value out of bounds")
446         self._tiles[index] = value
447
448     @property
449     def width(self) -> int:
450         """Width in tiles"""
451         return self._width
452
453     @property
454     def height(self) -> int:
455         """Height in tiles"""
456         return self._height
457
458     @property
459     def tile_width(self) -> int:
460         """Width of each tile in pixels"""
461         return self._tile_width
462
463     @property
464     def tile_height(self) -> int:
465         """Height of each tile in pixels"""
466         return self._tile_height