code.py
  1  # SPDX-FileCopyrightText: 2019 Dave Astels for Adafruit Industries
  2  #
  3  # SPDX-License-Identifier: MIT
  4  
  5  """
  6  Paint for PyPortal, PyBadge, PyGamer, and the like.
  7  
  8  Adafruit invests time and resources providing this open source code.
  9  Please support Adafruit and open source hardware by purchasing
 10  products from Adafruit!
 11  
 12  Written by Dave Astels for Adafruit Industries
 13  Copyright (c) 2019 Adafruit Industries
 14  Licensed under the MIT license.
 15  
 16  All text above must be included in any redistribution.
 17  """
 18  
 19  import gc
 20  import time
 21  import board
 22  import displayio
 23  import adafruit_logging as logging
 24  
 25  try:
 26      import adafruit_touchscreen
 27  except ImportError:
 28      pass
 29  try:
 30      from adafruit_cursorcontrol.cursorcontrol import Cursor
 31      from adafruit_cursorcontrol.cursorcontrol_cursormanager import DebouncedCursorManager
 32  except ImportError:
 33      pass
 34  
 35  
 36  class Color(object):
 37      """Standard colors"""
 38  
 39      WHITE = 0xFFFFFF
 40      BLACK = 0x000000
 41      RED = 0xFF0000
 42      ORANGE = 0xFFA500
 43      YELLOW = 0xFFFF00
 44      GREEN = 0x00FF00
 45      BLUE = 0x0000FF
 46      PURPLE = 0x800080
 47      PINK = 0xFFC0CB
 48  
 49      colors = (BLACK, RED, ORANGE, YELLOW, GREEN, BLUE, PURPLE, WHITE)
 50  
 51      def __init__(self):
 52          pass
 53  
 54  
 55  ################################################################################
 56  
 57  
 58  class TouchscreenPoller(object):
 59      """Get 'pressed' and location updates from a touch screen device."""
 60  
 61      def __init__(self, splash, cursor_bmp):
 62          logger = logging.getLogger("Paint")
 63          if not logger.hasHandlers():
 64              logger.addHandler(logging.StreamHandler())
 65          logger.debug("Creating a TouchscreenPoller")
 66          self._display_grp = splash
 67          self._touchscreen = adafruit_touchscreen.Touchscreen(
 68              board.TOUCH_XL, board.TOUCH_XR,
 69              board.TOUCH_YD, board.TOUCH_YU,
 70              calibration=((9000, 59000), (8000, 57000)),
 71              size=(320, 240),
 72          )
 73          self._cursor_grp = displayio.Group()
 74          self._cur_palette = displayio.Palette(3)
 75          self._cur_palette.make_transparent(0)
 76          self._cur_palette[1] = 0xFFFFFF
 77          self._cur_palette[2] = 0x0000
 78          self._cur_sprite = displayio.TileGrid(
 79              cursor_bmp, pixel_shader=self._cur_palette
 80          )
 81          self._cursor_grp.append(self._cur_sprite)
 82          self._display_grp.append(self._cursor_grp)
 83          self._x_offset = cursor_bmp.width // 2
 84          self._y_offset = cursor_bmp.height // 2
 85  
 86      def poll(self):
 87          """Check for input. Returns contact (a bool), False (no button B),
 88          and it's location ((x,y) or None)"""
 89  
 90          p = self._touchscreen.touch_point
 91          if p is not None:
 92              self._cursor_grp.x = p[0] - self._x_offset
 93              self._cursor_grp.y = p[1] - self._y_offset
 94              return True, p
 95          else:
 96              return False, None
 97  
 98      def poke(self, location=None):
 99          """Force a bitmap refresh."""
