]> Repositories - hackapet/Adafruit_Blinka_Displayio.git/commitdiff
Merge pull request #123 from makermelissa/remove-pillow 1.2.0
authorMelissa LeBlanc-Williams <melissa@adafruit.com>
Tue, 2 Jan 2024 17:36:10 +0000 (09:36 -0800)
committerGitHub <noreply@github.com>
Tue, 2 Jan 2024 17:36:10 +0000 (09:36 -0800)
Remove pillow requirement

displayio/_group.py
pyproject.toml
setup.py
vectorio/__init__.py [new file with mode: 0644]
vectorio/_circle.py [new file with mode: 0644]
vectorio/_polygon.py [new file with mode: 0644]
vectorio/_rectangle.py [new file with mode: 0644]
vectorio/_vectorshape.py [new file with mode: 0644]

index a5c6688f5f66d7d8b715dbb4a54159d700fef15b..a0d8f9d0f5ee8111425456cb70e70f252a8aa4d4 100644 (file)
@@ -20,6 +20,7 @@ displayio for Blinka
 from __future__ import annotations
 from typing import Union, Callable
 from circuitpython_typing import WriteableBuffer
+from vectorio._rectangle import _VectorShape
 from ._structs import TransformStruct
 from ._tilegrid import TileGrid
 from ._colorspace import Colorspace
@@ -54,7 +55,7 @@ class Group:
         self._hidden_group = False
         self._hidden_by_parent = False
         self._layers = []
-        self._supported_types = (TileGrid, Group)
+        self._supported_types = (TileGrid, Group, _VectorShape)
         self._in_group = False
         self._item_removed = False
         self._absolute_transform = TransformStruct(0, 0, 1, 1, 1, False, False, False)
@@ -94,33 +95,34 @@ class Group:
         layer = self._layers[index]
         layer._update_transform(self._absolute_transform)
 
-    def append(self, layer: Union[Group, TileGrid]) -> None:
+    def append(self, layer: Union[Group, TileGrid, _VectorShape]) -> None:
         """Append a layer to the group. It will be drawn
         above other layers.
         """
         self.insert(len(self._layers), layer)
 
-    def insert(self, index: int, layer: Union[Group, TileGrid]) -> None:
+    def insert(self, index: int, layer: Union[Group, TileGrid, _VectorShape]) -> None:
         """Insert a layer into the group."""
         if not isinstance(layer, self._supported_types):
             raise ValueError("Invalid Group Member")
-        if layer._in_group:  # pylint: disable=protected-access
-            raise ValueError("Layer already in a group.")
+        if isinstance(layer, (Group, TileGrid)):
+            if layer._in_group:  # pylint: disable=protected-access
+                raise ValueError("Layer already in a group.")
         self._layers.insert(index, layer)
         self._layer_update(index)
 
-    def index(self, layer: Union[Group, TileGrid]) -> int:
+    def index(self, layer: Union[Group, TileGrid, _VectorShape]) -> int:
         """Returns the index of the first copy of layer.
         Raises ValueError if not found.
         """
         return self._layers.index(layer)
 
-    def pop(self, index: int = -1) -> Union[Group, TileGrid]:
+    def pop(self, index: int = -1) -> Union[Group, TileGrid, _VectorShape]:
         """Remove the ith item and return it."""
         self._removal_cleanup(index)
         return self._layers.pop(index)
 
-    def remove(self, layer: Union[Group, TileGrid]) -> None:
+    def remove(self, layer: Union[Group, TileGrid, _VectorShape]) -> None:
         """Remove the first copy of layer. Raises ValueError
         if it is not present."""
         index = self.index(layer)
@@ -134,11 +136,13 @@ class Group:
         """Returns the number of layers in a Group"""
         return len(self._layers)
 
-    def __getitem__(self, index: int) -> Union[Group, TileGrid]:
+    def __getitem__(self, index: int) -> Union[Group, TileGrid, _VectorShape]:
         """Returns the value at the given index."""
         return self._layers[index]
 
-    def __setitem__(self, index: int, value: Union[Group, TileGrid]) -> None:
+    def __setitem__(
+        self, index: int, value: Union[Group, TileGrid, _VectorShape]
+    ) -> None:
         """Sets the value at the given index."""
         self._removal_cleanup(index)
         self._layers[index] = value
