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