# SPDX-FileCopyrightText: 2018 Kattni Rembor, Melissa LeBlanc-Williams # and Tony DiCola, for Adafruit Industries. # Original file created by Damien P. George # # SPDX-License-Identifier: MIT """ `adafruit_framebuf` ==================================================== CircuitPython pure-python framebuf module, based on the micropython framebuf module. Implementation Notes -------------------- **Hardware:** * `Adafruit SSD1306 OLED displays `_ * `Adafruit HT16K33 Matrix displays `_ **Software and Dependencies:** * Adafruit CircuitPython firmware for the supported boards: https://github.com/adafruit/circuitpython/releases """ __version__ = "0.0.0+auto.0" __repo__ = "https://github.com/adafruit/Adafruit_CircuitPython_framebuf.git" import os import struct # Framebuf format constants: MVLSB = 0 # Single bit displays (like SSD1306 OLED) RGB565 = 1 # 16-bit color displays GS4_HMSB = 2 # Unimplemented! MHMSB = 3 # Single bit displays like the Sharp Memory RGB888 = 4 # Neopixels and Dotstars GS2_HMSB = 5 # 2-bit color displays like the HT16K33 8x8 Matrix class GS2HMSBFormat: """GS2HMSBFormat""" @staticmethod def set_pixel(framebuf, x, y, color): """Set a given pixel to a color.""" index = (y * framebuf.stride + x) >> 2 pixel = framebuf.buf[index] shift = (x & 0b11) << 1 mask = 0b11 << shift color = (color & 0b11) << shift framebuf.buf[index] = color | (pixel & (~mask)) @staticmethod def get_pixel(framebuf, x, y): """Get the color of a given pixel""" index = (y * framebuf.stride + x) >> 2 pixel = framebuf.buf[index] shift = (x & 0b11) << 1 return (pixel >> shift) & 0b11 @staticmethod def fill(framebuf, color): """completely fill/clear the buffer with a color""" if color: bits = color & 0b11 fill = (bits << 6) | (bits << 4) | (bits << 2) | (bits << 0) else: fill = 0x00 framebuf.buf = [fill for i in range(len(framebuf.buf))] @staticmethod def rect(framebuf, x, y, width, height, color): """Draw the outline of a rectangle at the given location, size and color.""" # pylint: disable=too-many-arguments for _x in range(x, x + width): for _y in range(y, y + height): if _x in [x, x + width] or _y in [y, y + height]: GS2HMSBFormat.set_pixel(framebuf, _x, _y, color) @staticmethod def fill_rect(framebuf, x, y, width, height, color): """Draw the outline and interior of a rectangle at the given location, size and color.""" # pylint: disable=too-many-arguments for _x in range(x, x + width): for _y in range(y, y + height): GS2HMSBFormat.set_pixel(framebuf, _x, _y, color) class MHMSBFormat: """MHMSBFormat""" @staticmethod def set_pixel(framebuf, x, y, color): """Set a given pixel to a color.""" index = (y * framebuf.stride + x) // 8 offset = 7 - x & 0x07 framebuf.buf[index] = (framebuf.buf[index] & ~(0x01 << offset)) | ( (color != 0) << offset ) @staticmethod def get_pixel(framebuf, x, y): """Get the color of a given pixel""" index = (y * framebuf.stride + x) // 8 offset = 7 - x & 0x07 return (framebuf.buf[index] >> offset) & 0x01 @staticmethod def fill(framebuf, color): """completely fill/clear the buffer with a color""" if color: fill = 0xFF else: fill = 0x00 for i in range(len(framebuf.buf)): # pylint: disable=consider-using-enumerate framebuf.buf[i] = fill @staticmethod def fill_rect(framebuf, x, y, width, height, color): """Draw a rectangle at the given location, size and color. The ``fill_rect`` method draws both the outline and interior.""" # pylint: disable=too-many-arguments for _x in range(x, x + width): offset = 7 - _x & 0x07 for _y in range(y, y + height): index = (_y * framebuf.stride + _x) // 8 framebuf.buf[index] = (framebuf.buf[index] & ~(0x01 << offset)) | ( (color != 0) << offset ) class MVLSBFormat: """MVLSBFormat""" @staticmethod def set_pixel(framebuf, x, y, color): """Set a given pixel to a color.""" index = (y >> 3) * framebuf.stride + x offset = y & 0x07 framebuf.buf[index] = (framebuf.buf[index] & ~(0x01 << offset)) | ( (color != 0) << offset ) @staticmethod def get_pixel(framebuf, x, y): """Get the color of a given pixel""" index = (y >> 3) * framebuf.stride + x offset = y & 0x07 return (framebuf.buf[index] >> offset) & 0x01 @staticmethod def fill(framebuf, color): """completely fill/clear the buffer with a color""" if color: fill = 0xFF else: fill = 0x00 for i in range(len(framebuf.buf)): # pylint: disable=consider-using-enumerate framebuf.buf[i] = fill @staticmethod def fill_rect(framebuf, x, y, width, height, color): """Draw a rectangle at the given location, size and color. The ``fill_rect`` method draws both the outline and interior.""" # pylint: disable=too-many-arguments while height > 0: index = (y >> 3) * framebuf.stride + x offset = y & 0x07 for w_w in range(width): framebuf.buf[index + w_w] = ( framebuf.buf[index + w_w] & ~(0x01 << offset) ) | ((color != 0) << offset) y += 1 height -= 1 class RGB565Format: """ This class implements the RGB565 format It assumes a little-endian byte order in the frame buffer """ @staticmethod def color_to_rgb565(color): """Convert a color in either tuple or 24 bit integer form to RGB565, and return as two bytes""" if isinstance(color, tuple): hibyte = (color[0] & 0xF8) | (color[1] >> 5) lobyte = ((color[1] << 5) & 0xE0) | (color[2] >> 3) else: hibyte = ((color >> 16) & 0xF8) | ((color >> 13) & 0x07) lobyte = ((color >> 5) & 0xE0) | ((color >> 3) & 0x1F) return bytes([lobyte, hibyte]) def set_pixel(self, framebuf, x, y, color): """Set a given pixel to a color.""" index = (y * framebuf.stride + x) * 2 framebuf.buf[index : index + 2] = self.color_to_rgb565(color) @staticmethod def get_pixel(framebuf, x, y): """Get the color of a given pixel""" index = (y * framebuf.stride + x) * 2 lobyte, hibyte = framebuf.buf[index : index + 2] r = hibyte & 0xF8 g = ((hibyte & 0x07) << 5) | ((lobyte & 0xE0) >> 5) b = (lobyte & 0x1F) << 3 return (r << 16) | (g << 8) | b def fill(self, framebuf, color): """completely fill/clear the buffer with a color""" rgb565_color = self.color_to_rgb565(color) for i in range(0, len(framebuf.buf), 2): framebuf.buf[i : i + 2] = rgb565_color def fill_rect(self, framebuf, x, y, width, height, color): """Draw a rectangle at the given location, size and color. The ``fill_rect`` method draws both the outline and interior.""" # pylint: disable=too-many-arguments rgb565_color = self.color_to_rgb565(color) for _y in range(2 * y, 2 * (y + height), 2): offset2 = _y * framebuf.stride for _x in range(2 * x, 2 * (x + width), 2): index = offset2 + _x framebuf.buf[index : index + 2] = rgb565_color class RGB888Format: """RGB888Format""" @staticmethod def set_pixel(framebuf, x, y, color): """Set a given pixel to a color.""" index = (y * framebuf.stride + x) * 3 if isinstance(color, tuple): framebuf.buf[index : index + 3] = bytes(color) else: framebuf.buf[index : index + 3] = bytes( ((color >> 16) & 255, (color >> 8) & 255, color & 255) ) @staticmethod def get_pixel(framebuf, x, y): """Get the color of a given pixel""" index = (y * framebuf.stride + x) * 3 return ( (framebuf.buf[index] << 16) | (framebuf.buf[index + 1] << 8) | framebuf.buf[index + 2] ) @staticmethod def fill(framebuf, color): """completely fill/clear the buffer with a color""" fill = (color >> 16) & 255, (color >> 8) & 255, color & 255 for i in range(0, len(framebuf.buf), 3): framebuf.buf[i : i + 3] = bytes(fill) @staticmethod def fill_rect(framebuf, x, y, width, height, color): """Draw a rectangle at the given location, size and color. The ``fill_rect`` method draws both the outline and interior.""" # pylint: disable=too-many-arguments fill = (color >> 16) & 255, (color >> 8) & 255, color & 255 for _x in range(x, x + width): for _y in range(y, y + height): index = (_y * framebuf.stride + _x) * 3 framebuf.buf[index : index + 3] = bytes(fill) class FrameBuffer: """FrameBuffer object. :param buf: An object with a buffer protocol which must be large enough to contain every pixel defined by the width, height and format of the FrameBuffer. :param width: The width of the FrameBuffer in pixel :param height: The height of the FrameBuffer in pixel :param buf_format: Specifies the type of pixel used in the FrameBuffer; permissible values are listed under Constants below. These set the number of bits used to encode a color value and the layout of these bits in ``buf``. Where a color value c is passed to a method, c is a small integer with an encoding that is dependent on the format of the FrameBuffer. :param stride: The number of pixels between each horizontal line of pixels in the FrameBuffer. This defaults to ``width`` but may need adjustments when implementing a FrameBuffer within another larger FrameBuffer or screen. The ``buf`` size must accommodate an increased step size. """ def __init__(self, buf, width, height, buf_format=MVLSB, stride=None): # pylint: disable=too-many-arguments self.buf = buf self.width = width self.height = height self.stride = stride self._font = None if self.stride is None: self.stride = width if buf_format == MVLSB: self.format = MVLSBFormat() elif buf_format == MHMSB: self.format = MHMSBFormat() elif buf_format == RGB888: self.format = RGB888Format() elif buf_format == RGB565: self.format = RGB565Format() elif buf_format == GS2_HMSB: self.format = GS2HMSBFormat() else: raise ValueError("invalid format") self._rotation = 0 @property def rotation(self): """The rotation setting of the display, can be one of (0, 1, 2, 3)""" return self._rotation @rotation.setter def rotation(self, val): if not val in (0, 1, 2, 3): raise RuntimeError("Bad rotation setting") self._rotation = val def fill(self, color): """Fill the entire FrameBuffer with the specified color.""" self.format.fill(self, color) def fill_rect(self, x, y, width, height, color): """Draw a rectangle at the given location, size and color. The ``fill_rect`` method draws both the outline and interior.""" # pylint: disable=too-many-arguments, too-many-boolean-expressions self.rect(x, y, width, height, color, fill=True) def pixel(self, x, y, color=None): """If ``color`` is not given, get the color value of the specified pixel. If ``color`` is given, set the specified pixel to the given color.""" if self.rotation == 1: x, y = y, x x = self.width - x - 1 if self.rotation == 2: x = self.width - x - 1 y = self.height - y - 1 if self.rotation == 3: x, y = y, x y = self.height - y - 1 if x < 0 or x >= self.width or y < 0 or y >= self.height: return None if color is None: return self.format.get_pixel(self, x, y) self.format.set_pixel(self, x, y, color) return None def hline(self, x, y, width, color): """Draw a horizontal line up to a given length.""" self.rect(x, y, width, 1, color, fill=True) def vline(self, x, y, height, color): """Draw a vertical line up to a given length.""" self.rect(x, y, 1, height, color, fill=True) def circle(self, center_x, center_y, radius, color): """Draw a circle at the given midpoint location, radius and color. The ```circle``` method draws only a 1 pixel outline.""" x = radius - 1 y = 0 d_x = 1 d_y = 1 err = d_x - (radius << 1) while x >= y: self.pixel(center_x + x, center_y + y, color) self.pixel(center_x + y, center_y + x, color) self.pixel(center_x - y, center_y + x, color) self.pixel(center_x - x, center_y + y, color) self.pixel(center_x - x, center_y - y, color) self.pixel(center_x - y, center_y - x, color) self.pixel(center_x + y, center_y - x, color) self.pixel(center_x + x, center_y - y, color) if err <= 0: y += 1 err += d_y d_y += 2 if err > 0: x -= 1 d_x += 2 err += d_x - (radius << 1) def rect(self, x, y, width, height, color, *, fill=False): """Draw a rectangle at the given location, size and color. The ```rect``` method draws only a 1 pixel outline.""" # pylint: disable=too-many-arguments if self.rotation == 1: x, y = y, x width, height = height, width x = self.width - x - width if self.rotation == 2: x = self.width - x - width y = self.height - y - height if self.rotation == 3: x, y = y, x width, height = height, width y = self.height - y - height # pylint: disable=too-many-boolean-expressions if ( width < 1 or height < 1 or (x + width) <= 0 or (y + height) <= 0 or y >= self.height or x >= self.width ): return x_end = min(self.width - 1, x + width - 1) y_end = min(self.height - 1, y + height - 1) x = max(x, 0) y = max(y, 0) if fill: self.format.fill_rect(self, x, y, x_end - x + 1, y_end - y + 1, color) else: self.format.fill_rect(self, x, y, x_end - x + 1, 1, color) self.format.fill_rect(self, x, y, 1, y_end - y + 1, color) self.format.fill_rect(self, x, y_end, x_end - x + 1, 1, color) self.format.fill_rect(self, x_end, y, 1, y_end - y + 1, color) def line(self, x_0, y_0, x_1, y_1, color): # pylint: disable=too-many-arguments """Bresenham's line algorithm""" d_x = abs(x_1 - x_0) d_y = abs(y_1 - y_0) x, y = x_0, y_0 s_x = -1 if x_0 > x_1 else 1 s_y = -1 if y_0 > y_1 else 1 if d_x > d_y: err = d_x / 2.0 while x != x_1: self.pixel(x, y, color) err -= d_y if err < 0: y += s_y err += d_x x += s_x else: err = d_y / 2.0 while y != y_1: self.pixel(x, y, color) err -= d_x if err < 0: x += s_x err += d_y y += s_y self.pixel(x, y, color) def blit(self): """blit is not yet implemented""" raise NotImplementedError() def scroll(self, delta_x, delta_y): """shifts framebuf in x and y direction""" if delta_x < 0: shift_x = 0 xend = self.width + delta_x dt_x = 1 else: shift_x = self.width - 1 xend = delta_x - 1 dt_x = -1 if delta_y < 0: y = 0 yend = self.height + delta_y dt_y = 1 else: y = self.height - 1 yend = delta_y - 1 dt_y = -1 while y != yend: x = shift_x while x != xend: self.format.set_pixel( self, x, y, self.format.get_pixel(self, x - delta_x, y - delta_y) ) x += dt_x y += dt_y # pylint: disable=too-many-arguments def text(self, string, x, y, color, *, font_name="font5x8.bin", size=1): """Place text on the screen in variables sizes. Breaks on \n to next line. Does not break on line going off screen. """ # determine our effective width/height, taking rotation into account frame_width = self.width frame_height = self.height if self.rotation in (1, 3): frame_width, frame_height = frame_height, frame_width for chunk in string.split("\n"): if not self._font or self._font.font_name != font_name: # load the font! self._font = BitmapFont(font_name) width = self._font.font_width height = self._font.font_height for i, char in enumerate(chunk): char_x = x + (i * (width + 1)) * size if ( char_x + (width * size) > 0 and char_x < frame_width and y + (height * size) > 0 and y < frame_height ): self._font.draw_char(char, char_x, y, self, color, size=size) y += height * size # pylint: enable=too-many-arguments def image(self, img): """Set buffer to value of Python Imaging Library image. The image should be in 1 bit mode and a size equal to the display size.""" # determine our effective width/height, taking rotation into account width = self.width height = self.height if self.rotation in (1, 3): width, height = height, width if isinstance(self.format, (RGB565Format, RGB888Format)) and img.mode != "RGB": raise ValueError("Image must be in mode RGB.") if isinstance(self.format, (MHMSBFormat, MVLSBFormat)) and img.mode != "1": raise ValueError("Image must be in mode 1.") imwidth, imheight = img.size if imwidth != width or imheight != height: raise ValueError( f"Image must be same dimensions as display ({width}x{height})." ) # Grab all the pixels from the image, faster than getpixel. pixels = img.load() # Clear buffer for i in range(len(self.buf)): # pylint: disable=consider-using-enumerate self.buf[i] = 0 # Iterate through the pixels for x in range(width): # yes this double loop is slow, for y in range(height): # but these displays are small! if img.mode == "RGB": self.pixel(x, y, pixels[(x, y)]) elif pixels[(x, y)]: self.pixel(x, y, 1) # only write if pixel is true # MicroPython basic bitmap font renderer. # Author: Tony DiCola # License: MIT License (https://opensource.org/licenses/MIT) class BitmapFont: """A helper class to read binary font tiles and 'seek' through them as a file to display in a framebuffer. We use file access so we dont waste 1KB of RAM on a font!""" def __init__(self, font_name="font5x8.bin"): # Specify the drawing area width and height, and the pixel function to # call when drawing pixels (should take an x and y param at least). # Optionally specify font_name to override the font file to use (default # is font5x8.bin). The font format is a binary file with the following # format: # - 1 unsigned byte: font character width in pixels # - 1 unsigned byte: font character height in pixels # - x bytes: font data, in ASCII order covering all 255 characters. # Each character should have a byte for each pixel column of # data (i.e. a 5x8 font has 5 bytes per character). self.font_name = font_name # Open the font file and grab the character width and height values. # Note that only fonts up to 8 pixels tall are currently supported. try: self._font = open( # pylint: disable=consider-using-with self.font_name, "rb" ) self.font_width, self.font_height = struct.unpack("BB", self._font.read(2)) # simple font file validation check based on expected file size if 2 + 256 * self.font_width != os.stat(font_name)[6]: raise RuntimeError("Invalid font file: " + font_name) except OSError: print("Could not find font file", font_name) raise except OverflowError: # os.stat can throw this on boards without long int support # just hope the font file is valid and press on pass def deinit(self): """Close the font file as cleanup.""" self._font.close() def __enter__(self): """Initialize/open the font file""" self.__init__() return self def __exit__(self, exception_type, exception_value, traceback): """cleanup on exit""" self.deinit() def draw_char( self, char, x, y, framebuffer, color, size=1 ): # pylint: disable=too-many-arguments """Draw one character at position (x,y) to a framebuffer in a given color""" size = max(size, 1) # Don't draw the character if it will be clipped off the visible area. # if x < -self.font_width or x >= framebuffer.width or \ # y < -self.font_height or y >= framebuffer.height: # return # Go through each column of the character. for char_x in range(self.font_width): # Grab the byte for the current column of font data. self._font.seek(2 + (ord(char) * self.font_width) + char_x) try: line = struct.unpack("B", self._font.read(1))[0] except RuntimeError: continue # maybe character isnt there? go to next # Go through each row in the column byte. for char_y in range(self.font_height): # Draw a pixel for each bit that's flipped on. if (line >> char_y) & 0x1: framebuffer.fill_rect( x + char_x * size, y + char_y * size, size, size, color ) def width(self, text): """Return the pixel width of the specified text message.""" return len(text) * (self.font_width + 1) class FrameBuffer1(FrameBuffer): # pylint: disable=abstract-method """FrameBuffer1 object. Inherits from FrameBuffer."""