/ adafruit_framebuf.py
adafruit_framebuf.py
  1  # The MIT License (MIT)
  2  #
  3  # Copyright (c) 2018 Kattni Rembor for Adafruit Industries
  4  #
  5  # Permission is hereby granted, free of charge, to any person obtaining a copy
  6  # of this software and associated documentation files (the "Software"), to deal
  7  # in the Software without restriction, including without limitation the rights
  8  # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
  9  # copies of the Software, and to permit persons to whom the Software is
 10  # furnished to do so, subject to the following conditions:
 11  #
 12  # The above copyright notice and this permission notice shall be included in
 13  # all copies or substantial portions of the Software.
 14  #
 15  # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 16  # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 17  # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 18  # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 19  # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 20  # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
 21  # THE SOFTWARE.
 22  """
 23  `adafruit_framebuf`
 24  ====================================================
 25  
 26  CircuitPython pure-python framebuf module, based on the micropython framebuf module.
 27  
 28  * Author(s): Kattni Rembor, Tony DiCola, original file created by Damien P. George
 29  
 30  Implementation Notes
 31  --------------------
 32  
 33  **Hardware:**
 34  
 35  * `Adafruit SSD1306 OLED displays <https://www.adafruit.com/?q=ssd1306>`_
 36  
 37  **Software and Dependencies:**
 38  
 39  * Adafruit CircuitPython firmware for the supported boards:
 40    https://github.com/adafruit/circuitpython/releases
 41  
 42  """
 43  
 44  __version__ = "0.0.0-auto.0"
 45  __repo__ = "https://github.com/adafruit/Adafruit_CircuitPython_framebuf.git"
 46  
 47  import os
 48  import struct
 49  
 50  # Framebuf format constants:
 51  MVLSB = 0  # Single bit displays (like SSD1306 OLED)
 52  RGB565 = 1  # 16-bit color displays
 53  GS4_HMSB = 2  # Unimplemented!
 54  MHMSB = 3  # Single bit displays like the Sharp Memory
 55  
 56  
 57  class MHMSBFormat:
 58      """MHMSBFormat"""
 59  
 60      @staticmethod
 61      def set_pixel(framebuf, x, y, color):
 62          """Set a given pixel to a color."""
 63          index = (y * framebuf.stride + x) // 8
 64          offset = 7 - x & 0x07
 65          framebuf.buf[index] = (framebuf.buf[index] & ~(0x01 << offset)) | (
 66              (color != 0) << offset
 67          )
 68  
 69      @staticmethod
 70      def get_pixel(framebuf, x, y):
 71          """Get the color of a given pixel"""
 72          index = (y * framebuf.stride + x) // 8
 73          offset = 7 - x & 0x07
 74          return (framebuf.buf[index] >> offset) & 0x01
 75  
 76      @staticmethod
 77      def fill(framebuf, color):
 78          """completely fill/clear the buffer with a color"""
 79          if color:
 80              fill = 0xFF
 81          else:
 82              fill = 0x00
 83          for i in range(len(framebuf.buf)):
 84              framebuf.buf[i] = fill
 85  
 86      @staticmethod
 87      def fill_rect(framebuf, x, y, width, height, color):
 88          """Draw a rectangle at the given location, size and color. The ``fill_rect`` method draws
 89          both the outline and interior."""
 90          # pylint: disable=too-many-arguments
 91          for _x in range(x, x + width):
 92              offset = 7 - _x & 0x07
 93              for _y in range(y, y + height):
 94                  index = (_y * framebuf.stride + _x) // 8
 95                  framebuf.buf[index] = (framebuf.buf[index] & ~(0x01 << offset)) | (
 96                      (color != 0) << offset
 97                  )
 98  
 99  
