]> Repositories - hackapet/Adafruit_Blinka_Displayio.git/commitdiff
Merge pull request #114 from makermelissa/add-grayscale 1.0.0
authorMelissa LeBlanc-Williams <melissa@adafruit.com>
Fri, 29 Sep 2023 23:24:58 +0000 (16:24 -0700)
committerGitHub <noreply@github.com>
Fri, 29 Sep 2023 23:24:58 +0000 (16:24 -0700)
Revamp Displayio and Add grayscale

28 files changed:
.pylintrc
displayio/__init__.py
displayio/_area.py
displayio/_bitmap.py
displayio/_colorconverter.py
displayio/_constants.py
displayio/_display.py
displayio/_displaycore.py
displayio/_epaperdisplay.py
displayio/_fourwire.py
displayio/_group.py
displayio/_helpers.py [new file with mode: 0644]
displayio/_i2cdisplay.py
displayio/_ondiskbitmap.py
displayio/_palette.py
displayio/_shape.py
displayio/_structs.py
displayio/_tilegrid.py
displayio/resources/__init__.py [new file with mode: 0644]
displayio/resources/ter-u12n.pbm [new file with mode: 0644]
displayio/resources/ter-u12n.pbm.license [new file with mode: 0644]
displayio/resources/ter-u12n.pil [new file with mode: 0644]
displayio/resources/ter-u12n.pil.license [new file with mode: 0644]
fontio.py
paralleldisplay.py
requirements.txt
setup.py
terminalio.py

index 40208c3965be4d95bf88c6aa420dda7c4c856e25..f945e9204815bf2933a82c7cb973c78f3c61f9ea 100644 (file)
--- a/.pylintrc
+++ b/.pylintrc
@@ -396,4 +396,4 @@ min-public-methods=1
 
 # Exceptions that will emit a warning when being caught. Defaults to
 # "Exception"
-overgeneral-exceptions=Exception
+overgeneral-exceptions=builtins.Exception
index a908e1aa50bb54441ba43de0c821834af08d3d12..55bb1d53a145621142f61d72c09dcbec6cbb8d58 100644 (file)
@@ -16,7 +16,7 @@ displayio for Blinka
 * Author(s): Melissa LeBlanc-Williams
 
 """
-
+import threading
 from typing import Union
 from ._fourwire import FourWire
 from ._i2cdisplay import I2CDisplay
@@ -30,19 +30,68 @@ from ._ondiskbitmap import OnDiskBitmap
 from ._palette import Palette
 from ._shape import Shape
 from ._tilegrid import TileGrid
-from ._display import displays
 from ._displaybus import _DisplayBus
+from ._constants import CIRCUITPY_DISPLAY_LIMIT
 
 __version__ = "0.0.0+auto.0"
 __repo__ = "https://github.com/adafruit/Adafruit_Blinka_displayio.git"
 
 
+displays = []
+display_buses = []
+
+
+def _background():
+    """Main thread function to loop through all displays and update them"""
+    while True:
+        for display in displays:
+            display._background()  # pylint: disable=protected-access
+
+
 def release_displays() -> None:
     """Releases any actively used displays so their busses and pins can be used again.
 
     Use this once in your code.py if you initialize a display. Place it right before the
     initialization so the display is active as long as possible.
     """
-    for _disp in displays:
-        _disp._release()  # pylint: disable=protected-access
+    for display in displays:
+        display._release()  # pylint: disable=protected-access
     displays.clear()
+
+    for display_bus in display_buses:
+        display_bus.deinit()
+    display_buses.clear()
+
+
+def allocate_display(new_display: Union[Display, EPaperDisplay]) -> None:
+    """Add a display to the displays pool and return the new display"""
+    if len(displays) >= CIRCUITPY_DISPLAY_LIMIT:
+        raise RuntimeError("Too many displays")
+    displays.append(new_display)
+
+
+def allocate_display_bus(new_display_bus: _DisplayBus) -> None:
+    """Add a display bus to the display_buses pool and return the new display bus"""
+    if len(display_buses) >= CIRCUITPY_DISPLAY_LIMIT:
+        raise RuntimeError(
+            "Too many display busses; forgot displayio.release_displays() ?"
+        )
+    display_buses.append(new_display_bus)
+
+
+background_thread = threading.Thread(target=_background, daemon=True)
+
+
+# Start the background thread
+def _start_background():
+    if not background_thread.is_alive():
+        background_thread.start()
+
+
+def _stop_background():
+    if background_thread.is_alive():
+        # Stop the thread
+        background_thread.join()
+
+
+_start_background()
index 3eba700590bb7bb03249aad980ad2d9bc1f53275..f7272dd747bf25ae79ca94987ce278f0914b797a 100644 (file)
@@ -25,9 +25,9 @@ __repo__ = "https://github.com/adafruit/Adafruit_Blinka_Displayio.git"
 
 
 class Area:
-    # pylint: disable=invalid-name,missing-function-docstring
-    """Area Class to represent an area to be updated. Currently not used."""
+    """Area Class to represent an area to be updated."""
 
+    # pylint: disable=invalid-name
     def __init__(self, x1: int = 0, y1: int = 0, x2: int = 0, y2: int = 0):
         self.x1 = x1
         self.y1 = y1
@@ -38,25 +38,29 @@ class Area:
     def __str__(self):
         return f"Area TL({self.x1},{self.y1}) BR({self.x2},{self.y2})"
 
-    def _copy_into(self, dst) -> None:
+    def copy_into(self, dst) -> None:
+        """Copy the area into another area."""
         dst.x1 = self.x1
         dst.y1 = self.y1
         dst.x2 = self.x2
         dst.y2 = self.y2
 
-    def _scale(self, scale: int) -> None:
+    def scale(self, scale: int) -> None:
+        """Scale the area by scale."""
         self.x1 *= scale
         self.y1 *= scale
         self.x2 *= scale
         self.y2 *= scale
 
-    def _shift(self, dx: int, dy: int) -> None:
+    def shift(self, dx: int, dy: int) -> None:
+        """Shift the area by dx and dy."""
         self.x1 += dx
         self.y1 += dy
         self.x2 += dx
         self.y2 += dy
 
-    def _compute_overlap(self, other, overlap) -> bool:
+    def compute_overlap(self, other, overlap) -> bool:
+        """Compute the overlap between two areas. Returns True if there is an overlap."""
         a = self
         overlap.x1 = max(a.x1, other.x1)
         overlap.x2 = min(a.x2, other.x2)
@@ -69,22 +73,24 @@ class Area:
 
         return overlap.y1 < overlap.y2
 
-    def _empty(self):
+    def empty(self):
+        """Return True if the area is empty."""
         return (self.x1 == self.x2) or (self.y1 == self.y2)
 
-    def _canon(self):
+    def canon(self):
+        """Make sure the area is in canonical form."""
         if self.x1 > self.x2:
             self.x1, self.x2 = self.x2, self.x1
         if self.y1 > self.y2:
             self.y1, self.y2 = self.y2, self.y1
 
-    def _union(self, other, union):
-        # pylint: disable=protected-access
-        if self._empty():
-            self._copy_into(union)
+    def union(self, other, union):
+        """Combine this area along with another into union"""
+        if self.empty():
+            self.copy_into(union)
             return
-        if other._empty():
-            other._copy_into(union)
+        if other.empty():
+            other.copy_into(union)
             return
 
         union.x1 = min(self.x1, other.x1)
@@ -93,12 +99,15 @@ class Area:
         union.y2 = max(self.y2, other.y2)
 
     def width(self) -> int:
+        """Return the width of the area."""
         return self.x2 - self.x1
 
     def height(self) -> int:
+        """Return the height of the area."""
         return self.y2 - self.y1
 
     def size(self) -> int:
+        """Return the size of the area."""
         return self.width() * self.height()
 
     def __eq__(self, other):
@@ -113,7 +122,7 @@ class Area:
         )
 
     @staticmethod
-    def _transform_within(
+    def transform_within(
         mirror_x: bool,
         mirror_y: bool,
         transpose_xy: bool,
@@ -121,6 +130,7 @@ class Area:
         whole: Area,
         transformed: Area,
     ):
+        """Transform an area within a larger area."""
         # pylint: disable=too-many-arguments
         # Original and whole must be in the same coordinate space.
         if mirror_x:
index c61de47217a982550ccf4c6451aa9f822bf199a1..07eb7af8f4ddaba8cc31bed289b7051a8da9b030 100644 (file)
@@ -18,16 +18,37 @@ displayio for Blinka
 """
 
 from __future__ import annotations
+import struct
+from array import array
 from typing import Union, Tuple
-from PIL import Image
-from ._structs import RectangleStruct
+from circuitpython_typing import WriteableBuffer
+from ._area import Area
 
 __version__ = "0.0.0+auto.0"
 __repo__ = "https://github.com/adafruit/Adafruit_Blinka_displayio.git"
 
 
+def stride(width: int, bits_per_pixel: int) -> int:
+    """Return the number of bytes per row of a bitmap with the given width and bits per pixel."""
+    row_width = width * bits_per_pixel
+    return (row_width + (31)) // 32
+
+
 class Bitmap:
-    """Stores values of a certain size in a 2D array"""
+    """Stores values of a certain size in a 2D array
+
+    Bitmaps can be treated as read-only buffers. If the number of bits in a pixel is 8, 16,
+    or 32; and the number of bytes per row is a multiple of 4, then the resulting memoryview
+    will correspond directly with the bitmap's contents. Otherwise, the bitmap data is packed
+    into the memoryview with unspecified padding.
+
+    A Bitmap can be treated as a buffer, allowing its content to be
+    viewed and modified using e.g., with ``ulab.numpy.frombuffer``,
+    but the `displayio.Bitmap.dirty` method must be used to inform
+    displayio when a bitmap was modified through the buffer interface.
+
+    `bitmaptools.arrayblit` can also be useful to move data efficiently
+    into a Bitmap."""
 
     def __init__(self, width: int, height: int, value_count: int):
         """Create a Bitmap object with the given fixed size. Each pixel stores a value that is
@@ -35,12 +56,9 @@ class Bitmap:
         share the underlying Bitmap. value_count is used to minimize the memory used to store
         the Bitmap.
         """
-        self._bmp_width = width
-        self._bmp_height = height
-        self._read_only = False
 
-        if value_count < 0:
-            raise ValueError("value_count must be > 0")
+        if not 1 <= value_count <= 65535:
+            raise ValueError("value_count must be in the range of 1-65535")
 
         bits = 1
         while (value_count - 1) >> bits:
@@ -49,7 +67,28 @@ class Bitmap:
             else:
                 bits += 8
 
-        self._bits_per_value = bits
+        self._from_buffer(width, height, bits, None, False)
+
+    def _from_buffer(
+        self,
+        width: int,
+        height: int,
+        bits_per_value: int,
+        data: WriteableBuffer,
+        read_only: bool,
+    ) -> None:
+        # pylint: disable=too-many-arguments
+        self._bmp_width = width
+        self._bmp_height = height
+        self._stride = stride(width, bits_per_value)
+        self._data_alloc = False
+
+        if data is None or len(data) == 0:
+            data = array("L", [0] * self._stride * height)
+            self._data_alloc = True
+        self._data = data
+        self._read_only = read_only
+        self._bits_per_value = bits_per_value
 
         if (
             self._bits_per_value > 8
@@ -58,8 +97,23 @@ class Bitmap:
         ):
             raise NotImplementedError("Invalid bits per value")
 
-        self._image = Image.new("P", (width, height), 0)
-        self._dirty_area = RectangleStruct(0, 0, width, height)
+        # Division and modulus can be slow because it has to handle any integer. We know
+        # bits_per_value is a power of two. We divide and mod by bits_per_value to compute
+        # the offset into the byte array. So, we can the offset computation to simplify to
+        # a shift for division and mask for mod.
+
+        # Used to divide the index by the number of pixels per word. It's
+        # used in a shift which effectively divides by 2 ** x_shift.
+        self._x_shift = 0
+
+        power_of_two = 1
+        while power_of_two < 32 // bits_per_value:
+            self._x_shift += 1
+            power_of_two = power_of_two << 1
+
+        self._x_mask = (1 << self._x_shift) - 1  # Used as a modulus on the x value
+        self._bitmask = (1 << bits_per_value) - 1
+        self._dirty_area = Area(0, 0, width, height)
 
     def __getitem__(self, index: Union[Tuple[int, int], int]) -> int:
         """
@@ -74,9 +128,28 @@ class Bitmap:
         else:
             raise TypeError("Index is not an int, list, or tuple")
 
-        if x > self._image.width or y > self._image.height:
+        if x > self._bmp_width or x < 0 or y > self._bmp_height or y < 0:
             raise ValueError(f"Index {index} is out of range")
-        return self._image.getpixel((x, y))
+        return self._get_pixel(x, y)
+
+    def _get_pixel(self, x: int, y: int) -> int:
+        if x >= self._bmp_width or x < 0 or y >= self._bmp_height or y < 0:
+            return 0
+        row_start = y * self._stride
+        bytes_per_value = self._bits_per_value // 8
+        if bytes_per_value < 1:
+            word = self._data[row_start + (x >> self._x_shift)]
+            return (
+                word >> (32 - ((x & self._x_mask) + 1) * self._bits_per_value)
+            ) & self._bitmask
+        row = memoryview(self._data)[row_start : row_start + self._stride]
+        if bytes_per_value == 1:
+            return row[x]
+        if bytes_per_value == 2:
+            return struct.unpack_from("<H", row, x * 2)[0]
+        if bytes_per_value == 4:
+            return struct.unpack_from("<I", row, x * 4)[0]
+        return 0
 
     def __setitem__(self, index: Union[Tuple[int, int], int], value: int) -> None:
         """
@@ -92,21 +165,39 @@ class Bitmap:
         elif isinstance(index, int):
             x = index % self._bmp_width
             y = index // self._bmp_width
-        self._image.putpixel((x, y), value)
-        if self._dirty_area.x1 == self._dirty_area.x2:
-            self._dirty_area.x1 = x
-            self._dirty_area.x2 = x + 1
-            self._dirty_area.y1 = y
-            self._dirty_area.y2 = y + 1
+        # update the dirty region
+        self._set_dirty_area(Area(x, y, x + 1, y + 1))
+        self._write_pixel(x, y, value)
+
+    def _write_pixel(self, x: int, y: int, value: int) -> None:
+        if self._read_only:
+            raise RuntimeError("Read-only")
+
+        # Writes the color index value into a pixel position
+        # Must update the dirty area separately
+
+        # Don't write if out of area
+        if x < 0 or x >= self._bmp_width or y < 0 or y >= self._bmp_height:
+            return
+
+        # Update one pixel of data
+        row_start = y * self._stride
+        bytes_per_value = self._bits_per_value // 8
+        if bytes_per_value < 1:
+            bit_position = 32 - ((x & self._x_mask) + 1) * self._bits_per_value
+            index = row_start + (x >> self._x_shift)
+            word = self._data[index]
+            word &= ~(self._bitmask << bit_position)
+            word |= (value & self._bitmask) << bit_position
+            self._data[index] = word
         else:
-            if x < self._dirty_area.x1:
-                self._dirty_area.x1 = x
-            elif x >= self._dirty_area.x2:
-                self._dirty_area.x2 = x + 1
-            if y < self._dirty_area.y1:
-                self._dirty_area.y1 = y
-            elif y >= self._dirty_area.y2:
-                self._dirty_area.y2 = y + 1
+            row = memoryview(self._data)[row_start : row_start + self._stride]
+            if bytes_per_value == 1:
+                row[x] = value
+            elif bytes_per_value == 2:
+                struct.pack_into("<H", row, x * 2, value)
+            elif bytes_per_value == 4:
+                struct.pack_into("<I", row, x * 4, value)
 
     def _finish_refresh(self):
         self._dirty_area.x1 = 0
@@ -114,8 +205,18 @@ class Bitmap:
 
     def fill(self, value: int) -> None:
         """Fills the bitmap with the supplied palette index value."""
