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