@@ -157,7 +161,7 @@ class Group:
     ) -> bool:
         if not self._hidden_group:
             for layer in reversed(self._layers):
-                if isinstance(layer, (Group, TileGrid)):
+                if isinstance(layer, (Group, TileGrid, _VectorShape)):
                     if layer._fill_area(  # pylint: disable=protected-access
                         colorspace, area, mask, buffer
                     ):
@@ -170,13 +174,13 @@ class Group:
 
     def _finish_refresh(self):
         for layer in reversed(self._layers):
-            if isinstance(layer, (Group, TileGrid)):
+            if isinstance(layer, (Group, TileGrid, _VectorShape)):
                 layer._finish_refresh()  # pylint: disable=protected-access
 
     def _get_refresh_areas(self, areas: list[Area]) -> None:
         # pylint: disable=protected-access
         for layer in reversed(self._layers):
-            if isinstance(layer, Group):
+            if isinstance(layer, (Group, _VectorShape)):
                 layer._get_refresh_areas(areas)
             elif isinstance(layer, TileGrid):
                 if not layer._get_rendered_hidden():
@@ -191,6 +195,8 @@ class Group:
         for layer in self._layers:
             if isinstance(layer, (Group, TileGrid)):
                 layer._set_hidden_by_parent(hidden)  # pylint: disable=protected-access
+            elif isinstance(layer, _VectorShape):
+                layer._shape_set_dirty()  # pylint: disable=protected-access
 
     def _set_hidden_by_parent(self, hidden: bool) -> None:
         if self._hidden_by_parent == hidden:
@@ -201,6 +207,8 @@ class Group:
         for layer in self._layers:
             if isinstance(layer, (Group, TileGrid)):
                 layer._set_hidden_by_parent(hidden)  # pylint: disable=protected-access
+            elif isinstance(layer, _VectorShape):
+                layer._shape_set_dirty()  # pylint: disable=protected-access
 
     @property
     def hidden(self) -> bool:
index dc2d186fae96b90b4a3fc0f8cfe27ca347243eaf..b1e041c8f2554b97e9db3433b556c6fa14dfb87e 100644 (file)
@@ -17,7 +17,7 @@ readme = "README.rst"
 authors = [
     {name = "Adafruit Industries", email = "circuitpython@adafruit.com"}
 ]