-        self._image = Image.new("P", (self._bmp_width, self._bmp_height), value)
-        self._dirty_area = RectangleStruct(0, 0, self._bmp_width, self._bmp_height)
+        if self._read_only:
+            raise RuntimeError("Read-only")
+        self._set_dirty_area(Area(0, 0, self._bmp_width, self._bmp_height))
+
+        # build the packed word
+        word = 0
+        for i in range(32 // self._bits_per_value):
+            word |= (value & self._bitmask) << (32 - ((i + 1) * self._bits_per_value))
+
+        # copy it in
+        for i in range(self._stride * self._bmp_height):
+            self._data[i] = word
 
     def blit(
         self,
@@ -129,8 +230,8 @@ class Bitmap:
         y2: int,
         skip_index: int,
     ) -> None:
-        # pylint: disable=unnecessary-pass, invalid-name
         """Inserts the source_bitmap region defined by rectangular boundaries"""
+        # pylint: disable=invalid-name
         if x2 is None:
             x2 = source_bitmap.width
         if y2 is None:
@@ -172,9 +273,34 @@ class Bitmap:
                     break
 
     def dirty(self, x1: int = 0, y1: int = 0, x2: int = -1, y2: int = -1) -> None:
-        # pylint: disable=unnecessary-pass, invalid-name
         """Inform displayio of bitmap updates done via the buffer protocol."""
-        pass
+        # pylint: disable=invalid-name
+        if x2 == -1:
+            x2 = self._bmp_width
+        if y2 == -1:
+            y2 = self._bmp_height
+        self._set_dirty_area(Area(x1, y1, x2, y2))
+
+    def _set_dirty_area(self, dirty_area: Area) -> None:
+        if self._read_only:
+            raise RuntimeError("Read-only")
+
+        area = dirty_area
+        area.canon()
+        area.union(self._dirty_area, area)
+        bitmap_area = Area(0, 0, self._bmp_width, self._bmp_height)
+        area.compute_overlap(bitmap_area, self._dirty_area)
+
+    def _finish_refresh(self):
+        if self._read_only:
+            return
+        self._dirty_area.x1 = 0
+        self._dirty_area.x2 = 0
+
+    def _get_refresh_areas(self, areas: list[Area]) -> None:
+        if self._dirty_area.x1 == self._dirty_area.x2 or self._read_only:
+            return
+        areas.append(self._dirty_area)
 
     @property
     def width(self) -> int:
index 01c70d67d214fadd8793b82e5022792894096c2d..bba5316b31d36165a204e4fe9b3bc6098580851e 100644 (file)
@@ -21,6 +21,8 @@ __version__ = "0.0.0+auto.0"
 __repo__ = "https://github.com/adafruit/Adafruit_Blinka_displayio.git"
 
 from ._colorspace import Colorspace
+from ._structs import ColorspaceStruct, InputPixelStruct, OutputPixelStruct
+from ._helpers import clamp, bswap16
 
 
 class ColorConverter:
@@ -36,62 +38,124 @@ class ColorConverter:
         :param bool dither: Adds random noise to dither the output image
         """
         self._dither = dither
-        self._depth = 16
         self._transparent_color = None
-        self._rgba = False
+        self._rgba = False  # Todo set Output colorspace depth to 32 maybe?
         self._input_colorspace = input_colorspace
+        self._output_colorspace = ColorspaceStruct(16)
+        self._cached_colorspace = None
+        self._cached_input_pixel = None
+        self._cached_output_color = None
+        self._needs_refresh = False
 
-    def _compute_rgb565(self, color: int):
-        self._depth = 16
-        return (color[0] & 0xF8) << 8 | (color[1] & 0xFC) << 3 | color[2] >> 3
+    @staticmethod
+    def _dither_noise_1(noise):
+        noise = (noise >> 13) ^ noise
+        more_noise = (
+            noise * (noise * noise * 60493 + 19990303) + 1376312589
+        ) & 0x7FFFFFFF
+        return clamp(int((more_noise / (1073741824.0 * 2)) * 255), 0, 0xFFFFFFFF)
+
+    @staticmethod
+    def _dither_noise_2(x, y):
+        return ColorConverter._dither_noise_1(x + y * 0xFFFF)
+
+    @staticmethod
+    def _compute_rgb565(color_rgb888: int):
+        red5 = color_rgb888 >> 19
+        grn6 = (color_rgb888 >> 10) & 0x3F
+        blu5 = (color_rgb888 >> 3) & 0x1F
+        return red5 << 11 | grn6 << 5 | blu5
 
     @staticmethod
-    def _compute_luma(color: int):
-        red = color >> 16
-        green = (color >> 8) & 0xFF
-        blue = color & 0xFF
-        return (red * 19) / 255 + (green * 182) / 255 + (blue + 54) / 255
+    def _compute_rgb332(color_rgb888: int):
+        red3 = color_rgb888 >> 21
+        grn2 = (color_rgb888 >> 13) & 0x7
+        blu2 = (color_rgb888 >> 6) & 0x3
+        return red3 << 5 | grn2 << 3 | blu2
 
     @staticmethod
-    def _compute_chroma(color: int):
-        red = color >> 16
-        green = (color >> 8) & 0xFF
-        blue = color & 0xFF
-        return max(red, green, blue) - min(red, green, blue)
-
-    def _compute_hue(self, color: int):
-        red = color >> 16
-        green = (color >> 8) & 0xFF
-        blue = color & 0xFF
-        max_color = max(red, green, blue)
-        chroma = self._compute_chroma(color)
+    def _compute_rgbd(color_rgb888: int):
+        red1 = (color_rgb888 >> 23) & 0x1
+        grn1 = (color_rgb888 >> 15) & 0x1
+        blu1 = (color_rgb888 >> 7) & 0x1
+        return red1 << 3 | grn1 << 2 | blu1 << 1  # | dummy
+
+    @staticmethod
+    def _compute_luma(color_rgb888: int):
+        red8 = color_rgb888 >> 16
+        grn8 = (color_rgb888 >> 8) & 0xFF
+        blu8 = color_rgb888 & 0xFF
+        return (red8 * 19 + grn8 * 182 + blu8 + 54) // 255
+
+    @staticmethod
+    def _compute_chroma(color_rgb888: int):
+        red8 = color_rgb888 >> 16
+        grn8 = (color_rgb888 >> 8) & 0xFF
+        blu8 = color_rgb888 & 0xFF
+        return max(red8, grn8, blu8) - min(red8, grn8, blu8)
+
+    @staticmethod
+    def _compute_hue(color_rgb888: int):
+        red8 = color_rgb888 >> 16
+        grn8 = (color_rgb888 >> 8) & 0xFF
+        blu8 = color_rgb888 & 0xFF
+        max_color = max(red8, grn8, blu8)
+        chroma = max_color - min(red8, grn8, blu8)
         if chroma == 0:
             return 0
         hue = 0
-        if max_color == red:
-            hue = (((green - blue) * 40) / chroma) % 240
-        elif max_color == green:
-            hue = (((blue - red) + (2 * chroma)) * 40) / chroma
-        elif max_color == blue:
-            hue = (((red - green) + (4 * chroma)) * 40) / chroma
+        if max_color == red8:
+            hue = (((grn8 - blu8) * 40) // chroma) % 240
+        elif max_color == grn8:
+            hue = (((blu8 - red8) + (2 * chroma)) * 40) // chroma
+        elif max_color == blu8:
+            hue = (((red8 - grn8) + (4 * chroma)) * 40) // chroma
         if hue < 0:
             hue += 240
 
         return hue
 
     @staticmethod
-    def _dither_noise_1(noise):
-        noise = (noise >> 13) ^ noise
-        more_noise = (
-            noise * (noise * noise * 60493 + 19990303) + 1376312589
-        ) & 0x7FFFFFFF
-        return (more_noise / (1073741824.0 * 2)) * 255
-
-    def _dither_noise_2(self, x, y):
-        return self._dither_noise_1(x + y * 0xFFFF)
+    def _compute_sevencolor(color_rgb888: int):
+        # pylint: disable=too-many-return-statements
+        chroma = ColorConverter._compute_chroma(color_rgb888)
+        if chroma >= 64:
+            hue = ColorConverter._compute_hue(color_rgb888)
+            # Red 0
+            if hue < 10:
+                return 0x4
+            # Orange 21
+            if hue < 21 + 10:
+                return 0x6
+            # Yellow 42
+            if hue < 42 + 21:
+                return 0x5
+            # Green 85
+            if hue < 85 + 42:
+                return 0x2
+            # Blue 170
+            if hue < 170 + 42:
+                return 0x3
+            # The rest is red to 255
+            return 0x4
+        luma = ColorConverter._compute_luma(color_rgb888)
+        if luma >= 128:
+            return 0x1  # White
+        return 0x0  # Black
 
-    def _compute_tricolor(self):
-        pass
+    @staticmethod
+    def _compute_tricolor(
+        colorspace: ColorspaceStruct, pixel_hue: int, color: int
+    ) -> int:
+        hue_diff = colorspace.tricolor_hue - pixel_hue
+        if -10 <= hue_diff <= 10 or hue_diff <= -220 or hue_diff >= 220:
+            if colorspace.grayscale:
+                color = 0
+            else:
+                color = 1
+        elif not colorspace.grayscale:
+            color = 0
+        return color
 
     def convert(self, color: int) -> int:
         "Converts the given rgb888 color to RGB565"
@@ -105,11 +169,153 @@ class ColorConverter:
         else:
             raise ValueError("Color must be an integer or 3 or 4 value tuple")
 
-        if self._dither:
-            return color  # To Do: return a dithered color
-        if self._rgba:
-            return color
-        return self._compute_rgb565(color)
+        input_pixel = InputPixelStruct(color)
+        output_pixel = OutputPixelStruct()
+
+        self._convert(self._output_colorspace, input_pixel, output_pixel)
+
+        return output_pixel.pixel
+
+    def _convert(
+        self,
+        colorspace: Colorspace,
+        input_pixel: InputPixelStruct,
+        output_color: OutputPixelStruct,
+    ) -> None:
+        pixel = input_pixel.pixel
+
+        if self._transparent_color == pixel:
+            output_color.opaque = False
+            return
+
+        if (
+            not self._dither
+            and self._cached_colorspace == colorspace
+            and self._cached_input_pixel == input_pixel.pixel
+        ):
+            output_color = self._cached_output_color
+            return
+
+        rgb888_pixel = input_pixel
+        rgb888_pixel.pixel = self._convert_pixel(
+            self._input_colorspace, input_pixel.pixel
+        )
+        self._convert_color(colorspace, self._dither, rgb888_pixel, output_color)
+
+        if not self._dither:
+            self._cached_colorspace = colorspace
+            self._cached_input_pixel = input_pixel.pixel
+            self._cached_output_color = output_color.pixel
+
+    @staticmethod
+    def _convert_pixel(colorspace: Colorspace, pixel: int) -> int:
+        pixel = clamp(pixel, 0, 0xFFFFFFFF)
+        if colorspace in (
+            Colorspace.RGB565_SWAPPED,
+            Colorspace.RGB555_SWAPPED,
+            Colorspace.BGR565_SWAPPED,
+            Colorspace.BGR555_SWAPPED,
+        ):
+            pixel = bswap16(pixel)
+        if colorspace in (Colorspace.RGB565, Colorspace.RGB565_SWAPPED):
+            red8 = (pixel >> 11) << 3
+            grn8 = ((pixel >> 5) << 2) & 0xFF
+            blu8 = (pixel << 3) & 0xFF
+            return (red8 << 16) | (grn8 << 8) | blu8
+        if colorspace in (Colorspace.RGB555, Colorspace.RGB555_SWAPPED):
+            red8 = (pixel >> 10) << 3
+            grn8 = ((pixel >> 5) << 3) & 0xFF
+            blu8 = (pixel << 3) & 0xFF
+            return (red8 << 16) | (grn8 << 8) | blu8
+        if colorspace in (Colorspace.BGR565, Colorspace.BGR565_SWAPPED):
+            blu8 = (pixel >> 11) << 3
+            grn8 = ((pixel >> 5) << 2) & 0xFF
+            red8 = (pixel << 3) & 0xFF
+            return (red8 << 16) | (grn8 << 8) | blu8
+        if colorspace in (Colorspace.BGR555, Colorspace.BGR555_SWAPPED):
+            blu8 = (pixel >> 10) << 3
+            grn8 = ((pixel >> 5) << 3) & 0xFF
+            red8 = (pixel << 3) & 0xFF
+            return (red8 << 16) | (grn8 << 8) | blu8
+        if colorspace == Colorspace.L8:
+            return (pixel & 0xFF) & 0x01010101
+        return pixel
+
+    @staticmethod
+    def _convert_color(
+        colorspace: ColorspaceStruct,
+        dither: bool,
+        input_pixel: InputPixelStruct,
+        output_color: OutputPixelStruct,
+    ) -> None:
+        # pylint: disable=too-many-return-statements, too-many-branches, too-many-statements
+        pixel = input_pixel.pixel
+        if dither:
+            rand_red = ColorConverter._dither_noise_2(input_pixel.x, input_pixel.y)
+            rand_grn = ColorConverter._dither_noise_2(input_pixel.x + 33, input_pixel.y)
+            rand_blu = ColorConverter._dither_noise_2(input_pixel.x, input_pixel.y + 33)
+
+            red8 = pixel >> 16
+            grn8 = (pixel >> 8) & 0xFF
+            blu8 = pixel & 0xFF
+
+            if colorspace.depth == 16:
+                blu8 = min(255, blu8 + (rand_blu & 0x07))
+                red8 = min(255, red8 + (rand_red & 0x07))
+                grn8 = min(255, grn8 + (rand_grn & 0x03))
+            else:
+                bitmask = 0xFF >> colorspace.depth
+                blu8 = min(255, blu8 + (rand_blu & bitmask))
+                red8 = min(255, red8 + (rand_red & bitmask))
+                grn8 = min(255, grn8 + (rand_grn & bitmask))
+            pixel = (red8 << 16) | (grn8 << 8) | blu8
+
+        if colorspace.depth == 16:
+            packed = ColorConverter._compute_rgb565(pixel)
+            if colorspace.reverse_bytes_in_word:
+                packed = bswap16(packed)
+            output_color.pixel = packed
+            output_color.opaque = True
+            return
+        if colorspace.tricolor:
+            output_color.pixel = ColorConverter._compute_luma(pixel) >> (
+                8 - colorspace.depth
+            )
+            if ColorConverter._compute_chroma(pixel) <= 16:
+                if not colorspace.grayscale:
+                    output_color.pixel = 0
+                output_color.opaque = True
+                return
+            pixel_hue = ColorConverter._compute_hue(pixel)
+            output_color.pixel = ColorConverter._compute_tricolor(
+                colorspace, pixel_hue, output_color.pixel
+            )
+            return
+        if colorspace.grayscale and colorspace.depth <= 8:
+            bitmask = (1 << colorspace.depth) - 1
+            output_color.pixel = (
+                ColorConverter._compute_luma(pixel) >> colorspace.grayscale_bit
+            ) & bitmask
+            output_color.opaque = True
+            return
+        if colorspace.depth == 32:
+            output_color.pixel = pixel
+            output_color.opaque = True
+            return
+        if colorspace.depth == 8 and colorspace.grayscale:
+            packed = ColorConverter._compute_rgb332(pixel)
+            output_color.pixel = packed
+            output_color.opaque = True
+            return
+        if colorspace.depth == 4:
+            if colorspace.sevencolor:
+                packed = ColorConverter._compute_sevencolor(pixel)
+            else:
+                packed = ColorConverter._compute_rgbd(pixel)
+            output_color.pixel = packed
+            output_color.opaque = True
+            return
+        output_color.opaque = False
 
     def make_transparent(self, color: int) -> None:
         """Set the transparent color or index for the ColorConverter. This will
@@ -117,11 +323,13 @@ class ColorConverter:
         """
         self._transparent_color = color
 
-    def make_opaque(self, color: int) -> None:
-        # pylint: disable=unused-argument
+    def make_opaque(self, _color: int) -> None:
         """Make the ColorConverter be opaque and have no transparent pixels."""
         self._transparent_color = None
 
+    def _finish_refresh(self) -> None:
+        pass
+
     @property
     def dither(self) -> bool:
         """When true the color converter dithers the output by adding
index 938b49ceacd20382d6784e7567f2068f095fb087..19ecd0f9605b42cc10779f4417038adf0bfb0917 100644 (file)
@@ -18,3 +18,8 @@ CHIP_SELECT_TOGGLE_EVERY_BYTE = 1
 
 BACKLIGHT_IN_OUT = 1
 BACKLIGHT_PWM = 2
+
+NO_COMMAND = 0x100
+CIRCUITPY_DISPLAY_LIMIT = 1
+
+DELAY = 0x80
index 241a0aa7ccb9fcc73ed4c5631aed8fbdefb40e05..8551f8c87fded9cea51a104f207a773995c698ca 100644 (file)
@@ -18,20 +18,16 @@ displayio for Blinka
 """
 
 import time
-import struct
-import threading
+from array import array
 from typing import Optional
-from dataclasses import astuple
 import digitalio
-from PIL import Image
-import numpy
 import microcontroller
-import circuitpython_typing
+from circuitpython_typing import WriteableBuffer, ReadableBuffer
 from ._displaycore import _DisplayCore
 from ._displaybus import _DisplayBus
 from ._colorconverter import ColorConverter
-from ._group import Group
-from ._structs import RectangleStruct
+from ._group import Group, circuitpython_splash
+from ._area import Area
 from ._constants import (
     CHIP_SELECT_TOGGLE_EVERY_BYTE,
     CHIP_SELECT_UNTOUCHED,
@@ -39,16 +35,16 @@ from ._constants import (
     DISPLAY_DATA,
     BACKLIGHT_IN_OUT,
     BACKLIGHT_PWM,
+    NO_COMMAND,
+    DELAY,
 )
 
 __version__ = "0.0.0+auto.0"
 __repo__ = "https://github.com/adafruit/Adafruit_Blinka_displayio.git"
 
-displays = []
-
 
 class Display:
-    # pylint: disable=too-many-instance-attributes
+    # pylint: disable=too-many-instance-attributes, too-many-statements
     """This initializes a display and connects it into CircuitPython. Unlike other objects
     in CircuitPython, Display objects live until ``displayio.release_displays()`` is called.
     This is done so that CircuitPython can use the display itself.
@@ -60,7 +56,7 @@ class Display:
     def __init__(
         self,
         display_bus: _DisplayBus,
-        init_sequence: circuitpython_typing.ReadableBuffer,
+        init_sequence: ReadableBuffer,
         *,
         width: int,
         height: int,
@@ -79,16 +75,14 @@ class Display:
         backlight_pin: Optional[microcontroller.Pin] = None,
         brightness_command: Optional[int] = None,
         brightness: float = 1.0,
-        auto_brightness: bool = False,
         single_byte_bounds: bool = False,
         data_as_commands: bool = False,
         auto_refresh: bool = True,
         native_frames_per_second: int = 60,
         backlight_on_high: bool = True,
         SH1107_addressing: bool = False,
-        set_vertical_scroll: int = 0,
     ):
-        # pylint: disable=unused-argument,too-many-locals,invalid-name
+        # pylint: disable=too-many-locals,invalid-name, too-many-branches
         """Create a Display object on the given display bus (`displayio.FourWire` or
         `paralleldisplay.ParallelBus`).
 
@@ -118,11 +112,21 @@ class Display:
         The initialization sequence should always leave the display memory access inline with
         the scan of the display to minimize tearing artifacts.
         """
+
+        if rotation % 90 != 0:
+            raise ValueError("Display rotation must be in 90 degree increments")
+
+        if SH1107_addressing and color_depth != 1:
+            raise ValueError("color_depth must be 1 when SH1107_addressing is True")
+
+        # Turn off auto-refresh as we init
+        self._auto_refresh = False
         ram_width = 0x100
         ram_height = 0x100
         if single_byte_bounds:
             ram_width = 0xFF
             ram_height = 0xFF
+
         self._core = _DisplayCore(
             bus=display_bus,
             width=width,
@@ -138,63 +142,44 @@ class Display:
             bytes_per_cell=bytes_per_cell,
             reverse_pixels_in_byte=reverse_pixels_in_byte,
             reverse_bytes_in_word=reverse_bytes_in_word,
+            column_command=set_column_command,
+            row_command=set_row_command,
+            set_current_column_command=NO_COMMAND,
+            set_current_row_command=NO_COMMAND,
+            data_as_commands=data_as_commands,
+            always_toggle_chip_select=False,
+            sh1107_addressing=(SH1107_addressing and color_depth == 1),
+            address_little_endian=False,
         )
 
-        self._set_column_command = set_column_command
-        self._set_row_command = set_row_command
         self._write_ram_command = write_ram_command
         self._brightness_command = brightness_command
-        self._data_as_commands = data_as_commands
-        self._single_byte_bounds = single_byte_bounds
-        self._width = width
-        self._height = height
-        self._colstart = colstart
-        self._rowstart = rowstart
-        self._rotation = rotation
-        self._auto_brightness = auto_brightness
-        self._brightness = 1.0
-        self._auto_refresh = auto_refresh
-        self._initialize(init_sequence)
-        self._buffer = Image.new("RGB", (width, height))
-        self._subrectangles = []
-        self._bounds_encoding = ">BB" if single_byte_bounds else ">HH"
-        self._current_group = None
-        displays.append(self)
-        self._refresh_thread = None
-        if self._auto_refresh:
-            self.auto_refresh = True
-        self._colorconverter = ColorConverter()
+        self._first_manual_refresh = not auto_refresh
+        self._backlight_on_high = backlight_on_high
 
-        self._backlight_type = None
-        if backlight_pin is not None:
-            try:
-                from pwmio import PWMOut  # pylint: disable=import-outside-toplevel
+        self._native_frames_per_second = native_frames_per_second
+        self._native_ms_per_frame = 1000 // native_frames_per_second
 
-                # 100Hz looks decent and doesn't keep the CPU too busy
-                self._backlight = PWMOut(backlight_pin, frequency=100, duty_cycle=0)
-                self._backlight_type = BACKLIGHT_PWM
-            except ImportError:
-                # PWMOut not implemented on this platform
-                pass
-            if self._backlight_type is None:
-                self._backlight_type = BACKLIGHT_IN_OUT
-                self._backlight = digitalio.DigitalInOut(backlight_pin)
-                self._backlight.switch_to_output()
-            self.brightness = brightness
+        self._brightness = brightness
+        self._auto_refresh = auto_refresh
 
-    def _initialize(self, init_sequence):
         i = 0
         while i < len(init_sequence):
             command = init_sequence[i]
             data_size = init_sequence[i + 1]
-            delay = (data_size & 0x80) > 0
-            data_size &= ~0x80
+            delay = (data_size & DELAY) != 0
+            data_size &= ~DELAY
+            while self._core.begin_transaction():
+                pass
 
-            if self._data_as_commands:
+            if self._core.data_as_commands:
+                full_command = bytearray(data_size + 1)
+                full_command[0] = command
+                full_command[1:] = init_sequence[i + 2 : i + 2 + data_size]
                 self._core.send(
                     DISPLAY_COMMAND,
                     CHIP_SELECT_TOGGLE_EVERY_BYTE,
-                    bytes([command]) + init_sequence[i + 2 : i + 2 + data_size],
+                    full_command,
                 )
             else:
                 self._core.send(
@@ -205,6 +190,7 @@ class Display:
                     CHIP_SELECT_UNTOUCHED,
                     init_sequence[i + 2 : i + 2 + data_size],
                 )
+            self._core.end_transaction()
             delay_time_ms = 10
             if delay:
                 data_size += 1
@@ -214,33 +200,61 @@ class Display:
             time.sleep(delay_time_ms / 1000)
             i += 2 + data_size
 
-    def _send(self, command, data):
-        self._core.begin_transaction()
-        if self._data_as_commands:
-            self._core.send(
-                DISPLAY_COMMAND, CHIP_SELECT_TOGGLE_EVERY_BYTE, bytes([command]) + data
-            )
-        else:
-            self._core.send(
-                DISPLAY_COMMAND, CHIP_SELECT_TOGGLE_EVERY_BYTE, bytes([command])
-            )
-            self._core.send(DISPLAY_DATA, CHIP_SELECT_UNTOUCHED, data)
-        self._core.end_transaction()
+        self._current_group = None
+        self._last_refresh_call = 0
+        self._refresh_thread = None
+        self._colorconverter = ColorConverter()
+
+        self._backlight_type = None
+        if backlight_pin is not None:
+            try:
+                from pwmio import PWMOut  # pylint: disable=import-outside-toplevel
+
+                # 100Hz looks decent and doesn't keep the CPU too busy
+                self._backlight = PWMOut(backlight_pin, frequency=100, duty_cycle=0)
+                self._backlight_type = BACKLIGHT_PWM
+            except ImportError:
+                # PWMOut not implemented on this platform
+                pass
+            if self._backlight_type is None:
+                self._backlight_type = BACKLIGHT_IN_OUT
+                self._backlight = digitalio.DigitalInOut(backlight_pin)
+                self._backlight.switch_to_output()
+        self.brightness = brightness
+        if not circuitpython_splash._in_group:
+            self._set_root_group(circuitpython_splash)
+        self.auto_refresh = auto_refresh
+
+    def __new__(cls, *args, **kwargs):
+        from . import (  # pylint: disable=import-outside-toplevel, cyclic-import
+            allocate_display,
+        )
 
-    def _send_pixels(self, data):
-        if not self._data_as_commands:
+        display_instance = super().__new__(cls)
+        allocate_display(display_instance)
+        return display_instance
+
+    def _send_pixels(self, pixels):
+        if not self._core.data_as_commands:
             self._core.send(
                 DISPLAY_COMMAND,
                 CHIP_SELECT_TOGGLE_EVERY_BYTE,
                 bytes([self._write_ram_command]),
             )
-        self._core.send(DISPLAY_DATA, CHIP_SELECT_UNTOUCHED, data)
+        self._core.send(DISPLAY_DATA, CHIP_SELECT_UNTOUCHED, pixels)
 
     def show(self, group: Group) -> None:
         """Switches to displaying the given group of layers. When group is None, the
         default CircuitPython terminal will be shown.
         """
-        self._core.show(group)
+        if group is None:
+            group = circuitpython_splash
+        self._core.set_root_group(group)
+
+    def _set_root_group(self, root_group: Group) -> None:
+        ok = self._core.set_root_group(root_group)
+        if not ok:
+            raise ValueError("Group already used")
 
     def refresh(
         self,
@@ -248,7 +262,6 @@ class Display:
         target_frames_per_second: Optional[int] = None,
         minimum_frames_per_second: int = 0,
     ) -> bool:
-        # pylint: disable=unused-argument, protected-access
         """When auto refresh is off, waits for the target frame rate and then refreshes the
         display, returning True. If the call has taken too long since the last refresh call
         for the given target frame rate, then the refresh returns False immediately without
@@ -260,122 +273,178 @@ class Display:
         When auto refresh is on, updates the display immediately. (The display will also
         update without calls to this.)
         """
-        if not self._core.start_refresh():
-            return False
+        maximum_ms_per_real_frame = 0xFFFFFFFF
+        if minimum_frames_per_second > 0:
+            maximum_ms_per_real_frame = 1000 // minimum_frames_per_second
 
-        # Go through groups and and add each to buffer
-        if self._core._current_group is not None:
-            buffer = Image.new("RGBA", (self._core._width, self._core._height))
-            # Recursively have everything draw to the image
-            self._core._current_group._fill_area(
-                buffer
-            )  # pylint: disable=protected-access
-            # save image to buffer (or probably refresh buffer so we can compare)
-            self._buffer.paste(buffer)
+        if target_frames_per_second is None:
+            target_ms_per_frame = 0xFFFFFFFF
+        else:
+            target_ms_per_frame = 1000 // target_frames_per_second
+
+        if (
+            not self._auto_refresh
+            and not self._first_manual_refresh
+            and target_ms_per_frame != 0xFFFFFFFF
+        ):
+            current_time = time.monotonic() * 1000
+            current_ms_since_real_refresh = current_time - self._core.last_refresh
+            if current_ms_since_real_refresh > maximum_ms_per_real_frame:
+                raise RuntimeError("Below minimum frame rate")
+            current_ms_since_last_call = current_time - self._last_refresh_call
+            self._last_refresh_call = current_time
+            if current_ms_since_last_call > target_ms_per_frame:
+                return False
+
+            remaining_time = target_ms_per_frame - (
+                current_ms_since_real_refresh % target_ms_per_frame
+            )
+            time.sleep(remaining_time / 1000)
+        self._first_manual_refresh = False
+        self._refresh_display()
+        return True
 
-        self._subrectangles = self._core.get_refresh_areas()
+    def _refresh_display(self):
+        if not self._core.start_refresh():
+            return False
 
-        for area in self._subrectangles:
-            self._refresh_display_area(area)
+        areas_to_refresh = self._get_refresh_areas()
+        for area in areas_to_refresh:
+            self._refresh_area(area)
 
         self._core.finish_refresh()
 
         return True
 
-    def _refresh_loop(self):
-        while self._auto_refresh:
+    def _get_refresh_areas(self) -> list[Area]:
+        """Get a list of areas to be refreshed"""
+        areas = []
+        if self._core.full_refresh:
+            areas.append(self._core.area)
+        elif self._core.current_group is not None:
+            self._core.current_group._get_refresh_areas(  # pylint: disable=protected-access
+                areas
+            )
+        return areas
+
+    def _background(self):
+        """Run background refresh tasks. Do not call directly"""
+        if (
+            self._auto_refresh
+            and (time.monotonic() * 1000 - self._core.last_refresh)
+            > self._native_ms_per_frame
+        ):
             self.refresh()
 
-    def _refresh_display_area(self, rectangle):
-        """Loop through dirty rectangles and redraw that area."""
-        img = self._buffer.convert("RGB").crop(astuple(rectangle))
-        img = img.rotate(360 - self._rotation, expand=True)
-
-        display_rectangle = self._apply_rotation(rectangle)
-        img = img.crop(astuple(self._clip(display_rectangle)))
-
-        data = numpy.array(img).astype("uint16")
-        color = (
-            ((data[:, :, 0] & 0xF8) << 8)
-            | ((data[:, :, 1] & 0xFC) << 3)
-            | (data[:, :, 2] >> 3)
-        )
-
-        pixels = bytes(
-            numpy.dstack(((color >> 8) & 0xFF, color & 0xFF)).flatten().tolist()
-        )
-
-        self._send(
-            self._set_column_command,
-            self._encode_pos(
-                display_rectangle.x1 + self._colstart,
-                display_rectangle.x2 + self._colstart - 1,
-            ),
-        )
-        self._send(
-            self._set_row_command,
-            self._encode_pos(
-                display_rectangle.y1 + self._rowstart,
-                display_rectangle.y2 + self._rowstart - 1,
-            ),
-        )
+    def _refresh_area(self, area) -> bool:
+        """Loop through dirty areas and redraw that area."""
+        # pylint: disable=too-many-locals, too-many-branches
+
+        clipped = Area()
+        # Clip the area to the display by overlapping the areas.
+        # If there is no overlap then we're done.
+        if not self._core.clip_area(area, clipped):
+            return True
+
+        rows_per_buffer = clipped.height()
+        pixels_per_word = 32 // self._core.colorspace.depth
+        pixels_per_buffer = clipped.size()
+
+        # We should have lots of memory
+        buffer_size = clipped.size() // pixels_per_word
+
+        subrectangles = 1
+        # for SH1107 and other boundary constrained controllers
+        #      write one single row at a time
+        if self._core.sh1107_addressing:
+            subrectangles = rows_per_buffer // 8
+            rows_per_buffer = 8
+        elif clipped.size() > buffer_size * pixels_per_word:
+            rows_per_buffer = buffer_size * pixels_per_word // clipped.width()
+            if rows_per_buffer == 0:
+                rows_per_buffer = 1
+            # If pixels are packed by column then ensure rows_per_buffer is on a byte boundary
+            if (
+                self._core.colorspace.depth < 8
+                and self._core.colorspace.pixels_in_byte_share_row
+            ):
+                pixels_per_byte = 8 // self._core.colorspace.depth
+                if rows_per_buffer % pixels_per_byte != 0:
+                    rows_per_buffer -= rows_per_buffer % pixels_per_byte
+            subrectangles = clipped.height() // rows_per_buffer
+            if clipped.height() % rows_per_buffer != 0:
+                subrectangles += 1
+            pixels_per_buffer = rows_per_buffer * clipped.width()
+            buffer_size = pixels_per_buffer // pixels_per_word
+            if pixels_per_buffer % pixels_per_word:
+                buffer_size += 1
+        mask_length = (pixels_per_buffer // 8) + 1  # 1 bit per pixel + 1
+        remaining_rows = clipped.height()
+
+        for subrect_index in range(subrectangles):
+            subrectangle = Area(
+                clipped.x1,
+                clipped.y1 + rows_per_buffer * subrect_index,
+                clipped.x2,
+                clipped.y1 + rows_per_buffer * (subrect_index + 1),
+            )
+            if remaining_rows < rows_per_buffer:
+                subrectangle.y2 = subrectangle.y1 + remaining_rows
+            remaining_rows -= rows_per_buffer
+            self._core.set_region_to_update(subrectangle)
+            if self._core.colorspace.depth >= 8:
+                subrectangle_size_bytes = subrectangle.size() * (
+                    self._core.colorspace.depth // 8
+                )
+            else:
+                subrectangle_size_bytes = subrectangle.size() // (
+                    8 // self._core.colorspace.depth
+                )
 
-        self._core.begin_transaction()
-        self._send_pixels(pixels)
-        self._core.end_transaction()
+            buffer = memoryview(bytearray([0] * (buffer_size * 4)))
+            mask = memoryview(bytearray([0] * mask_length))
+            self._core.fill_area(subrectangle, mask, buffer)
 
-    def _clip(self, rectangle):
-        if self._rotation in (90, 270):
-            width, height = self._height, self._width
-        else:
-            width, height = self._width, self._height
-
-        rectangle.x1 = max(rectangle.x1, 0)
-        rectangle.y1 = max(rectangle.y1, 0)
-        rectangle.x2 = min(rectangle.x2, width)
-        rectangle.y2 = min(rectangle.y2, height)
-
-        return rectangle
-
-    def _apply_rotation(self, rectangle):
-        """Adjust the rectangle coordinates based on rotation"""
-        if self._rotation == 90:
-            return RectangleStruct(
-                self._height - rectangle.y2,
-                rectangle.x1,
-                self._height - rectangle.y1,
-                rectangle.x2,
-            )
-        if self._rotation == 180:
-            return RectangleStruct(
-                self._width - rectangle.x2,
-                self._height - rectangle.y2,
-                self._width - rectangle.x1,
-                self._height - rectangle.y1,
-            )
-        if self._rotation == 270:
-            return RectangleStruct(
-                rectangle.y1,
-                self._width - rectangle.x2,
-                rectangle.y2,
-                self._width - rectangle.x1,
-            )
-        return rectangle
+            # Can't acquire display bus; skip the rest of the data.
+            if not self._core.bus_free():
+                return False
 
-    def _encode_pos(self, x, y):
-        """Encode a postion into bytes."""
-        return struct.pack(self._bounds_encoding, x, y)  # pylint: disable=no-member
+            self._core.begin_transaction()
+            self._send_pixels(buffer[:subrectangle_size_bytes])
+            self._core.end_transaction()
+        return True
 
-    def fill_row(
-        self, y: int, buffer: circuitpython_typing.WriteableBuffer
-    ) -> circuitpython_typing.WriteableBuffer:
+    def fill_row(self, y: int, buffer: WriteableBuffer) -> WriteableBuffer:
         """Extract the pixels from a single row"""
-        for x in range(0, self._width):
-            _rgb_565 = self._colorconverter.convert(self._buffer.getpixel((x, y)))
-            buffer[x * 2] = (_rgb_565 >> 8) & 0xFF
-            buffer[x * 2 + 1] = _rgb_565 & 0xFF
+        if self._core.colorspace.depth != 16:
+            raise ValueError("Display must have a 16 bit colorspace.")
+
+        area = Area(0, y, self._core.width, y + 1)
+        pixels_per_word = 32 // self._core.colorspace.depth
+        buffer_size = self._core.width // pixels_per_word
+        pixels_per_buffer = area.size()
+        if pixels_per_buffer % pixels_per_word:
+            buffer_size += 1
+
+        buffer = bytearray([0] * (buffer_size * 4))
+        mask_length = (pixels_per_buffer // 32) + 1
+        mask = array("L", [0x00000000] * mask_length)
+        self._core.fill_area(area, mask, buffer)
         return buffer
 
+    def _release(self) -> None:
+        """Release the display and free its resources"""
+        self.auto_refresh = False
+        self._core.release_display_core()
+
+    def _reset(self) -> None:
+        """Reset the display"""
+        self.auto_refresh = True
+        circuitpython_splash.x = 0
+        circuitpython_splash.y = 0
+        if not circuitpython_splash._in_group:  # pylint: disable=protected-access
+            self._set_root_group(circuitpython_splash)
+
     @property
     def auto_refresh(self) -> bool:
         """True when the display is refreshed automatically."""
@@ -383,53 +452,47 @@ class Display:
 
     @auto_refresh.setter
     def auto_refresh(self, value: bool):
+        self._first_manual_refresh = not value
         self._auto_refresh = value
-        if self._refresh_thread is None:
-            self._refresh_thread = threading.Thread(
-                target=self._refresh_loop, daemon=True
-            )
-        if value and not self._refresh_thread.is_alive():
-            # Start the thread
-            self._refresh_thread.start()
-        elif not value and self._refresh_thread.is_alive():
-            # Stop the thread
-            self._refresh_thread.join()
-            self._refresh_thread = None
 
     @property
     def brightness(self) -> float:
-        """The brightness of the display as a float. 0.0 is off and 1.0 is full `brightness`.
-        When `auto_brightness` is True, the value of `brightness` will change automatically.
-        If `brightness` is set, `auto_brightness` will be disabled and will be set to False.
-        """
+        """The brightness of the display as a float. 0.0 is off and 1.0 is full `brightness`."""
         return self._brightness
 
     @brightness.setter
     def brightness(self, value: float):
         if 0 <= float(value) <= 1.0:
-            self._brightness = value
-            if self._backlight_type == BACKLIGHT_IN_OUT:
-                self._backlight.value = round(self._brightness)
-            elif self._backlight_type == BACKLIGHT_PWM:
-                self._backlight.duty_cycle = self._brightness * 65535
+            if not self._backlight_on_high:
+                value = 1.0 - value
+
+            if self._backlight_type == BACKLIGHT_PWM:
+                self._backlight.duty_cycle = value * 0xFFFF
+            elif self._backlight_type == BACKLIGHT_IN_OUT:
+                self._backlight.value = value > 0.99
             elif self._brightness_command is not None:
-                self._send(self._brightness_command, round(value * 255))
+                okay = self._core.begin_transaction()
+                if okay:
+                    if self._core.data_as_commands:
+                        self._core.send(
+                            DISPLAY_COMMAND,
+                            CHIP_SELECT_TOGGLE_EVERY_BYTE,
+                            bytes([self._brightness_command, round(0xFF * value)]),
+                        )
+                    else:
+                        self._core.send(
+                            DISPLAY_COMMAND,
+                            CHIP_SELECT_TOGGLE_EVERY_BYTE,
+                            bytes([self._brightness_command]),
+                        )
+                        self._core.send(
+                            DISPLAY_DATA, CHIP_SELECT_UNTOUCHED, round(value * 255)
+                        )
+                    self._core.end_transaction()
+            self._brightness = value
         else:
             raise ValueError("Brightness must be between 0.0 and 1.0")
 
-    @property
-    def auto_brightness(self) -> bool:
-        """True when the display brightness is adjusted automatically, based on an ambient
-        light sensor or other method. Note that some displays may have this set to True by
-        default, but not actually implement automatic brightness adjustment.
-        `auto_brightness` is set to False if `brightness` is set manually.
-        """
-        return self._auto_brightness
-
-    @auto_brightness.setter
-    def auto_brightness(self, value: bool):
-        self._auto_brightness = value
-
     @property
     def width(self) -> int:
         """Display Width"""
@@ -447,6 +510,8 @@ class Display:
 
     @rotation.setter
     def rotation(self, value: int):
+        if value % 90 != 0:
+            raise ValueError("Display rotation must be in 90 degree increments")
         self._core.set_rotation(value)
 
     @property
index a50d094fb656a20bb4b75396490855fda378ffc2..0672513c067d776cc6859abcf502fe9bc019c72b 100644 (file)
@@ -22,20 +22,28 @@ __version__ = "0.0.0+auto.0"
 __repo__ = "https://github.com/adafruit/Adafruit_Blinka_Displayio.git"
 
 
-from typing import Union
-import circuitpython_typing
+import time
+import struct
+from circuitpython_typing import WriteableBuffer, ReadableBuffer
 from paralleldisplay import ParallelBus
 from ._fourwire import FourWire
 from ._group import Group
 from ._i2cdisplay import I2CDisplay
-from ._structs import ColorspaceStruct, TransformStruct, RectangleStruct
+from ._structs import ColorspaceStruct, TransformStruct
 from ._area import Area
-
-displays = []
+from ._displaybus import _DisplayBus
+from ._helpers import bswap16
+from ._constants import (
+    CHIP_SELECT_UNTOUCHED,
+    CHIP_SELECT_TOGGLE_EVERY_BYTE,
+    DISPLAY_COMMAND,
+    DISPLAY_DATA,
+    NO_COMMAND,
+)
 
 
 class _DisplayCore:
-    # pylint: disable=too-many-arguments, too-many-instance-attributes
+    # pylint: disable=too-many-arguments, too-many-instance-attributes, too-many-locals, too-many-branches, too-many-statements
 
     def __init__(
         self,
@@ -53,8 +61,16 @@ class _DisplayCore:
         bytes_per_cell: int,
         reverse_pixels_in_byte: bool,
         reverse_bytes_in_word: bool,
+        column_command: int,
+        row_command: int,
+        set_current_column_command: int,
+        set_current_row_command: int,
+        data_as_commands: bool,
+        always_toggle_chip_select: bool,
+        sh1107_addressing: bool,
+        address_little_endian: bool,
     ):
-        self._colorspace = ColorspaceStruct(
+        self.colorspace = ColorspaceStruct(
             depth=color_depth,
             grayscale=grayscale,
             grayscale_bit=8 - color_depth,
@@ -64,16 +80,28 @@ class _DisplayCore:
             reverse_bytes_in_word=reverse_bytes_in_word,
             dither=False,
         )
-        self._current_group = None
-        self._colstart = colstart
-        self._rowstart = rowstart
-        self._last_refresh = 0
-        self._refresh_in_progress = False
-        self._full_refresh = False
+        self.current_group = None
+        self.colstart = colstart
+        self.rowstart = rowstart
+        self.last_refresh = 0
+
+        self.column_command = column_command
+        self.row_command = row_command
+        self.set_current_column_command = set_current_column_command
+        self.set_current_row_command = set_current_row_command
+        self.data_as_commands = data_as_commands
+        self.always_toggle_chip_select = always_toggle_chip_select
+        self.sh1107_addressing = sh1107_addressing
+        self.address_little_endian = address_little_endian
+
+        self.refresh_in_progress = False
+        self.full_refresh = False
+        self.last_refresh = 0
 
         if bus:
             if isinstance(bus, (FourWire, I2CDisplay, ParallelBus)):
                 self._bus_reset = bus.reset
+                self._bus_free = bus._free
                 self._begin_transaction = bus._begin_transaction
                 self._send = bus._send
                 self._end_transaction = bus._end_transaction
@@ -81,111 +109,100 @@ class _DisplayCore:
                 raise ValueError("Unsupported display bus type")
 
         self._bus = bus
-        self._area = Area(0, 0, width, height)
+        self.area = Area(0, 0, width, height)
 
-        self._width = width
-        self._height = height
-        self._ram_width = ram_width
-        self._ram_height = ram_height
-        self._rotation = rotation
-        self._transform = TransformStruct()
+        self.width = width
+        self.height = height
+        self.ram_width = ram_width
+        self.ram_height = ram_height
+        self.rotation = rotation
+        self.transform = TransformStruct()
 
     def set_rotation(self, rotation: int) -> None:
         """
         Sets the rotation of the display as an int in degrees.
         """
         # pylint: disable=protected-access, too-many-branches
-        transposed = self._rotation in (90, 270)
+        transposed = self.rotation in (90, 270)
         will_be_transposed = rotation in (90, 270)
         if transposed != will_be_transposed:
-            self._width, self._height = self._height, self._width
+            self.width, self.height = self.height, self.width
 
-        height = self._height
-        width = self._width
+        height = self.height
+        width = self.width
 
         rotation %= 360
-        self._rotation = rotation
-        self._transform.x = 0
-        self._transform.y = 0
-        self._transform.scale = 1
-        self._transform.mirror_x = False
-        self._transform.mirror_y = False
-        self._transform.transpose_xy = False
+        self.rotation = rotation
+        self.transform.x = 0
+        self.transform.y = 0
+        self.transform.scale = 1
+        self.transform.mirror_x = False
+        self.transform.mirror_y = False
+        self.transform.transpose_xy = False
 
         if rotation in (0, 180):
             if rotation == 180:
-                self._transform.mirror_x = True
-                self._transform.mirror_y = True
+                self.transform.mirror_x = True
+                self.transform.mirror_y = True
         else:
-            self._transform.transpose_xy = True
+            self.transform.transpose_xy = True
             if rotation == 270:
-                self._transform.mirror_y = True
+                self.transform.mirror_y = True
             else:
-                self._transform.mirror_x = True
-
-        self._area.x1 = 0
-        self._area.y1 = 0
-        self._area.next = None
-
-        self._transform.dx = 1
-        self._transform.dy = 1
-        if self._transform.transpose_xy:
-            self._area.x2 = height
-            self._area.y2 = width
-            if self._transform.mirror_x:
-                self._transform.x = height
-                self._transform.dx = -1
-            if self._transform.mirror_y:
-                self._transform.y = width
-                self._transform.dy = -1
+                self.transform.mirror_x = True
+
+        self.area.x1 = 0
+        self.area.y1 = 0
+        self.area.next = None
+
+        self.transform.dx = 1
+        self.transform.dy = 1
+        if self.transform.transpose_xy:
+            self.area.x2 = height
+            self.area.y2 = width
+            if self.transform.mirror_x:
+                self.transform.x = height
+                self.transform.dx = -1
+            if self.transform.mirror_y:
+                self.transform.y = width
+                self.transform.dy = -1
         else:
-            self._area.x2 = width
-            self._area.y2 = height
-            if self._transform.mirror_x:
-                self._transform.x = width
-                self._transform.dx = -1
-            if self._transform.mirror_y:
-                self._transform.y = height
-                self._transform.dy = -1
-
-        if self._current_group is not None:
-            self._current_group._update_transform(self._transform)
-
-    def show(self, root_group: Group) -> bool:
-        # pylint: disable=protected-access
-
+            self.area.x2 = width
+            self.area.y2 = height
+            if self.transform.mirror_x:
+                self.transform.x = width
+                self.transform.dx = -1
+            if self.transform.mirror_y:
+                self.transform.y = height
+                self.transform.dy = -1
+
+        if self.current_group is not None:
+            self.current_group._update_transform(self.transform)
+
+    def set_root_group(self, root_group: Group) -> bool:
         """
         Switches to displaying the given group of layers. When group is `None`, the
         default CircuitPython terminal will be shown.
 
         :param Optional[displayio.Group] root_group: The group to show.
         """
+        # pylint: disable=protected-access
 
-        """
-        # TODO: Implement Supervisor
-        if root_group is None:
-            circuitpython_splash = _Supervisor().circuitpython_splash
-            if not circuitpython_splash._in_group:
-                root_group = circuitpython_splash
-            elif self._current_group == circuitpython_splash:
-                return True
-        """
-
-        if root_group == self._current_group:
+        if root_group == self.current_group:
             return True
 
         if root_group is not None and root_group._in_group:
             return False
 
-        if self._current_group is not None:
-            self._current_group._in_group = False
+        if self.current_group is not None:
+            self.current_group._in_group = False
 
         if root_group is not None:
-            root_group._update_transform(self._transform)
+            root_group._update_transform(self.transform)
             root_group._in_group = True
 
-        self._current_group = root_group
-        self._full_refresh = True
+        self.current_group = root_group
+        self.full_refresh = True
 
         return True
 
@@ -193,64 +210,58 @@ class _DisplayCore:
         # pylint: disable=protected-access
         """Mark the display core as currently being refreshed"""
 
-        if self._refresh_in_progress:
+        if self.refresh_in_progress:
             return False
 
-        self._refresh_in_progress = True
-        # self._last_refresh = _Supervisor()._ticks_ms64()
+        self.refresh_in_progress = True
+        self.last_refresh = time.monotonic() * 1000
         return True
 
     def finish_refresh(self) -> None:
         # pylint: disable=protected-access
         """Unmark the display core as currently being refreshed"""
 
-        if self._current_group is not None:
-            self._current_group._finish_refresh()
+        if self.current_group is not None:
+            self.current_group._finish_refresh()
 
-        self._full_refresh = False
-        self._refresh_in_progress = False
-        # self._last_refresh = _Supervisor()._ticks_ms64()
+        self.full_refresh = False
+        self.refresh_in_progress = False
+        self.last_refresh = time.monotonic() * 1000
 
-    def get_refresh_areas(self) -> list:
-        """Get a list of areas to be refreshed"""
-        subrectangles = []
-        if self._current_group is not None:
-            # Eventually calculate dirty rectangles here
-            subrectangles.append(RectangleStruct(0, 0, self._width, self._height))
-        return subrectangles
-
-    def release(self) -> None:
+    def release_display_core(self) -> None:
         """Release the display from the current group"""
         # pylint: disable=protected-access
 
-        if self._current_group is not None:
-            self._current_group._in_group = False
+        if self.current_group is not None:
+            self.current_group._in_group = False
 
     def fill_area(
         self,
         area: Area,
-        mask: circuitpython_typing.WriteableBuffer,
-        buffer: circuitpython_typing.WriteableBuffer,
+        mask: WriteableBuffer,
+        buffer: WriteableBuffer,
     ) -> bool:
-        # pylint: disable=protected-access
         """Call the current group's fill area function"""
-
-        return self._current_group._fill_area(self._colorspace, area, mask, buffer)
+        if self.current_group is not None:
+            return self.current_group._fill_area(  # pylint: disable=protected-access
+                self.colorspace, area, mask, buffer
+            )
+        return False
 
     def clip_area(self, area: Area, clipped: Area) -> bool:
         """Shrink the area to the region shared by the two areas"""
-        # pylint: disable=protected-access
 
-        overlaps = self._area._compute_overlap(area, clipped)
+        overlaps = self.area.compute_overlap(area, clipped)
         if not overlaps:
             return False
 
-        # Expand the area if we have multiple pixels per byte and we need to byte align the bounds
-        if self._colorspace.depth < 8:
+        # Expand the area if we have multiple pixels per byte and we need to byte
+        # align the bounds
+        if self.colorspace.depth < 8:
             pixels_per_byte = (
-                8 // self._colorspace.depth * self._colorspace.bytes_per_cell
+                8 // self.colorspace.depth * self.colorspace.bytes_per_cell
             )
-            if self._colorspace.pixels_in_byte_share_row:
+            if self.colorspace.pixels_in_byte_share_row:
                 if clipped.x1 % pixels_per_byte != 0:
                     clipped.x1 -= clipped.x1 % pixels_per_byte
                 if clipped.x2 % pixels_per_byte != 0:
@@ -263,22 +274,122 @@ class _DisplayCore:
 
         return True
 
+    def set_region_to_update(self, area: Area) -> None:
+        """Set the region to update"""
+        region_x1 = area.x1 + self.colstart
+        region_x2 = area.x2 + self.colstart
+        region_y1 = area.y1 + self.rowstart
+        region_y2 = area.y2 + self.rowstart
+
+        if self.colorspace.depth < 8:
+            pixels_per_byte = 8 // self.colorspace.depth
+            if self.colorspace.pixels_in_byte_share_row:
+                region_x1 //= pixels_per_byte * self.colorspace.bytes_per_cell
+                region_x2 //= pixels_per_byte * self.colorspace.bytes_per_cell
+            else:
+                region_y1 //= pixels_per_byte * self.colorspace.bytes_per_cell
+                region_y2 //= pixels_per_byte * self.colorspace.bytes_per_cell
+
+        region_x2 -= 1
+        region_y2 -= 1
+
+        chip_select = CHIP_SELECT_UNTOUCHED
+        if self.always_toggle_chip_select or self.data_as_commands:
+            chip_select = CHIP_SELECT_TOGGLE_EVERY_BYTE
+
+        # Set column
+        self.begin_transaction()
+        data = bytearray([self.column_command])
+        data_type = DISPLAY_DATA
+        if not self.data_as_commands:
+            self.send(DISPLAY_COMMAND, CHIP_SELECT_UNTOUCHED, data)
+            data = bytearray(0)
+        else:
+            data_type = DISPLAY_COMMAND
+
+        if self.ram_width < 0x100:  # Single Byte Bounds
+            data += struct.pack(">BB", region_x1, region_x2)
+        else:
+            if self.address_little_endian:
+                region_x1 = bswap16(region_x1)
+                region_x2 = bswap16(region_x2)
+            data += struct.pack(">HH", region_x1, region_x2)
+
+        # Quirk for SH1107 "SH1107_addressing"
+        #     Column lower command = 0x00, Column upper command = 0x10
+        if self.sh1107_addressing:
+            data = struct.pack(
+                ">BB",
+                ((region_x1 >> 4) & 0xF0) | 0x10,  # 0x10 to 0x17
+                region_x1 & 0x0F,  # 0x00 to 0x0F
+            )
+
+        self.send(data_type, chip_select, data)
+        self.end_transaction()
+
+        if self.set_current_column_command != NO_COMMAND:
+            self.begin_transaction()
+            self.send(
+                DISPLAY_COMMAND, chip_select, bytes([self.set_current_column_command])
+            )
+            # Only send the first half of data because it is the first coordinate.
+            self.send(DISPLAY_DATA, chip_select, data[: len(data) // 2])
+            self.end_transaction()
+
+        # Set row
+        self.begin_transaction()
+        data = bytearray([self.row_command])
+
+        if not self.data_as_commands:
+            self.send(DISPLAY_COMMAND, CHIP_SELECT_UNTOUCHED, data)
+            data = bytearray(0)
+        if self.ram_height < 0x100:  # Single Byte Bounds
+            data += struct.pack(">BB", region_y1, region_y2)
+        else:
+            if self.address_little_endian:
+                region_y1 = bswap16(region_y1)
+                region_y2 = bswap16(region_y2)
+            data += struct.pack(">HH", region_y1, region_y2)
+
+        # Quirk for SH1107 "SH1107_addressing"
+        #     Page address command = 0xB0
+        if self.sh1107_addressing:
+            data = struct.pack(">B", 0xB0 | region_y1)
+
+        self.send(data_type, chip_select, data)
+        self.end_transaction()
+
+        if self.set_current_row_command != NO_COMMAND:
+            self.begin_transaction()
+            self.send(
+                DISPLAY_COMMAND, chip_select, bytes([self.set_current_row_command])
+            )
+            # Only send the first half of data because it is the first coordinate.
+            self.send(DISPLAY_DATA, chip_select, data[: len(data) // 2])
+            self.end_transaction()
+
     def send(
         self,
         data_type: int,
         chip_select: int,
-        data: circuitpython_typing.ReadableBuffer,
+        data: ReadableBuffer,
     ) -> None:
         """
         Send the data to the current bus
         """
         self._send(data_type, chip_select, data)
 
-    def begin_transaction(self) -> None:
+    def bus_free(self) -> bool:
+        """
+        Check if the bus is free
+        """
+        return self._bus_free()
+
+    def begin_transaction(self) -> bool:
         """
         Begin Bus Transaction
         """
-        self._begin_transaction()
+        return self._begin_transaction()
 
     def end_transaction(self) -> None:
         """
@@ -290,21 +401,21 @@ class _DisplayCore:
         """
         Gets the width of the display in pixels.
         """
-        return self._width
+        return self.width
 
     def get_height(self) -> int:
         """
         Gets the height of the display in pixels.
         """
-        return self._height
+        return self.height
 
     def get_rotation(self) -> int:
         """
         Gets the rotation of the display as an int in degrees.
         """
-        return self._rotation
+        return self.rotation
 
-    def get_bus(self) -> Union[FourWire, ParallelBus, I2CDisplay]:
+    def get_bus(self) -> _DisplayBus:
         """
         The bus being used by the display. [readonly]
         """
index 686f8bb12d58aae1bdc3356352e0c2d6a7421601..114506c4af915a483befb54d95261ed7f23c6203 100644 (file)
@@ -19,7 +19,7 @@ displayio for Blinka
 
 from typing import Optional
 import microcontroller
-import circuitpython_typing
+from circuitpython_typing import ReadableBuffer
 from ._group import Group
 from ._displaybus import _DisplayBus
 
@@ -42,8 +42,8 @@ class EPaperDisplay:
     def __init__(
         self,
         display_bus: _DisplayBus,
-        start_sequence: circuitpython_typing.ReadableBuffer,
-        stop_sequence: circuitpython_typing.ReadableBuffer,
+        start_sequence: ReadableBuffer,
+        stop_sequence: ReadableBuffer,
         *,
         width: int,
         height: int,
@@ -84,9 +84,9 @@ class EPaperDisplay:
 
         :param display_bus: The bus that the display is connected to
         :type _DisplayBus: displayio.FourWire or displayio.ParallelBus
-        :param ~circuitpython_typing.ReadableBuffer start_sequence: Byte-packed
+        :param ~ReadableBuffer start_sequence: Byte-packed
             initialization sequence.
-        :param ~circuitpython_typing.ReadableBuffer stop_sequence: Byte-packed
+        :param ~ReadableBuffer stop_sequence: Byte-packed
             initialization sequence.
         :param int width: Width in pixels
         :param int height: Height in pixels
index 31b1be5e5a1ea1d578d9ab22640b966742f1145a..61be810d45303555fa45291437e38c1e64967b8d 100644 (file)
@@ -22,7 +22,7 @@ from typing import Optional
 import digitalio
 import busio
 import microcontroller
-import circuitpython_typing
+from circuitpython_typing import ReadableBuffer
 from ._constants import (
     CHIP_SELECT_TOGGLE_EVERY_BYTE,
     CHIP_SELECT_UNTOUCHED,
@@ -95,7 +95,7 @@ class FourWire:
     def send(
         self,
         command,
-        data: circuitpython_typing.ReadableBuffer,
+        data: ReadableBuffer,
         *,
         toggle_every_byte: bool = False,
     ) -> None:
@@ -120,7 +120,7 @@ class FourWire:
         self,
         data_type: int,
         chip_select: int,
-        data: circuitpython_typing.ReadableBuffer,
+        data: ReadableBuffer,
     ):
         self._dc.value = data_type == DISPLAY_DATA
         if chip_select == CHIP_SELECT_TOGGLE_EVERY_BYTE:
@@ -132,16 +132,24 @@ class FourWire:
         else:
             self._spi.write(data)
 
-    def _begin_transaction(self):
+    def _free(self) -> bool:
+        """Attempt to free the bus and return False if busy"""
+        if not self._spi.try_lock():
+            return False
+        self._spi.unlock()
+        return True
+
+    def _begin_transaction(self) -> bool:
         """Begin the SPI transaction by locking, configuring, and setting Chip Select"""
-        while not self._spi.try_lock():
-            pass
+        if not self._spi.try_lock():
+            return False
         self._spi.configure(
             baudrate=self._frequency, polarity=self._polarity, phase=self._phase
         )
         self._chip_select.value = False
+        return True
 
-    def _end_transaction(self):
+    def _end_transaction(self) -> None:
         """End the SPI transaction by unlocking and setting Chip Select"""
         self._chip_select.value = True
         self._spi.unlock()
index 505a3bffb19c4f598c4a2b4e4e732e1dfb5d7efa..a5c6688f5f66d7d8b715dbb4a54159d700fef15b 100644 (file)
@@ -19,14 +19,18 @@ displayio for Blinka
 
 from __future__ import annotations
 from typing import Union, Callable
+from circuitpython_typing import WriteableBuffer
 from ._structs import TransformStruct
 from ._tilegrid import TileGrid
+from ._colorspace import Colorspace
+from ._area import Area
 
 __version__ = "0.0.0+auto.0"
 __repo__ = "https://github.com/adafruit/Adafruit_Blinka_displayio.git"
 
 
 class Group:
+    # pylint: disable=too-many-instance-attributes
     """
     Manage a group of sprites and groups and how they are inter-related.
 
@@ -44,12 +48,15 @@ class Group:
         if not isinstance(scale, int) or scale < 1:
             raise ValueError("Scale must be >= 1")
         self._scale = 1  # Use the setter below to actually set the scale
+        self._name = "Group"
         self._group_x = x
         self._group_y = y
         self._hidden_group = False
+        self._hidden_by_parent = False
         self._layers = []
         self._supported_types = (TileGrid, Group)
         self._in_group = False
+        self._item_removed = False
         self._absolute_transform = TransformStruct(0, 0, 1, 1, 1, False, False, False)
         self._set_scale(scale)  # Set the scale via the setter
 
@@ -141,23 +148,60 @@ class Group:
         """Deletes the value at the given index."""
         del self._layers[index]
 
-    def _fill_area(self, buffer):
-        if self._hidden_group:
-            return
-
-        for layer in self._layers:
-            if isinstance(layer, (Group, TileGrid)):
-                layer._fill_area(buffer)  # pylint: disable=protected-access
+    def _fill_area(
+        self,
+        colorspace: Colorspace,
+        area: Area,
+        mask: WriteableBuffer,
+        buffer: WriteableBuffer,
+    ) -> bool:
+        if not self._hidden_group:
+            for layer in reversed(self._layers):
+                if isinstance(layer, (Group, TileGrid)):
+                    if layer._fill_area(  # pylint: disable=protected-access
+                        colorspace, area, mask, buffer
+                    ):
+                        return True
+        return False
 
     def sort(self, key: Callable, reverse: bool) -> None:
         """Sort the members of the group."""
         self._layers.sort(key=key, reverse=reverse)
 
     def _finish_refresh(self):
-        for layer in self._layers:
+        for layer in reversed(self._layers):
             if isinstance(layer, (Group, TileGrid)):
                 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):
+                layer._get_refresh_areas(areas)
+            elif isinstance(layer, TileGrid):
+                if not layer._get_rendered_hidden():
+                    layer._get_refresh_areas(areas)
+
+    def _set_hidden(self, hidden: bool) -> None:
+        if self._hidden_group == hidden:
+            return
+        self._hidden_group = hidden
+        if self._hidden_by_parent:
+            return
+        for layer in self._layers:
+            if isinstance(layer, (Group, TileGrid)):
+                layer._set_hidden_by_parent(hidden)  # pylint: disable=protected-access
+
+    def _set_hidden_by_parent(self, hidden: bool) -> None:
+        if self._hidden_by_parent == hidden:
+            return
+        self._hidden_by_parent = hidden
+        if self._hidden_group:
+            return
+        for layer in self._layers:
+            if isinstance(layer, (Group, TileGrid)):
+                layer._set_hidden_by_parent(hidden)  # pylint: disable=protected-access
+
     @property
     def hidden(self) -> bool:
         """True when the Group and all of it's layers are not visible. When False, the
@@ -166,10 +210,11 @@ class Group:
         return self._hidden_group
 
     @hidden.setter
-    def hidden(self, value: bool):
+    def hidden(self, value: bool) -> None:
         if not isinstance(value, (bool, int)):
             raise ValueError("Expecting a boolean or integer value")
-        self._hidden_group = bool(value)
+        value = bool(value)
+        self._set_hidden(value)
 
     @property
     def scale(self) -> int:
@@ -237,3 +282,6 @@ class Group:
                 self._absolute_transform.y += dy_value * (value - self._group_y)
             self._group_y = value
             self._update_child_transforms()
+
+
+circuitpython_splash = Group(scale=2, x=0, y=0)
diff --git a/displayio/_helpers.py b/displayio/_helpers.py
new file mode 100644 (file)
index 0000000..2d77730
--- /dev/null
@@ -0,0 +1,36 @@
+# SPDX-FileCopyrightText: 2023 Melissa LeBlanc-Williams for Adafruit Industries
+#
+# SPDX-License-Identifier: MIT
+
+"""
+`displayio.helpers`
+================================================================================
+
+displayio for Blinka
+
+**Software and Dependencies:**
+
+* Adafruit Blinka:
+  https://github.com/adafruit/Adafruit_Blinka/releases
+
+* Author(s): Melissa LeBlanc-Williams
+
+"""
+
+__version__ = "0.0.0+auto.0"
+__repo__ = "https://github.com/adafruit/Adafruit_Blinka_displayio.git"
+
+
+def clamp(value, min_value, max_value):
+    """Clamp a value between a minimum and maximum value"""
+    return max(min(max_value, value), min_value)
+
+
+def bswap16(value):
+    """Swap the bytes in a 16 bit value"""
+    return (value & 0xFF00) >> 8 | (value & 0x00FF) << 8
+
+
+def read_word(header: memoryview, index: int) -> int:
+    """Read a 32-bit value from a memoryview cast as 16-bit values"""
+    return header[index] | header[index + 1] << 16
index 26476599fa61acc0379b3d3b27c5b54163feb86d..9611fb09187a687351c308a3cbb07097997b8eca 100644 (file)
@@ -23,7 +23,7 @@ displayio for Blinka
 import time
 import busio
 import digitalio
-import circuitpython_typing
+from circuitpython_typing import ReadableBuffer
 from ._constants import CHIP_SELECT_UNTOUCHED, DISPLAY_COMMAND
 
 __version__ = "0.0.0+auto.0"
@@ -53,6 +53,15 @@ class I2CDisplay:
         self._i2c = i2c_bus
         self._dev_addr = device_address
 
+    def __new__(cls, *args, **kwargs):
+        from . import (  # pylint: disable=import-outside-toplevel, cyclic-import
+            allocate_display_bus,
+        )
+
+        display_bus_instance = super().__new__(cls)
+        allocate_display_bus(display_bus_instance)
+        return display_bus_instance
+
     def _release(self):
         self.reset()
         self._i2c.deinit()
@@ -71,12 +80,7 @@ class I2CDisplay:
         time.sleep(0.0001)
         self._reset.value = True
 
-    def _begin_transaction(self) -> None:
-        """Lock the bus before sending data."""
-        while not self._i2c.try_lock():
-            pass
-
-    def send(self, command: int, data: circuitpython_typing.ReadableBuffer) -> None:
+    def send(self, command: int, data: ReadableBuffer) -> None:
         """
         Sends the given command value followed by the full set of data. Display state,
         such as vertical scroll, set via ``send`` may or may not be reset once the code is
@@ -89,10 +93,9 @@ class I2CDisplay:
     def _send(
         self,
         data_type: int,
-        chip_select: int,
-        data: circuitpython_typing.ReadableBuffer,
+        _chip_select: int,  # Chip select behavior
+        data: ReadableBuffer,
     ):
-        # pylint: disable=unused-argument
         if data_type == DISPLAY_COMMAND:
             n = len(data)
             if n > 0:
@@ -101,12 +104,37 @@ class I2CDisplay:
                     command_bytes[2 * i] = 0x80
                     command_bytes[2 * i + 1] = data[i]
 
-                self._i2c.writeto(self._dev_addr, buffer=command_bytes, stop=True)
+            try:
+                self._i2c.writeto(self._dev_addr, buffer=command_bytes)
+            except OSError as error:
+                if error.errno == 121:
+                    raise RuntimeError(
+                        f"I2C write error to 0x{self._dev_addr:02x}"
+                    ) from error
+                raise error
         else:
             data_bytes = bytearray(len(data) + 1)
             data_bytes[0] = 0x40
             data_bytes[1:] = data
-            self._i2c.writeto(self._dev_addr, buffer=data_bytes, stop=True)
+            try:
+                self._i2c.writeto(self._dev_addr, buffer=data_bytes)
+            except OSError as error:
+                if error.errno == 121:
+                    raise RuntimeError(
+                        f"I2C write error to 0x{self._dev_addr:02x}"
+                    ) from error
+                raise error
+
+    def _free(self) -> bool:
+        """Attempt to free the bus and return False if busy"""
+        if not self._i2c.try_lock():
+            return False
+        self._i2c.unlock()
+        return True
+
+    def _begin_transaction(self) -> bool:
+        """Lock the bus before sending data."""
+        return self._i2c.try_lock()
 
     def _end_transaction(self) -> None:
         """Release the bus after sending data."""
index 02647d10ed9f0244edd7e06b51c710983860f78f..30598ef185936afe352f6a3ee0deadd4354d3833 100644 (file)
@@ -1,4 +1,5 @@
 # SPDX-FileCopyrightText: 2020 Melissa LeBlanc-Williams for Adafruit Industries
+# SPDX-FileCopyrightText: 2021 James Carr
 #
 # SPDX-License-Identifier: MIT
 
@@ -13,12 +14,12 @@ displayio for Blinka
 * Adafruit Blinka:
   https://github.com/adafruit/Adafruit_Blinka/releases
 
-* Author(s): Melissa LeBlanc-Williams
+* Author(s): Melissa LeBlanc-Williams, James Carr
 
 """
 
 from typing import Union, BinaryIO
-from PIL import Image
+from ._helpers import read_word
 from ._colorconverter import ColorConverter
 from ._palette import Palette
 
@@ -27,41 +28,244 @@ __repo__ = "https://github.com/adafruit/Adafruit_Blinka_displayio.git"
 
 
 class OnDiskBitmap:
+    # pylint: disable=too-many-instance-attributes
     """
-    Loads values straight from disk. This minimizes memory use but can lead to much slower
-    pixel load times. These load times may result in frame tearing where only part of the
-    image is visible."""
+    Loads values straight from disk. This minimizes memory use but can lead to much slower pixel
+    load times. These load times may result in frame tearing where only part of the image is
+    visible.
 
-    def __init__(self, file: Union[str, BinaryIO]):
-        self._image = Image.open(file).convert("RGBA")
+    It's easiest to use on a board with a built in display such as the `Hallowing M0 Express
+    <https://www.adafruit.com/product/3900>`_.
+
+    .. code-block:: Python
+
+        import board
+        import displayio
+        import time
+        import pulseio
+
+        board.DISPLAY.auto_brightness = False
+        board.DISPLAY.brightness = 0
+        splash = displayio.Group()
+        board.DISPLAY.show(splash)
+
+        odb = displayio.OnDiskBitmap(\'/sample.bmp\')
+        face = displayio.TileGrid(odb, pixel_shader=odb.pixel_shader)
+        splash.append(face)
+        # Wait for the image to load.
+        board.DISPLAY.refresh(target_frames_per_second=60)
+
+        # Fade up the backlight
+        for i in range(100):
+          board.DISPLAY.brightness = 0.01 * i
+          time.sleep(0.05)
+
+        # Wait forever
+        while True:
+          pass
+
+    """
+
+    def __init__(self, file: Union[str, BinaryIO]) -> None:
+        # pylint: disable=too-many-locals, too-many-branches, too-many-statements
+        """
+        Create an OnDiskBitmap object with the given file.
+
+        :param file file: The name of the bitmap file. For backwards compatibility, a file opened
+            in binary mode may also be passed.
+
+        Older versions of CircuitPython required a file opened in binary mode. CircuitPython 7.0
+        modified OnDiskBitmap so that it takes a filename instead, and opens the file internally.
+        A future version of CircuitPython will remove the ability to pass in an opened file.
+        """
+
+        if isinstance(file, str):
+            file = open(file, "rb")  # pylint: disable=consider-using-with
+
+        if not (file.readable() and file.seekable()):
+            raise TypeError("file must be a file opened in byte mode")
+
+        self._pixel_shader_base: Union[ColorConverter, Palette, None] = None
+
+        try:
+            self._file = file
+            file.seek(0)
+            bmp_header = memoryview(file.read(138)).cast(
+                "H"
+            )  # cast as unsigned 16-bit int
+
+            if len(bmp_header.tobytes()) != 138 or bmp_header.tobytes()[0:2] != b"BM":
+                raise ValueError("Invalid BMP file")
+
+            self._data_offset = read_word(bmp_header, 5)
+
+            header_size = read_word(bmp_header, 7)
+            bits_per_pixel = bmp_header[14]
+            compression = read_word(bmp_header, 15)
+            number_of_colors = read_word(bmp_header, 23)
+
+            indexed = bits_per_pixel <= 8
+            self._bitfield_compressed = compression == 3
+            self._bits_per_pixel = bits_per_pixel
+            self._width = read_word(bmp_header, 9)
+            self._height = read_word(bmp_header, 11)
+
+            self._colorconverter = ColorConverter()
+
+            if bits_per_pixel == 16:
+                if header_size >= 56 or self._bitfield_compressed:
+                    self._r_bitmask = read_word(bmp_header, 27)
+                    self._g_bitmask = read_word(bmp_header, 29)
+                    self._b_bitmask = read_word(bmp_header, 31)
+                else:
+                    # No compression or short header mean 5:5:5
+                    self._r_bitmask = 0x7C00
+                    self._g_bitmask = 0x03E0
+                    self._b_bitmask = 0x001F
+            elif indexed:
+                if number_of_colors == 0:
+                    number_of_colors = 1 << bits_per_pixel
+
+                palette = Palette(number_of_colors)
+
+                if number_of_colors > 1:
+                    palette_size = number_of_colors * 4
+                    palette_offset = 0xE + header_size
+
+                    file.seek(palette_offset)
+
+                    palette_data = memoryview(file.read(palette_size)).cast(
+                        "I"
+                    )  # cast as unsigned 32-bit int
+                    if len(palette_data.tobytes()) != palette_size:
+                        raise ValueError("Unable to read color palette data")
+
+                    for i in range(number_of_colors):
+                        palette[i] = palette_data[i]
+                else:
+                    palette[0] = 0x000000
+                    palette[1] = 0xFFFFFF
+                self._palette = palette
+            elif header_size not in (12, 40, 108, 124):
+                raise ValueError(
+                    "Only Windows format, uncompressed BMP supported: "
+                    f"given header size is {header_size}"
+                )
+
+            if bits_per_pixel == 8 and number_of_colors == 0:
+                raise ValueError(
+                    "Only monochrome, indexed 4bpp or 8bpp, and 16bpp or greater BMPs supported: "
+                    f"{bits_per_pixel} bpp given"
+                )
+
+            bytes_per_pixel = (
+                self._bits_per_pixel // 8 if (self._bits_per_pixel // 8) else 1
+            )
+            pixels_per_byte = 8 // self._bits_per_pixel
+            if pixels_per_byte == 0:
+                self._stride = self._width * bytes_per_pixel
+                if self._stride % 4 != 0:
+                    self._stride += 4 - self._stride % 4
+            else:
+                bit_stride = self._width * self._bits_per_pixel
+                if bit_stride % 32 != 0:
+                    bit_stride += 32 - bit_stride % 32
+                self._stride = bit_stride // 8
+        except IOError as error:
+            raise OSError from error
 
     @property
     def width(self) -> int:
-        """Width of the bitmap. (read only)"""
-        return self._image.width
+        """
+        Width of the bitmap. (read only)
+
+        :type: int
+        """
+
+        return self._width
 
     @property
     def height(self) -> int:
-        """Height of the bitmap. (read only)"""
-        return self._image.height
+        """
+        Height of the bitmap. (read only)
+
+        :type: int
+        """
+
+        return self._height
 
     @property
     def pixel_shader(self) -> Union[ColorConverter, Palette]:
-        """The ColorConverter or Palette for this image. (read only)"""
-        return self._image.getpalette()
-
-    def __getitem__(self, index: Union[tuple, list, int]) -> int:
         """
-        Returns the value at the given index. The index can either be
-        an x,y tuple or an int equal to `y * width + x`.
+        The image's pixel_shader. The type depends on the underlying `Bitmap`'s structure. The
+        pixel shader can be modified (e.g., to set the transparent pixel or, for paletted images,
+        to update the palette)
+
+        :type: Union[ColorConverter, Palette]
         """
-        if isinstance(index, (tuple, list)):
-            x = index[0]
-            y = index[1]
-        elif isinstance(index, int):
-            x = index % self._image._width
-            y = index // self._image._width
-        if not 0 <= x < self._image.width or not 0 <= y < self._image.height:
+
+        return self._pixel_shader_base
+
+    @property
+    def _colorconverter(self) -> ColorConverter:
+        return self._pixel_shader_base
+
+    @_colorconverter.setter
+    def _colorconverter(self, colorconverter: ColorConverter) -> None:
+        self._pixel_shader_base = colorconverter
+
+    @property
+    def _palette(self) -> Palette:
+        return self._pixel_shader_base
+
+    @_palette.setter
+    def _palette(self, palette: Palette) -> None:
+        self._pixel_shader_base = palette
+
+    def _get_pixel(self, x: int, y: int) -> int:
+        if not (0 <= x < self.width and 0 <= y < self.height):
             return 0
 
-        return self._image.getpixel((x, y))
+        bytes_per_pixel = (
+            self._bits_per_pixel // 8 if (self._bits_per_pixel // 8) else 1
+        )
+        pixels_per_byte = 8 // self._bits_per_pixel
+        if pixels_per_byte == 0:
+            location = (
+                self._data_offset
+                + (self.height - y - 1) * self._stride
+                + x * bytes_per_pixel
+            )
+        else:
+            location = (
+                self._data_offset
+                + (self.height - y - 1) * self._stride
+                + x // pixels_per_byte
+            )
+
+        self._file.seek(location)
+
+        pixel_data = memoryview(self._file.read(4)).cast(
+            "I"
+        )  # cast as unsigned 32-bit int
+        pixel_data = pixel_data[0]  # We only need a single 32-bit uint
+        if bytes_per_pixel == 1:
+            offset = (x % pixels_per_byte) * self._bits_per_pixel
+            mask = (1 << self._bits_per_pixel) - 1
+            return (pixel_data >> ((8 - self._bits_per_pixel) - offset)) & mask
+        if bytes_per_pixel == 2:
+            if self._g_bitmask == 0x07E0:  # 565
+                red = (pixel_data & self._r_bitmask) >> 11
+                green = (pixel_data & self._g_bitmask) >> 5
+                blue = pixel_data & self._b_bitmask
+            else:  # 555
+                red = (pixel_data & self._r_bitmask) >> 10
+                green = (pixel_data & self._g_bitmask) >> 4
+                blue = pixel_data & self._b_bitmask
+            return red << 19 | green << 10 | blue << 3
+        if bytes_per_pixel == 4 and self._bitfield_compressed:
+            return pixel_data & 0x00FFFFFF
+        return pixel_data
+
+    def _finish_refresh(self) -> None:
+        pass
index 969c769b9ae6738cbce64e806c184d8805110976..81f3bee5081e3c315f8866eb62f06a6d236dd084 100644 (file)
@@ -18,42 +18,38 @@ displayio for Blinka
 """
 
 from typing import Optional, Union, Tuple
-import circuitpython_typing
+from circuitpython_typing import ReadableBuffer
+from ._colorconverter import ColorConverter
+from ._colorspace import Colorspace
+from ._structs import InputPixelStruct, OutputPixelStruct, ColorStruct
 
 __version__ = "0.0.0+auto.0"
 __repo__ = "https://github.com/adafruit/Adafruit_Blinka_displayio.git"
 
 
 class Palette:
-    """Map a pixel palette_index to a full color. Colors are transformed to the display’s
+    """Map a pixel palette_index to a full color. Colors are transformed to the display's
     format internally to save memory.
     """
 
-    def __init__(self, color_count: int):
-        """Create a Palette object to store a set number of colors."""
+    def __init__(self, color_count: int, *, dither: bool = False):
+        """Create a Palette object to store a set number of colors.
+
+        :param int color_count: The number of colors in the Palette
+        :param bool dither: When true, dither the RGB color before converting to the
+                            display's color space
+        """
         self._needs_refresh = False
+        self._dither = dither
 
         self._colors = []
         for _ in range(color_count):
             self._colors.append(self._make_color(0))
-            self._update_rgba(len(self._colors) - 1)
-
-    def _update_rgba(self, index):
-        color = self._colors[index]["rgb888"]
-        transparent = self._colors[index]["transparent"]
-        self._colors[index]["rgba"] = (
-            color >> 16,
-            (color >> 8) & 0xFF,
-            color & 0xFF,
-            0 if transparent else 0xFF,
-        )
 
-    def _make_color(self, value, transparent=False):
-        color = {
-            "transparent": transparent,
-            "rgb888": 0,
-            "rgba": (0, 0, 0, 255),
-        }
+    @staticmethod
+    def _make_color(value, transparent=False):
+        color = ColorStruct(transparent=transparent)
+
         if isinstance(value, (tuple, list, bytes, bytearray)):
             value = (value[0] & 0xFF) << 16 | (value[1] & 0xFF) << 8 | value[2] & 0xFF
         elif isinstance(value, int):
@@ -61,8 +57,7 @@ class Palette:
                 raise ValueError("Color must be between 0x000000 and 0xFFFFFF")
         else:
             raise TypeError("Color buffer must be a buffer, tuple, list, or int")
-        color["rgb888"] = value
-        self._needs_refresh = True
+        color.rgb888 = value
 
         return color
 
@@ -73,7 +68,7 @@ class Palette:
     def __setitem__(
         self,
         index: int,
-        value: Union[int, circuitpython_typing.ReadableBuffer, Tuple[int, int, int]],
+        value: Union[int, ReadableBuffer, Tuple[int, int, int]],
     ) -> None:
         """Sets the pixel color at the given index. The index should be
         an integer in the range 0 to color_count-1.
@@ -82,41 +77,75 @@ class Palette:
         (to represent an RGB value). Value can be an int, bytes (3 bytes (RGB) or
         4 bytes (RGB + pad byte)), bytearray, or a tuple or list of 3 integers.
         """
-        if self._colors[index]["rgb888"] != value:
-            self._colors[index] = self._make_color(value)
-            self._update_rgba(index)
+        if self._colors[index].rgb888 == value:
+            return
+        self._colors[index] = self._make_color(value)
+        self._colors[index].cached_colorspace = None
+        self._needs_refresh = True
 
     def __getitem__(self, index: int) -> Optional[int]:
         if not 0 <= index < len(self._colors):
             raise ValueError("Palette index out of range")
-        return self._colors[index]["rgb888"]
+        return self._colors[index].rgb888
 
     def make_transparent(self, palette_index: int) -> None:
         """Set the palette index to be a transparent color"""
-        self._colors[palette_index]["transparent"] = True
-        self._update_rgba(palette_index)
+        self._colors[palette_index].transparent = True
+        self._needs_refresh = True
 
     def make_opaque(self, palette_index: int) -> None:
         """Set the palette index to be an opaque color"""
-        self._colors[palette_index]["transparent"] = False
-        self._update_rgba(palette_index)
-
-    def _get_palette(self):
-        """Generate a palette for use with PIL"""
-        palette = []
-        for color in self._colors:
-            palette += color["rgba"][0:3]
-        return palette
-
-    def _get_alpha_palette(self):
-        """Generate an alpha channel palette with white being
-        opaque and black being transparent"""
-        palette = []
-        for color in self._colors:
-            for _ in range(3):
-                palette += [0 if color["transparent"] else 255]
-        return palette
+        self._colors[palette_index].transparent = False
+        self._needs_refresh = True
+
+    def _get_color(
+        self,
+        colorspace: Colorspace,
+        input_pixel: InputPixelStruct,
+        output_color: OutputPixelStruct,
+    ):
+        palette_index = input_pixel.pixel
+        if palette_index > len(self._colors) or self._colors[palette_index].transparent:
+            output_color.opaque = False
+            return
+
+        color = self._colors[palette_index]
+        if (
+            not self._dither
+            and color.cached_colorspace == colorspace
+            and color.cached_colorspace_grayscale_bit == colorspace.grayscale_bit
+            and color.cached_colorspace_grayscale == colorspace.grayscale
+        ):
+            output_color.pixel = self._colors[palette_index].cached_color
+            return
+
+        rgb888_pixel = input_pixel
+        rgb888_pixel.pixel = self._colors[palette_index].rgb888
+        ColorConverter._convert_color(  # pylint: disable=protected-access
+            colorspace, self._dither, rgb888_pixel, output_color
+        )
+        if not self._dither:
+            color.cached_colorspace = colorspace
+            color.cached_color = output_color.pixel
+            color.cached_colorspace_grayscale = colorspace.grayscale
+            color.cached_colorspace_grayscale_bit = colorspace.grayscale_bit
 
     def is_transparent(self, palette_index: int) -> bool:
         """Returns True if the palette index is transparent. Returns False if opaque."""
-        return self._colors[palette_index]["transparent"]
+        return self._colors[palette_index].transparent
+
+    def _finish_refresh(self):
+        self._needs_refresh = False
+
+    @property
+    def dither(self) -> bool:
+        """When true the palette dithers the output by adding
+        random noise when truncating to display bitdepth
+        """
+        return self._dither
+
+    @dither.setter
+    def dither(self, value: bool):
+        if not isinstance(value, bool):
+            raise ValueError("Value should be boolean")
+        self._dither = value
index 946919d41fcf804fa7336d5a35fadf820147a445..8545cf14b4847cbc35edf1aef6f6a174daa378cd 100644 (file)
@@ -19,6 +19,8 @@ displayio for Blinka
 """
 
 from ._bitmap import Bitmap
+from ._area import Area
+from ._helpers import clamp
 
 __version__ = "0.0.0+auto.0"
 __repo__ = "https://github.com/adafruit/Adafruit_Blinka_displayio.git"
@@ -33,14 +35,95 @@ class Shape(Bitmap):
     def __init__(
         self, width: int, height: int, *, mirror_x: bool = False, mirror_y: bool = False
     ):
-        # pylint: disable=unused-argument
         """Create a Shape object with the given fixed size. Each pixel is one bit and is
         stored by the column boundaries of the shape on each row. Each row’s boundary
         defaults to the full row.
         """
+        self._mirror_x = mirror_x
+        self._mirror_y = mirror_y
+        self._width = width
+        self._height = height
+        if self._mirror_x:
+            width //= 2
+            width += self._width % 2
+        self._half_width = width
+        if self._mirror_y:
+            height //= 2
+            height += self._height % 2
+        self._half_height = height
+        self._data = bytearray(height * 4)
+        for i in range(height):
+            self._data[2 * i] = 0
+            self._data[2 * i + 1] = width
+
+        self._dirty_area = Area(0, 0, width, height)
         super().__init__(width, height, 2)
 
     def set_boundary(self, y: int, start_x: int, end_x: int) -> None:
-        # pylint: disable=unnecessary-pass
         """Loads pre-packed data into the given row."""
-        pass
+        max_y = self._height - 1
+        if self._mirror_y:
+            max_y = self._half_height - 1
+        y = clamp(y, 0, max_y)
+        max_x = self._width - 1
+        if self._mirror_x:
+            max_x = self._half_width - 1
+        start_x = clamp(start_x, 0, max_x)
+        end_x = clamp(end_x, 0, max_x)
+
+        # find x-boundaries for updating based on current data and start_x, end_x, and mirror_x
+        lower_x = min(start_x, self._data[2 * y])
+
+        if self._mirror_x:
+            upper_x = (
+                self._width - lower_x + 1
+            )  # dirty rectangles are treated with max value exclusive
+        else:
+            upper_x = max(
+                end_x, self._data[2 * y + 1]
+            )  # dirty rectangles are treated with max value exclusive
+
+        # find y-boundaries based on y and mirror_y
+        lower_y = y
+
+        if self._mirror_y:
+            upper_y = (
+                self._height - lower_y + 1
+            )  # dirty rectangles are treated with max value exclusive
+        else:
+            upper_y = y + 1  # dirty rectangles are treated with max value exclusive
+
+        self._data[2 * y] = start_x  # update the data array with the new boundaries
+        self._data[2 * y + 1] = end_x
+
+        if self._dirty_area.x1 == self._dirty_area.x2:  # dirty region is empty
+            self._dirty_area.x1 = lower_x
+            self._dirty_area.x2 = upper_x
+            self._dirty_area.y1 = lower_y
+            self._dirty_area.y2 = upper_y
+        else:
+            self._dirty_area.x1 = min(lower_x, self._dirty_area.x1)
+            self._dirty_area.x2 = max(upper_x, self._dirty_area.x2)
+            self._dirty_area.y1 = min(lower_y, self._dirty_area.y1)
+            self._dirty_area.y2 = max(upper_y, self._dirty_area.y2)
+
+    def _get_pixel(self, x: int, y: int) -> int:
+        if x >= self._width or x < 0 or y >= self._height or y < 0:
+            return 0
+        if self._mirror_x and x >= self._half_width:
+            x = self._width - x - 1
+        if self._mirror_y and y >= self._half_height:
+            y = self._height - y - 1
+        start_x = self._data[2 * y]
+        end_x = self._data[2 * y + 1]
+        if x < start_x or x >= end_x:
+            return 0
+        return 1
+
+    def _finish_refresh(self):
+        self._dirty_area.x1 = 0
+        self._dirty_area.x2 = 0
+
+    def _get_refresh_areas(self, areas: list[Area]) -> None:
+        if self._dirty_area.x1 != self._dirty_area.x2:
+            areas.append(self._dirty_area)
index 955b9b4a1a8c803ba66d890bed013b4500379a84..3603295bbeb4bb0a306a33db810e14f8e27ea352 100644 (file)
@@ -23,16 +23,6 @@ __version__ = "0.0.0+auto.0"
 __repo__ = "https://github.com/adafruit/Adafruit_Blinka_Displayio.git"
 
 
-@dataclass
-class RectangleStruct:
-    # pylint: disable=invalid-name
-    """Rectangle Struct Dataclass. To eventually be replaced by Area."""
-    x1: int
-    y1: int
-    x2: int
-    y2: int
-
-
 @dataclass
 class TransformStruct:
     # pylint: disable=invalid-name
@@ -42,14 +32,16 @@ class TransformStruct:
     dx: int = 1
     dy: int = 1
     scale: int = 1
-    transpose_xy: bool = False
+    width: int = 0
+    height: int = 0
     mirror_x: bool = False
     mirror_y: bool = False
+    transpose_xy: bool = False
 
 
 @dataclass
 class ColorspaceStruct:
-    # pylint: disable=invalid-name
+    # pylint: disable=invalid-name, too-many-instance-attributes
     """Colorspace Struct Dataclass"""
     depth: int
     bytes_per_cell: int = 0
@@ -58,7 +50,52 @@ class ColorspaceStruct:
     grayscale_bit: int = 0
     grayscale: bool = False
     tricolor: bool = False
+    sevencolor: bool = False  # Acep e-ink screens.
     pixels_in_byte_share_row: bool = False
     reverse_pixels_in_byte: bool = False
     reverse_bytes_in_word: bool = False
     dither: bool = False
+
+
+@dataclass
+class InputPixelStruct:
+    """InputPixel Struct Dataclass"""
+
+    pixel: int = 0
+    x: int = 0
+    y: int = 0
+    tile: int = 0
+    tile_x: int = 0
+    tile_y: int = 0
+
+
+@dataclass
+class OutputPixelStruct:
+    """OutputPixel Struct Dataclass"""
+
+    pixel: int = 0
+    opaque: bool = False
+
+
+@dataclass
+class ColorStruct:
+    """Color Struct Dataclass"""
+
+    rgb888: int = 0
+    cached_colorspace: ColorspaceStruct = None
+    cached_color: int = 0
+    cached_colorspace_grayscale_bit: int = 0
+    cached_colorspace_grayscale: bool = False
+    transparent: bool = False
+
+    def rgba(self) -> tuple[int, int, int, int]:
+        """Return the color as a tuple of red, green, blue, alpha"""
+        return (
+            self.rgb888 >> 16,
+            (self.rgb888 >> 8) & 0xFF,
+            self.rgb888 & 0xFF,
+            0 if self.transparent else 0xFF,
+        )
+
+
+null_transform = TransformStruct()  # Use defaults
index 41ff504058eda9746dd708946aea07cc7c742592..5abfad44296ea7441930e5bd0b372f88888cdf4a 100644 (file)
@@ -17,21 +17,28 @@ displayio for Blinka
 
 """
 
+import struct
 from typing import Union, Optional, Tuple
-from PIL import Image
+from circuitpython_typing import WriteableBuffer
 from ._bitmap import Bitmap
 from ._colorconverter import ColorConverter
 from ._ondiskbitmap import OnDiskBitmap
 from ._shape import Shape
 from ._palette import Palette
-from ._structs import RectangleStruct, TransformStruct
+from ._structs import (
+    InputPixelStruct,
+    OutputPixelStruct,
+    null_transform,
+)
+from ._colorspace import Colorspace
+from ._area import Area
 
 __version__ = "0.0.0+auto.0"
 __repo__ = "https://github.com/adafruit/Adafruit_Blinka_displayio.git"
 
 
 class TileGrid:
-    # pylint: disable=too-many-instance-attributes
+    # pylint: disable=too-many-instance-attributes, too-many-statements
     """Position a grid of tiles sourced from a bitmap and pixel_shader combination. Multiple
     grids can share bitmaps and pixel shaders.
 
@@ -71,10 +78,13 @@ class TileGrid:
         if isinstance(self._pixel_shader, ColorConverter):
             self._pixel_shader._rgba = True  # pylint: disable=protected-access
         self._hidden_tilegrid = False
+        self._hidden_by_parent = False
+        self._rendered_hidden = False
+        self._name = "Tilegrid"
         self._x = x
         self._y = y
-        self._width = width  # Number of Tiles Wide
-        self._height = height  # Number of Tiles High
+        self._width_in_tiles = width
+        self._height_in_tiles = height
         self._transpose_xy = False
         self._flip_x = False
         self._flip_y = False
@@ -96,18 +106,28 @@ class TileGrid:
             raise ValueError("Default Tile is out of range")
         self._pixel_width = width * tile_width
         self._pixel_height = height * tile_height
-        self._tiles = (self._width * self._height) * [default_tile]
-        self._in_group = False
-        self._absolute_transform = TransformStruct(0, 0, 1, 1, 1, False, False, False)
-        self._current_area = RectangleStruct(
-            0, 0, self._pixel_width, self._pixel_height
+        self._tiles = bytearray(
+            (self._width_in_tiles * self._height_in_tiles) * [default_tile]
         )
+        self._in_group = False
+        self._absolute_transform = None
+        self._current_area = Area(0, 0, self._pixel_width, self._pixel_height)
+        self._dirty_area = Area(0, 0, 0, 0)
+        self._previous_area = Area(0xFFFF, 0xFFFF, 0xFFFF, 0xFFFF)
         self._moved = False
+        self._full_change = True
+        self._partial_change = True
+        self._bitmap_width_in_tiles = bitmap_width // tile_width
+        self._tiles_in_bitmap = self._bitmap_width_in_tiles * (
+            bitmap_height // tile_height
+        )
 
     def _update_transform(self, absolute_transform):
         """Update the parent transform and child transforms"""
+        self._in_group = absolute_transform is not None
         self._absolute_transform = absolute_transform
         if self._absolute_transform is not None:
+            self._moved = True
             self._update_current_x()
             self._update_current_y()
 
@@ -116,13 +136,19 @@ class TileGrid:
             width = self._pixel_height
         else:
             width = self._pixel_width
-        if self._absolute_transform.transpose_xy:
+
+        absolute_transform = (
+            null_transform
+            if self._absolute_transform is None
+            else self._absolute_transform
+        )
+
+        if absolute_transform.transpose_xy:
             self._current_area.y1 = (
-                self._absolute_transform.y + self._absolute_transform.dy * self._x
+                absolute_transform.y + absolute_transform.dy * self._x
             )
-            self._current_area.y2 = (
-                self._absolute_transform.y
-                + self._absolute_transform.dy * (self._x + width)
+            self._current_area.y2 = absolute_transform.y + absolute_transform.dy * (
+                self._x + width
             )
             if self._current_area.y2 < self._current_area.y1:
                 self._current_area.y1, self._current_area.y2 = (
@@ -131,11 +157,10 @@ class TileGrid:
                 )
         else:
             self._current_area.x1 = (
-                self._absolute_transform.x + self._absolute_transform.dx * self._x
+                absolute_transform.x + absolute_transform.dx * self._x
             )
-            self._current_area.x2 = (
-                self._absolute_transform.x
-                + self._absolute_transform.dx * (self._x + width)
+            self._current_area.x2 = absolute_transform.x + absolute_transform.dx * (
+                self._x + width
             )
             if self._current_area.x2 < self._current_area.x1:
                 self._current_area.x1, self._current_area.x2 = (
@@ -148,13 +173,19 @@ class TileGrid:
             height = self._pixel_width
         else:
             height = self._pixel_height
-        if self._absolute_transform.transpose_xy:
+
+        absolute_transform = (
+            null_transform
+            if self._absolute_transform is None
+            else self._absolute_transform
+        )
+
+        if absolute_transform.transpose_xy:
             self._current_area.x1 = (
-                self._absolute_transform.x + self._absolute_transform.dx * self._y
+                absolute_transform.x + absolute_transform.dx * self._y
             )
-            self._current_area.x2 = (
-                self._absolute_transform.x
-                + self._absolute_transform.dx * (self._y + height)
+            self._current_area.x2 = absolute_transform.x + absolute_transform.dx * (
+                self._y + height
             )
             if self._current_area.x2 < self._current_area.x1:
                 self._current_area.x1, self._current_area.x2 = (
@@ -163,11 +194,10 @@ class TileGrid:
                 )
         else:
             self._current_area.y1 = (
-                self._absolute_transform.y + self._absolute_transform.dy * self._y
+                absolute_transform.y + absolute_transform.dy * self._y
             )
-            self._current_area.y2 = (
-                self._absolute_transform.y
-                + self._absolute_transform.dy * (self._y + height)
+            self._current_area.y2 = absolute_transform.y + absolute_transform.dy * (
+                self._y + height
             )
             if self._current_area.y2 < self._current_area.y1:
                 self._current_area.y1, self._current_area.y2 = (
@@ -196,107 +226,340 @@ class TileGrid:
         )
         image.putalpha(alpha.convert("L"))
 
-    def _fill_area(self, buffer):
-        # pylint: disable=too-many-locals,too-many-branches,too-many-statements
+    def _fill_area(
+        self,
+        colorspace: Colorspace,
+        area: Area,
+        mask: WriteableBuffer,
+        buffer: WriteableBuffer,
+    ) -> bool:
         """Draw onto the image"""
-        if self._hidden_tilegrid:
-            return
+        # pylint: disable=too-many-locals,too-many-branches,too-many-statements
+
+        # If no tiles are present we have no impact
+        tiles = self._tiles
+
+        if tiles is None or len(tiles) == 0:
+            return False
+
+        if self._hidden_tilegrid or self._hidden_by_parent:
+            return False
+        overlap = Area()  # area, current_area, overlap
+        if not area.compute_overlap(self._current_area, overlap):
+            return False
+        # else:
+        #    print("Checking", area.x1, area.y1, area.x2, area.y2)
+        #    print("Overlap", overlap.x1, overlap.y1, overlap.x2, overlap.y2)
 
         if self._bitmap.width <= 0 or self._bitmap.height <= 0:
-            return
+            return False
+
+        x_stride = 1
+        y_stride = area.width()
+
+        flip_x = self._flip_x
+        flip_y = self._flip_y
+        if self._transpose_xy != self._absolute_transform.transpose_xy:
+            flip_x, flip_y = flip_y, flip_x
+
+        start = 0
+        if (self._absolute_transform.dx < 0) != flip_x:
+            start += (area.x2 - area.x1 - 1) * x_stride
+            x_stride *= -1
+        if (self._absolute_transform.dy < 0) != flip_y:
+            start += (area.y2 - area.y1 - 1) * y_stride
+            y_stride *= -1
+
+        # Track if this layer finishes filling in the given area. We can ignore any remaining
+        # layers at that point.
+        full_coverage = area == overlap
+
+        transformed = Area()
+        area.transform_within(
+            flip_x != (self._absolute_transform.dx < 0),
+            flip_y != (self._absolute_transform.dy < 0),
+            self.transpose_xy != self._absolute_transform.transpose_xy,
+            overlap,
+            self._current_area,
+            transformed,
+        )
 
-        # Copy class variables to local variables in case something changes
-        x = self._x
-        y = self._y
-        width = self._width
-        height = self._height
-        tile_width = self._tile_width
-        tile_height = self._tile_height
-        bitmap_width = self._bitmap.width
-        pixel_width = self._pixel_width
-        pixel_height = self._pixel_height
-        tiles = self._tiles
-        absolute_transform = self._absolute_transform
-        pixel_shader = self._pixel_shader
-        bitmap = self._bitmap
-        tiles = self._tiles
+        start_x = transformed.x1 - self._current_area.x1
+        end_x = transformed.x2 - self._current_area.x1
+        start_y = transformed.y1 - self._current_area.y1
+        end_y = transformed.y2 - self._current_area.y1
+
+        if (self._absolute_transform.dx < 0) != flip_x:
+            x_shift = area.x2 - overlap.x2
+        else:
+            x_shift = overlap.x1 - area.x1
+        if (self._absolute_transform.dy < 0) != flip_y:
+            y_shift = area.y2 - overlap.y2
+        else:
+            y_shift = overlap.y1 - area.y1
+
+        # This untransposes x and y so it aligns with bitmap rows
+        if self._transpose_xy != self._absolute_transform.transpose_xy:
+            x_stride, y_stride = y_stride, x_stride
+            x_shift, y_shift = y_shift, x_shift
+
+        pixels_per_byte = 8 // colorspace.depth
+
+        input_pixel = InputPixelStruct()
+        output_pixel = OutputPixelStruct()
+        for input_pixel.y in range(start_y, end_y):
+            row_start = (
+                start + (input_pixel.y - start_y + y_shift) * y_stride
+            )  # In Pixels
+            local_y = input_pixel.y // self._absolute_transform.scale
+            for input_pixel.x in range(start_x, end_x):
+                # Compute the destination pixel in the buffer and mask based on the transformations
+                offset = (
+                    row_start + (input_pixel.x - start_x + x_shift) * x_stride
+                )  # In Pixels
+
+                # Check the mask first to see if the pixel has already been set
+                if mask[offset // 8] & (1 << (offset % 8)):
+                    continue
+                local_x = input_pixel.x // self._absolute_transform.scale
+                tile_location = (
+                    (local_y // self._tile_height + self._top_left_y)
+                    % self._height_in_tiles
+                ) * self._width_in_tiles + (
+                    local_x // self._tile_width + self._top_left_x
+                ) % self._width_in_tiles
+                input_pixel.tile = tiles[tile_location]
+                input_pixel.tile_x = (
+                    input_pixel.tile % self._bitmap_width_in_tiles
+                ) * self._tile_width + local_x % self._tile_width
+                input_pixel.tile_y = (
+                    input_pixel.tile // self._bitmap_width_in_tiles
+                ) * self._tile_height + local_y % self._tile_height
+
+                output_pixel.pixel = 0
+                input_pixel.pixel = 0
+
+                # We always want to read bitmap pixels by row first and then transpose into
+                # the destination buffer because most bitmaps are row associated.
+                if isinstance(self._bitmap, (Bitmap, Shape, OnDiskBitmap)):
+                    input_pixel.pixel = (
+                        self._bitmap._get_pixel(  # pylint: disable=protected-access
+                            input_pixel.tile_x, input_pixel.tile_y
+                        )
+                    )
+
+                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
+                else:
+                    mask[offset // 8] |= 1 << (offset % 8)
+                    # print("Mask", mask)
+                    if colorspace.depth == 16:
+                        struct.pack_into(
+                            "H",
+                            buffer,
+                            offset * 2,
+                            output_pixel.pixel,
+                        )
+                    elif colorspace.depth == 32:
+                        struct.pack_into(
+                            "I",
+                            buffer,
+                            offset * 4,
+                            output_pixel.pixel,
+                        )
+                    elif colorspace.depth == 8:
+                        buffer[offset] = 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:
+                            width = area.width()
+                            row = offset // width
+                            col = offset % width
+                            # Dividing by pixels_per_byte does truncated division
+                            # even if we multiply it back out
+                            offset = (
+                                col * pixels_per_byte
+                                + (row // pixels_per_byte) * pixels_per_byte * width
+                                + (row % pixels_per_byte)
+                            )
+                        shift = (offset % 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[offset // pixels_per_byte] |= output_pixel.pixel << shift
+
+        return full_coverage
+
+    def _finish_refresh(self):
+        first_draw = self._previous_area.x1 == self._previous_area.x2
+        hidden = self._hidden_tilegrid or self._hidden_by_parent
+        if not first_draw and hidden:
+            self._previous_area.x2 = self._previous_area.x1
+        elif self._moved or first_draw:
+            self._current_area.copy_into(self._previous_area)
 
-        tile_count_x = bitmap_width // tile_width
+        self._moved = False
+        self._full_change = False
+        self._partial_change = False
+        if isinstance(self._pixel_shader, (Palette, ColorConverter)):
+            self._pixel_shader._finish_refresh()  # pylint: disable=protected-access
+        if isinstance(self._bitmap, (Bitmap, Shape)):
+            self._bitmap._finish_refresh()  # pylint: disable=protected-access
+
+    def _get_refresh_areas(self, areas: list[Area]) -> None:
+        # pylint: disable=invalid-name, too-many-branches, too-many-statements
+        first_draw = self._previous_area.x1 == self._previous_area.x2
+        hidden = self._hidden_tilegrid or self._hidden_by_parent
+
+        # Check hidden first because it trumps all other changes
+        if hidden:
+            self._rendered_hidden = True
+            if not first_draw:
+                areas.append(self._previous_area)
+            return
+        if self._moved and not first_draw:
+            self._previous_area.union(self._current_area, self._dirty_area)
+            if self._dirty_area.size() < 2 * self._pixel_width * self._pixel_height:
+                areas.append(self._dirty_area)
+                return
+            areas.append(self._current_area)
+            areas.append(self._previous_area)
+            return
 
-        image = Image.new(
-            "RGBA",
-            (width * tile_width, height * tile_height),
-            (0, 0, 0, 0),
+        tail = areas[-1] if areas else None
+        # If we have an in-memory bitmap, then check it for modifications
+        if isinstance(self._bitmap, Bitmap):
+            self._bitmap._get_refresh_areas(areas)  # pylint: disable=protected-access
+            refresh_area = areas[-1] if areas else None
+            if refresh_area != tail:
+                # Special case a TileGrid that shows a full bitmap and use its
+                # dirty area. Copy it to ours so we can transform it.
+                if self._tiles_in_bitmap == 1:
+                    refresh_area.copy_into(self._dirty_area)
+                    self._partial_change = True
+                else:
+                    self._full_change = True
+        elif isinstance(self._bitmap, Shape):
+            self._bitmap._get_refresh_areas(areas)  # pylint: disable=protected-access
+            refresh_area = areas[-1] if areas else None
+            if refresh_area != tail:
+                refresh_area.copy_into(self._dirty_area)
+                self._partial_change = True
+
+        self._full_change = self._full_change or (
+            isinstance(self._pixel_shader, (Palette, ColorConverter))
+            and self._pixel_shader._needs_refresh  # pylint: disable=protected-access
         )
+        if self._full_change or first_draw:
+            areas.append(self._current_area)
+            return
 
-        for tile_x in range(width):
-            for tile_y in range(height):
-                tile_index = tiles[tile_y * width + tile_x]
-                tile_index_x = tile_index % tile_count_x
-                tile_index_y = tile_index // tile_count_x
-                tile_image = bitmap._image  # pylint: disable=protected-access
-                if isinstance(pixel_shader, Palette):
-                    tile_image = tile_image.copy().convert("P")
-                    self._apply_palette(tile_image)
-                    tile_image = tile_image.convert("RGBA")
-                    self._add_alpha(tile_image)
-                elif isinstance(pixel_shader, ColorConverter):
-                    # This will be needed for eInks, grayscale, and monochrome displays
-                    pass
-                image.alpha_composite(
-                    tile_image,
-                    dest=(tile_x * tile_width, tile_y * tile_height),
-                    source=(
-                        tile_index_x * tile_width,
-                        tile_index_y * tile_height,
-                        tile_index_x * tile_width + tile_width,
-                        tile_index_y * tile_height + tile_height,
-                    ),
+        if self._partial_change:
+            x = self._x
+            y = self._y
+            if self._absolute_transform.transpose_xy:
+                x, y = y, x
+            x1 = self._dirty_area.x1
+            x2 = self._dirty_area.x2
+            if self._flip_x:
+                x1 = self._pixel_width - x1
+                x2 = self._pixel_width - x2
+            y1 = self._dirty_area.y1
+            y2 = self._dirty_area.y2
+            if self._flip_y:
+                y1 = self._pixel_height - y1
+                y2 = self._pixel_height - y2
+            if self._transpose_xy != self._absolute_transform.transpose_xy:
+                x1, y1 = y1, x1
+                x2, y2 = y2, x2
+            self._dirty_area.x1 = (
+                self._absolute_transform.x + self._absolute_transform.dx * (x + x1)
+            )
+            self._dirty_area.y1 = (
+                self._absolute_transform.y + self._absolute_transform.dy * (y + y1)
+            )
+            self._dirty_area.x2 = (
+                self._absolute_transform.x + self._absolute_transform.dx * (x + x2)
+            )
+            self._dirty_area.y2 = (
+                self._absolute_transform.y + self._absolute_transform.dy * (y + y2)
+            )
+            if self._dirty_area.y2 < self._dirty_area.y1:
+                self._dirty_area.y1, self._dirty_area.y2 = (
+                    self._dirty_area.y2,
+                    self._dirty_area.y1,
                 )
-
-        if absolute_transform is not None:
-            if absolute_transform.scale > 1:
-                image = image.resize(
-                    (
-                        int(pixel_width * absolute_transform.scale),
-                        int(
-                            pixel_height * absolute_transform.scale,
-                        ),
-                    ),
-                    resample=Image.NEAREST,
+            if self._dirty_area.x2 < self._dirty_area.x1:
+                self._dirty_area.x1, self._dirty_area.x2 = (
+                    self._dirty_area.x2,
+                    self._dirty_area.x1,
                 )
-            if absolute_transform.mirror_x != self._flip_x:
-                image = image.transpose(Image.FLIP_LEFT_RIGHT)
-            if absolute_transform.mirror_y != self._flip_y:
-                image = image.transpose(Image.FLIP_TOP_BOTTOM)
-            if absolute_transform.transpose_xy != self._transpose_xy:
-                image = image.transpose(Image.TRANSPOSE)
-            x *= absolute_transform.dx
-            y *= absolute_transform.dy
-            x += absolute_transform.x
-            y += absolute_transform.y
-
-        source_x = source_y = 0
-        if x < 0:
-            source_x = round(0 - x)
-            x = 0
-        if y < 0:
-            source_y = round(0 - y)
-            y = 0
-
-        x = round(x)
-        y = round(y)
-
-        if (
-            x <= buffer.width
-            and y <= buffer.height
-            and source_x <= image.width
-            and source_y <= image.height
-        ):
-            buffer.alpha_composite(image, (x, y), source=(source_x, source_y))
+            areas.append(self._dirty_area)
+
+    def _set_hidden(self, hidden: bool) -> None:
+        self._hidden_tilegrid = hidden
+        self._rendered_hidden = False
+        if not hidden:
+            self._full_change = True
+
+    def _set_hidden_by_parent(self, hidden: bool) -> None:
+        self._hidden_by_parent = hidden
+        self._rendered_hidden = False
+        if not hidden:
+            self._full_change = True
+
+    def _get_rendered_hidden(self) -> bool:
+        return self._rendered_hidden
+
+    def _set_all_tiles(self, tile_index: int) -> None:
+        """Set all tiles to the given tile index"""
+        if tile_index >= self._tiles_in_bitmap:
+            raise ValueError("Tile index out of bounds")
+        self._tiles = bytearray(
+            (self._width_in_tiles * self._height_in_tiles) * [tile_index]
+        )
+        self._full_change = True
 
-    def _finish_refresh(self):
-        pass
+    def _set_tile(self, x: int, y: int, tile_index: int) -> None:
+        self._tiles[y * self._width_in_tiles + x] = tile_index
+        temp_area = Area()
+        if not self._partial_change:
+            tile_area = self._dirty_area
+        else:
+            tile_area = temp_area
+        top_x = (x - self._top_left_x) % self._width_in_tiles
+        if top_x < 0:
+            top_x += self._width_in_tiles
+        tile_area.x1 = top_x * self._tile_width
+        tile_area.x2 = tile_area.x1 + self._tile_width
+        top_y = (y - self._top_left_y) % self._height_in_tiles
+        if top_y < 0:
+            top_y += self._height_in_tiles
+        tile_area.y1 = top_y * self._tile_height
+        tile_area.y2 = tile_area.y1 + self._tile_height
+
+        if self._partial_change:
+            self._dirty_area.union(temp_area, self._dirty_area)
+
+        self._partial_change = True
+
+    def _set_top_left(self, x: int, y: int) -> None:
+        self._top_left_x = x
+        self._top_left_y = y
+        self._full_change = True
 
     @property
     def hidden(self) -> bool:
@@ -308,7 +571,8 @@ class TileGrid:
     def hidden(self, value: bool):
         if not isinstance(value, (bool, int)):
             raise ValueError("Expecting a boolean or integer value")
-        self._hidden_tilegrid = bool(value)
+        value = bool(value)
+        self._set_hidden(value)
 
     @property
     def x(self) -> int:
@@ -320,8 +584,10 @@ class TileGrid:
         if not isinstance(value, int):
             raise TypeError("X should be a integer type")
         if self._x != value:
+            self._moved = True
             self._x = value
-            self._update_current_x()
+            if self._absolute_transform is not None:
+                self._update_current_x()
 
     @property
     def y(self) -> int:
@@ -333,8 +599,10 @@ class TileGrid:
         if not isinstance(value, int):
             raise TypeError("Y should be a integer type")
         if self._y != value:
+            self._moved = True
             self._y = value
-            self._update_current_y()
+            if self._absolute_transform is not None:
+                self._update_current_y()
 
     @property
     def flip_x(self) -> bool:
@@ -347,6 +615,7 @@ class TileGrid:
             raise TypeError("Flip X should be a boolean type")
         if self._flip_x != value:
             self._flip_x = value
+            self._full_change = True
 
     @property
     def flip_y(self) -> bool:
@@ -359,6 +628,7 @@ class TileGrid:
             raise TypeError("Flip Y should be a boolean type")
         if self._flip_y != value:
             self._flip_y = value
+            self._full_change = True
 
     @property
     def transpose_xy(self) -> bool:
@@ -368,13 +638,17 @@ class TileGrid:
         return self._transpose_xy
 
     @transpose_xy.setter
-    def transpose_xy(self, value: bool):
+    def transpose_xy(self, value: bool) -> None:
         if not isinstance(value, bool):
             raise TypeError("Transpose XY should be a boolean type")
         if self._transpose_xy != value:
             self._transpose_xy = value
+            if self._pixel_width == self._pixel_height:
+                self._full_change = True
+                return
             self._update_current_x()
             self._update_current_y()
+            self._moved = True
 
     @property
     def pixel_shader(self) -> Union[ColorConverter, Palette]:
@@ -391,6 +665,7 @@ class TileGrid:
             )
 
         self._pixel_shader = new_pixel_shader
+        self._full_change = True
 
     @property
     def bitmap(self) -> Union[Bitmap, OnDiskBitmap, Shape]:
@@ -415,44 +690,49 @@ class TileGrid:
             raise ValueError("New bitmap must be same size as old bitmap")
 
         self._bitmap = new_bitmap
+        self._full_change = True
 
     def _extract_and_check_index(self, index):
         if isinstance(index, (tuple, list)):
             x = index[0]
             y = index[1]
-            index = y * self._width + x
+            index = y * self._width_in_tiles + x
         elif isinstance(index, int):
-            x = index % self._width
-            y = index // self._width
-        if x > self._width or y > self._height or index >= len(self._tiles):
+            x = index % self._width_in_tiles
+            y = index // self._width_in_tiles
+        if (
+            x > self._width_in_tiles
+            or y > self._height_in_tiles
+            or index >= len(self._tiles)
+        ):
             raise ValueError("Tile index out of bounds")
-        return index
+        return x, y
 
     def __getitem__(self, index: Union[Tuple[int, int], int]) -> int:
         """Returns the tile index at the given index. The index can either be
         an x,y tuple or an int equal to ``y * width + x``'.
         """
-        index = self._extract_and_check_index(index)
-        return self._tiles[index]
+        x, y = self._extract_and_check_index(index)
+        return self._tiles[y * self._width_in_tiles + x]
 
     def __setitem__(self, index: Union[Tuple[int, int], int], value: int) -> None:
         """Sets the tile index at the given index. The index can either be
         an x,y tuple or an int equal to ``y * width + x``.
         """
-        index = self._extract_and_check_index(index)
+        x, y = self._extract_and_check_index(index)
         if not 0 <= value <= 255:
             raise ValueError("Tile value out of bounds")
-        self._tiles[index] = value
+        self._set_tile(x, y, value)
 
     @property
     def width(self) -> int:
         """Width in tiles"""
-        return self._width
+        return self._width_in_tiles
 
     @property
     def height(self) -> int:
         """Height in tiles"""
-        return self._height
+        return self._height_in_tiles
 
     @property
     def tile_width(self) -> int:
diff --git a/displayio/resources/__init__.py b/displayio/resources/__init__.py
new file mode 100644 (file)
index 0000000..780b7a6
--- /dev/null
@@ -0,0 +1,3 @@
+# SPDX-FileCopyrightText: 2023 Melissa LeBlanc-Williams for Adafruit Industries
+#
+# SPDX-License-Identifier: MIT
diff --git a/displayio/resources/ter-u12n.pbm b/displayio/resources/ter-u12n.pbm
new file mode 100644 (file)
index 0000000..9805237
Binary files /dev/null and b/displayio/resources/ter-u12n.pbm differ
diff --git a/displayio/resources/ter-u12n.pbm.license b/displayio/resources/ter-u12n.pbm.license
new file mode 100644 (file)
index 0000000..9a506cd
--- /dev/null
@@ -0,0 +1,3 @@
+SPDX-FileCopyrightText: 2023 Melissa LeBlanc-Williams, written for Adafruit Industries
+
+SPDX-License-Identifier: MIT
diff --git a/displayio/resources/ter-u12n.pil b/displayio/resources/ter-u12n.pil
new file mode 100644 (file)
index 0000000..b13bdb0
Binary files /dev/null and b/displayio/resources/ter-u12n.pil differ
diff --git a/displayio/resources/ter-u12n.pil.license b/displayio/resources/ter-u12n.pil.license
new file mode 100644 (file)
index 0000000..9a506cd
--- /dev/null
@@ -0,0 +1,3 @@
+SPDX-FileCopyrightText: 2023 Melissa LeBlanc-Williams, written for Adafruit Industries
+
+SPDX-License-Identifier: MIT
index a9200c9051e8775671c3983e9edd5eb4fa3a8f80..2eddfa47b21826ece12944951c773cf94ae17d02 100644 (file)
--- a/fontio.py
+++ b/fontio.py
@@ -17,6 +17,7 @@ fontio for Blinka
 
 """
 
+import os
 from typing import Union, Tuple, Optional
 from PIL import ImageFont
 from displayio import Bitmap
@@ -29,6 +30,8 @@ except ImportError:
 __version__ = "0.0.0+auto.0"
 __repo__ = "https://github.com/adafruit/Adafruit_Blinka_displayio.git"
 
+DEFAULT_FONT = "displayio/resources/ter-u12n.pil"
+
 
 class FontProtocol(Protocol):
     """A protocol shared by `BuiltinFont` and classes in ``adafruit_bitmap_font``"""
@@ -52,7 +55,7 @@ class BuiltinFont:
     """Simulate a font built into CircuitPython"""
 
     def __init__(self):
-        self._font = ImageFont.load_default()
+        self._font = ImageFont.load(os.path.dirname(__file__) + "/" + DEFAULT_FONT)
         self._generate_bitmap(0x20, 0x7E)
 
     def _generate_bitmap(self, start_range, end_range):
index 59fb08c91bc06202762f00eb4de4749b79009c33..4e828cce49253a4a89cb07b8a5f088c6099df624 100644 (file)
@@ -17,8 +17,9 @@ paralleldisplay for Blinka
 
 """
 
+from typing import Optional
 import microcontroller
-import circuitpython_typing
+from circuitpython_typing import ReadableBuffer
 
 __version__ = "0.0.0+auto.0"
 __repo__ = "https://github.com/adafruit/Adafruit_Blinka_displayio.git"
@@ -37,8 +38,8 @@ class ParallelBus:
         command: microcontroller.Pin,
         chip_select: microcontroller.Pin,
         write: microcontroller.Pin,
-        read: microcontroller.Pin,
-        reset: microcontroller.Pin,
+        read: Optional[microcontroller.Pin],
+        reset: Optional[microcontroller.Pin] = None,
         frequency: int = 30000000,
     ):
         # pylint: disable=unnecessary-pass
@@ -61,9 +62,26 @@ class ParallelBus:
         """
         raise NotImplementedError("ParallelBus reset has not been implemented yet")
 
-    def send(self, command: int, data: circuitpython_typing.ReadableBuffer) -> None:
+    def send(self, command: int, data: ReadableBuffer) -> None:
         """Sends the given command value followed by the full set of data. Display state,
         such as vertical scroll, set via ``send`` may or may not be reset once the code is
         done.
         """
         raise NotImplementedError("ParallelBus send has not been implemented yet")
+
+    def _send(
+        self,
+        _data_type: int,
+        _chip_select: int,
+        _data: ReadableBuffer,
+    ) -> None:
+        pass
+
+    def _free(self) -> bool:
+        """Attempt to free the bus and return False if busy"""
+
+    def _begin_transaction(self) -> bool:
+        pass
+
+    def _end_transaction(self) -> None:
+        pass
index c42ccf2b705f4852340a897793e2d82aa3366699..27df04912c916e92ccc22e92f665575da2a13d85 100644 (file)
@@ -5,4 +5,3 @@
 Adafruit-Blinka>=7.0.0
 adafruit-circuitpython-typing
 pillow>=9.2.0
-numpy
index 0f5285713619be08abafe6e28bb14771a2799700..e4d2d1429e8be5d24ba9b476cd4cdf770bc56aa1 100644 (file)
--- a/setup.py
+++ b/setup.py
@@ -22,7 +22,6 @@ requirements = [
     "Adafruit-Blinka>=7.0.0",
     "adafruit-circuitpython-typing",
     "pillow",
-    "numpy",
 ]
 
 if sys.version_info > (3, 9):
index ce177e80840bbd25cf89271459ee68cb65272f93..c346ceb7a97f7a704b70aa5430cc076a57319a52 100644 (file)
@@ -17,7 +17,6 @@ terminalio for Blinka
 
 """
 
-import sys  # pylint: disable=unused-import
 import fontio
 
 __version__ = "0.0.0+auto.0"
@@ -26,5 +25,7 @@ __repo__ = "https://github.com/adafruit/Adafruit_Blinka_displayio.git"
 FONT = fontio.BuiltinFont()
 
 # TODO: Tap into stdout to get the REPL
+# Look at how Adafruit_Python_Shell's run_command works as an option
+# Additionally, adding supervisor to Blinka may be helpful to keep track of REPL output
 # sys.stdout = open('out.dat', 'w')
 # sys.stdout.close()