]> Repositories - hackapet/Adafruit_Blinka_Displayio.git/blob - displayio/_tilegrid.py
dc392b95a90b1369336fb58142bca1e293908024
[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         overlap = Area()  # area, current_area, overlap
248         if not area.compute_overlap(self._current_area, overlap):
249             return False
250         # else:
251         #    print("Checking", area.x1, area.y1, area.x2, area.y2)
252         #    print("Overlap", overlap.x1, overlap.y1, overlap.x2, overlap.y2)
253
254         if self._bitmap.width <= 0 or self._bitmap.height <= 0:
255             return False
256
257         x_stride = 1
258         y_stride = area.width()
259
260         flip_x = self._flip_x
261         flip_y = self._flip_y
262         if self._transpose_xy != self._absolute_transform.transpose_xy:
263             flip_x, flip_y = flip_y, flip_x
264
265         start = 0
266         if (self._absolute_transform.dx < 0) != flip_x:
267             start += (area.x2 - area.x1 - 1) * x_stride
268             x_stride *= -1
269         if (self._absolute_transform.dy < 0) != flip_y:
270             start += (area.y2 - area.y1 - 1) * y_stride
271             y_stride *= -1
272
273         # Track if this layer finishes filling in the given area. We can ignore any remaining
274         # layers at that point.
275         full_coverage = area == overlap
276
277         transformed = Area()
278         area.transform_within(
279             flip_x != (self._absolute_transform.dx < 0),
280             flip_y != (self._absolute_transform.dy < 0),
281             self.transpose_xy != self._absolute_transform.transpose_xy,
282             overlap,
283             self._current_area,
284             transformed,
285         )
286
287         start_x = transformed.x1 - self._current_area.x1
288         end_x = transformed.x2 - self._current_area.x1
289         start_y = transformed.y1 - self._current_area.y1
290         end_y = transformed.y2 - self._current_area.y1
291
292         if (self._absolute_transform.dx < 0) != flip_x:
293             x_shift = area.x2 - overlap.x2
294         else:
295             x_shift = overlap.x1 - area.x1
296         if (self._absolute_transform.dy < 0) != flip_y:
297             y_shift = area.y2 - overlap.y2
298         else:
299             y_shift = overlap.y1 - area.y1
300
301         # This untransposes x and y so it aligns with bitmap rows
302         if self._transpose_xy != self._absolute_transform.transpose_xy:
303             x_stride, y_stride = y_stride, x_stride
304             x_shift, y_shift = y_shift, x_shift
305
306         pixels_per_byte = 8 // colorspace.depth
307
308         input_pixel = InputPixelStruct()
309         output_pixel = OutputPixelStruct()
310         for input_pixel.y in range(start_y, end_y):
311             row_start = (
312                 start + (input_pixel.y - start_y + y_shift) * y_stride
313             )  # In Pixels
314             local_y = input_pixel.y // self._absolute_transform.scale
315             for input_pixel.x in range(start_x, end_x):
316                 # Compute the destination pixel in the buffer and mask based on the transformations
317                 offset = (
318                     row_start + (input_pixel.x - start_x + x_shift) * x_stride
319                 )  # In Pixels
320
321                 # Check the mask first to see if the pixel has already been set
322                 if mask[offset // 8] & (1 << (offset % 8)):
323                     continue
324                 local_x = input_pixel.x // self._absolute_transform.scale
325                 tile_location = (
326                     (local_y // self._tile_height + self._top_left_y)
327                     % self._height_in_tiles
328                 ) * self._width_in_tiles + (
329                     local_x // self._tile_width + self._top_left_x
330                 ) % self._width_in_tiles
331                 input_pixel.tile = tiles[tile_location]
332                 input_pixel.tile_x = (
333                     input_pixel.tile % self._bitmap_width_in_tiles
334                 ) * self._tile_width + local_x % self._tile_width
335                 input_pixel.tile_y = (
336                     input_pixel.tile // self._bitmap_width_in_tiles
337                 ) * self._tile_height + local_y % self._tile_height
338
339                 output_pixel.pixel = 0
340                 input_pixel.pixel = 0
341
342                 # We always want to read bitmap pixels by row first and then transpose into
343                 # the destination buffer because most bitmaps are row associated.
344                 if isinstance(self._bitmap, (Bitmap, Shape, OnDiskBitmap)):
345                     input_pixel.pixel = (
346                         self._bitmap._get_pixel(  # pylint: disable=protected-access
347                             input_pixel.tile_x, input_pixel.tile_y
348                         )
349                     )
350
351                 output_pixel.opaque = True
352                 if self._pixel_shader is None:
353                     output_pixel.pixel = input_pixel.pixel
354                 elif isinstance(self._pixel_shader, Palette):
355                     self._pixel_shader._get_color(  # pylint: disable=protected-access
356                         colorspace, input_pixel, output_pixel
357                     )
358                 elif isinstance(self._pixel_shader, ColorConverter):
359                     self._pixel_shader._convert(  # pylint: disable=protected-access
360                         colorspace, input_pixel, output_pixel
361                     )
362
363                 if not output_pixel.opaque:
364                     full_coverage = False
365                 else:
366                     mask[offset // 8] |= 1 << (offset % 8)
367                     # print("Mask", mask)
368                     if colorspace.depth == 16:
369                         struct.pack_into(
370                             "H",
371                             buffer,
372                             offset * 2,
373                             output_pixel.pixel,
374                         )
375                     elif colorspace.depth == 32:
376                         struct.pack_into(
377                             "I",
378                             buffer,
379                             offset * 4,
380                             output_pixel.pixel,
381                         )
382                     elif colorspace.depth == 8:
383                         buffer[offset] = output_pixel.pixel & 0xFF
384                     elif colorspace.depth < 8:
385                         # Reorder the offsets to pack multiple rows into
386                         # a byte (meaning they share a column).
387                         if not colorspace.pixels_in_byte_share_row:
388                             width = area.width()
389                             row = offset // width
390                             col = offset % width
391                             # Dividing by pixels_per_byte does truncated division
392                             # even if we multiply it back out
393                             offset = (
394                                 col * pixels_per_byte
395                                 + (row // pixels_per_byte) * width
396                                 + (row % pixels_per_byte)
397                             )
398                         shift = (offset % pixels_per_byte) * colorspace.depth
399                         if colorspace.reverse_pixels_in_byte:
400                             # Reverse the shift by subtracting it from the leftmost shift
401                             shift = (pixels_per_byte - 1) * colorspace.depth - shift
402                         buffer[offset // pixels_per_byte] |= output_pixel.pixel << shift
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, Shape)):
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         elif isinstance(self._bitmap, Shape):
456             self._bitmap._get_refresh_areas(areas)  # pylint: disable=protected-access
457             refresh_area = areas[-1] if areas else None
458             if refresh_area != tail:
459                 refresh_area.copy_into(self._dirty_area)
460                 self._partial_change = True
461
462         self._full_change = self._full_change or (
463             isinstance(self._pixel_shader, (Palette, ColorConverter))
464             and self._pixel_shader._needs_refresh  # pylint: disable=protected-access
465         )
466         if self._full_change or first_draw:
467             areas.append(self._current_area)
468             return
469
470         if self._partial_change:
471             x = self._x
472             y = self._y
473             if self._absolute_transform.transpose_xy:
474                 x, y = y, x
475             x1 = self._dirty_area.x1
476             x2 = self._dirty_area.x2
477             if self._flip_x:
478                 x1 = self._pixel_width - x1
479                 x2 = self._pixel_width - x2
480             y1 = self._dirty_area.y1
481             y2 = self._dirty_area.y2
482             if self._flip_y:
483                 y1 = self._pixel_height - y1
484                 y2 = self._pixel_height - y2
485             if self._transpose_xy != self._absolute_transform.transpose_xy:
486                 x1, y1 = y1, x1
487                 x2, y2 = y2, x2
488             self._dirty_area.x1 = (
489                 self._absolute_transform.x + self._absolute_transform.dx * (x + x1)
490             )
491             self._dirty_area.y1 = (
492                 self._absolute_transform.y + self._absolute_transform.dy * (y + y1)
493             )
494             self._dirty_area.x2 = (
495                 self._absolute_transform.x + self._absolute_transform.dx * (x + x2)
496             )
497             self._dirty_area.y2 = (
498                 self._absolute_transform.y + self._absolute_transform.dy * (y + y2)
499             )
500             if self._dirty_area.y2 < self._dirty_area.y1:
501                 self._dirty_area.y1, self._dirty_area.y2 = (
502                     self._dirty_area.y2,
503                     self._dirty_area.y1,
504                 )
505             if self._dirty_area.x2 < self._dirty_area.x1:
506                 self._dirty_area.x1, self._dirty_area.x2 = (
507                     self._dirty_area.x2,
508                     self._dirty_area.x1,
509                 )
510             areas.append(self._dirty_area)
511
512     def _set_hidden(self, hidden: bool) -> None:
513         self._hidden_tilegrid = hidden
514         self._rendered_hidden = False
515         if not hidden:
516             self._full_change = True
517
518     def _set_hidden_by_parent(self, hidden: bool) -> None:
519         self._hidden_by_parent = hidden
520         self._rendered_hidden = False
521         if not hidden:
522             self._full_change = True
523
524     def _get_rendered_hidden(self) -> bool:
525         return self._rendered_hidden
526
527     def _set_all_tiles(self, tile_index: int) -> None:
528         """Set all tiles to the given tile index"""
529         if tile_index >= self._tiles_in_bitmap:
530             raise ValueError("Tile index out of bounds")
531         self._tiles = bytearray(
532             (self._width_in_tiles * self._height_in_tiles) * [tile_index]
533         )
534         self._full_change = True
535
536     def _set_tile(self, x: int, y: int, tile_index: int) -> None:
537         self._tiles[y * self._width_in_tiles + x] = tile_index
538         temp_area = Area()
539         if not self._partial_change:
540             tile_area = self._dirty_area
541         else:
542             tile_area = temp_area
543         top_x = (x - self._top_left_x) % self._width_in_tiles
544         if top_x < 0:
545             top_x += self._width_in_tiles
546         tile_area.x1 = top_x * self._tile_width
547         tile_area.x2 = tile_area.x1 + self._tile_width
548         top_y = (y - self._top_left_y) % self._height_in_tiles
549         if top_y < 0:
550             top_y += self._height_in_tiles
551         tile_area.y1 = top_y * self._tile_height
552         tile_area.y2 = tile_area.y1 + self._tile_height
553
554         if self._partial_change:
555             self._dirty_area.union(temp_area, self._dirty_area)
556
557         self._partial_change = True
558
559     def _set_top_left(self, x: int, y: int) -> None:
560         self._top_left_x = x
561         self._top_left_y = y
562         self._full_change = True
563
564     @property
565     def hidden(self) -> bool:
566         """True when the TileGrid is hidden. This may be False even
567         when a part of a hidden Group."""
568         return self._hidden_tilegrid
569
570     @hidden.setter
571     def hidden(self, value: bool):
572         if not isinstance(value, (bool, int)):
573             raise ValueError("Expecting a boolean or integer value")
574         value = bool(value)
575         self._set_hidden(value)
576
577     @property
578     def x(self) -> int:
579         """X position of the left edge in the parent."""
580         return self._x
581
582     @x.setter
583     def x(self, value: int):
584         if not isinstance(value, int):
585             raise TypeError("X should be a integer type")
586         if self._x != value:
587             self._moved = True
588             self._x = value
589             if self._absolute_transform is not None:
590                 self._update_current_x()
591
592     @property
593     def y(self) -> int:
594         """Y position of the top edge in the parent."""
595         return self._y
596
597     @y.setter
598     def y(self, value: int):
599         if not isinstance(value, int):
600             raise TypeError("Y should be a integer type")
601         if self._y != value:
602             self._moved = True
603             self._y = value
604             if self._absolute_transform is not None:
605                 self._update_current_y()
606
607     @property
608     def flip_x(self) -> bool:
609         """If true, the left edge rendered will be the right edge of the right-most tile."""
610         return self._flip_x
611
612     @flip_x.setter
613     def flip_x(self, value: bool):
614         if not isinstance(value, bool):
615             raise TypeError("Flip X should be a boolean type")
616         if self._flip_x != value:
617             self._flip_x = value
618             self._full_change = True
619
620     @property
621     def flip_y(self) -> bool:
622         """If true, the top edge rendered will be the bottom edge of the bottom-most tile."""
623         return self._flip_y
624
625     @flip_y.setter
626     def flip_y(self, value: bool):
627         if not isinstance(value, bool):
628             raise TypeError("Flip Y should be a boolean type")
629         if self._flip_y != value:
630             self._flip_y = value
631             self._full_change = True
632
633     @property
634     def transpose_xy(self) -> bool:
635         """If true, the TileGrid's axis will be swapped. When combined with mirroring, any 90
636         degree rotation can be achieved along with the corresponding mirrored version.
637         """
638         return self._transpose_xy
639
640     @transpose_xy.setter
641     def transpose_xy(self, value: bool) -> None:
642         if not isinstance(value, bool):
643             raise TypeError("Transpose XY should be a boolean type")
644         if self._transpose_xy != value:
645             self._transpose_xy = value
646             if self._pixel_width == self._pixel_height:
647                 self._full_change = True
648                 return
649             self._update_current_x()
650             self._update_current_y()
651             self._moved = True
652
653     @property
654     def pixel_shader(self) -> Union[ColorConverter, Palette]:
655         """The pixel shader of the tilegrid."""
656         return self._pixel_shader
657
658     @pixel_shader.setter
659     def pixel_shader(self, new_pixel_shader: Union[ColorConverter, Palette]) -> None:
660         if not isinstance(new_pixel_shader, ColorConverter) and not isinstance(
661             new_pixel_shader, Palette
662         ):
663             raise TypeError(
664                 "Unsupported Type: new_pixel_shader must be ColorConverter or Palette"
665             )
666
667         self._pixel_shader = new_pixel_shader
668         self._full_change = True
669
670     @property
671     def bitmap(self) -> Union[Bitmap, OnDiskBitmap, Shape]:
672         """The Bitmap, OnDiskBitmap, or Shape that is assigned to this TileGrid"""
673         return self._bitmap
674
675     @bitmap.setter
676     def bitmap(self, new_bitmap: Union[Bitmap, OnDiskBitmap, Shape]) -> None:
677         if (
678             not isinstance(new_bitmap, Bitmap)
679             and not isinstance(new_bitmap, OnDiskBitmap)
680             and not isinstance(new_bitmap, Shape)
681         ):
682             raise TypeError(
683                 "Unsupported Type: new_bitmap must be Bitmap, OnDiskBitmap, or Shape"
684             )
685
686         if (
687             new_bitmap.width != self.bitmap.width
688             or new_bitmap.height != self.bitmap.height
689         ):
690             raise ValueError("New bitmap must be same size as old bitmap")
691
692         self._bitmap = new_bitmap
693         self._full_change = True
694
695     def _extract_and_check_index(self, index):
696         if isinstance(index, (tuple, list)):
697             x = index[0]
698             y = index[1]
699             index = y * self._width_in_tiles + x
700         elif isinstance(index, int):
701             x = index % self._width_in_tiles
702             y = index // self._width_in_tiles
703         if (
704             x > self._width_in_tiles
705             or y > self._height_in_tiles
706             or index >= len(self._tiles)
707         ):
708             raise ValueError("Tile index out of bounds")
709         return x, y
710
711     def __getitem__(self, index: Union[Tuple[int, int], int]) -> int:
712         """Returns the tile index at the given index. The index can either be
713         an x,y tuple or an int equal to ``y * width + x``'.
714         """
715         x, y = self._extract_and_check_index(index)
716         return self._tiles[y * self._width_in_tiles + x]
717
718     def __setitem__(self, index: Union[Tuple[int, int], int], value: int) -> None:
719         """Sets the tile index at the given index. The index can either be
720         an x,y tuple or an int equal to ``y * width + x``.
721         """
722         x, y = self._extract_and_check_index(index)
723         if not 0 <= value <= 255:
724             raise ValueError("Tile value out of bounds")
725         self._set_tile(x, y, value)
726
727     @property
728     def width(self) -> int:
729         """Width in tiles"""
730         return self._width_in_tiles
731
732     @property
733     def height(self) -> int:
734         """Height in tiles"""
735         return self._height_in_tiles
736
737     @property
738     def tile_width(self) -> int:
739         """Width of each tile in pixels"""
740         return self._tile_width
741
742     @property
743     def tile_height(self) -> int:
744         """Height of each tile in pixels"""
745         return self._tile_height