-urls = {Homepage = "https://github.com/adafruit/Adafruit_CircuitPython_SI1145.git"}
+urls = {Homepage = "https://github.com/adafruit/Adafruit_Blinka_Displayio.git"}
 keywords = [
     "adafruit",
     "blinka",
@@ -42,7 +42,7 @@ dynamic = ["dependencies"]
 
 [tool.setuptools]
 py-modules = ["fontio", "terminalio", "paralleldisplay"]
-packages = ["displayio"]
+packages = ["displayio", "vectorio"]
 
 [tool.setuptools.dynamic]
 dependencies = {file = ["requirements.txt"]}
index 5304dba1cae4374043659a40c2fa2dc7f59b092b..7a04dcec57b95164fd8288967b2ec15feb9078a2 100644 (file)
--- a/setup.py
+++ b/setup.py
@@ -57,5 +57,5 @@ setup(
     # You can just specify the packages manually here if your project is
     # simple. Or you can use find_packages().
     py_modules=["fontio", "terminalio", "paralleldisplay"],
-    packages=["displayio"],
+    packages=["displayio", "vectorio"],
 )
diff --git a/vectorio/__init__.py b/vectorio/__init__.py
new file mode 100644 (file)
index 0000000..cada4d0
--- /dev/null
@@ -0,0 +1,26 @@
+# SPDX-FileCopyrightText: 2020 Melissa LeBlanc-Williams for Adafruit Industries
+#
+# SPDX-License-Identifier: MIT
+
+"""
+`vectorio`
+================================================================================
+
+vectorio for Blinka
+
+**Software and Dependencies:**
+
+* Adafruit Blinka:
+  https://github.com/adafruit/Adafruit_Blinka/releases
+
+* Author(s): Melissa LeBlanc-Williams
+
+"""
+
+from typing import Union
+from ._rectangle import Rectangle
+from ._circle import Circle
+from ._polygon import Polygon
+
+__version__ = "0.0.0+auto.0"
+__repo__ = "https://github.com/adafruit/Adafruit_Blinka_displayio.git"
diff --git a/vectorio/_circle.py b/vectorio/_circle.py
new file mode 100644 (file)
index 0000000..cc87351
--- /dev/null
@@ -0,0 +1,91 @@
+# SPDX-FileCopyrightText: 2020 Melissa LeBlanc-Williams for Adafruit Industries
+#
+# SPDX-License-Identifier: MIT
+
+"""
+`vectorio.circle`
+================================================================================
+
+vectorio Circle for Blinka
+
+**Software and Dependencies:**
+
+* Adafruit Blinka:
+  https://github.com/adafruit/Adafruit_Blinka/releases
+
+* Author(s): Melissa LeBlanc-Williams
+
+"""
+
+from typing import Union
+from displayio._colorconverter import ColorConverter
+from displayio._palette import Palette
+from displayio._area import Area
+from ._vectorshape import _VectorShape
+
+__version__ = "0.0.0+auto.0"
+__repo__ = "https://github.com/adafruit/Adafruit_Blinka_displayio.git"
+
+
+class Circle(_VectorShape):
+    """Vectorio Circle"""
+
+    def __init__(
+        self,
+        *,
+        pixel_shader: Union[ColorConverter, Palette],
+        radius: int,
+        x: int,
+        y: int,
+    ):
+        """Circle is positioned on screen by its center point.
+
+        :param Union[~displayio.ColorConverter,~displayio.Palette] pixel_shader:
+            The pixel shader that produces colors from values
+        :param int radius: The radius of the circle in pixels
+        :param int x: Initial x position of the axis.
+        :param int y: Initial y position of the axis.
+        :param int color_index: Initial color_index to use when selecting color from the palette.
+        """
+        self._radius = 1
+        self._color_index = 1
+        super().__init__(pixel_shader, x, y)
+        self.radius = radius
+
+    @property
+    def radius(self) -> int:
+        """The radius of the circle in pixels"""
+        return self._radius
+
+    @radius.setter
+    def radius(self, value: int) -> None:
+        if value < 1:
+            raise ValueError("radius must be >= 1")
+        self._radius = abs(value)
+        self._shape_set_dirty()
+
+    @property
+    def color_index(self) -> int:
+        """The color_index of the circle as 0 based index of the palette."""
+        return self._color_index - 1
+
+    @color_index.setter
+    def color_index(self, value: int) -> None:
+        self._color_index = abs(value + 1)
+        self._shape_set_dirty()
+
+    def _get_pixel(self, x: int, y: int) -> int:
+        x = abs(x)
+        y = abs(y)
+        if x + y <= self._radius:
+            return self._color_index
+        if x > self._radius or y > self._radius:
+            return 0
+        pythagoras_smaller_than_radius = x * x + y * y <= self._radius * self._radius
+        return self._color_index if pythagoras_smaller_than_radius else 0
+
+    def _get_area(self, out_area: Area) -> None:
+        out_area.x1 = -1 * self._radius - 1
+        out_area.y1 = -1 * self._radius - 1
+        out_area.x2 = self._radius + 1
+        out_area.y2 = self._radius + 1
diff --git a/vectorio/_polygon.py b/vectorio/_polygon.py
new file mode 100644 (file)
index 0000000..4e6e444
--- /dev/null
@@ -0,0 +1,129 @@
+# SPDX-FileCopyrightText: 2020 Melissa LeBlanc-Williams for Adafruit Industries
+#
+# SPDX-License-Identifier: MIT
+
+"""
+`vectorio.polygon`
+================================================================================
+
+vectorio Polygon for Blinka
+
+**Software and Dependencies:**
+
+* Adafruit Blinka:
+  https://github.com/adafruit/Adafruit_Blinka/releases
+
+* Author(s): Melissa LeBlanc-Williams
+
+"""
+
+from typing import Union, Tuple
+from displayio._colorconverter import ColorConverter
+from displayio._palette import Palette
+from displayio._area import Area
+from ._vectorshape import _VectorShape
+
+__version__ = "0.0.0+auto.0"
+__repo__ = "https://github.com/adafruit/Adafruit_Blinka_displayio.git"
+
+
+class Polygon(_VectorShape):
+    """Vectorio Polygon"""
+
+    def __init__(
+        self,
+        *,
+        pixel_shader: Union[ColorConverter, Palette],
+        points: list | Tuple[int, int],
+        x: int,
+        y: int,
+    ):
+        """Represents a closed shape by ordered vertices. The path will be treated as
+        'closed', the last point will connect to the first point.
+
+        :param Union[~displayio.ColorConverter,~displayio.Palette] pixel_shader: The pixel
+            shader that produces colors from values
+        :param List[Tuple[int,int]] points: Vertices for the polygon
+        :param int x: Initial screen x position of the 0,0 origin in the points list.
+        :param int y: Initial screen y position of the 0,0 origin in the points list.
+        :param int color_index: Initial color_index to use when selecting color from the palette.
+        """
+        self._color_index = 1
+        self._points = []
+        super().__init__(pixel_shader, x, y)
+        self.points = points
+
+    @property
+    def points(self) -> list | Tuple[int, int]:
+        """The points of the polygon in pixels"""
+        return self._points
+
+    @points.setter
+    def points(self, value: list | Tuple[int, int]) -> None:
+        if len(value) < 3:
+            raise ValueError("Polygon needs at least 3 points")
+        self._points = value
+        self._shape_set_dirty()
+
+    @property
+    def color_index(self) -> int:
+        """The color_index of the polygon as 0 based index of the palette."""
+        return self._color_index - 1
+
+    @color_index.setter
+    def color_index(self, value: int) -> None:
+        self._color_index = abs(value + 1)
+        self._shape_set_dirty()
+
+    @staticmethod
+    def _line_side(
+        line_x1: int,
+        line_y1: int,
+        line_x2: int,
+        line_y2: int,
+        point_x: int,
+        point_y: int,
+    ):
+        # pylint: disable=too-many-arguments
+        return (point_x - line_x1) * (line_y2 - line_y1) - (point_y - line_y1) * (
+            line_x2 - line_x1
+        )
+
+    def _get_pixel(self, x: int, y: int) -> int:
+        # pylint: disable=invalid-name
+        if len(self._points) == 0:
+            return 0
+        winding_number = 0
+        x1 = self._points[0][0]
+        y1 = self._points[0][1]
+        for i in range(1, len(self._points)):
+            x2 = self._points[i][0]
+            y2 = self._points[i][1]
+            if y1 <= y:
+                if y2 > y and self._line_side(x1, y1, x2, y2, x, y) < 0:
+                    # Wind up, point is to the left of the edge vector
+                    winding_number += 1
+            elif y2 <= y and self._line_side(x1, y1, x2, y2, x, y) > 0:
+                # Wind down, point is to the right of the edge vector
+                winding_number -= 1
+            x1 = x2
+            y1 = y2
+
+        return 0 if winding_number == 0 else self._color_index
+
+    def _get_area(self, out_area: Area) -> None:
+        # Figure out the shape dimensions by using min and max
+        out_area.x1 = 32768
+        out_area.y1 = 32768
+        out_area.x2 = 0
+        out_area.y2 = 0
+
+        for x, y in self._points:
+            if x < out_area.x1:
+                out_area.x1 = x
+            if y < out_area.y1:
+                out_area.y1 = y
+            if x > out_area.x2:
+                out_area.x2 = x
+            if y > out_area.y2:
+                out_area.y2 = y
diff --git a/vectorio/_rectangle.py b/vectorio/_rectangle.py
new file mode 100644 (file)
index 0000000..e4c5952
--- /dev/null
@@ -0,0 +1,104 @@
+# SPDX-FileCopyrightText: 2020 Melissa LeBlanc-Williams for Adafruit Industries
+#
+# SPDX-License-Identifier: MIT
+
+"""
+`vectorio.rectangle`
+================================================================================
+
+vectorio Rectangle for Blinka
+
+**Software and Dependencies:**
+
+* Adafruit Blinka:
+  https://github.com/adafruit/Adafruit_Blinka/releases
+
+* Author(s): Melissa LeBlanc-Williams
+
+"""
+
+from typing import Union
+from displayio._colorconverter import ColorConverter
+from displayio._palette import Palette
+from displayio._area import Area
+from ._vectorshape import _VectorShape
+
+__version__ = "0.0.0+auto.0"
+__repo__ = "https://github.com/adafruit/Adafruit_Blinka_displayio.git"
+
+
+class Rectangle(_VectorShape):
+    """Vectorio Rectangle"""
+
+    def __init__(
+        self,
+        *,
+        pixel_shader: Union[ColorConverter, Palette],
+        width: int,
+        height: int,
+        x: int,
+        y: int,
+    ):
+        """Represents a rectangle by defining its bounds
+
+        :param Union[~displayio.ColorConverter,~displayio.Palette] pixel_shader:
+            The pixel shader that produces colors from values
+        :param int width: The number of pixels wide
+        :param int height: The number of pixels high
+        :param int x: Initial x position of the top left corner.
+        :param int y: Initial y position of the top left corner.
+        :param int color_index: Initial color_index to use when selecting color from the palette.
+        """
+        self._width = 1
+        self._height = 1
+        self._color_index = 1
+
+        super().__init__(pixel_shader, x, y)
+        self.width = width
+        self.height = height
+
+    @property
+    def width(self) -> int:
+        """The width of the rectangle in pixels"""
+        return self._width
+
+    @width.setter
+    def width(self, value: int) -> None:
+        if value < 1:
+            raise ValueError("width must be >= 1")
+
+        self._width = abs(value)
+        self._shape_set_dirty()
+
+    @property
+    def height(self) -> int:
+        """The height of the rectangle in pixels"""
+        return self._height
+
+    @height.setter
+    def height(self, value: int) -> None:
+        if value < 1:
+            raise ValueError("height must be >= 1")
+        self._height = abs(value)
+        self._shape_set_dirty()
+
+    @property
+    def color_index(self) -> int:
+        """The color_index of the rectangle as 0 based index of the palette."""
+        return self._color_index - 1
+
+    @color_index.setter
+    def color_index(self, value: int) -> None:
+        self._color_index = abs(value + 1)
+        self._shape_set_dirty()
+
+    def _get_pixel(self, x: int, y: int) -> int:
+        if 0 <= x < self._width and 0 <= y < self._height:
+            return self._color_index
+        return 0
+
+    def _get_area(self, out_area: Area) -> None:
+        out_area.x1 = 0
+        out_area.y1 = 0
+        out_area.x2 = self._width
+        out_area.y2 = self._height
diff --git a/vectorio/_vectorshape.py b/vectorio/_vectorshape.py
new file mode 100644 (file)
index 0000000..d3d1ddb
--- /dev/null
@@ -0,0 +1,382 @@
+# SPDX-FileCopyrightText: 2020 Melissa LeBlanc-Williams for Adafruit Industries
+#
+# SPDX-License-Identifier: MIT
+
+"""
+`vectorio._vectorshape`
+================================================================================
+
+vectorio for Blinka
+
+**Software and Dependencies:**
+
+* Adafruit Blinka:
+  https://github.com/adafruit/Adafruit_Blinka/releases
+
+* Author(s): Melissa LeBlanc-Williams
+
+"""
+
+import struct
+from typing import Union, Tuple
+from circuitpython_typing import WriteableBuffer
+from displayio._colorconverter import ColorConverter
+from displayio._colorspace import Colorspace
+from displayio._palette import Palette
+from displayio._area import Area
+from displayio._structs import null_transform, InputPixelStruct, OutputPixelStruct
+
+__version__ = "0.0.0+auto.0"
+__repo__ = "https://github.com/adafruit/Adafruit_Blinka_displayio.git"
+
+
+class _VectorShape:
+    def __init__(
+        self,
+        pixel_shader: Union[ColorConverter, Palette],
+        x: int,
+        y: int,
+    ):
+        self._x = x
+        self._y = y
+        self._pixel_shader = pixel_shader
+        self._hidden = False
+        self._current_area_dirty = True
+        self._current_area = Area(0, 0, 0, 0)
+        self._ephemeral_dirty_area = Area(0, 0, 0, 0)
+        self._absolute_transform = null_transform
+        self._get_screen_area(self._current_area)
+
+    @property
+    def x(self) -> int:
+        """X position of the center point of the circle in the parent."""
+        return self._x
+
+    @x.setter
+    def x(self, value: int) -> None:
+        if self._x == value:
+            return
+        self._x = value
+        self._shape_set_dirty()
+
+    @property
+    def y(self) -> int:
+        """Y position of the center point of the circle in the parent."""
+        return self._y
+
+    @y.setter
+    def y(self, value: int) -> None:
+        if self._y == value:
+            return
+        self._y = value
+        self._shape_set_dirty()
+
+    @property
+    def hidden(self) -> bool:
+        """Hide the circle or not."""
+        return self._hidden
+
+    @hidden.setter
+    def hidden(self, value: bool) -> None:
+        self._hidden = value
+        self._shape_set_dirty()
+
+    @property
+    def location(self) -> Tuple[int, int]:
+        """(X,Y) position of the center point of the circle in the parent."""
+        return (self._x, self._y)
+
+    @location.setter
+    def location(self, value: Tuple[int, int]) -> None:
+        if len(value) != 2:
+            raise ValueError("location must be a list or tuple with exactly 2 integers")
+        x = value[0]
+        y = value[1]
+        dirty = False
+        if self._x != x:
+            self._x = x
+            dirty = True
+        if self._y != y:
+            self._y = y
+            dirty = True
+        if dirty:
+            self._shape_set_dirty()
+
+    @property
+    def pixel_shader(self) -> Union[ColorConverter, Palette]:
+        """The pixel shader of the circle."""
+        return self._pixel_shader
+
+    @pixel_shader.setter
+    def pixel_shader(self, value: Union[ColorConverter, Palette]) -> None:
+        self._pixel_shader = value
+
+    def _get_area(self, _out_area: Area) -> Area:
+        raise NotImplementedError("Subclass must implement _get_area")
+
+    def _get_pixel(self, _x: int, _y: int) -> int:
+        raise NotImplementedError("Subclass must implement _get_pixel")
+
+    def _shape_set_dirty(self) -> None:
+        current_area = Area()
+        self._get_screen_area(current_area)
+        moved = current_area != self._current_area
+        if moved:
+            self._current_area.union(
+                self._ephemeral_dirty_area, self._ephemeral_dirty_area
+            )
+            # Dirty area tracks the shape's footprint between draws.  It's reset on refresh finish.
+            current_area.copy_into(self._current_area)
+        self._current_area_dirty = True
+
+    def _get_dirty_area(self, out_area: Area) -> Area:
+        out_area.x1 = out_area.x2
+        self._ephemeral_dirty_area.union(self._current_area, out_area)
+        return True  # For now just always redraw.
+
+    def _get_screen_area(self, out_area) -> Area:
+        self._get_area(out_area)
+        if self._absolute_transform.transpose_xy:
+            x = self._absolute_transform.x + self._absolute_transform.dx * self._y
+            y = self._absolute_transform.y + self._absolute_transform.dy * self._x
+            if self._absolute_transform.dx < 1:
+                out_area.y1 = out_area.y1 * -1 + 1
+                out_area.y2 = out_area.y2 * -1 + 1
+            if self._absolute_transform.dy < 1:
+                out_area.x1 = out_area.x1 * -1 + 1
+                out_area.x2 = out_area.x2 * -1 + 1
+            self._area_transpose(out_area)
+        else:
+            x = self._absolute_transform.x + self._absolute_transform.dx * self._x
+            y = self._absolute_transform.y + self._absolute_transform.dy * self._y
+            if self._absolute_transform.dx < 1:
+                out_area.x1 = out_area.x1 * -1 + 1
+                out_area.x2 = out_area.x2 * -1 + 1
+            if self._absolute_transform.dy < 1:
+                out_area.y1 = out_area.y1 * -1 + 1
+                out_area.y2 = out_area.y2 * -1 + 1
+        out_area.canon()
+        out_area.shift(x, y)
+
+    @staticmethod
+    def _area_transpose(to_transpose: Area) -> Area:
+        to_transpose.x1, to_transpose.y1 = to_transpose.y1, to_transpose.x1
+        to_transpose.x2, to_transpose.y2 = to_transpose.y2, to_transpose.x2
+
+    def _screen_to_shape_coordinates(self, x: int, y: int) -> Tuple[int, int]:
+        """Get the target pixel based on the shape's coordinate space"""
+        if self._absolute_transform.transpose_xy:
+            out_shape_x = (
+                y - self._absolute_transform.y - self._absolute_transform.dy * self._x
+            )
+            out_shape_y = (
+                x - self._absolute_transform.x - self._absolute_transform.dx * self._y
+            )
+
+            if self._absolute_transform.dx < 1:
+                out_shape_x *= -1
+            if self._absolute_transform.dy < 1:
+                out_shape_y *= -1
+        else:
+            out_shape_x = (
+                x - self._absolute_transform.x - self._absolute_transform.dx * self._x
+            )
+            out_shape_y = (
+                y - self._absolute_transform.y - self._absolute_transform.dy * self._y
+            )
+
+            if self._absolute_transform.dx < 1:
+                out_shape_x *= -1
+            if self._absolute_transform.dy < 1:
+                out_shape_y *= -1
+
+            # It's mirrored via dx. Maybe we need to add support for also separately mirroring?
+            # if self.absolute_transform.mirror_x:
+            #    pixel_to_get_x = (
+            #        (shape_area.x2 - shape_area.x1)
+            #        - (pixel_to_get_x - shape_area.x1)
+            #        + shape_area.x1
+            #        - 1
+            #    )
+            # if self.absolute_transform.mirror_y:
+            #    pixel_to_get_y = (
+            #        (shape_area.y2 - shape_area.y1)
+            #        - (pixel_to_get_y - shape_area.y1)
+            #        + +shape_area.y1
+            #        - 1
+            #    )
+
+        return out_shape_x, out_shape_y
+
+    def _shape_contains(self, x: int, y: int) -> bool:
+        shape_x, shape_y = self._screen_to_shape_coordinates(x, y)
+        return self._get_pixel(shape_x, shape_y) != 0
+
+    def _fill_area(
+        self,
+        colorspace: Colorspace,
+        area: Area,
+        mask: WriteableBuffer,
+        buffer: WriteableBuffer,
+    ) -> bool:
+        # pylint: disable=too-many-locals,too-many-branches,too-many-statements
+        if self._hidden:
+            return False
+
+        overlap = Area()
+        if not area.compute_overlap(self._current_area, overlap):
+            return False
+
+        full_coverage = area == overlap
+        pixels_per_byte = 8 // colorspace.depth
+        linestride_px = area.width()
+        line_dirty_offset_px = (overlap.y1 - area.y1) * linestride_px
+        column_dirty_offset_px = overlap.x1 - area.x1
+
+        input_pixel = InputPixelStruct()
+        output_pixel = OutputPixelStruct()
+
+        shape_area = Area()
+        self._get_area(shape_area)
+
+        mask_start_px = line_dirty_offset_px
+
+        for input_pixel.y in range(overlap.y1, overlap.y2):
+            mask_start_px += column_dirty_offset_px
+            for input_pixel.x in range(overlap.x1, overlap.x2):
+                # Check the mask first to see if the pixel has already been set.
+                pixel_index = mask_start_px + (input_pixel.x - overlap.x1)
+                mask_doubleword = mask[pixel_index // 32]
+                mask_bit = pixel_index % 32
+                if (mask_doubleword & (1 << mask_bit)) != 0:
+                    continue
+                output_pixel.pixel = 0
+
+                # Cast input screen coordinates to shape coordinates to pick the pixel to draw
+                pixel_to_get_x, pixel_to_get_y = self._screen_to_shape_coordinates(
+                    input_pixel.x, input_pixel.y
+                )
+                input_pixel.pixel = self._get_pixel(pixel_to_get_x, pixel_to_get_y)
+
+                # vectorio shapes use 0 to mean "area is not covered."
+                # We can skip all the rest of the work for this pixel
+                # if it's not currently covered by the shape.
+                if input_pixel.pixel == 0:
+                    full_coverage = False
+                else:
+                    # Pixel is not transparent. Let's pull the pixel value index down
+                    # to 0-base for more error-resistant palettes.
+                    input_pixel.pixel -= 1
+                    output_pixel.opaque = True
+                    if self._pixel_shader is None:
+                        output_pixel.pixel = input_pixel.pixel
+                    elif isinstance(self._pixel_shader, Palette):
+                        self._pixel_shader._get_color(  # pylint: disable=protected-access
+                            colorspace, input_pixel, output_pixel
+                        )
+                    elif isinstance(self._pixel_shader, ColorConverter):
+                        self._pixel_shader._convert(  # pylint: disable=protected-access
+                            colorspace, input_pixel, output_pixel
+                        )
+
+                    if not output_pixel.opaque:
+                        full_coverage = False
+
+                    mask[pixel_index // 32] |= 1 << (pixel_index % 32)
+                    if colorspace.depth == 16:
+                        struct.pack_into(
+                            "H",
+                            buffer.cast("B"),
+                            pixel_index * 2,
+                            output_pixel.pixel,
+                        )
+                    elif colorspace.depth == 32:
+                        struct.pack_into(
+                            "I",
+                            buffer.cast("B"),
+                            pixel_index * 4,
+                            output_pixel.pixel,
+                        )
+                    elif colorspace.depth == 8:
+                        buffer.cast("B")[pixel_index] = output_pixel.pixel & 0xFF
+                    elif colorspace.depth < 8:
+                        # Reorder the offsets to pack multiple rows into
+                        # a byte (meaning they share a column).
+                        if not colorspace.pixels_in_byte_share_row:
+                            row = pixel_index // linestride_px
+                            col = pixel_index % linestride_px
+                            # Dividing by pixels_per_byte does truncated division
+                            # even if we multiply it back out
+                            pixel_index = (
+                                col * pixels_per_byte
+                                + (row // pixels_per_byte)
+                                * pixels_per_byte
+                                * linestride_px
+                                + (row % pixels_per_byte)
+                            )
+                        shift = (pixel_index % pixels_per_byte) * colorspace.depth
+                        if colorspace.reverse_pixels_in_byte:
+                            # Reverse the shift by subtracting it from the leftmost shift
+                            shift = (pixels_per_byte - 1) * colorspace.depth - shift
+                        buffer.cast("B")[pixel_index // pixels_per_byte] |= (
+                            output_pixel.pixel << shift
+                        )
+            mask_start_px += linestride_px - column_dirty_offset_px
+
+        return full_coverage
+
+    def _finish_refresh(self) -> None:
+        if self._ephemeral_dirty_area.empty() and not self._current_area_dirty:
+            return
+        # Reset dirty area to nothing
+        self._ephemeral_dirty_area.x1 = self._ephemeral_dirty_area.x2
+        self._current_area_dirty = False
+
+        if isinstance(self._pixel_shader, (Palette, ColorConverter)):
+            self._pixel_shader._finish_refresh()  # pylint: disable=protected-access
+
+    def _get_refresh_areas(self, areas: list[Area]) -> None:
+        if self._current_area_dirty or (
+            isinstance(self._pixel_shader, (Palette, ColorConverter))
+            and self._pixel_shader._needs_refresh  # pylint: disable=protected-access
+        ):
+            if not self._ephemeral_dirty_area.empty():
+                # Both are dirty, check if we should combine the areas or draw separately
+                # Draws as few pixels as possible both when animations move short distances
+                # and large distances. The display core implementation currently doesn't
+                # combine areas to reduce redrawing of masked areas. If it does, this could
+                # be simplified to just return the 2 possibly overlapping areas.
+                area_swap = Area()
+                self._ephemeral_dirty_area.compute_overlap(
+                    self._current_area, area_swap
+                )
+                overlap_size = area_swap.size()
+                self._ephemeral_dirty_area.union(self._current_area, area_swap)
+                union_size = area_swap.size()
+                current_size = self._current_area.size()
+                dirty_size = self._ephemeral_dirty_area.size()
+
+                if union_size - dirty_size - current_size + overlap_size <= min(
+                    dirty_size, current_size
+                ):
+                    # The excluded / non-overlapping area from the disjoint dirty and current
+                    # areas is smaller than the smallest area we need to draw. Redrawing the
+                    # overlapping area would cost more than just drawing the union disjoint
+                    # area once.
+                    area_swap.copy_into(self._ephemeral_dirty_area)
+                else:
+                    # The excluded area between the 2 dirty areas is larger than the smallest
+                    # dirty area. It would be more costly to combine these areas than possibly
+                    # redraw some overlap.
+                    areas.append(self._current_area)
+                areas.append(self._ephemeral_dirty_area)
+            else:
+                areas.append(self._current_area)
+        elif not self._ephemeral_dirty_area.empty():
+            areas.append(self._ephemeral_dirty_area)
+
+    def _update_transform(self, group_transform) -> None:
+        self._absolute_transform = (
+            null_transform if group_transform is None else group_transform
+        )
+        self._shape_set_dirty()