]> Repositories - hackapet/Adafruit_Blinka_Displayio.git/blob - displayio/_tilegrid.py
Bug fixes and optimizations. Super close now
[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 // 32] & (1 << (offset % 32)):
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 // 32] |= 1 << (offset % 32)
367                     # print("Mask", mask)
368                     if colorspace.depth == 16:
369                         struct.pack_into(
370                             "H",
371                             buffer.cast("H"),
372                             offset,
373                             output_pixel.pixel,
374                         )
375                     elif colorspace.depth == 32:
376                         struct.pack_into(
377                             "I",
378                             buffer,
379                             offset,
380                             output_pixel.pixel,
381                         )
382                     elif colorspace.depth == 8:
383                         buffer.cast("B")[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) * 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.cast("B")[offset // pixels_per_byte] |= (
403                             output_pixel.pixel << shift
404                         )
405
406         return full_coverage
407
408     def _finish_refresh(self):
409         first_draw = self._previous_area.x1 == self._previous_area.x2
410         hidden = self._hidden_tilegrid or self._hidden_by_parent
411         if not first_draw and hidden:
412             self._previous_area.x2 = self._previous_area.x1
413         elif self._moved or first_draw:
414             self._current_area.copy_into(self._previous_area)
415
416         self._moved = False
417         self._full_change = False
418         self._partial_change = False
419         if isinstance(self._pixel_shader, (Palette, ColorConverter)):
420             self._pixel_shader._finish_refresh()  # pylint: disable=protected-access
421         if isinstance(self._bitmap, (Bitmap, Shape)):
422             self._bitmap._finish_refresh()  # pylint: disable=protected-access
423
424     def _get_refresh_areas(self, areas: list[Area]) -> None:
425         # pylint: disable=invalid-name, too-many-branches, too-many-statements
426         first_draw = self._previous_area.x1 == self._previous_area.x2
427         hidden = self._hidden_tilegrid or self._hidden_by_parent
428
429         # Check hidden first because it trumps all other changes
430         if hidden:
431             self._rendered_hidden = True
432             if not first_draw:
433                 areas.append(self._previous_area)
434             return
435         if self._moved and not first_draw:
436             self._previous_area.union(self._current_area, self._dirty_area)
437             if self._dirty_area.size() < 2 * self._pixel_width * self._pixel_height:
438                 areas.append(self._dirty_area)
439                 return
440             areas.append(self._current_area)
441             areas.append(self._previous_area)
442             return
443
444         tail = areas[-1] if areas else None
445         # If we have an in-memory bitmap, then check it for modifications
446         if isinstance(self._bitmap, Bitmap):
447             self._bitmap._get_refresh_areas(areas)  # pylint: disable=protected-access
448             refresh_area = areas[-1] if areas else None
449             if refresh_area != tail:
450                 # Special case a TileGrid that shows a full bitmap and use its
451                 # dirty area. Copy it to ours so we can transform it.
452                 if self._tiles_in_bitmap == 1:
453                     refresh_area.copy_into(self._dirty_area)
454                     self._partial_change = True
455                 else:
456                     self._full_change = True
457         elif isinstance(self._bitmap, Shape):
458             self._bitmap._get_refresh_areas(areas)  # pylint: disable=protected-access
459             refresh_area = areas[-1] if areas else None
460             if refresh_area != tail:
461                 refresh_area.copy_into(self._dirty_area)
462                 self._partial_change = True
463
464         self._full_change = self._full_change or (
465             isinstance(self._pixel_shader, (Palette, ColorConverter))
466             and self._pixel_shader._needs_refresh  # pylint: disable=protected-access
467         )
468         if self._full_change or first_draw:
469             areas.append(self._current_area)
470             return
471
472         if self._partial_change:
473             x = self._x
474             y = self._y
475             if self._absolute_transform.transpose_xy:
476                 x, y = y, x
477             x1 = self._dirty_area.x1
478             x2 = self._dirty_area.x2
479             if self._flip_x:
480                 x1 = self._pixel_width - x1
481                 x2 = self._pixel_width - x2
482             y1 = self._dirty_area.y1
483             y2 = self._dirty_area.y2
484             if self._flip_y:
485                 y1 = self._pixel_height - y1
486                 y2 = self._pixel_height - y2
487             if self._transpose_xy != self._absolute_transform.transpose_xy:
488                 x1, y1 = y1, x1
489                 x2, y2 = y2, x2
490             self._dirty_area.x1 = (
491                 self._absolute_transform.x + self._absolute_transform.dx * (x + x1)
492             )
493             self._dirty_area.y1 = (
494                 self._absolute_transform.y + self._absolute_transform.dy * (y + y1)
495             )
496             self._dirty_area.x2 = (
497                 self._absolute_transform.x + self._absolute_transform.dx * (x + x2)
498             )
499             self._dirty_area.y2 = (
500                 self._absolute_transform.y + self._absolute_transform.dy * (y + y2)
501             )
502             if self._dirty_area.y2 < self._dirty_area.y1:
503                 self._dirty_area.y1, self._dirty_area.y2 = (
504                     self._dirty_area.y2,
505                     self._dirty_area.y1,
506                 )
507             if self._dirty_area.x2 < self._dirty_area.x1:
508                 self._dirty_area.x1, self._dirty_area.x2 = (
509                     self._dirty_area.x2,
510                     self._dirty_area.x1,
511                 )
512             areas.append(self._dirty_area)
513
514     def _set_hidden(self, hidden: bool) -> None:
515         self._hidden_tilegrid = hidden
516         self._rendered_hidden = False
517         if not hidden:
518             self._full_change = True
519
520     def _set_hidden_by_parent(self, hidden: bool) -> None:
521         self._hidden_by_parent = hidden
522         self._rendered_hidden = False
523         if not hidden:
524             self._full_change = True
525
526     def _get_rendered_hidden(self) -> bool:
527         return self._rendered_hidden
528
529     def _set_all_tiles(self, tile_index: int) -> None:
530         """Set all tiles to the given tile index"""
531         if tile_index >= self._tiles_in_bitmap:
532             raise ValueError("Tile index out of bounds")
533         self._tiles = bytearray(
534             (self._width_in_tiles * self._height_in_tiles) * [tile_index]
535         )
536         self._full_change = True
537
538     def _set_tile(self, x: int, y: int, tile_index: int) -> None:
539         self._tiles[y * self._width_in_tiles + x] = tile_index
540         temp_area = Area()
541         if not self._partial_change:
542             tile_area = self._dirty_area
543         else:
544             tile_area = temp_area
545         top_x = (x - self._top_left_x) % self._width_in_tiles
546         if top_x < 0:
547             top_x += self._width_in_tiles
548         tile_area.x1 = top_x * self._tile_width
549         tile_area.x2 = tile_area.x1 + self._tile_width
550         top_y = (y - self._top_left_y) % self._height_in_tiles
551         if top_y < 0:
552             top_y += self._height_in_tiles
553         tile_area.y1 = top_y * self._tile_height
554         tile_area.y2 = tile_area.y1 + self._tile_height
555
556         if self._partial_change:
557             self._dirty_area.union(temp_area, self._dirty_area)
558
559         self._partial_change = True
560
561     def _set_top_left(self, x: int, y: int) -> None:
562         self._top_left_x = x
563         self._top_left_y = y
564         self._full_change = True
565
566     @property
567     def hidden(self) -> bool:
568         """True when the TileGrid is hidden. This may be False even
569         when a part of a hidden Group."""
570         return self._hidden_tilegrid
571
572     @hidden.setter
573     def hidden(self, value: bool):
574         if not isinstance(value, (bool, int)):
575             raise ValueError("Expecting a boolean or integer value")
576         value = bool(value)
577         self._set_hidden(value)
578
579     @property
580     def x(self) -> int:
581         """X position of the left edge in the parent."""
582         return self._x
583
584     @x.setter
585     def x(self, value: int):
586         if not isinstance(value, int):
587             raise TypeError("X should be a integer type")
588         if self._x != value:
589             self._moved = True
590             self._x = value
591             if self._absolute_transform is not None:
592                 self._update_current_x()
593
594     @property
595     def y(self) -> int:
596         """Y position of the top edge in the parent."""
597         return self._y
598
599     @y.setter
600     def y(self, value: int):
601         if not isinstance(value, int):
602             raise TypeError("Y should be a integer type")
603         if self._y != value:
604             self._moved = True
605             self._y = value
606             if self._absolute_transform is not None:
607                 self._update_current_y()
608
609     @property
610     def flip_x(self) -> bool:
611         """If true, the left edge rendered will be the right edge of the right-most tile."""
612         return self._flip_x
613
614     @flip_x.setter
615     def flip_x(self, value: bool):
616         if not isinstance(value, bool):
617             raise TypeError("Flip X should be a boolean type")
618         if self._flip_x != value:
619             self._flip_x = value
620             self._full_change = True
621
622     @property
623     def flip_y(self) -> bool:
624         """If true, the top edge rendered will be the bottom edge of the bottom-most tile."""
625         return self._flip_y
626
627     @flip_y.setter
628     def flip_y(self, value: bool):
629         if not isinstance(value, bool):
630             raise TypeError("Flip Y should be a boolean type")
631         if self._flip_y != value:
632             self._flip_y = value
633             self._full_change = True
634
635     @property
636     def transpose_xy(self) -> bool:
637         """If true, the TileGrid's axis will be swapped. When combined with mirroring, any 90
638         degree rotation can be achieved along with the corresponding mirrored version.
639         """
640         return self._transpose_xy
641
642     @transpose_xy.setter
643     def transpose_xy(self, value: bool) -> None:
644         if not isinstance(value, bool):
645             raise TypeError("Transpose XY should be a boolean type")
646         if self._transpose_xy != value:
647             self._transpose_xy = value
648             if self._pixel_width == self._pixel_height:
649                 self._full_change = True
650                 return
651             self._update_current_x()
652             self._update_current_y()
653             self._moved = True
654
655     @property
656     def pixel_shader(self) -> Union[ColorConverter, Palette]:
657         """The pixel shader of the tilegrid."""
658         return self._pixel_shader
659
660     @pixel_shader.setter
661     def pixel_shader(self, new_pixel_shader: Union[ColorConverter, Palette]) -> None:
662         if not isinstance(new_pixel_shader, ColorConverter) and not isinstance(
663             new_pixel_shader, Palette
664         ):
665             raise TypeError(
666                 "Unsupported Type: new_pixel_shader must be ColorConverter or Palette"
667             )
668
669         self._pixel_shader = new_pixel_shader
670         self._full_change = True
671
672     @property
673     def bitmap(self) -> Union[Bitmap, OnDiskBitmap, Shape]:
674         """The Bitmap, OnDiskBitmap, or Shape that is assigned to this TileGrid"""
675         return self._bitmap
676
677     @bitmap.setter
678     def bitmap(self, new_bitmap: Union[Bitmap, OnDiskBitmap, Shape]) -> None:
679         if (
680             not isinstance(new_bitmap, Bitmap)
681             and not isinstance(new_bitmap, OnDiskBitmap)
682             and not isinstance(new_bitmap, Shape)
683         ):
684             raise TypeError(
685                 "Unsupported Type: new_bitmap must be Bitmap, OnDiskBitmap, or Shape"
686             )
687
688         if (
689             new_bitmap.width != self.bitmap.width
690             or new_bitmap.height != self.bitmap.height
691         ):
692             raise ValueError("New bitmap must be same size as old bitmap")
693
694         self._bitmap = new_bitmap
695         self._full_change = True
696
697     def _extract_and_check_index(self, index):
698         if isinstance(index, (tuple, list)):
699             x = index[0]
700             y = index[1]
701             index = y * self._width_in_tiles + x
702         elif isinstance(index, int):
703             x = index % self._width_in_tiles
704             y = index // self._width_in_tiles
705         if (
706             x > self._width_in_tiles
707             or y > self._height_in_tiles
708             or index >= len(self._tiles)
709         ):
710             raise ValueError("Tile index out of bounds")
711         return x, y
712
713     def __getitem__(self, index: Union[Tuple[int, int], int]) -> int:
714         """Returns the tile index at the given index. The index can either be
715         an x,y tuple or an int equal to ``y * width + x``'.
716         """
717         x, y = self._extract_and_check_index(index)
718         return self._tiles[y * self._width_in_tiles + x]
719
720     def __setitem__(self, index: Union[Tuple[int, int], int], value: int) -> None:
721         """Sets the tile index at the given index. The index can either be
722         an x,y tuple or an int equal to ``y * width + x``.
723         """
724         x, y = self._extract_and_check_index(index)
725         if not 0 <= value <= 255:
726             raise ValueError("Tile value out of bounds")
727         self._set_tile(x, y, value)
728
729     @property
730     def width(self) -> int:
731         """Width in tiles"""
732         return self._width_in_tiles
733
734     @property
735     def height(self) -> int:
736         """Height in tiles"""
737         return self._height_in_tiles
738
739     @property
740     def tile_width(self) -> int:
741         """Width of each tile in pixels"""
742         return self._tile_width
743
744     @property
745     def tile_height(self) -> int:
746         """Height of each tile in pixels"""
747         return self._tile_height