100  class MVLSBFormat:
101      """MVLSBFormat"""
102  
103      @staticmethod
104      def set_pixel(framebuf, x, y, color):
105          """Set a given pixel to a color."""
106          index = (y >> 3) * framebuf.stride + x
107          offset = y & 0x07
108          framebuf.buf[index] = (framebuf.buf[index] & ~(0x01 << offset)) | (
109              (color != 0) << offset
110          )
111  
112      @staticmethod
113      def get_pixel(framebuf, x, y):
114          """Get the color of a given pixel"""
115          index = (y >> 3) * framebuf.stride + x
116          offset = y & 0x07
117          return (framebuf.buf[index] >> offset) & 0x01
118  
119      @staticmethod
120      def fill(framebuf, color):
121          """completely fill/clear the buffer with a color"""
122          if color:
123              fill = 0xFF
124          else:
125              fill = 0x00
126          for i in range(len(framebuf.buf)):
127              framebuf.buf[i] = fill
128  
129      @staticmethod
130      def fill_rect(framebuf, x, y, width, height, color):
131          """Draw a rectangle at the given location, size and color. The ``fill_rect`` method draws
132          both the outline and interior."""
133          # pylint: disable=too-many-arguments
134          while height > 0:
135              index = (y >> 3) * framebuf.stride + x
136              offset = y & 0x07
137              for w_w in range(width):
138                  framebuf.buf[index + w_w] = (
139                      framebuf.buf[index + w_w] & ~(0x01 << offset)
140                  ) | ((color != 0) << offset)
141              y += 1
142              height -= 1
143  
144  
145  class FrameBuffer:
146      """FrameBuffer object.
147  
148      :param buf: An object with a buffer protocol which must be large enough to contain every
149                  pixel defined by the width, height and format of the FrameBuffer.
150      :param width: The width of the FrameBuffer in pixel
151      :param height: The height of the FrameBuffer in pixel
152      :param buf_format: Specifies the type of pixel used in the FrameBuffer; permissible values
153                          are listed under Constants below. These set the number of bits used to
154                          encode a color value and the layout of these bits in ``buf``. Where a
155                          color value c is passed to a method, c is  a small integer with an encoding
156                          that is dependent on the format of the FrameBuffer.
157      :param stride: The number of pixels between each horizontal line of pixels in the
158                     FrameBuffer. This defaults to ``width`` but may need adjustments when
159                     implementing a FrameBuffer within another larger FrameBuffer or screen. The
160                     ``buf`` size must accommodate an increased step size.
161  
162      """
163  
164      def __init__(self, buf, width, height, buf_format=MVLSB, stride=None):
165          # pylint: disable=too-many-arguments
166          self.buf = buf
167          self.width = width
168          self.height = height
169          self.stride = stride
170          self._font = None
171          if self.stride is None:
172              self.stride = width
173          if buf_format == MVLSB:
174              self.format = MVLSBFormat()
175          elif buf_format == MHMSB:
176              self.format = MHMSBFormat()
177          else:
178              raise ValueError("invalid format")
179          self._rotation = 0
180  
181      @property
182      def rotation(self):
183          """The rotation setting of the display, can be one of (0, 1, 2, 3)"""
184          return self._rotation
185  
186      @rotation.setter
187      def rotation(self, val):
188          if not val in (0, 1, 2, 3):
189              raise RuntimeError("Bad rotation setting")
190          self._rotation = val
191  
192      def fill(self, color):
193          """Fill the entire FrameBuffer with the specified color."""
194          self.format.fill(self, color)
195  
196      def fill_rect(self, x, y, width, height, color):
197          """Draw a rectangle at the given location, size and color. The ``fill_rect`` method draws
198          both the outline and interior."""
199          # pylint: disable=too-many-arguments, too-many-boolean-expressions
200          self.rect(x, y, width, height, color, fill=True)
201  
202      def pixel(self, x, y, color=None):
203          """If ``color`` is not given, get the color value of the specified pixel. If ``color`` is
204          given, set the specified pixel to the given color."""
205          if self.rotation == 1:
206              x, y = y, x
207              x = self.width - x - 1
208          if self.rotation == 2:
209              x = self.width - x - 1
210              y = self.height - y - 1
211          if self.rotation == 3:
212              x, y = y, x
213              y = self.height - y - 1
214  
215          if x < 0 or x >= self.width or y < 0 or y >= self.height:
216              return None
217          if color is None:
218              return self.format.get_pixel(self, x, y)
219          self.format.set_pixel(self, x, y, color)
220          return None
221  
222      def hline(self, x, y, width, color):
223          """Draw a horizontal line up to a given length."""
224          self.rect(x, y, width, 1, color, fill=True)
225  
226      def vline(self, x, y, height, color):
227          """Draw a vertical line up to a given length."""
228          self.rect(x, y, 1, height, color, fill=True)
229  
230      def circle(self, center_x, center_y, radius, color):
231          """Draw a circle at the given midpoint location, radius and color.
232          The ```circle``` method draws only a 1 pixel outline."""
233          x = radius - 1
234          y = 0
235          d_x = 1
236          d_y = 1
237          err = d_x - (radius << 1)
238          while x >= y:
239              self.pixel(center_x + x, center_y + y, color)
240              self.pixel(center_x + y, center_y + x, color)
241              self.pixel(center_x - y, center_y + x, color)
242              self.pixel(center_x - x, center_y + y, color)
243              self.pixel(center_x - x, center_y - y, color)
244              self.pixel(center_x - y, center_y - x, color)
245              self.pixel(center_x + y, center_y - x, color)
246              self.pixel(center_x + x, center_y - y, color)
247              if err <= 0:
248                  y += 1
249                  err += d_y
250                  d_y += 2
251              if err > 0:
252                  x -= 1
253                  d_x += 2
254                  err += d_x - (radius << 1)
255  
256      def rect(self, x, y, width, height, color, *, fill=False):
257          """Draw a rectangle at the given location, size and color. The ```rect``` method draws only
258          a 1 pixel outline."""
259          # pylint: disable=too-many-arguments
260          if self.rotation == 1:
261              x, y = y, x
262              width, height = height, width
263              x = self.width - x - width
264          if self.rotation == 2:
265              x = self.width - x - width
266              y = self.height - y - height
267          if self.rotation == 3:
268              x, y = y, x
269              width, height = height, width
270              y = self.height - y - height
271  
272          # pylint: disable=too-many-boolean-expressions
273          if (
274              width < 1
275              or height < 1
276              or (x + width) <= 0
277              or (y + height) <= 0
278              or y >= self.height
279              or x >= self.width
280          ):
281              return
282          x_end = min(self.width - 1, x + width - 1)
283          y_end = min(self.height - 1, y + height - 1)
284          x = max(x, 0)
285          y = max(y, 0)
286          if fill:
287              self.format.fill_rect(self, x, y, x_end - x + 1, y_end - y + 1, color)
288          else:
289              self.format.fill_rect(self, x, y, x_end - x + 1, 1, color)
290              self.format.fill_rect(self, x, y, 1, y_end - y + 1, color)
291              self.format.fill_rect(self, x, y_end, x_end - x + 1, 1, color)
292              self.format.fill_rect(self, x_end, y, 1, y_end - y + 1, color)
293  
294      def line(self, x_0, y_0, x_1, y_1, color):
295          # pylint: disable=too-many-arguments
296          """Bresenham's line algorithm"""
297          d_x = abs(x_1 - x_0)
298          d_y = abs(y_1 - y_0)
299          x, y = x_0, y_0
300          s_x = -1 if x_0 > x_1 else 1
301          s_y = -1 if y_0 > y_1 else 1
302          if d_x > d_y:
303              err = d_x / 2.0
304              while x != x_1:
305                  self.pixel(x, y, color)
306                  err -= d_y
307                  if err < 0:
308                      y += s_y
309                      err += d_x
310                  x += s_x
311          else:
312              err = d_y / 2.0
313              while y != y_1:
314                  self.pixel(x, y, color)
315                  err -= d_x
316                  if err < 0:
317                      x += s_x
318                      err += d_y
319                  y += s_y
320          self.pixel(x, y, color)
321  
322      def blit(self):
323          """blit is not yet implemented"""
324          raise NotImplementedError()
325  
326      def scroll(self, delta_x, delta_y):
327          """shifts framebuf in x and y direction"""
328          if delta_x < 0:
329              shift_x = 0
330              xend = self.width + delta_x
331              dt_x = 1
332          else:
333              shift_x = self.width - 1
334              xend = delta_x - 1
335              dt_x = -1
336          if delta_y < 0:
337              y = 0
338              yend = self.height + delta_y
339              dt_y = 1
340          else:
341              y = self.height - 1
342              yend = delta_y - 1
343              dt_y = -1
344          while y != yend:
345              x = shift_x
346              while x != xend:
347                  self.format.set_pixel(
348                      self, x, y, self.format.get_pixel(self, x - delta_x, y - delta_y)
349                  )
350                  x += dt_x
351              y += dt_y
352  
353      # pylint: disable=too-many-arguments
354      def text(self, string, x, y, color, *, font_name="font5x8.bin", size=1):
355          """Place text on the screen in variables sizes. Breaks on \n to next line.
356  
357          Does not break on line going off screen.
358          """
359          for chunk in string.split("\n"):
360              if not self._font or self._font.font_name != font_name:
361                  # load the font!
362                  self._font = BitmapFont(font_name)
363              w = self._font.font_width
364              for i, char in enumerate(chunk):
365                  self._font.draw_char(
366                      char, x + (i * (w + 1)) * size, y, self, color, size=size
367                  )
368              y += self._font.font_height * size
369  
370      # pylint: enable=too-many-arguments
371  
372      def image(self, img):
373          """Set buffer to value of Python Imaging Library image.  The image should
374          be in 1 bit mode and a size equal to the display size."""
375          if img.mode != "1":
376              raise ValueError("Image must be in mode 1.")
377          imwidth, imheight = img.size
378          if imwidth != self.width or imheight != self.height:
379              raise ValueError(
380                  "Image must be same dimensions as display ({0}x{1}).".format(
381                      self.width, self.height
382                  )
383              )
384          # Grab all the pixels from the image, faster than getpixel.
385          pixels = img.load()
386          # Clear buffer
387          for i in range(len(self.buf)):
388              self.buf[i] = 0
389          # Iterate through the pixels
390          for x in range(self.width):  # yes this double loop is slow,
391              for y in range(self.height):  #  but these displays are small!
392                  if pixels[(x, y)]:
393                      self.pixel(x, y, 1)  # only write if pixel is true
394  
395  
396  # MicroPython basic bitmap font renderer.
397  # Author: Tony DiCola
398  # License: MIT License (https://opensource.org/licenses/MIT)
399  class BitmapFont:
400      """A helper class to read binary font tiles and 'seek' through them as a
401      file to display in a framebuffer. We use file access so we dont waste 1KB
402      of RAM on a font!"""
403  
404      def __init__(self, font_name="font5x8.bin"):
405          # Specify the drawing area width and height, and the pixel function to
406          # call when drawing pixels (should take an x and y param at least).
407          # Optionally specify font_name to override the font file to use (default
408          # is font5x8.bin).  The font format is a binary file with the following
409          # format:
410          # - 1 unsigned byte: font character width in pixels
411          # - 1 unsigned byte: font character height in pixels
412          # - x bytes: font data, in ASCII order covering all 255 characters.
413          #            Each character should have a byte for each pixel column of
414          #            data (i.e. a 5x8 font has 5 bytes per character).
415          self.font_name = font_name
416  
417          # Open the font file and grab the character width and height values.
418          # Note that only fonts up to 8 pixels tall are currently supported.
419          try:
420              self._font = open(self.font_name, "rb")
421              self.font_width, self.font_height = struct.unpack("BB", self._font.read(2))
422              # simple font file validation check based on expected file size
423              if 2 + 256 * self.font_width != os.stat(font_name)[6]:
424                  raise RuntimeError("Invalid font file: " + font_name)
425          except OSError:
426              print("Could not find font file", font_name)
427              raise
428          except OverflowError:
429              # os.stat can throw this on boards without long int support
430              # just hope the font file is valid and press on
431              pass
432  
433      def deinit(self):
434          """Close the font file as cleanup."""
435          self._font.close()
436  
437      def __enter__(self):
438          """Initialize/open the font file"""
439          self.__init__()
440          return self
441  
442      def __exit__(self, exception_type, exception_value, traceback):
443          """cleanup on exit"""
444          self.deinit()
445  
446      def draw_char(
447          self, char, x, y, framebuffer, color, size=1
448      ):  # pylint: disable=too-many-arguments
449          """Draw one character at position (x,y) to a framebuffer in a given color"""
450          size = max(size, 1)
451          # Don't draw the character if it will be clipped off the visible area.
452          # if x < -self.font_width or x >= framebuffer.width or \
453          #   y < -self.font_height or y >= framebuffer.height:
454          #    return
455          # Go through each column of the character.
456          for char_x in range(self.font_width):
457              # Grab the byte for the current column of font data.
458              self._font.seek(2 + (ord(char) * self.font_width) + char_x)
459              try:
460                  line = struct.unpack("B", self._font.read(1))[0]
461              except RuntimeError:
462                  continue  # maybe character isnt there? go to next
463              # Go through each row in the column byte.
464              for char_y in range(self.font_height):
465                  # Draw a pixel for each bit that's flipped on.
466                  if (line >> char_y) & 0x1:
467                      framebuffer.fill_rect(
468                          x + char_x * size, y + char_y * size, size, size, color
469                      )
470  
471      def width(self, text):
472          """Return the pixel width of the specified text message."""
473          return len(text) * (self.font_width + 1)
474  
475  
476  class FrameBuffer1(FrameBuffer):  # pylint: disable=abstract-method
477      """FrameBuffer1 object. Inherits from FrameBuffer."""