]> Repositories - hackapet/Adafruit_Blinka_Displayio.git/blob - displayio/_tilegrid.py
91b32326c6244f117cc5f30e2fd914531b339e56
[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 (
29     InputPixelStruct,
30     OutputPixelStruct,
31     null_transform,
32 )
33 from ._colorspace import Colorspace
34 from ._area import Area
35
36 __version__ = "0.0.0+auto.0"
37 __repo__ = "https://github.com/adafruit/Adafruit_Blinka_displayio.git"
38
39
40 class TileGrid:
41     # pylint: disable=too-many-instance-attributes, too-many-statements
42     """Position a grid of tiles sourced from a bitmap and pixel_shader combination. Multiple
43     grids can share bitmaps and pixel shaders.
44
45     A single tile grid is also known as a Sprite.
46     """
47
48     def __init__(
49         self,
50         bitmap: Union[Bitmap, OnDiskBitmap, Shape],
51         *,
52         pixel_shader: Union[ColorConverter, Palette],
53         width: int = 1,
54         height: int = 1,
55         tile_width: Optional[int] = None,
56         tile_height: Optional[int] = None,
57         default_tile: int = 0,
58         x: int = 0,
59         y: int = 0,
60     ):
61         """Create a TileGrid object. The bitmap is source for 2d pixels. The pixel_shader is
62         used to convert the value and its location to a display native pixel color. This may
63         be a simple color palette lookup, a gradient, a pattern or a color transformer.
64
65         tile_width and tile_height match the height of the bitmap by default.
66         """
67         if not isinstance(bitmap, (Bitmap, OnDiskBitmap, Shape)):
68             raise ValueError("Unsupported Bitmap type")
69         self._bitmap = bitmap
70         bitmap_width = bitmap.width
71         bitmap_height = bitmap.height
72
73         if pixel_shader is not None and not isinstance(
74             pixel_shader, (ColorConverter, Palette)
75         ):
76             raise ValueError("Unsupported Pixel Shader type")
77         self._pixel_shader = pixel_shader
78         if isinstance(self._pixel_shader, ColorConverter):
79             self._pixel_shader._rgba = True  # pylint: disable=protected-access
80         self._hidden_tilegrid = False
81         self._hidden_by_parent = False
82         self._rendered_hidden = False
83         self._name = "Tilegrid"
84         self._x = x
85         self._y = y
86         self._width_in_tiles = width
87         self._height_in_tiles = height
88         self._transpose_xy = False
89         self._flip_x = False
90         self._flip_y = False
91         self._top_left_x = 0
92         self._top_left_y = 0
93         if tile_width is None or tile_width == 0:
94             tile_width = bitmap_width
95         if tile_height is None or tile_width == 0:
96             tile_height = bitmap_height
97         if tile_width < 1 or tile_height < 1:
98             raise ValueError("Tile width and height must be greater than 0")
99         if bitmap_width % tile_width != 0:
100             raise ValueError("Tile width must exactly divide bitmap width")
101         self._tile_width = tile_width
102         if bitmap_height % tile_height != 0:
103             raise ValueError("Tile height must exactly divide bitmap height")
104         self._tile_height = tile_height
105         if not 0 <= default_tile <= 255:
106             raise ValueError("Default Tile is out of range")
107         self._pixel_width = width * tile_width
108         self._pixel_height = height * tile_height
109         self._tiles = bytearray(
110             (self._width_in_tiles * self._height_in_tiles) * [default_tile]
111         )
112         self._in_group = False
113         self._absolute_transform = None
114         self._current_area = Area(0, 0, self._pixel_width, self._pixel_height)
115         self._dirty_area = Area(0, 0, 0, 0)
116         self._previous_area = Area(0xFFFF, 0xFFFF, 0xFFFF, 0xFFFF)
117         self._moved = False
118         self._full_change = True
119         self._partial_change = True
120         self._bitmap_width_in_tiles = bitmap_width // tile_width
121         self._tiles_in_bitmap = self._bitmap_width_in_tiles * (
122             bitmap_height // tile_height
123         )
124
125     def _update_transform(self, absolute_transform):
126         """Update the parent transform and child transforms"""
127         self._in_group = absolute_transform is not None
128         self._absolute_transform = absolute_transform
129         if self._absolute_transform is not None:
130             self._moved = True
131             self._update_current_x()
132             self._update_current_y()
133
134     def _update_current_x(self):
135         if self._transpose_xy:
136             width = self._pixel_height
137         else:
138             width = self._pixel_width
139
140         absolute_transform = (
141             null_transform
142             if self._absolute_transform is None
143             else self._absolute_transform
144         )
145
146         if absolute_transform.transpose_xy:
147             self._current_area.y1 = (
148                 absolute_transform.y + absolute_transform.dy * self._x
149             )
150             self._current_area.y2 = absolute_transform.y + absolute_transform.dy * (
151                 self._x + width
152             )
153             if self._current_area.y2 < self._current_area.y1:
154                 self._current_area.y1, self._current_area.y2 = (
155                     self._current_area.y2,
156                     self._current_area.y1,
157                 )
158         else:
159             self._current_area.x1 = (
160                 absolute_transform.x + absolute_transform.dx * self._x
161             )
162             self._current_area.x2 = absolute_transform.x + absolute_transform.dx * (
163                 self._x + width
164             )
165             if self._current_area.x2 < self._current_area.x1:
166                 self._current_area.x1, self._current_area.x2 = (
167                     self._current_area.x2,
168                     self._current_area.x1,
169                 )
170
171     def _update_current_y(self):
172         if self._transpose_xy:
173             height = self._pixel_width
174         else:
175             height = self._pixel_height
176
177         absolute_transform = (
178             null_transform
179             if self._absolute_transform is None
180             else self._absolute_transform
181         )
182
183         if absolute_transform.transpose_xy:
184             self._current_area.x1 = (
185                 absolute_transform.x + absolute_transform.dx * self._y
186             )
187             self._current_area.x2 = absolute_transform.x + absolute_transform.dx * (
188                 self._y + height
189             )
190             if self._current_area.x2 < self._current_area.x1:
191                 self._current_area.x1, self._current_area.x2 = (
192                     self._current_area.x2,
193                     self._current_area.x1,
194                 )
195         else:
196             self._current_area.y1 = (
197                 absolute_transform.y + absolute_transform.dy * self._y
198             )
199             self._current_area.y2 = absolute_transform.y + absolute_transform.dy * (
200                 self._y + height
201             )
202             if self._current_area.y2 < self._current_area.y1:
203                 self._current_area.y1, self._current_area.y2 = (
204                     self._current_area.y2,
205                     self._current_area.y1,
206                 )
207
208     def _shade(self, pixel_value):
209         if isinstance(self._pixel_shader, Palette):
210             return self._pixel_shader[pixel_value]
211         if isinstance(self._pixel_shader, ColorConverter):
212             return self._pixel_shader.convert(pixel_value)
213         return pixel_value
214
215     def _apply_palette(self, image):
216         image.putpalette(
217             self._pixel_shader._get_palette()  # pylint: disable=protected-access
218         )
219
220     def _add_alpha(self, image):
221         alpha = self._bitmap._image.copy().convert(  # pylint: disable=protected-access
222             "P"
223         )
224         alpha.putpalette(
225             self._pixel_shader._get_alpha_palette()  # pylint: disable=protected-access
226         )
227         image.putalpha(alpha.convert("L"))
228
229     def _fill_area(
230         self,
231         colorspace: Colorspace,
232         area: Area,
233         mask: WriteableBuffer,
234         buffer: WriteableBuffer,
235     ) -> bool:
236         """Draw onto the image"""
237         # pylint: disable=too-many-locals,too-many-branches,too-many-statements
238
239         # If no tiles are present we have no impact
240         tiles = self._tiles
241
242         if tiles is None or len(tiles) == 0:
243             return False
244
245         if self._hidden_tilegrid or self._hidden_by_parent:
246             return False
247
248         overlap = Area()  # area, current_area, overlap
249         if not area.compute_overlap(self._current_area, overlap):
250             return False
251
252         if self._bitmap.width <= 0 or self._bitmap.height <= 0:
253             return False
254
255         x_stride = 1
256         y_stride = area.width()
257
258         flip_x = self._flip_x
259         flip_y = self._flip_y
260         if self._transpose_xy != self._absolute_transform.transpose_xy:
261             flip_x, flip_y = flip_y, flip_x
262
263         start = 0
264         if (self._absolute_transform.dx < 0) != flip_x:
265             start += (area.x2 - area.x1 - 1) * x_stride
266             x_stride *= -1
267         if (self._absolute_transform.dy < 0) != flip_y:
268             start += (area.y2 - area.y1 - 1) * y_stride
269             y_stride *= -1
270
271         # Track if this layer finishes filling in the given area. We can ignore any remaining
272         # layers at that point.
273         full_coverage = area == overlap
274
275         transformed = Area()
276         area.transform_within(
277             flip_x != (self._absolute_transform.dx < 0),
278             flip_y != (self._absolute_transform.dy < 0),
279             self.transpose_xy != self._absolute_transform.transpose_xy,
280             overlap,
281             self._current_area,
282             transformed,
283         )
284
285         start_x = transformed.x1 - self._current_area.x1
286         end_x = transformed.x2 - self._current_area.x1
287         start_y = transformed.y1 - self._current_area.y1
288         end_y = transformed.y2 - self._current_area.y1
289
290         if (self._absolute_transform.dx < 0) != flip_x:
291             x_shift = area.x2 - overlap.x2
292         else:
293             x_shift = overlap.x1 - area.x1
294         if (self._absolute_transform.dy < 0) != flip_y:
295             y_shift = area.y2 - overlap.y2
296         else:
297             y_shift = overlap.y1 - area.y1
298
299         # This untransposes x and y so it aligns with bitmap rows
300         if self._transpose_xy != self._absolute_transform.transpose_xy:
301             x_stride, y_stride = y_stride, x_stride
302             x_shift, y_shift = y_shift, x_shift
303
304         pixels_per_byte = 8 // colorspace.depth
305
306         input_pixel = InputPixelStruct()
307         output_pixel = OutputPixelStruct()
308         for input_pixel.y in range(start_y, end_y):
309             row_start = (
310                 start + (input_pixel.y - start_y + y_shift) * y_stride
311             )  # In Pixels
312             local_y = input_pixel.y // self._absolute_transform.scale
313             for input_pixel.x in range(start_x, end_x):
314                 # Compute the destination pixel in the buffer and mask based on the transformations
315                 offset = (
316                     row_start + (input_pixel.x - start_x + x_shift) * x_stride
317                 )  # In Pixels
318
319                 # Check the mask first to see if the pixel has already been set
320                 if mask[offset // 32] & (1 << (offset % 32)):
321                     continue
322                 local_x = input_pixel.x // self._absolute_transform.scale
323                 tile_location = (
324                     (local_y // self._tile_height + self._top_left_y)
325                     % self._height_in_tiles
326                 ) * self._width_in_tiles + (
327                     local_x // self._tile_width + self._top_left_x
328                 ) % self._width_in_tiles
329                 input_pixel.tile = tiles[tile_location]
330                 input_pixel.tile_x = (
331                     input_pixel.tile % self._bitmap_width_in_tiles
332                 ) * self._tile_width + local_x % self._tile_width
333                 input_pixel.tile_y = (
334                     input_pixel.tile // self._bitmap_width_in_tiles
335                 ) * self._tile_height + local_y % self._tile_height
336
337                 output_pixel.pixel = 0
338                 input_pixel.pixel = 0
339
340                 # We always want to read bitmap pixels by row first and then transpose into
341                 # the destination buffer because most bitmaps are row associated.
342                 if isinstance(self._bitmap, (Bitmap, Shape, OnDiskBitmap)):
343                     input_pixel.pixel = (
344                         self._bitmap._get_pixel(  # pylint: disable=protected-access
345                             input_pixel.tile_x, input_pixel.tile_y
346                         )
347                     )
348
349                 output_pixel.opaque = True
350                 if self._pixel_shader is None:
351                     output_pixel.pixel = input_pixel.pixel
352                 elif isinstance(self._pixel_shader, Palette):
353                     self._pixel_shader._get_color(  # pylint: disable=protected-access
354                         colorspace, input_pixel, output_pixel
355                     )
356                 elif isinstance(self._pixel_shader, ColorConverter):
357                     self._pixel_shader._convert(  # pylint: disable=protected-access
358                         colorspace, input_pixel, output_pixel
359                     )
360
361                 if not output_pixel.opaque:
362                     full_coverage = False
363                 else:
364                     mask[offset // 32] |= 1 << (offset % 32)
365                     if colorspace.depth == 16:
366                         struct.pack_into(
367                             "H",
368                             buffer,
369                             offset * 2,
370                             output_pixel.pixel,
371                         )
372                     elif colorspace.depth == 32:
373                         struct.pack_into(
374                             "I",
375                             buffer,
376                             offset * 4,
377                             output_pixel.pixel,
378                         )
379                     elif colorspace.depth == 8:
380                         buffer[offset] = output_pixel.pixel & 0xFF
381                     elif colorspace.depth < 8:
382                         # Reorder the offsets to pack multiple rows into
383                         # a byte (meaning they share a column).
384                         if not colorspace.pixels_in_byte_share_row:
385                             width = area.width()
386                             row = offset // width
387                             col = offset % width
388                             # Dividing by pixels_per_byte does truncated division
389                             # even if we multiply it back out
390                             offset = (
391                                 col * pixels_per_byte
392                                 + (row // pixels_per_byte) * width
393                                 + (row % pixels_per_byte)
394                             )
395                         shift = (offset % pixels_per_byte) * colorspace.depth
396                         if colorspace.reverse_pixels_in_byte:
397                             # Reverse the shift by subtracting it from the leftmost shift
398                             shift = (pixels_per_byte - 1) * colorspace.depth - shift
399                         buffer[offset // pixels_per_byte] |= output_pixel.pixel << shift
400         return full_coverage
401
402     def _finish_refresh(self):
403         first_draw = self._previous_area.x1 == self._previous_area.x2
404         hidden = self._hidden_tilegrid or self._hidden_by_parent
405         if not first_draw and hidden:
406             self._previous_area.x2 = self._previous_area.x1
407         elif self._moved or first_draw:
408             self._current_area.copy_into(self._previous_area)
409
410         self._moved = False
411         self._full_change = False
412         self._partial_change = False
413         if isinstance(self._pixel_shader, (Palette, ColorConverter)):
414             self._pixel_shader._finish_refresh()  # pylint: disable=protected-access
415         if isinstance(self._bitmap, (Bitmap, Shape)):
416             self._bitmap._finish_refresh()  # pylint: disable=protected-access
417
418     def _get_refresh_areas(self, areas: list[Area]) -> None:
419         # pylint: disable=invalid-name, too-many-branches, too-many-statements
420         first_draw = self._previous_area.x1 == self._previous_area.x2
421         hidden = self._hidden_tilegrid or self._hidden_by_parent
422
423         # Check hidden first because it trumps all other changes
424         if hidden:
425             self._rendered_hidden = True
426             if not first_draw:
427                 areas.append(self._previous_area)
428             return
429         if self._moved and not first_draw:
430             self._previous_area.union(self._current_area, self._dirty_area)
431             if self._dirty_area.size() < 2 * self._pixel_width * self._pixel_height:
432                 areas.append(self._dirty_area)
433                 return
434             areas.append(self._current_area)
435             areas.append(self._previous_area)
436             return
437
438         tail = areas[-1] if areas else None
439         # If we have an in-memory bitmap, then check it for modifications
440         if isinstance(self._bitmap, Bitmap):
441             self._bitmap._get_refresh_areas(areas)  # pylint: disable=protected-access
442             refresh_area = areas[-1] if areas else None
443             if tail != refresh_area:
444                 # Special case a TileGrid that shows a full bitmap and use its
445                 # dirty area. Copy it to ours so we can transform it.
446                 if self._tiles_in_bitmap == 1:
447                     refresh_area.copy_into(self._dirty_area)
448                     self._partial_change = True
449                 else:
450                     self._full_change = True
451         elif isinstance(self._bitmap, Shape):
452             self._bitmap._get_refresh_areas(areas)  # pylint: disable=protected-access
453             refresh_area = areas[-1] if areas else None
454             if refresh_area != tail:
455                 refresh_area.copy_into(self._dirty_area)
456                 self._partial_change = True
457
458         self._full_change = self._full_change or (
459             isinstance(self._pixel_shader, (Palette, ColorConverter))
460             and self._pixel_shader._needs_refresh  # pylint: disable=protected-access
461         )
462         if self._full_change or first_draw:
463             areas.append(self._current_area)
464             return
465
466         if self._partial_change:
467             x = self._x
468             y = self._y
469             if self._absolute_transform.transpose_xy:
470                 x, y = y, x
471             x1 = self._dirty_area.x1
472             x2 = self._dirty_area.x2
473             if self._flip_x:
474                 x1 = self._pixel_width - x1
475                 x2 = self._pixel_width - x2
476             y1 = self._dirty_area.y1
477             y2 = self._dirty_area.y2
478             if self._flip_y:
479                 y1 = self._pixel_height - y1
480                 y2 = self._pixel_height - y2
481             if self._transpose_xy != self._absolute_transform.transpose_xy:
482                 x1, y1 = y1, x1
483                 x2, y2 = y2, x2
484             self._dirty_area.x1 = (
485                 self._absolute_transform.x + self._absolute_transform.dx * (x + x1)
486             )
487             self._dirty_area.y1 = (
488                 self._absolute_transform.y + self._absolute_transform.dy * (y + y1)
489             )
490             self._dirty_area.x2 = (
491                 self._absolute_transform.x + self._absolute_transform.dx * (x + x2)
492             )
493             self._dirty_area.y2 = (
494                 self._absolute_transform.y + self._absolute_transform.dy * (y + y2)
495             )
496             if self._dirty_area.y2 < self._dirty_area.y1:
497                 self._dirty_area.y1, self._dirty_area.y2 = (
498                     self._dirty_area.y2,
499                     self._dirty_area.y1,
500                 )
501             if self._dirty_area.x2 < self._dirty_area.x1:
502                 self._dirty_area.x1, self._dirty_area.x2 = (
503                     self._dirty_area.x2,
504                     self._dirty_area.x1,
505                 )
506             areas.append(self._dirty_area)
507
508     def _set_hidden(self, hidden: bool) -> None:
509         self._hidden_tilegrid = hidden
510         self._rendered_hidden = False
511         if not hidden:
512             self._full_change = True
513
514     def _set_hidden_by_parent(self, hidden: bool) -> None:
515         self._hidden_by_parent = hidden
516         self._rendered_hidden = False
517         if not hidden:
518             self._full_change = True
519
520     def _get_rendered_hidden(self) -> bool:
521         return self._rendered_hidden
522
523     def _set_all_tiles(self, tile_index: int) -> None:
524         """Set all tiles to the given tile index"""
525         if tile_index >= self._tiles_in_bitmap:
526             raise ValueError("Tile index out of bounds")
527         self._tiles = bytearray(
528             (self._width_in_tiles * self._height_in_tiles) * [tile_index]
529         )
530         self._full_change = True
531
532     def _set_top_left(self, x: int, y: int) -> None:
533         self._top_left_x = x
534         self._top_left_y = y
535         self._full_change = True
536
537     @property
538     def hidden(self) -> bool:
539         """True when the TileGrid is hidden. This may be False even
540         when a part of a hidden Group."""
541         return self._hidden_tilegrid
542
543     @hidden.setter
544     def hidden(self, value: bool):
545         if not isinstance(value, (bool, int)):
546             raise ValueError("Expecting a boolean or integer value")
547         value = bool(value)
548         self._set_hidden(value)
549
550     @property
551     def x(self) -> int:
552         """X position of the left edge in the parent."""
553         return self._x
554
555     @x.setter
556     def x(self, value: int):
557         if not isinstance(value, int):
558             raise TypeError("X should be a integer type")
559         if self._x != value:
560             self._moved = True
561             self._x = value
562             if self._absolute_transform is not None:
563                 self._update_current_x()
564
565     @property
566     def y(self) -> int:
567         """Y position of the top edge in the parent."""
568         return self._y
569
570     @y.setter
571     def y(self, value: int):
572         if not isinstance(value, int):
573             raise TypeError("Y should be a integer type")
574         if self._y != value:
575             self._moved = True
576             self._y = value
577             if self._absolute_transform is not None:
578                 self._update_current_y()
579
580     @property
581     def flip_x(self) -> bool:
582         """If true, the left edge rendered will be the right edge of the right-most tile."""
583         return self._flip_x
584
585     @flip_x.setter
586     def flip_x(self, value: bool):
587         if not isinstance(value, bool):
588             raise TypeError("Flip X should be a boolean type")
589         if self._flip_x != value:
590             self._flip_x = value
591             self._full_change = True
592
593     @property
594     def flip_y(self) -> bool:
595         """If true, the top edge rendered will be the bottom edge of the bottom-most tile."""
596         return self._flip_y
597
598     @flip_y.setter
599     def flip_y(self, value: bool):
600         if not isinstance(value, bool):
601             raise TypeError("Flip Y should be a boolean type")
602         if self._flip_y != value:
603             self._flip_y = value
604             self._full_change = True
605
606     @property
607     def transpose_xy(self) -> bool:
608         """If true, the TileGrid's axis will be swapped. When combined with mirroring, any 90
609         degree rotation can be achieved along with the corresponding mirrored version.
610         """
611         return self._transpose_xy
612
613     @transpose_xy.setter
614     def transpose_xy(self, value: bool) -> None:
615         if not isinstance(value, bool):
616             raise TypeError("Transpose XY should be a boolean type")
617         if self._transpose_xy != value:
618             self._transpose_xy = value
619             if self._pixel_width == self._pixel_height:
620                 self._full_change = True
621                 return
622             self._update_current_x()
623             self._update_current_y()
624             self._moved = True
625
626     @property
627     def pixel_shader(self) -> Union[ColorConverter, Palette]:
628         """The pixel shader of the tilegrid."""
629         return self._pixel_shader
630
631     @pixel_shader.setter
632     def pixel_shader(self, new_pixel_shader: Union[ColorConverter, Palette]) -> None:
633         if not isinstance(new_pixel_shader, ColorConverter) and not isinstance(
634             new_pixel_shader, Palette
635         ):
636             raise TypeError(
637                 "Unsupported Type: new_pixel_shader must be ColorConverter or Palette"
638             )
639
640         self._pixel_shader = new_pixel_shader
641         self._full_change = True
642
643     @property
644     def bitmap(self) -> Union[Bitmap, OnDiskBitmap, Shape]:
645         """The Bitmap, OnDiskBitmap, or Shape that is assigned to this TileGrid"""
646         return self._bitmap
647
648     @bitmap.setter
649     def bitmap(self, new_bitmap: Union[Bitmap, OnDiskBitmap, Shape]) -> None:
650         if (
651             not isinstance(new_bitmap, Bitmap)
652             and not isinstance(new_bitmap, OnDiskBitmap)
653             and not isinstance(new_bitmap, Shape)
654         ):
655             raise TypeError(
656                 "Unsupported Type: new_bitmap must be Bitmap, OnDiskBitmap, or Shape"
657             )
658
659         if (
660             new_bitmap.width != self.bitmap.width
661             or new_bitmap.height != self.bitmap.height
662         ):
663             raise ValueError("New bitmap must be same size as old bitmap")
664
665         self._bitmap = new_bitmap
666         self._full_change = True
667
668     def _extract_and_check_index(self, index):
669         if isinstance(index, (tuple, list)):
670             x = index[0]
671             y = index[1]
672             index = y * self._width_in_tiles + x
673         elif isinstance(index, int):
674             x = index % self._width_in_tiles
675             y = index // self._width_in_tiles
676         if (
677             x > self._width_in_tiles
678             or y > self._height_in_tiles
679             or index >= len(self._tiles)
680         ):
681             raise ValueError("Tile index out of bounds")
682         return index
683
684     def __getitem__(self, index: Union[Tuple[int, int], int]) -> int:
685         """Returns the tile index at the given index. The index can either be
686         an x,y tuple or an int equal to ``y * width + x``'.
687         """
688         index = self._extract_and_check_index(index)
689         return self._tiles[index]
690
691     def __setitem__(self, index: Union[Tuple[int, int], int], value: int) -> None:
692         """Sets the tile index at the given index. The index can either be
693         an x,y tuple or an int equal to ``y * width + x``.
694         """
695         index = self._extract_and_check_index(index)
696         if not 0 <= value <= 255:
697             raise ValueError("Tile value out of bounds")
698         self._tiles[index] = value
699
700     @property
701     def width(self) -> int:
702         """Width in tiles"""
703         return self._width_in_tiles
704
705     @property
706     def height(self) -> int:
707         """Height in tiles"""
708         return self._height_in_tiles
709
710     @property
711     def tile_width(self) -> int:
712         """Width of each tile in pixels"""
713         return self._tile_width
714
715     @property
716     def tile_height(self) -> int:
717         """Height of each tile in pixels"""
718         return self._tile_height