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.show(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()