From b598e8fe55aac339ba7d5a3c9b6eac56507bccb5 Mon Sep 17 00:00:00 2001 From: Melissa LeBlanc-Williams Date: Fri, 29 Dec 2023 12:16:55 -0800 Subject: [PATCH] Add vectorio support --- displayio/_group.py | 34 ++-- setup.py | 2 +- vectorio/__init__.py | 26 +++ vectorio/_circle.py | 91 ++++++++++ vectorio/_polygon.py | 129 +++++++++++++ vectorio/_rectangle.py | 104 +++++++++++ vectorio/_vectorshape.py | 382 +++++++++++++++++++++++++++++++++++++++ 7 files changed, 754 insertions(+), 14 deletions(-) create mode 100644 vectorio/__init__.py create mode 100644 vectorio/_circle.py create mode 100644 vectorio/_polygon.py create mode 100644 vectorio/_rectangle.py create mode 100644 vectorio/_vectorshape.py diff --git a/displayio/_group.py b/displayio/_group.py index a5c6688..a0d8f9d 100644 --- a/displayio/_group.py +++ b/displayio/_group.py @@ -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: diff --git a/setup.py b/setup.py index e4d2d14..f94e283 100644 --- a/setup.py +++ b/setup.py @@ -61,5 +61,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 index 0000000..cada4d0 --- /dev/null +++ b/vectorio/__init__.py @@ -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 index 0000000..cc87351 --- /dev/null +++ b/vectorio/_circle.py @@ -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 index 0000000..4e6e444 --- /dev/null +++ b/vectorio/_polygon.py @@ -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 index 0000000..e4c5952 --- /dev/null +++ b/vectorio/_rectangle.py @@ -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 index 0000000..d3d1ddb --- /dev/null +++ b/vectorio/_vectorshape.py @@ -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() -- 2.49.0