The latest crop of built for CircuitPython boards have screens and an input method. These boards include:

Board

PyPortal

PyBadge

PyGamer

Screen

3.2″ 320 x 240

1.8" 160x128

1.8" 160x128 

Input

Touchscreen

D-Pad and A/B buttons

Joystick and A/B buttons

Each board has a different niche use. The PyPortal is great for making touchscreen based IoT devices. The PyBadge for making conference badges and simple games. The PyGamer is a handheld gaming system. What they have in common is a color screen and a way to input location or direction. That means we can write a simple paint program!

The most challenging part is writing a program that will work on all three types of systems.

Front view of a Adafruit PyPortal - CircuitPython Powered Internet Display with a pyportal logo image on the display.
PyPortal, our easy-to-use IoT device that allows you to create all the things for the “Internet of Things” in minutes. Make custom touch screen interface...
$54.95
In Stock
Angled shot of a Adafruit PyBadge for MakeCode Arcade, CircuitPython, or Arduino.
What's the size of a credit card and can run CircuitPython, MakeCode Arcade or Arduino? That's right, its the Adafruit PyBadge! We wanted to see how much we...
$34.95
In Stock
Angled shot of Adafruit PyGamer for MakeCode Arcade, CircuitPython or Arduino.
What fits in your pocket, is fully Open Source, and can run CircuitPython, MakeCode Arcade or Arduino games you write yourself? That's right, it's the Adafruit...
Out of Stock
Adafruit PyGamer Starter Kit with PCB, enclosure, buttons, and storage bag
Please note: you may get a royal blue or purple case with your starter kit (they're both lovely colors)What fits in your pocket, is fully Open...
Out of Stock

CircuitPython is a programming language based on Python, one of the fastest growing programming languages in the world. It is specifically designed to simplify experimenting and learning to code on low-cost microcontroller boards. Here is a guide which covers the basics:

Be sure you have the latest CircuitPython for your board loaded onto your board, as described here. You will want at least version 4.1 (possibly a beta version of it) for maximum graphics performance. While we'll reference the PyPortal in this guide, but this will work on all of the TFT based CircuitPython boards such as PyBadge and PyGamer.

CircuitPython is easiest to use within the Mu Editor. If you haven't previously used Mu, this guide will get you started.

Libraries

Plug your board into your computer via a USB cable. Please be sure the cable is a good power+data cable so the computer can talk to the board.

A new disk should appear in your computer's file explorer/finder called CIRCUITPY. This is the place we'll copy the code and code library. If you can only get a drive named PORTALBOOT or PYGAMERBOOT, load CircuitPython per the guide mentioned above.

Create a new directory on the CIRCUITPY drive named lib.

Download the latest CircuitPython driver package to your computer using the green button below. Match the library you get to the version of CircuitPython you are using. Save to your computer's hard drive where you can find it.

With your file explorer/finder, browse to the bundle and open it up.

For a PyBadge or PyGamer

Copy the following folders and files from the library bundle to your CIRCUITPY lib directory you made earlier:

  • adafruit_cursorcontrol
  • adafruit_debouncer.mpy
  • adafruit_logging.mpy

Your CIRCUITPY/lib directory should look like the snapshot below.

For a PyPortal

Copy the following folders and files from the library bundle to your CIRCUITPY lib directory you made earlier:

  • adafruit_logging.mpy
  • adafruit_touchscreen.mpy

Your CIRCUITPY/lib directory should look like the snapshot below.

All of the other necessary libraries are baked into CircuitPython!

One of the first things to do is set up the colors we can paint with. We can wrap that in a class to provide constants and a list of them.

class Color(object):
    """Standard colors"""
    WHITE = 0xFFFFFF
    BLACK = 0x000000
    RED = 0xFF0000
    ORANGE = 0xFFA500
    YELLOW = 0xFFFF00
    GREEN = 0x00FF00
    BLUE = 0x0000FF
    PURPLE = 0x800080
    PINK = 0xFFC0CB

    colors = (BLACK, RED, ORANGE, YELLOW, GREEN, BLUE, PURPLE, WHITE)

    def __init__(self):
        pass

Let's move to the main loop that drives the program and then work backwards. It repeatedly check for input and responds accordingly.

def run(self):
        """Run the painting program."""
        while True:
            self._update()
            if self._was_a_just_pressed:
                self._handle_a_press(self._location)
            elif self._was_a_just_released:
                self._handle_a_release(self._location)
            if self._did_move and self._a_pressed:
                self._handle_motion(self._last_location, self._location)
            time.sleep(0.1)

It first updates the state of the user's input. There are properties that indicate whether the action button ("A" at the moment) has been pressed or released since the previous call to update. There is also a property that indicates whether the location has changed. The business with None in _did_move handles when there is no contact with the touchscreen.

Note that at the moment the release event isn't being used.

@property
    def _was_a_just_pressed(self):
        return self._a_pressed and not self._last_a_pressed

    @property
    def _was_a_just_released(self):
        return not self._a_pressed and self._last_a_pressed

    @property
    def _did_move(self):
        if self._location is not None and self._last_location is not None:
            x_changed = self._location[0] != self._last_location[0]
            y_changed = self._location[1] != self._last_location[1]
            return x_changed or y_changed
        else:
            return False

    def _update(self):
        self._last_a_pressed, self._last_location = self._a_pressed, self._location
        self._a_pressed, self._location = self._poller.poll()

The next thing to look at are the methods that handle each of those events. The event is logged and the appropriate action is performed. As mentioned above, release is currently ignored.

If the location has changed, a line is drawn from the previous location to the new one.

The press event handler first checks whether the location of the press/touch is in the leftmost 10% of the screen. If so, it's a touch in the color palette and the drawing color is changed based on where vertically in the palette the press/touch occurred. 

If the press/touch wasn't in the palette, a dot is added to the canvas at the location and an update is forced.

def _handle_palette_selection(self, location):
        selected = location[1] // self._swatch_height
        if selected >= self._number_of_palette_options:
            return
        self._logger.debug('Palette selection: %d', selected)
        if selected < len(Color.colors):
            self._pencolor = selected
        else:
            self._brush = selected - len(Color.colors)
            self._poller.set_cursor_bitmap(self._cursor_bitmaps[self._brush])

    def _handle_motion(self, start, end):
        self._logger.debug('Moved: (%d, %d) -> (%d, %d)', start[0], start[1], end[0], end[1])
        self._draw_line(start, end)

    def _handle_a_press(self, location):
        self._logger.debug('A Pressed!')
        if location[0] < self._w // 10:   # in color picker
            self._handle_palette_selection(location)
        else:
            self._plot(location[0], location[1], self._pencolor)
            self._poller.poke()

    #pylint:disable=unused-argument
    def _handle_a_release(self, location):
        self._logger.debug('A Released!')
    #pylint:enable=unused-argument

The _plot method puts a dot of the current drawing color on the canvas bitmap. Trying to draw off the screen (not so much of an issue in this application) is ignored.

    def _plot(self, x, y, c):
        if self._brush == 0:
            r = [0]
        else:
            r = [-1, 0, 1]
        for i in r:
            for j in r:
                try:
                    self._fg_bitmap[int(x + i), int(y + j)] = c
                except IndexError:
                    pass

The _goto method draws a line between two locations. It's the same (with a slight tweak to the update force code) as the _goto method used in the turtle graphics library.

def _goto(self, start, end):
        """Draw a line from the previous position to the current one.

        :param start: a tuple of (x, y) coordinatess to fram from
        :param end: a tuple of (x, y) coordinates to draw to
        """
        x0 = start[0]
        y0 = start[1]
        x1 = end[0]
        y1 = end[1]
        self._logger.debug("* GoTo from (%d, %d) to (%d, %d)", x0, y0, x1, y1)
        steep = abs(y1 - y0) > abs(x1 - x0)
        rev = False
        dx = x1 - x0

        if steep:
            x0, y0 = y0, x0
            x1, y1 = y1, x1
            dx = x1 - x0

        if x0 > x1:
            rev = True
            dx = x0 - x1

        dy = abs(y1 - y0)
        err = dx / 2
        ystep = -1
        if y0 < y1:
            ystep = 1

        while (not rev and x0 <= x1) or (rev and x1 <= x0):
            if steep:
                try:
                    self._plot(int(y0), int(x0), self._pencolor)
                except IndexError:
                    pass
                self._x = y0
                self._y = x0
                self._poller.poke((int(y0), int(x0)))
                time.sleep(0.003)
            else:
                try:
                    self._plot(int(x0), int(y0), self._pencolor)
                except IndexError:
                    pass
                self._x = x0
                self._y = y0
                self._poller.poke((int(x0), int(y0)))
                time.sleep(0.003)
            err -= dy
            if err < 0:
                y0 += ystep
                err += dx
            if rev:
                x0 -= 1
            else:
                x0 += 1

Now let's jump to the Paint class's constructor. Most of it builds up the various displayio instances of  Bitmap, Palette, TileGrid, and so forth. The rest initializes instance variables.

One particularly interesting bit is near the end:

        if hasattr(board, 'TOUCH_XL'):
            self._poller = TouchscreenPoller(self._splash, self._cursor_bitmap())
        elif hasattr(board, 'BUTTON_CLOCK'):
            self._poller = CursorPoller(self._splash, self._cursor_bitmap())
        else:
            raise AttributeError('PYOA requires a touchscreen or cursor.')

It looks at the board module and checks for TOUCH_XL or BUTTON_CLOCK. The presence of TOUCH_XL indicates that the code is running on a board with a touchscreen, while BUTTON_CLOCK indicates that there is a D-Pad or joystick. Depending on which one is found (with preference given to a touchscreen) a specific Poller class is instantiated. If neither is found, then the board doesn't support the required input capabilities so an exception is raised.

There's a few methods to build the color/brush palette and well as the cursor bitmaps that correspond to the brushes.

    def _make_palette(self):
        self._palette_bitmap = displayio.Bitmap(self._w // 10, self._h, 5)
        self._palette_palette = displayio.Palette(len(Color.colors))
        for i, c in enumerate(Color.colors):
            self._palette_palette[i] = c
            for y in range(self._swatch_height):
                for x in range(self._swatch_width):
                    self._palette_bitmap[x, self._swatch_height * i + y] = i

        swatch_x_offset = (self._swatch_width - 9) // 2
        swatch_y_offset = (self._swatch_height - 9) // 2
        swatch_y = self._swatch_height * len(Color.colors) + swatch_y_offset
        for i in range(9):
            self._palette_bitmap[swatch_x_offset + 4, swatch_y + i] = 1
            self._palette_bitmap[swatch_x_offset + i, swatch_y + 4] = 1
            self._palette_bitmap[swatch_x_offset + 4, swatch_y + 4] = 0

        swatch_y += self._swatch_height
        for i in range(9):
            self._palette_bitmap[swatch_x_offset + 3, swatch_y + i] = 1
            self._palette_bitmap[swatch_x_offset + 4, swatch_y + i] = 1
            self._palette_bitmap[swatch_x_offset + 5, swatch_y + i] = 1
            self._palette_bitmap[swatch_x_offset + i, swatch_y + 3] = 1
            self._palette_bitmap[swatch_x_offset + i, swatch_y + 4] = 1
            self._palette_bitmap[swatch_x_offset + i, swatch_y + 5] = 1
        for i in range(swatch_x_offset + 3, swatch_x_offset + 6):
            for j in range(swatch_y + 3, swatch_y + 6):
                self._palette_bitmap[i, j] = 0

        for i in range(self._h):
            self._palette_bitmap[self._swatch_width - 1, i] = 7

        return displayio.TileGrid(self._palette_bitmap,
                                  pixel_shader=self._palette_palette,
                                  x=0, y=0)

    def _cursor_bitmap_1(self):
        bmp = displayio.Bitmap(9, 9, 3)
        for i in range(9):
            bmp[4, i] = 1
            bmp[i, 4] = 1
        bmp[4, 4] = 0
        return bmp

    def _cursor_bitmap_3(self):
        bmp = displayio.Bitmap(9, 9, 3)
        for i in range(9):
            bmp[3, i] = 1
            bmp[4, i] = 1
            bmp[5, i] = 1
            bmp[i, 3] = 1
            bmp[i, 4] = 1
            bmp[i, 5] = 1
        for i in range(3, 6):
            for j in range(3, 6):
                bmp[i, j] = 0
        return bmp

Other than the constructor, the pollers have two methods:

poll: This checks the inputs and returns whether A is currently being pressed or the screen has been touched and the location of the cursor (see below) or touch

poke: forces a screen update

The TouchscreenPoller gets the location of the touch, and manages the cursor. You might need to play around with the calibration numbers since the reported touch location is used as the place to center the cursor so they should line up. Tweak the calibration numbers until the cursor tracks the touch close enough. Using a stylus (like the one from a Nintendo DS, for example) is useful with the program.

class TouchscreenPoller(object):
    """Get 'pressed' and location updates from a touch screen device."""

    def __init__(self, splash, cursor_bmp):
        logging.getLogger("Paint").debug("Creating a TouchscreenPoller")
        self._display_grp = splash
        self._touchscreen = adafruit_touchscreen.Touchscreen(
            board.TOUCH_XL, board.TOUCH_XR,
            board.TOUCH_YD, board.TOUCH_YU,
            calibration=((9000, 59000), (8000, 57000)),
            size=(320, 240),
        )
        self._cursor_grp = displayio.Group()
        self._cur_palette = displayio.Palette(3)
        self._cur_palette.make_transparent(0)
        self._cur_palette[1] = 0xFFFFFF
        self._cur_palette[2] = 0x0000
        self._cur_sprite = displayio.TileGrid(
            cursor_bmp, pixel_shader=self._cur_palette
        )
        self._cursor_grp.append(self._cur_sprite)
        self._display_grp.append(self._cursor_grp)
        self._x_offset = cursor_bmp.width // 2
        self._y_offset = cursor_bmp.height // 2

    def poll(self):
        """Check for input. Returns contact (a bool), False (no button B),
        and it's location ((x,y) or None)"""

        p = self._touchscreen.touch_point
        if p is not None:
            self._cursor_grp.x = p[0] - self._x_offset
            self._cursor_grp.y = p[1] - self._y_offset
            return True, p
        else:
            return False, None

    def poke(self, location=None):
        """Force a bitmap refresh."""
        self._display_grp.remove(self._cursor_grp)
        if location is not None:
            self._cursor_grp.x = location[0] - self._x_offset
            self._cursor_grp.y = location[1] - self._y_offset
        self._display_grp.append(self._cursor_grp)

    def set_cursor_bitmap(self, bmp):
        """Update the cursor bitmap.
        :param bmp: the new cursor bitmap
        """
        self._cursor_grp.remove(self._cur_sprite)
        self._cur_sprite = displayio.TileGrid(bmp, pixel_shader=self._cur_palette)
        self._cursor_grp.append(self._cur_sprite)
        self.poke()

CursorPoller uses the cursorcontrol library to allow the user to move a cursor (used as the drawing pen) as well as read the A button.

class CursorPoller(object):
    """Get 'pressed' and location updates from a D-Pad/joystick device."""

    def __init__(self, splash, cursor_bmp):
        logging.getLogger('Paint').debug('Creating a CursorPoller')
        self._mouse_cursor = Cursor(board.DISPLAY,
                                    display_group=splash,
                                    bmp=cursor_bmp,
                                    cursor_speed=2)
        self._x_offset = cursor_bmp.width // 2
        self._y_offset = cursor_bmp.height // 2
        self._cursor = DebouncedCursorManager(self._mouse_cursor)
        self._logger = logging.getLogger('Paint')

    def poll(self):
        """Check for input. Returns press of A (a bool), B,
        and the cursor location ((x,y) or None)"""
        location = None
        self._cursor.update()
        a_button = self._cursor.held
        if a_button:
            location = (self._mouse_cursor.x + self._x_offset,
                        self._mouse_cursor.y + self._y_offset)
        return a_button, location

    #pylint:disable=unused-argument
    def poke(self, x=None, y=None):
        """Force a bitmap refresh."""
        self._mouse_cursor.hide()
        self._mouse_cursor.show()
    #pylint:enable=unused-argument

    def set_cursor_bitmap(self, bmp):
        """Update the cursor bitmap.

        :param bmp: the new cursor bitmap
        """
        self._mouse_cursor.cursor_bitmap = bmp
        self.poke()

The final piece of interest is in the import section. It avoids having to have both touchscreen and cursorcontrol libraries installed.

try:
    import adafruit_touchscreen
except ImportError:
    pass
try:
    from adafruit_cursorcontrol.cursorcontrol import Cursor
    from adafruit_cursorcontrol.cursorcontrol_cursormanager import DebouncedCursorManager
except ImportError:
    pass

Here is the code in it's entirety.

# SPDX-FileCopyrightText: 2019 Dave Astels for Adafruit Industries
#
# SPDX-License-Identifier: MIT

"""
Paint for PyPortal, PyBadge, PyGamer, and the like.

Adafruit invests time and resources providing this open source code.
Please support Adafruit and open source hardware by purchasing
products from Adafruit!

Written by Dave Astels for Adafruit Industries
Copyright (c) 2019 Adafruit Industries
Licensed under the MIT license.

All text above must be included in any redistribution.
"""

import gc
import time
import board
import displayio
import adafruit_logging as logging

try:
    import adafruit_touchscreen
except ImportError:
    pass
try:
    from adafruit_cursorcontrol.cursorcontrol import Cursor
    from adafruit_cursorcontrol.cursorcontrol_cursormanager import DebouncedCursorManager
except ImportError:
    pass


class Color(object):
    """Standard colors"""

    WHITE = 0xFFFFFF
    BLACK = 0x000000
    RED = 0xFF0000
    ORANGE = 0xFFA500
    YELLOW = 0xFFFF00
    GREEN = 0x00FF00
    BLUE = 0x0000FF
    PURPLE = 0x800080
    PINK = 0xFFC0CB

    colors = (BLACK, RED, ORANGE, YELLOW, GREEN, BLUE, PURPLE, WHITE)

    def __init__(self):
        pass


################################################################################


class TouchscreenPoller(object):
    """Get 'pressed' and location updates from a touch screen device."""

    def __init__(self, splash, cursor_bmp):
        logger = logging.getLogger("Paint")
        if not logger.hasHandlers():
            logger.addHandler(logging.StreamHandler())
        logger.debug("Creating a TouchscreenPoller")
        self._display_grp = splash
        self._touchscreen = adafruit_touchscreen.Touchscreen(
            board.TOUCH_XL, board.TOUCH_XR,
            board.TOUCH_YD, board.TOUCH_YU,
            calibration=((9000, 59000), (8000, 57000)),
            size=(320, 240),
        )
        self._cursor_grp = displayio.Group()
        self._cur_palette = displayio.Palette(3)
        self._cur_palette.make_transparent(0)
        self._cur_palette[1] = 0xFFFFFF
        self._cur_palette[2] = 0x0000
        self._cur_sprite = displayio.TileGrid(
            cursor_bmp, pixel_shader=self._cur_palette
        )
        self._cursor_grp.append(self._cur_sprite)
        self._display_grp.append(self._cursor_grp)
        self._x_offset = cursor_bmp.width // 2
        self._y_offset = cursor_bmp.height // 2

    def poll(self):
        """Check for input. Returns contact (a bool), False (no button B),
        and it's location ((x,y) or None)"""

        p = self._touchscreen.touch_point
        if p is not None:
            self._cursor_grp.x = p[0] - self._x_offset
            self._cursor_grp.y = p[1] - self._y_offset
            return True, p
        else:
            return False, None

    def poke(self, location=None):
        """Force a bitmap refresh."""
        self._display_grp.remove(self._cursor_grp)
        if location is not None:
            self._cursor_grp.x = location[0] - self._x_offset
            self._cursor_grp.y = location[1] - self._y_offset
        self._display_grp.append(self._cursor_grp)

    def set_cursor_bitmap(self, bmp):
        """Update the cursor bitmap.

        :param bmp: the new cursor bitmap
        """
        self._cursor_grp.remove(self._cur_sprite)
        self._cur_sprite = displayio.TileGrid(bmp, pixel_shader=self._cur_palette)
        self._cursor_grp.append(self._cur_sprite)
        self.poke()


################################################################################


class CursorPoller(object):
    """Get 'pressed' and location updates from a D-Pad/joystick device."""

    def __init__(self, splash, cursor_bmp):
        self._logger = logging.getLogger("Paint")
        if not self._logger.hasHandlers():
            self._logger.addHandler(logging.StreamHandler())
        self._logger.debug("Creating a CursorPoller")
        self._mouse_cursor = Cursor(
            board.DISPLAY, display_group=splash, bmp=cursor_bmp, cursor_speed=2
        )
        self._x_offset = cursor_bmp.width // 2
        self._y_offset = cursor_bmp.height // 2
        self._cursor = DebouncedCursorManager(self._mouse_cursor)

    def poll(self):
        """Check for input. Returns press of A (a bool), B,
        and the cursor location ((x,y) or None)"""
        location = None
        self._cursor.update()
        a_button = self._cursor.held
        if a_button:
            location = (
                self._mouse_cursor.x + self._x_offset,
                self._mouse_cursor.y + self._y_offset,
            )
        return a_button, location

    def poke(self, x=None, y=None):
        """Force a bitmap refresh."""
        self._mouse_cursor.hide()
        self._mouse_cursor.show()

    def set_cursor_bitmap(self, bmp):
        """Update the cursor bitmap.

        :param bmp: the new cursor bitmap
        """
        self._mouse_cursor.cursor_bitmap = bmp
        self.poke()


################################################################################


class Paint(object):
    def __init__(self, display=board.DISPLAY):
        self._logger = logging.getLogger("Paint")
        if not self._logger.hasHandlers():
            self._logger.addHandler(logging.StreamHandler())
        self._logger.setLevel(logging.DEBUG)
        self._display = display
        self._w = self._display.width
        self._h = self._display.height
        self._x = self._w // 2
        self._y = self._h // 2

        self._splash = displayio.Group()

        self._bg_bitmap = displayio.Bitmap(self._w, self._h, 1)
        self._bg_palette = displayio.Palette(1)
        self._bg_palette[0] = Color.BLACK
        self._bg_sprite = displayio.TileGrid(
            self._bg_bitmap, pixel_shader=self._bg_palette, x=0, y=0
        )
        self._splash.append(self._bg_sprite)

        self._palette_bitmap = displayio.Bitmap(self._w, self._h, 5)
        self._palette_palette = displayio.Palette(len(Color.colors))
        for i, c in enumerate(Color.colors):
            self._palette_palette[i] = c
        self._palette_sprite = displayio.TileGrid(
            self._palette_bitmap, pixel_shader=self._palette_palette, x=0, y=0
        )
        self._splash.append(self._palette_sprite)

        self._fg_bitmap = displayio.Bitmap(self._w, self._h, 5)
        self._fg_palette = displayio.Palette(len(Color.colors))
        for i, c in enumerate(Color.colors):
            self._fg_palette[i] = c
        self._fg_sprite = displayio.TileGrid(
            self._fg_bitmap, pixel_shader=self._fg_palette, x=0, y=0
        )
        self._splash.append(self._fg_sprite)

        self._number_of_palette_options = len(Color.colors) + 2
        self._swatch_height = self._h // self._number_of_palette_options
        self._swatch_width = self._w // 10
        self._logger.debug("Height: %d", self._h)
        self._logger.debug("Swatch height: %d", self._swatch_height)

        self._palette = self._make_palette()
        self._splash.append(self._palette)

        self._display.root_group = self._splash
        try:
            gc.collect()
            self._display.refresh(target_frames_per_second=60)
        except AttributeError:
            self._display.refresh_soon()
            gc.collect()
            self._display.wait_for_frame()

        self._brush = 0
        self._cursor_bitmaps = [self._cursor_bitmap_1(), self._cursor_bitmap_3()]
        if hasattr(board, "TOUCH_XL"):
            self._poller = TouchscreenPoller(self._splash, self._cursor_bitmaps[0])
        elif hasattr(board, "BUTTON_CLOCK"):
            self._poller = CursorPoller(self._splash, self._cursor_bitmaps[0])
        else:
            raise AttributeError("PyPaint requires a touchscreen or cursor.")

        self._a_pressed = False
        self._last_a_pressed = False
        self._location = None
        self._last_location = None

        self._pencolor = 7

    def _make_palette(self):
        self._palette_bitmap = displayio.Bitmap(self._w // 10, self._h, 5)
        self._palette_palette = displayio.Palette(len(Color.colors))
        for i, c in enumerate(Color.colors):
            self._palette_palette[i] = c
            for y in range(self._swatch_height):
                for x in range(self._swatch_width):
                    self._palette_bitmap[x, self._swatch_height * i + y] = i

        swatch_x_offset = (self._swatch_width - 9) // 2
        swatch_y_offset = (self._swatch_height - 9) // 2
        swatch_y = self._swatch_height * len(Color.colors) + swatch_y_offset
        for i in range(9):
            self._palette_bitmap[swatch_x_offset + 4, swatch_y + i] = 1
            self._palette_bitmap[swatch_x_offset + i, swatch_y + 4] = 1
            self._palette_bitmap[swatch_x_offset + 4, swatch_y + 4] = 0

        swatch_y += self._swatch_height
        for i in range(9):
            self._palette_bitmap[swatch_x_offset + 3, swatch_y + i] = 1
            self._palette_bitmap[swatch_x_offset + 4, swatch_y + i] = 1
            self._palette_bitmap[swatch_x_offset + 5, swatch_y + i] = 1
            self._palette_bitmap[swatch_x_offset + i, swatch_y + 3] = 1
            self._palette_bitmap[swatch_x_offset + i, swatch_y + 4] = 1
            self._palette_bitmap[swatch_x_offset + i, swatch_y + 5] = 1
        for i in range(swatch_x_offset + 3, swatch_x_offset + 6):
            for j in range(swatch_y + 3, swatch_y + 6):
                self._palette_bitmap[i, j] = 0

        for i in range(self._h):
            self._palette_bitmap[self._swatch_width - 1, i] = 7

        return displayio.TileGrid(
            self._palette_bitmap, pixel_shader=self._palette_palette, x=0, y=0
        )

    def _cursor_bitmap_1(self):
        bmp = displayio.Bitmap(9, 9, 3)
        for i in range(9):
            bmp[4, i] = 1
            bmp[i, 4] = 1
        bmp[4, 4] = 0
        return bmp

    def _cursor_bitmap_3(self):
        bmp = displayio.Bitmap(9, 9, 3)
        for i in range(9):
            bmp[3, i] = 1
            bmp[4, i] = 1
            bmp[5, i] = 1
            bmp[i, 3] = 1
            bmp[i, 4] = 1
            bmp[i, 5] = 1
        for i in range(3, 6):
            for j in range(3, 6):
                bmp[i, j] = 0
        return bmp

    def _plot(self, x, y, c):
        if self._brush == 0:
            r = [0]
        else:
            r = [-1, 0, 1]
        for i in r:
            for j in r:
                try:
                    self._fg_bitmap[int(x + i), int(y + j)] = c
                except IndexError:
                    pass

    def _draw_line(self, start, end):
        """Draw a line from the previous position to the current one.

        :param start: a tuple of (x, y) coordinatess to fram from
        :param end: a tuple of (x, y) coordinates to draw to
        """
        x0 = start[0]
        y0 = start[1]
        x1 = end[0]
        y1 = end[1]
        self._logger.debug("* GoTo from (%d, %d) to (%d, %d)", x0, y0, x1, y1)
        steep = abs(y1 - y0) > abs(x1 - x0)
        rev = False
        dx = x1 - x0

        if steep:
            x0, y0 = y0, x0
            x1, y1 = y1, x1
            dx = x1 - x0

        if x0 > x1:
            rev = True
            dx = x0 - x1

        dy = abs(y1 - y0)
        err = dx / 2
        ystep = -1
        if y0 < y1:
            ystep = 1

        while (not rev and x0 <= x1) or (rev and x1 <= x0):
            if steep:
                try:
                    self._plot(int(y0), int(x0), self._pencolor)
                except IndexError:
                    pass
                self._x = y0
                self._y = x0
                self._poller.poke((int(y0), int(x0)))
                time.sleep(0.003)
            else:
                try:
                    self._plot(int(x0), int(y0), self._pencolor)
                except IndexError:
                    pass
                self._x = x0
                self._y = y0
                self._poller.poke((int(x0), int(y0)))
                time.sleep(0.003)
            err -= dy
            if err < 0:
                y0 += ystep
                err += dx
            if rev:
                x0 -= 1
            else:
                x0 += 1

    def _handle_palette_selection(self, location):
        selected = location[1] // self._swatch_height
        if selected >= self._number_of_palette_options:
            return
        self._logger.debug("Palette selection: %d", selected)
        if selected < len(Color.colors):
            self._pencolor = selected
        else:
            self._brush = selected - len(Color.colors)
            self._poller.set_cursor_bitmap(self._cursor_bitmaps[self._brush])

    def _handle_motion(self, start, end):
        self._logger.debug(
            "Moved: (%d, %d) -> (%d, %d)", start[0], start[1], end[0], end[1]
        )
        self._draw_line(start, end)

    def _handle_a_press(self, location):
        self._logger.debug("A Pressed!")
        if location[0] < self._w // 10:  # in color picker
            self._handle_palette_selection(location)
        else:
            self._plot(location[0], location[1], self._pencolor)
            self._poller.poke()

    def _handle_a_release(self, location):
        self._logger.debug("A Released!")

    @property
    def _was_a_just_pressed(self):
        return self._a_pressed and not self._last_a_pressed

    @property
    def _was_a_just_released(self):
        return not self._a_pressed and self._last_a_pressed

    @property
    def _did_move(self):
        if self._location is not None and self._last_location is not None:
            x_changed = self._location[0] != self._last_location[0]
            y_changed = self._location[1] != self._last_location[1]
            return x_changed or y_changed
        else:
            return False

    def _update(self):
        self._last_a_pressed, self._last_location = self._a_pressed, self._location
        self._a_pressed, self._location = self._poller.poll()

    def run(self):
        """Run the painting program."""
        while True:
            self._update()
            if self._was_a_just_pressed:
                self._handle_a_press(self._location)
            elif self._was_a_just_released:
                self._handle_a_release(self._location)
            if self._did_move and self._a_pressed:
                self._handle_motion(self._last_location, self._location)
            time.sleep(0.1)


painter = Paint()
painter.run()

All platforms have a palette on the left edge of the screen that contains 8 colors and two brush sizes. selecting a color changes the active drawing color, while selecting one of the brush cursors will change the drawing cursor as well as the size of the brush: 1 pixel square or 3 pixels square.

On the PyPortal, Use the touch screen to control the cursor. A simple stylus works really well for this. Touch on the palette to change the drawing color or brush. Touch anywhere else to place a spot of the current drawing color at the touched location. Moving while touching the screen will leave a trail. Recall that each time a change in touch location is detected, a line is drawn from the previous location to the new one. That means that if you move faster than the update cycle, you'll get a polygon because points are relatively far apart. You can see this in the photo below. If you want a smooth curved line, move slowly.

On a PyBadge, use the D-Pad (the buttons on the left) to move the cursor. Eight directions are supported, corresponding to single and adjacent pairs of buttons. To select a color or brush, move over the desired place on the palette and press/release the A button. To draw, move while holding A pressed.

The PyGamer works much the same, except that you use the joystick instead of the PyBadge's D-Pad. The current cursor control library uses the joystick to simulate a D-Pad, future version may provide more granular control.

The program lets you draw single points and single pixel width lines. There's plenty of features that could be added, like different line widths, brushes, airbrushing, erasing, and so on.

One of the challenges is that the screens are small and adding a tool palette will take up more of it. Using a popup palette will make better use of screen space. Button B could be used to open the palette, or touching in one corner of the touchscreen. That would provide plenty of room for color, tool, and brush selection without using up canvas space.

Another enhancement is to add a Poller class specific to the joystick on the PyGamer. This could provide more granular direction control or absolute positioning (the cursor location would correspond to the joystick position).

This guide was first published on Jul 06, 2019. It was last updated on Mar 29, 2024.