diff --git a/CircuitPython_PyPaint/code.py b/CircuitPython_PyPaint/code.py index b0bbf3620..d0e919dbf 100644 --- a/CircuitPython_PyPaint/code.py +++ b/CircuitPython_PyPaint/code.py @@ -16,12 +16,26 @@ Licensed under the MIT license. All text above must be included in any redistribution. """ +import array import gc import time +import os +import supervisor import board import displayio import adafruit_logging as logging +try: + import usb.core + usb_available = True +except ImportError: + usb_available = False + +try: + from adafruit_fruitjam.peripherals import request_display_config + from adafruit_fruitjam.peripherals import VALID_DISPLAY_SIZES +except ImportError: + request_display_config = None try: import adafruit_touchscreen except ImportError: @@ -91,9 +105,9 @@ class TouchscreenPoller(object): 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 + return True, False, p else: - return False, None + return False, False, None def poke(self, location=None): """Force a bitmap refresh.""" @@ -143,7 +157,7 @@ class CursorPoller(object): self._mouse_cursor.x + self._x_offset, self._mouse_cursor.y + self._y_offset, ) - return a_button, location + return a_button, False, location def poke(self, x=None, y=None): """Force a bitmap refresh.""" @@ -161,9 +175,281 @@ class CursorPoller(object): ################################################################################ +class MousePoller(object): + """Get 'pressed' and location updates from a USB mouse.""" + + def __init__(self, splash, cursor_bmp, screen_width, screen_height): + self._logger = logging.getLogger("Paint") + if not self._logger.hasHandlers(): + self._logger.addHandler(logging.StreamHandler()) + self._logger.debug("Creating a MousePoller") + self._display_grp = splash + 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 + + self.SCREEN_WIDTH = screen_width + self.SCREEN_HEIGHT = screen_height + + # Mouse state + self.last_left_button_state = 0 + self.last_right_button_state = 0 + self.left_button_pressed = False + self.right_button_pressed = False + self.mouse = None + self.buf = None + self.in_endpoint = None + + # Mouse position + self.mouse_x = screen_width // 2 + self.mouse_y = screen_height // 2 + + mouse_found = False + if self.find_mouse(): + mouse_found = True + else: + self._logger.debug("WARNING: Mouse not found after multiple attempts.") + self._logger.debug("The application will run, but mouse control may not work.") + + + def find_mouse(self): + """Find the mouse device with multiple retry attempts""" + MAX_ATTEMPTS = 5 + RETRY_DELAY = 1 # seconds + + if not usb_available: + self._logger.debug("USB library not available; cannot find mouse.") + return False + + for attempt in range(MAX_ATTEMPTS): + try: + self._logger.debug(f"Mouse detection attempt {attempt+1}/{MAX_ATTEMPTS}") + + # Constants for USB control transfers + DIR_OUT = 0 + # DIR_IN = 0x80 # Unused variable + REQTYPE_CLASS = 1 << 5 + REQREC_INTERFACE = 1 << 0 + HID_REQ_SET_PROTOCOL = 0x0B + + # Find all USB devices + devices_found = False + + for device in usb.core.find(find_all=True): + devices_found = True + self._logger.debug(f"Found device: {device.idVendor:04x}:{device.idProduct:04x}") + + try: + # Try to get device info + try: + manufacturer = device.manufacturer + product = device.product + except Exception: # pylint: disable=broad-except + manufacturer = "Unknown" + product = "Unknown" + + # Just use whatever device we find + self.mouse = device + + # Try to detach kernel driver + try: + has_kernel_driver = hasattr(device, 'is_kernel_driver_active') + if has_kernel_driver and device.is_kernel_driver_active(0): + device.detach_kernel_driver(0) + except Exception as e: # pylint: disable=broad-except + self._logger.debug(f"Error detaching kernel driver: {e}") + + # Set configuration + try: + device.set_configuration() + except Exception as e: # pylint: disable=broad-except + self._logger.debug(f"Error setting configuration: {e}") + continue # Try next device + + # Just assume endpoint 0x81 (common for mice) + self.in_endpoint = 0x81 + self._logger.debug(f"Using mouse: {manufacturer}, {product}") + + # Set to report protocol mode + try: + bmRequestType = DIR_OUT | REQTYPE_CLASS | REQREC_INTERFACE + bRequest = HID_REQ_SET_PROTOCOL + wValue = 1 # 1 = report protocol + wIndex = 0 # First interface + + buf = bytearray(1) + device.ctrl_transfer(bmRequestType, bRequest, wValue, wIndex, buf) + self._logger.debug("Set to report protocol mode") + except Exception as e: # pylint: disable=broad-except + self._logger.debug(f"Could not set protocol: {e}") + + # Buffer for reading data + self.buf = array.array("B", [0] * 4) + self._logger.debug("Created 4-byte buffer for mouse data") + + # Verify mouse works by reading from it + try: + # Try to read some data with a short timeout + data = device.read(self.in_endpoint, self.buf, timeout=100) + self._logger.debug(f"Mouse test read successful: {data} bytes") + return True + except usb.core.USBTimeoutError: + # Timeout is normal if mouse isn't moving + self._logger.debug("Mouse connected but not sending data (normal)") + return True + except Exception as e: # pylint: disable=broad-except + self._logger.debug(f"Mouse test read failed: {e}") + # Continue to try next device or retry + self.mouse = None + self.in_endpoint = None + continue + + except Exception as e: # pylint: disable=broad-except + self._logger.debug(f"Error initializing device: {e}") + continue + + if not devices_found: + self._logger.debug("No USB devices found") + + # If we get here without returning, no suitable mouse was found + self._logger.debug(f"No working mouse found on attempt {attempt+1}, retrying...") + gc.collect() + time.sleep(RETRY_DELAY) + + except Exception as e: # pylint: disable=broad-except + self._logger.debug(f"Error during mouse detection: {e}") + gc.collect() + time.sleep(RETRY_DELAY) + + self._logger.debug("Failed to find a working mouse after multiple attempts") + return False + + + def poll(self): + """Check for input. Returns contact (a bool), False (no button B), + and it's location ((x,y) or None)""" + + if self._process_mouse_input(): + self._cursor_grp.x = self.mouse_x - self._x_offset + self._cursor_grp.y = self.mouse_y - self._y_offset + return self.left_button_pressed, self.right_button_pressed, \ + (self.mouse_x, self.mouse_y) + else: + return False, 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() + + def _process_mouse_input(self): + """Process mouse input - simplified version without wheel support""" + + try: + # Attempt to read data from the mouse (10ms timeout) + count = self.mouse.read(self.in_endpoint, self.buf, timeout=10) + + except usb.core.USBTimeoutError: + # Timeout is normal if mouse isn't moving + return False + except usb.core.USBError as e: + # Handle timeouts silently + if e.errno == 110: # Operation timed out + return False + + # Handle disconnections + if e.errno == 19: # No such device + self._logger.debug("Mouse disconnected") + self.mouse = None + self.in_endpoint = None + gc.collect() + + return False + except Exception as e: # pylint: disable=broad-except + self._logger.debug(f"Error reading mouse: {type(e).__name__}") + return False + + if count >= 3: # We need at least buttons, X and Y + # Extract mouse button states + buttons = self.buf[0] + x = self.buf[1] + y = self.buf[2] + + # Convert to signed values if needed + if x > 127: + x = x - 256 + if y > 127: + y = y - 256 + + # Extract button states + current_left_button_state = buttons & 0x01 + current_right_button_state = (buttons & 0x02) >> 1 + + # Detect button presses + if current_left_button_state == 1 and self.last_left_button_state == 0: + self.left_button_pressed = True + elif current_left_button_state == 0 and self.last_left_button_state == 1: + self.left_button_pressed = False + + if current_right_button_state == 1 and self.last_right_button_state == 0: + self.right_button_pressed = True + elif current_right_button_state == 0 and self.last_right_button_state == 1: + self.right_button_pressed = False + + # Update button states + self.last_left_button_state = current_left_button_state + self.last_right_button_state = current_right_button_state + + # Update position + self.mouse_x += x + self.mouse_y += y + + # Ensure position stays within bounds + self.mouse_x = max(0, min(self.SCREEN_WIDTH - 1, self.mouse_x)) + self.mouse_y = max(0, min(self.SCREEN_HEIGHT - 1, self.mouse_y)) + + return True + + return False + +################################################################################ + class Paint(object): - def __init__(self, display=board.DISPLAY): + def __init__(self, display=None): + + if display is None: + if hasattr(board, "DISPLAY"): + display = board.DISPLAY + else: + if request_display_config is not None: + request_display_config(320, 240) + display = supervisor.runtime.display + else: + raise RuntimeError("No display found.") + self._logger = logging.getLogger("Paint") if not self._logger.hasHandlers(): self._logger.addHandler(logging.StreamHandler()) @@ -184,7 +470,7 @@ class Paint(object): ) self._splash.append(self._bg_sprite) - self._palette_bitmap = displayio.Bitmap(self._w, self._h, 5) + self._palette_bitmap = displayio.Bitmap(self._w, self._h, 8) self._palette_palette = displayio.Palette(len(Color.colors)) for i, c in enumerate(Color.colors): self._palette_palette[i] = c @@ -193,10 +479,11 @@ class Paint(object): ) self._splash.append(self._palette_sprite) - self._fg_bitmap = displayio.Bitmap(self._w, self._h, 5) - self._fg_palette = displayio.Palette(len(Color.colors)) + self._fg_bitmap = displayio.Bitmap(self._w, self._h, 9) + self._fg_palette = displayio.Palette(len(Color.colors)+1) for i, c in enumerate(Color.colors): self._fg_palette[i] = c + self._fg_palette[8] = Color.PINK # Marker for filled areas self._fg_sprite = displayio.TileGrid( self._fg_bitmap, pixel_shader=self._fg_palette, x=0, y=0 ) @@ -226,18 +513,24 @@ class Paint(object): self._poller = TouchscreenPoller(self._splash, self._cursor_bitmaps[0]) elif hasattr(board, "BUTTON_CLOCK"): self._poller = CursorPoller(self._splash, self._cursor_bitmaps[0]) + elif usb_available: + self._poller = MousePoller(self._splash, self._cursor_bitmaps[0], self._w, self._h) + if not self._poller.mouse: + raise RuntimeError("No mouse found. Please connect a USB mouse.") else: raise AttributeError("PyPaint requires a touchscreen or cursor.") self._a_pressed = False self._last_a_pressed = False + self._b_pressed = False + self._last_b_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_bitmap = displayio.Bitmap(self._w // 10, self._h, 8) self._palette_palette = displayio.Palette(len(Color.colors)) for i, c in enumerate(Color.colors): self._palette_palette[i] = c @@ -364,6 +657,73 @@ class Paint(object): else: x0 += 1 + def _fill(self, x, y, c): + """Fill an area with the current color. + + :param x: x coordinate to start filling from + :param y: y coordinate to start filling from + :param c: color to fill with + """ + MARKER = 8 # Marker for filled areas + self._logger.debug("Filling at (%d, %d) with color %d", x, y, c) + + if self._fg_bitmap[x, y] != c: + blank_color = self._fg_bitmap[x, y] + self._fg_bitmap[x, y] = MARKER + done = False + min_row = 0 + max_row = self._h - 1 + min_col = (self._w // 10) + 1 + max_col = self._w - 1 + while not done: + newmin_row = self._h - 1 + newmax_row = 0 + newmin_col = self._w - 1 + newmax_col = (self._w // 10) + 1 + done = True + #self._logger.debug("Rows: %d to %d Cols: %d to %d" , min_row, max_row, min_col, max_col) + for i in range(min_row,max_row): + for j in range(min_col,max_col): + if self._fg_bitmap[j, i] == MARKER: + newmarker = False + if j > self._w // 10 and self._fg_bitmap[j - 1, i] == blank_color: + self._fg_bitmap[j - 1, i] = MARKER + newmarker = True + if j < self._w - 1 and self._fg_bitmap[j + 1, i] == blank_color: + self._fg_bitmap[j + 1, i] = MARKER + newmarker = True + if i > 0 and self._fg_bitmap[j, i - 1] == blank_color: + self._fg_bitmap[j, i - 1] = MARKER + newmarker = True + if i < self._h - 1 and self._fg_bitmap[j, i + 1] == blank_color: + self._fg_bitmap[j, i + 1] = MARKER + newmarker = True + + if newmarker: + done = False + if i < newmin_row: + newmin_row = i + if i > newmax_row: + newmax_row = i + if j < newmin_col: + newmin_col = j + if j > newmax_col: + newmax_col = j + + max_row = min(newmax_row + 2, self._h - 1) + min_row = max(newmin_row - 2, 0) + max_col = min(newmax_col + 2, self._w - 1) + min_col = max(newmin_col - 2, (self._w // 10) + 1) + + self._poller.poke() + + for i in range(self._h): + for j in range(self._w): + if self._fg_bitmap[j, i] == MARKER: + self._fg_bitmap[j, i] = c + + self._poller.poke() + def _handle_palette_selection(self, location): selected = location[1] // self._swatch_height if selected >= self._number_of_palette_options: @@ -392,6 +752,12 @@ class Paint(object): def _handle_a_release(self, location): self._logger.debug("A Released!") + def _handle_b_release(self, location): + self._logger.debug("B Released!") + if location[0] >= self._w // 10: # not in color picker + self._fill(location[0], location[1], self._pencolor) + self._poller.poke() + @property def _was_a_just_pressed(self): return self._a_pressed and not self._last_a_pressed @@ -400,6 +766,10 @@ class Paint(object): def _was_a_just_released(self): return not self._a_pressed and self._last_a_pressed + @property + def _was_b_just_released(self): + return not self._b_pressed and self._last_b_pressed + @property def _did_move(self): if self._location is not None and self._last_location is not None: @@ -410,8 +780,11 @@ class Paint(object): 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() + self._last_a_pressed = self._a_pressed + self._last_b_pressed = self._b_pressed + if self._location is not None: + self._last_location = self._location + self._a_pressed, self._b_pressed, self._location = self._poller.poll() def run(self): """Run the painting program.""" @@ -421,6 +794,8 @@ class Paint(object): self._handle_a_press(self._location) elif self._was_a_just_released: self._handle_a_release(self._location) + if self._was_b_just_released: + self._handle_b_release(self._last_location) if self._did_move and self._a_pressed: self._handle_motion(self._last_location, self._location) time.sleep(0.1) diff --git a/CircuitPython_PyPaint/icon.bmp b/CircuitPython_PyPaint/icon.bmp new file mode 100644 index 000000000..ed1e7b918 Binary files /dev/null and b/CircuitPython_PyPaint/icon.bmp differ diff --git a/CircuitPython_PyPaint/metadata.json b/CircuitPython_PyPaint/metadata.json new file mode 100644 index 000000000..21c325ddc --- /dev/null +++ b/CircuitPython_PyPaint/metadata.json @@ -0,0 +1,4 @@ +{ + "title": "PyPaint", + "icon": "icon.bmp" +}