100          self._display_grp.remove(self._cursor_grp)
101          if location is not None:
102              self._cursor_grp.x = location[0] - self._x_offset
103              self._cursor_grp.y = location[1] - self._y_offset
104          self._display_grp.append(self._cursor_grp)
105  
106      def set_cursor_bitmap(self, bmp):
107          """Update the cursor bitmap.
108  
109          :param bmp: the new cursor bitmap
110          """
111          self._cursor_grp.remove(self._cur_sprite)
112          self._cur_sprite = displayio.TileGrid(bmp, pixel_shader=self._cur_palette)
113          self._cursor_grp.append(self._cur_sprite)
114          self.poke()
115  
116  
117  ################################################################################
118  
119  
120  class CursorPoller(object):
121      """Get 'pressed' and location updates from a D-Pad/joystick device."""
122  
123      def __init__(self, splash, cursor_bmp):
124          self._logger = logging.getLogger("Paint")
125          if not self._logger.hasHandlers():
126              self._logger.addHandler(logging.StreamHandler())
127          self._logger.debug("Creating a CursorPoller")
128          self._mouse_cursor = Cursor(
129              board.DISPLAY, display_group=splash, bmp=cursor_bmp, cursor_speed=2
130          )
131          self._x_offset = cursor_bmp.width // 2
132          self._y_offset = cursor_bmp.height // 2
133          self._cursor = DebouncedCursorManager(self._mouse_cursor)
134  
135      def poll(self):
136          """Check for input. Returns press of A (a bool), B,
137          and the cursor location ((x,y) or None)"""
138          location = None
139          self._cursor.update()
140          a_button = self._cursor.held
141          if a_button:
142              location = (
143                  self._mouse_cursor.x + self._x_offset,
144                  self._mouse_cursor.y + self._y_offset,
145              )
146          return a_button, location
147  
148      def poke(self, x=None, y=None):
149          """Force a bitmap refresh."""
150          self._mouse_cursor.hide()
151          self._mouse_cursor.show()
152  
153      def set_cursor_bitmap(self, bmp):
154          """Update the cursor bitmap.
155  
156          :param bmp: the new cursor bitmap
157          """
158          self._mouse_cursor.cursor_bitmap = bmp
159          self.poke()
160  
161  
162  ################################################################################
163  
164  
165  class Paint(object):
166      def __init__(self, display=board.DISPLAY):
167          self._logger = logging.getLogger("Paint")
168          if not self._logger.hasHandlers():
169              self._logger.addHandler(logging.StreamHandler())
170          self._logger.setLevel(logging.DEBUG)
171          self._display = display
172          self._w = self._display.width
173          self._h = self._display.height
174          self._x = self._w // 2
175          self._y = self._h // 2
176  
177          self._splash = displayio.Group()
178  
179          self._bg_bitmap = displayio.Bitmap(self._w, self._h, 1)
180          self._bg_palette = displayio.Palette(1)
181          self._bg_palette[0] = Color.BLACK
182          self._bg_sprite = displayio.TileGrid(
183              self._bg_bitmap, pixel_shader=self._bg_palette, x=0, y=0
184          )
185          self._splash.append(self._bg_sprite)
186  
187          self._palette_bitmap = displayio.Bitmap(self._w, self._h, 5)
188          self._palette_palette = displayio.Palette(len(Color.colors))
189          for i, c in enumerate(Color.colors):
190              self._palette_palette[i] = c
191          self._palette_sprite = displayio.TileGrid(
192              self._palette_bitmap, pixel_shader=self._palette_palette, x=0, y=0
193          )
194          self._splash.append(self._palette_sprite)
195  
196          self._fg_bitmap = displayio.Bitmap(self._w, self._h, 5)
197          self._fg_palette = displayio.Palette(len(Color.colors))
198          for i, c in enumerate(Color.colors):
199              self._fg_palette[i] = c
200          self._fg_sprite = displayio.TileGrid(
201              self._fg_bitmap, pixel_shader=self._fg_palette, x=0, y=0
202          )
203          self._splash.append(self._fg_sprite)
204  
205          self._number_of_palette_options = len(Color.colors) + 2
206          self._swatch_height = self._h // self._number_of_palette_options
207          self._swatch_width = self._w // 10
208          self._logger.debug("Height: %d", self._h)
209          self._logger.debug("Swatch height: %d", self._swatch_height)
210  
211          self._palette = self._make_palette()
212          self._splash.append(self._palette)
213  
214          self._display.show(self._splash)
215          try:
216              gc.collect()
217              self._display.refresh(target_frames_per_second=60)
218          except AttributeError:
219              self._display.refresh_soon()
220              gc.collect()
221              self._display.wait_for_frame()
222  
223          self._brush = 0
224          self._cursor_bitmaps = [self._cursor_bitmap_1(), self._cursor_bitmap_3()]
225          if hasattr(board, "TOUCH_XL"):
226              self._poller = TouchscreenPoller(self._splash, self._cursor_bitmaps[0])
227          elif hasattr(board, "BUTTON_CLOCK"):
228              self._poller = CursorPoller(self._splash, self._cursor_bitmaps[0])
229          else:
230              raise AttributeError("PyPaint requires a touchscreen or cursor.")
231  
232          self._a_pressed = False
233          self._last_a_pressed = False
234          self._location = None
235          self._last_location = None
236  
237          self._pencolor = 7
238  
239      def _make_palette(self):
240          self._palette_bitmap = displayio.Bitmap(self._w // 10, self._h, 5)
241          self._palette_palette = displayio.Palette(len(Color.colors))
242          for i, c in enumerate(Color.colors):
243              self._palette_palette[i] = c
244              for y in range(self._swatch_height):
245                  for x in range(self._swatch_width):
246                      self._palette_bitmap[x, self._swatch_height * i + y] = i
247  
248          swatch_x_offset = (self._swatch_width - 9) // 2
249          swatch_y_offset = (self._swatch_height - 9) // 2
250          swatch_y = self._swatch_height * len(Color.colors) + swatch_y_offset
251          for i in range(9):
252              self._palette_bitmap[swatch_x_offset + 4, swatch_y + i] = 1
253              self._palette_bitmap[swatch_x_offset + i, swatch_y + 4] = 1
254              self._palette_bitmap[swatch_x_offset + 4, swatch_y + 4] = 0
255  
256          swatch_y += self._swatch_height
257          for i in range(9):
258              self._palette_bitmap[swatch_x_offset + 3, swatch_y + i] = 1
259              self._palette_bitmap[swatch_x_offset + 4, swatch_y + i] = 1
260              self._palette_bitmap[swatch_x_offset + 5, swatch_y + i] = 1
261              self._palette_bitmap[swatch_x_offset + i, swatch_y + 3] = 1
262              self._palette_bitmap[swatch_x_offset + i, swatch_y + 4] = 1
263              self._palette_bitmap[swatch_x_offset + i, swatch_y + 5] = 1
264          for i in range(swatch_x_offset + 3, swatch_x_offset + 6):
265              for j in range(swatch_y + 3, swatch_y + 6):
266                  self._palette_bitmap[i, j] = 0
267  
268          for i in range(self._h):
269              self._palette_bitmap[self._swatch_width - 1, i] = 7
270  
271          return displayio.TileGrid(
272              self._palette_bitmap, pixel_shader=self._palette_palette, x=0, y=0
273          )
274  
275      def _cursor_bitmap_1(self):
276          bmp = displayio.Bitmap(9, 9, 3)
277          for i in range(9):
278              bmp[4, i] = 1
279              bmp[i, 4] = 1
280          bmp[4, 4] = 0
281          return bmp
282  
283      def _cursor_bitmap_3(self):
284          bmp = displayio.Bitmap(9, 9, 3)
285          for i in range(9):
286              bmp[3, i] = 1
287              bmp[4, i] = 1
288              bmp[5, i] = 1
289              bmp[i, 3] = 1
290              bmp[i, 4] = 1
291              bmp[i, 5] = 1
292          for i in range(3, 6):
293              for j in range(3, 6):
294                  bmp[i, j] = 0
295          return bmp
296  
297      def _plot(self, x, y, c):
298          if self._brush == 0:
299              r = [0]
300          else:
301              r = [-1, 0, 1]
302          for i in r:
303              for j in r:
304                  try:
305                      self._fg_bitmap[int(x + i), int(y + j)] = c
306                  except IndexError:
307                      pass
308  
309      def _draw_line(self, start, end):
310          """Draw a line from the previous position to the current one.
311  
312          :param start: a tuple of (x, y) coordinatess to fram from
313          :param end: a tuple of (x, y) coordinates to draw to
314          """
315          x0 = start[0]
316          y0 = start[1]
317          x1 = end[0]
318          y1 = end[1]
319          self._logger.debug("* GoTo from (%d, %d) to (%d, %d)", x0, y0, x1, y1)
320          steep = abs(y1 - y0) > abs(x1 - x0)
321          rev = False
322          dx = x1 - x0
323  
324          if steep:
325              x0, y0 = y0, x0
326              x1, y1 = y1, x1
327              dx = x1 - x0
328  
329          if x0 > x1:
330              rev = True
331              dx = x0 - x1
332  
333          dy = abs(y1 - y0)
334          err = dx / 2
335          ystep = -1
336          if y0 < y1:
337              ystep = 1
338  
339          while (not rev and x0 <= x1) or (rev and x1 <= x0):
340              if steep:
341                  try:
342                      self._plot(int(y0), int(x0), self._pencolor)
343                  except IndexError:
344                      pass
345                  self._x = y0
346                  self._y = x0
347                  self._poller.poke((int(y0), int(x0)))
348                  time.sleep(0.003)
349              else:
350                  try:
351                      self._plot(int(x0), int(y0), self._pencolor)
352                  except IndexError:
353                      pass
354                  self._x = x0
355                  self._y = y0
356                  self._poller.poke((int(x0), int(y0)))
357                  time.sleep(0.003)
358              err -= dy
359              if err < 0:
360                  y0 += ystep
361                  err += dx
362              if rev:
363                  x0 -= 1
364              else:
365                  x0 += 1
366  
367      def _handle_palette_selection(self, location):
368          selected = location[1] // self._swatch_height
369          if selected >= self._number_of_palette_options:
370              return
371          self._logger.debug("Palette selection: %d", selected)
372          if selected < len(Color.colors):
373              self._pencolor = selected
374          else:
375              self._brush = selected - len(Color.colors)
376              self._poller.set_cursor_bitmap(self._cursor_bitmaps[self._brush])
377  
378      def _handle_motion(self, start, end):
379          self._logger.debug(
380              "Moved: (%d, %d) -> (%d, %d)", start[0], start[1], end[0], end[1]
381          )
382          self._draw_line(start, end)
383  
384      def _handle_a_press(self, location):
385          self._logger.debug("A Pressed!")
386          if location[0] < self._w // 10:  # in color picker
387              self._handle_palette_selection(location)
388          else:
389              self._plot(location[0], location[1], self._pencolor)
390              self._poller.poke()
391  
392      def _handle_a_release(self, location):
393          self._logger.debug("A Released!")
394  
395      @property
396      def _was_a_just_pressed(self):
397          return self._a_pressed and not self._last_a_pressed
398  
399      @property
400      def _was_a_just_released(self):
401          return not self._a_pressed and self._last_a_pressed
402  
403      @property
404      def _did_move(self):
405          if self._location is not None and self._last_location is not None:
406              x_changed = self._location[0] != self._last_location[0]
407              y_changed = self._location[1] != self._last_location[1]
408              return x_changed or y_changed
409          else:
410              return False
411  
412      def _update(self):
413          self._last_a_pressed, self._last_location = self._a_pressed, self._location
414          self._a_pressed, self._location = self._poller.poll()
415  
416      def run(self):
417          """Run the painting program."""
418          while True:
419              self._update()
420              if self._was_a_just_pressed:
421                  self._handle_a_press(self._location)
422              elif self._was_a_just_released:
423                  self._handle_a_release(self._location)
424              if self._did_move and self._a_pressed:
425                  self._handle_motion(self._last_location, self._location)
426              time.sleep(0.1)
427  
428  
429  painter = Paint()
430  painter.run()