diff --git a/.pylintrc b/.pylintrc index 40208c3..f945e92 100644 --- 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 diff --git a/displayio/__init__.py b/displayio/__init__.py index a908e1a..55bb1d5 100644 --- a/displayio/__init__.py +++ b/displayio/__init__.py @@ -16,7 +16,7 @@ * Author(s): Melissa LeBlanc-Williams """ - +import threading from typing import Union from ._fourwire import FourWire from ._i2cdisplay import I2CDisplay @@ -30,19 +30,68 @@ 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() diff --git a/displayio/_area.py b/displayio/_area.py index 3eba700..f7272dd 100644 --- a/displayio/_area.py +++ b/displayio/_area.py @@ -25,9 +25,9 @@ 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 @@ def __init__(self, x1: int = 0, y1: int = 0, x2: int = 0, y2: int = 0): 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 @@ def _compute_overlap(self, other, overlap) -> bool: 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 @@ def _union(self, other, union): 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 @@ def __eq__(self, other): ) @staticmethod - def _transform_within( + def transform_within( mirror_x: bool, mirror_y: bool, transpose_xy: bool, @@ -121,6 +130,7 @@ def _transform_within( 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: diff --git a/displayio/_bitmap.py b/displayio/_bitmap.py index c61de47..07eb7af 100644 --- a/displayio/_bitmap.py +++ b/displayio/_bitmap.py @@ -18,16 +18,37 @@ """ 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 @@ def __init__(self, width: int, height: int, value_count: int): 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 @@ def __init__(self, width: int, height: int, value_count: int): 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 @@ def __init__(self, width: int, height: int, value_count: int): ): 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 @@ def __getitem__(self, index: Union[Tuple[int, int], int]) -> int: 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(" None: """ @@ -92,21 +165,39 @@ def __setitem__(self, index: Union[Tuple[int, int], int], value: int) -> None: 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(" 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 @@ def blit( 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 @@ def blit( 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: diff --git a/displayio/_colorconverter.py b/displayio/_colorconverter.py index 01c70d6..bba5316 100644 --- a/displayio/_colorconverter.py +++ b/displayio/_colorconverter.py @@ -21,6 +21,8 @@ __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 @@ def __init__( :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 @@ def convert(self, color: int) -> int: 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 @@ def make_transparent(self, color: int) -> None: """ 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 diff --git a/displayio/_constants.py b/displayio/_constants.py index 938b49c..19ecd0f 100644 --- a/displayio/_constants.py +++ b/displayio/_constants.py @@ -18,3 +18,8 @@ BACKLIGHT_IN_OUT = 1 BACKLIGHT_PWM = 2 + +NO_COMMAND = 0x100 +CIRCUITPY_DISPLAY_LIMIT = 1 + +DELAY = 0x80 diff --git a/displayio/_display.py b/displayio/_display.py index 241a0aa..8551f8c 100644 --- a/displayio/_display.py +++ b/displayio/_display.py @@ -18,20 +18,16 @@ """ 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 @@ 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 @@ def __init__( 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 @@ def __init__( 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 @@ def __init__( 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 @@ def _initialize(self, init_sequence): 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 @@ def _initialize(self, init_sequence): 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 @@ def refresh( 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 @@ def refresh( 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 @@ def auto_refresh(self) -> bool: @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 @@ def rotation(self) -> int: @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 diff --git a/displayio/_displaycore.py b/displayio/_displaycore.py index a50d094..0672513 100644 --- a/displayio/_displaycore.py +++ b/displayio/_displaycore.py @@ -22,20 +22,28 @@ __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 @@ def __init__( 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 @@ def __init__( 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 @@ def __init__( 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 @@ def start_refresh(self) -> bool: # 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 @@ def clip_area(self, area: Area, clipped: Area) -> bool: 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 @@ def get_width(self) -> int: """ 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] """ diff --git a/displayio/_epaperdisplay.py b/displayio/_epaperdisplay.py index 686f8bb..114506c 100644 --- a/displayio/_epaperdisplay.py +++ b/displayio/_epaperdisplay.py @@ -19,7 +19,7 @@ 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 @@ def __init__( :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 diff --git a/displayio/_fourwire.py b/displayio/_fourwire.py index 31b1be5..61be810 100644 --- a/displayio/_fourwire.py +++ b/displayio/_fourwire.py @@ -22,7 +22,7 @@ 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 @@ def reset(self) -> None: def send( self, command, - data: circuitpython_typing.ReadableBuffer, + data: ReadableBuffer, *, toggle_every_byte: bool = False, ) -> None: @@ -120,7 +120,7 @@ def _send( 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 @@ def _send( 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() diff --git a/displayio/_group.py b/displayio/_group.py index 505a3bf..a5c6688 100644 --- a/displayio/_group.py +++ b/displayio/_group.py @@ -19,14 +19,18 @@ 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 @@ def __init__(self, *, scale: int = 1, x: int = 0, y: int = 0): 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 @@ def __delitem__(self, index: int) -> None: """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 @@ def hidden(self) -> bool: 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 @@ def y(self, value: int): 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 index 0000000..2d77730 --- /dev/null +++ b/displayio/_helpers.py @@ -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 diff --git a/displayio/_i2cdisplay.py b/displayio/_i2cdisplay.py index 2647659..9611fb0 100644 --- a/displayio/_i2cdisplay.py +++ b/displayio/_i2cdisplay.py @@ -23,7 +23,7 @@ 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 @@ def __init__(self, i2c_bus: busio.I2C, *, device_address: int, reset=None): 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 @@ def reset(self) -> None: 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 @@ def send(self, command: int, data: circuitpython_typing.ReadableBuffer) -> None: 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 @@ def _send( 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.""" diff --git a/displayio/_ondiskbitmap.py b/displayio/_ondiskbitmap.py index 02647d1..30598ef 100644 --- a/displayio/_ondiskbitmap.py +++ b/displayio/_ondiskbitmap.py @@ -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 @@ * 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 @@ 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 + `_. + + .. 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 diff --git a/displayio/_palette.py b/displayio/_palette.py index 969c769..81f3bee 100644 --- a/displayio/_palette.py +++ b/displayio/_palette.py @@ -18,42 +18,38 @@ """ 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 @@ def _make_color(self, value, transparent=False): 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 @@ def __len__(self) -> int: 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 @@ def __setitem__( (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 diff --git a/displayio/_shape.py b/displayio/_shape.py index 946919d..8545cf1 100644 --- a/displayio/_shape.py +++ b/displayio/_shape.py @@ -19,6 +19,8 @@ """ 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) diff --git a/displayio/_structs.py b/displayio/_structs.py index 955b9b4..3603295 100644 --- a/displayio/_structs.py +++ b/displayio/_structs.py @@ -23,16 +23,6 @@ __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 diff --git a/displayio/_tilegrid.py b/displayio/_tilegrid.py index 41ff504..5abfad4 100644 --- a/displayio/_tilegrid.py +++ b/displayio/_tilegrid.py @@ -17,21 +17,28 @@ """ +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 @@ def __init__( 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 @@ def __init__( 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 @@ def _update_current_x(self): 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 @@ def _update_current_x(self): ) 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 @@ def _update_current_y(self): 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 @@ def _update_current_y(self): ) 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 @@ def _add_alpha(self, image): ) 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 @@ def hidden(self) -> bool: 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 @@ def x(self, value: int): 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 @@ def y(self, value: int): 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 @@ def flip_x(self, value: bool): 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 @@ def flip_y(self, value: bool): 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 @@ def transpose_xy(self) -> bool: 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 @@ def pixel_shader(self, new_pixel_shader: Union[ColorConverter, Palette]) -> None ) self._pixel_shader = new_pixel_shader + self._full_change = True @property def bitmap(self) -> Union[Bitmap, OnDiskBitmap, Shape]: @@ -415,44 +690,49 @@ def bitmap(self, new_bitmap: Union[Bitmap, OnDiskBitmap, Shape]) -> None: 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 index 0000000..780b7a6 --- /dev/null +++ b/displayio/resources/__init__.py @@ -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 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 index 0000000..9a506cd --- /dev/null +++ b/displayio/resources/ter-u12n.pbm.license @@ -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 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 index 0000000..9a506cd --- /dev/null +++ b/displayio/resources/ter-u12n.pil.license @@ -0,0 +1,3 @@ +SPDX-FileCopyrightText: 2023 Melissa LeBlanc-Williams, written for Adafruit Industries + +SPDX-License-Identifier: MIT diff --git a/fontio.py b/fontio.py index a9200c9..2eddfa4 100644 --- a/fontio.py +++ b/fontio.py @@ -17,6 +17,7 @@ """ +import os from typing import Union, Tuple, Optional from PIL import ImageFont from displayio import Bitmap @@ -29,6 +30,8 @@ __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): diff --git a/paralleldisplay.py b/paralleldisplay.py index 59fb08c..4e828cc 100644 --- a/paralleldisplay.py +++ b/paralleldisplay.py @@ -17,8 +17,9 @@ """ +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 @@ def __init__( 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 @@ def reset(self) -> None: """ 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 diff --git a/requirements.txt b/requirements.txt index c42ccf2..27df049 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,4 +5,3 @@ Adafruit-Blinka>=7.0.0 adafruit-circuitpython-typing pillow>=9.2.0 -numpy diff --git a/setup.py b/setup.py index 0f52857..e4d2d14 100644 --- a/setup.py +++ b/setup.py @@ -22,7 +22,6 @@ "Adafruit-Blinka>=7.0.0", "adafruit-circuitpython-typing", "pillow", - "numpy", ] if sys.version_info > (3, 9): diff --git a/terminalio.py b/terminalio.py index ce177e8..c346ceb 100644 --- a/terminalio.py +++ b/terminalio.py @@ -17,7 +17,6 @@ """ -import sys # pylint: disable=unused-import import fontio __version__ = "0.0.0+auto.0" @@ -26,5 +25,7 @@ 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()