From 3b2d301c1db539575a932cc263dbc6551fd758d6 Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Thu, 15 Oct 2026 02:15:48 -0500 Subject: [PATCH 1/4] displaydev: draw a whole blit in one JavaScript call; add WorkerDisplay for an OffscreenCanvas in a Web Worker PSDisplay.blit_rect converted RGB565 one pixel at a time through the Python-to-JavaScript bridge, four calls a pixel: about two seconds for a full 1280x720 LVGL frame. It now converts the whole buffer in one JavaScript call, byte for byte the same pixels. The drawing and the input handling move into two shared helpers (_webcanvas, _webinput) so a second canvas display can use them: WorkerDisplay draws on an OffscreenCanvas the page transfers to a Web Worker and takes its input from events the page relays. A board config, board_configs/workerdisplay, pairs them, and LVGL's display_driver runs on it unchanged. --- board_configs/workerdisplay/board_config.py | 24 ++ .../workerdisplay/board_peripherals.py | 74 ++++ board_configs/workerdisplay/package.json | 19 + lib/displaydev/__init__.py | 8 + lib/displaydev/_webcanvas.py | 184 ++++++++ lib/displaydev/_webinput.py | 259 ++++++++++++ lib/displaydev/psdisplay.py | 393 +----------------- lib/displaydev/workerdisplay.py | 160 +++++++ mip-split.toml | 6 +- pydevices-desktop.toml | 3 + scripts/validate_board_mip_installers.py | 1 + tests/test_workerdisplay.py | 125 ++++++ 12 files changed, 870 insertions(+), 386 deletions(-) create mode 100644 board_configs/workerdisplay/board_config.py create mode 100644 board_configs/workerdisplay/board_peripherals.py create mode 100644 board_configs/workerdisplay/package.json create mode 100644 lib/displaydev/_webcanvas.py create mode 100644 lib/displaydev/_webinput.py create mode 100644 lib/displaydev/workerdisplay.py create mode 100644 tests/test_workerdisplay.py diff --git a/board_configs/workerdisplay/board_config.py b/board_configs/workerdisplay/board_config.py new file mode 100644 index 00000000..1f676b48 --- /dev/null +++ b/board_configs/workerdisplay/board_config.py @@ -0,0 +1,24 @@ +""" +Board configuration for Python running in a Web Worker. + +The page transfers its canvas to the worker as an ``OffscreenCanvas`` and +sets it on the worker's global scope as ``display_canvas`` before this runs; +see ``displaydev.workerdisplay`` for the page side and the input relay. +""" + +from boarddev import env_int +from displaydev.workerdisplay import WorkerDisplay +from js import display_canvas + +width = env_int("PYDEVICES_WIDTH", int(display_canvas.width)) +height = env_int("PYDEVICES_HEIGHT", int(display_canvas.height)) + +display_drv = WorkerDisplay(display_canvas, width, height) + +host_read = display_drv.get_events + +display_drv.fill(0) + +from board_peripherals import PERIPHERALS, load_peripherals + +load_peripherals(globals()) diff --git a/board_configs/workerdisplay/board_peripherals.py b/board_configs/workerdisplay/board_peripherals.py new file mode 100644 index 00000000..bd26f947 --- /dev/null +++ b/board_configs/workerdisplay/board_peripherals.py @@ -0,0 +1,74 @@ +"""Audio roles for the Web Worker display board package. + +Audio roles follow the shared contract (docs/audio.md): one name, one return +type. ``pcm_out``/``pcm_in`` hand back a raw ``PCMOutput``/``PCMInput``; +``audio_out`` hands back an ``AudioOut`` sample player. There is no +``audio_in`` -- capture has no player layer. +""" + +import sys + +import boarddev +from audiodev import AudioCapability, AudioFactory, AudioFormat, adapt_channels, negotiate + +PERIPHERALS = frozenset({"audio_out", "pcm_out", "pcm_in"}) + +# Audio roles are factories: first attribute access must not construct, so a +# caller can pass a format. See boarddev.bind_lazy. +FACTORY_ROLES = PERIPHERALS + +_DEFAULT = AudioFormat(24000, 1, 16) + +# A host mixer takes whatever it is handed and resamples in the OS layer, so +# rates are continuous and both channel counts open natively. +AUDIO_OUT = AudioCapability( + _DEFAULT, rates=None, channels=(1, 2), native_channels=2, bits=(8, 16, 32) +) +AUDIO_IN = AudioCapability( + _DEFAULT, rates=None, channels=(1, 2), native_channels=1, bits=(8, 16, 32) +) + + +def load_peripherals(ns): + boarddev.bind_lazy(ns, sys.modules[__name__]) + + +def _backend(): + from audiodev import web_audio + + return web_audio + + +def _pcm_out(format=None, **kwargs): + """Raw PCM sink at *format* (``None`` = this host's default).""" + wire, source = negotiate(AUDIO_OUT, format) + device = _backend().pcm_out(wire, **kwargs) + if source is not wire: + from audiodev.accel import best_remix + + device = adapt_channels(device, source, remix=best_remix()) + return device + + +def _pcm_in(format=None, **kwargs): + """Raw PCM source at *format* (``None`` = this host's default).""" + wire, _ = negotiate(AUDIO_IN, format) + return _backend().pcm_in(wire, **kwargs) + + +def _audio_out(format=None, **kwargs): + """``AudioOut`` sample player: ``play(sample, loop=)``/``stop()``/ + ``pause()``/``resume()``/``playing`` over any CircuitPython-shaped + audiosample. Requires audiodsp; use ``pcm_out`` if you only have PCM.""" + from audiodev.sample_out import AudioOut + + pump = {} + for key in ("chunk_ms", "lookahead_chunks", "max_catchup_chunks"): + if key in kwargs: + pump[key] = kwargs.pop(key) + return AudioOut(_pcm_out(format, **kwargs), **pump) + + +pcm_out = AudioFactory(_pcm_out, AUDIO_OUT) +pcm_in = AudioFactory(_pcm_in, AUDIO_IN) +audio_out = AudioFactory(_audio_out, AUDIO_OUT) diff --git a/board_configs/workerdisplay/package.json b/board_configs/workerdisplay/package.json new file mode 100644 index 00000000..cf67098b --- /dev/null +++ b/board_configs/workerdisplay/package.json @@ -0,0 +1,19 @@ +{ + "urls": [ + [ + "board_config.py", + "github:PyDevices/pydevices/board_configs/workerdisplay/board_config.py" + ], + [ + "board_peripherals.py", + "github:PyDevices/pydevices/board_configs/workerdisplay/board_peripherals.py" + ] + ], + "deps": [ + [ + "pydevices-desktop", + "latest" + ] + ], + "version": "0.1" +} diff --git a/lib/displaydev/__init__.py b/lib/displaydev/__init__.py index 8d89db2c..1c004153 100644 --- a/lib/displaydev/__init__.py +++ b/lib/displaydev/__init__.py @@ -120,6 +120,14 @@ def capabilities(): "touch_scale": "canvas layout scale", "scroll_emulation": True, }, + "workerdisplay": { + "appdev": True, + "auto_refresh": True, + "default_period_ms": _DEFAULT_AUTO_REFRESH_PERIOD, + "async_default": True, + "touch_scale": "canvas layout scale (the page relays it)", + "scroll_emulation": True, + }, "jndisplay": { "appdev": True, "auto_refresh": True, diff --git a/lib/displaydev/_webcanvas.py b/lib/displaydev/_webcanvas.py new file mode 100644 index 00000000..b05a5a9e --- /dev/null +++ b/lib/displaydev/_webcanvas.py @@ -0,0 +1,184 @@ +# SPDX-FileCopyrightText: 2024 Brad Barnett +# +# SPDX-License-Identifier: MIT + +""" +displaydev._webcanvas + +The drawing half shared by the PyScript canvas (:class:`PSDisplay`) and the +Web Worker canvas (:class:`WorkerDisplay`): an offscreen buffer the size of +the panel, copied to the visible canvas with vertical scroll applied. + +``blit_rect`` converts a whole RGB565 buffer to RGBA in one JavaScript call. +Going through the Python-to-JavaScript bridge once per pixel costs about two +seconds for a full 1280x720 frame; one call costs a few milliseconds. +""" + +from displaydev import color_rgb +from displaydev._desktop import DesktopDisplay + +_blit565 = None + + +def blit565(ctx, buf, x, y, w, h): + """Put a little-endian RGB565 buffer on a 2D context at ``(x, y)``.""" + global _blit565 + from pyodide.ffi import to_js + + if _blit565 is None: + from js import Function + + _blit565 = Function.new( + "ctx", "src", "x", "y", "w", "h", + """ + const img = ctx.createImageData(w, h); + const out = new Uint32Array(img.data.buffer); + for (let i = 0, j = 0, n = w * h; j < n; i += 2, j++) { + const lo = src[i], hi = src[i + 1]; + const r = (hi & 0xF8) | ((hi >> 5) & 0x07); + const g = ((hi << 5) & 0xE0) | ((lo >> 3) & 0x1F); + const b = ((lo << 3) & 0xF8) | ((lo >> 2) & 0x07); + out[j] = 0xFF000000 | (b << 16) | (g << 8) | r; + } + ctx.putImageData(img, x, y); + """, + ) + _blit565(ctx, to_js(memoryview(buf)), x, y, w, h) + + +class CanvasDisplay(DesktopDisplay): + """A panel drawn on an HTML canvas or an OffscreenCanvas. + + Subclasses pass the visible canvas and provide :meth:`_new_buffer`, which + makes the offscreen canvas everything is drawn to first. + """ + + needs_refresh = True + + def __init__(self, canvas, width=None, height=None, *, quiet=False): + self._canvas = canvas + self._vis_ctx = canvas.getContext("2d") + self._buffer = None + self._buf_ctx = None + self._width = width or canvas.width + self._height = height or canvas.height + self._requires_byteswap = False + self._rotation = 0 + self.color_depth = 16 + super().__init__(quiet=quiet) + + def _new_buffer(self, width, height): + raise NotImplementedError + + ############### Required API Methods ################ + + def init(self) -> None: + """ + Initializes the display instance. Called by __init__ and rotation setter. + """ + self._canvas.width = self.width + self._canvas.height = self.height + if self._buffer is None: + self._buffer = self._new_buffer(self.width, self.height) + self._buf_ctx = self._buffer.getContext("2d") + self._buffer.width = self.width + self._buffer.height = self.height + # Match PGDisplay / SDLDisplay / JNDisplay: default full-height scroll region + # so vscsad() works before an explicit set_vscroll/vscrdef. + self._reset_vscroll() + + def fill_rect(self, x, y, w, h, c): + """ + Fills a rectangle with the given color. + + Args: + x (int): The x-coordinate of the top-left corner of the rectangle. + y (int): The y-coordinate of the top-left corner of the rectangle. + w (int): The width of the rectangle. + h (int): The height of the rectangle. + c (int): The color to fill the rectangle with. + + Returns: + (tuple): A tuple containing the x, y, w, h values + """ + r, g, b = color_rgb(c) + self._buf_ctx.fillStyle = f"rgb({r},{g},{b})" + self._buf_ctx.fillRect(x, y, w, h) + self.render((x, y, w, h)) + return (x, y, w, h) + + def blit_rect(self, buf, x, y, w, h): + """ + Blits a buffer to the display. + + Args: + buf (bytearray): The buffer to blit, RGB565 little-endian. + x (int): The x-coordinate of the top-left corner of the buffer. + y (int): The y-coordinate of the top-left corner of the buffer. + w (int): The width of the buffer. + h (int): The height of the buffer. + + Returns: + (tuple): A tuple containing the x, y, w, h values + """ + BPP = self.color_depth // 8 + if x < 0 or y < 0 or x + w > self.width or y + h > self.height: + raise ValueError("The provided x, y, w, h values are out of range") + if len(buf) != w * h * BPP: + raise ValueError("The source buffer is not the correct size") + blit565(self._buf_ctx, buf, x, y, w, h) + self.render((x, y, w, h)) + return (x, y, w, h) + + def pixel(self, x, y, c): + """ + Sets a pixel to the given color. + + Args: + x (int): The x-coordinate of the pixel. + y (int): The y-coordinate of the pixel. + c (int): The color to set the pixel to. + + Returns: + (tuple): A tuple containing the x, y, w & h values. + """ + return self.fill_rect(x, y, 1, 1, c) + + ############### Scrolling (ILI9341-style, like SDLDisplay / PGDisplay) ################ + + def _scroll_changed(self) -> None: + """Repaint immediately; this backend has no deferred-render tick.""" + self.render() + + def render(self, render_rect=None) -> None: + """Copy the offscreen buffer to the visible canvas, applying vertical scroll.""" + buf = self._buffer + vis = self._vis_ctx + w, h = self.width, self.height + y_start = self.vscsad() + if not y_start: + if render_rect is not None: + x, y, rw, rh = render_rect + vis.drawImage(buf, x, y, rw, rh, x, y, rw, rh) + else: + vis.drawImage(buf, 0, 0, w, h, 0, 0, w, h) + return + + tfa = self._tfa + vsa = self._vsa + bfa = self._bfa + if tfa > 0: + vis.drawImage(buf, 0, 0, w, tfa, 0, 0, w, tfa) + + vsa_top_height = vsa + tfa - y_start + vis.drawImage(buf, 0, y_start, w, vsa_top_height, 0, tfa, w, vsa_top_height) + + vsa_btm_height = vsa - vsa_top_height + vis.drawImage(buf, 0, tfa, w, vsa_btm_height, 0, tfa + vsa_top_height, w, vsa_btm_height) + + if bfa > 0: + vis.drawImage(buf, 0, tfa + vsa, w, bfa, 0, tfa + vsa, w, bfa) + + def show(self, _timer=None) -> None: + """Present the offscreen canvas (alias of :meth:`render`).""" + self.render() diff --git a/lib/displaydev/_webinput.py b/lib/displaydev/_webinput.py new file mode 100644 index 00000000..654643be --- /dev/null +++ b/lib/displaydev/_webinput.py @@ -0,0 +1,259 @@ +# SPDX-FileCopyrightText: 2024 Brad Barnett +# +# SPDX-License-Identifier: MIT + +""" +displaydev._webinput + +Browser input turned into ``events`` objects, shared by the PyScript canvas +(:mod:`displaydev.psdisplay`, which listens on the DOM itself) and the Web +Worker display (:mod:`displaydev.workerdisplay`, which is handed the same +events by the page). Nothing here touches ``document``, so it imports in a +worker too. +""" + +import events +import keys +from displaydev._domkeys import ( + dom_key_scrolls_page, + enrich_mod, + key_to_keycode, + mod_mask, +) + +try: # Gamepads are a page API; a worker has no getGamepads(). + from js import navigator +except ImportError: + navigator = None + + +def _buttons_tuple(buttons): + """Convert a DOM ``buttons`` bitmask to an (left, middle, right) tuple.""" + return ( + 1 if buttons & 1 else 0, # left + 1 if buttons & 4 else 0, # middle + 1 if buttons & 2 else 0, # right + ) + + +class WebInput: + """Pointer, wheel, keyboard and gamepad input as an appdev HOST device. + + Subclasses decide where the browser events come from; the handlers below + turn each one into ``events`` objects, drained through :meth:`read`. + """ + + canvas = None + + def __init__(self, display=None): + self._display = display + self._queue = [] + self._pressed = set() + # Gamepad state keyed by gamepad index: (axes list, pressed-bool list). + self._gp_axes = {} + self._gp_buttons = {} + + def _focus_canvas(self): + """Move keyboard focus to the canvas so keys reach the app, not the page.""" + try: + self.canvas.focus() + except Exception: + pass + + def read(self): + """ + Returns queued input events for an appdev device. + + Polls connected gamepads (if any) and returns all events received since + the last call. + + Returns: + list or None: The events, or None if there were none. + """ + self._poll_gamepads() + if not self._queue: + return None + queued = self._queue + self._queue = [] + return queued + + ############### Pointer (mouse / touch / pen) ################ + + def _is_touch(self, e): + try: + return e.pointerType != "mouse" + except Exception: + return False + + def _map_pos(self, x, y): + try: + return (int(x), int(y)) + except Exception: + return (int(float(x)), int(float(y))) + + def _map_rel(self, dx, dy): + try: + return (int(dx), int(dy)) + except Exception: + return (int(float(dx)), int(float(dy))) + + def _local_xy(self, e): + """Element-local CSS pixels (prefer ``client*`` - rect for synthetics).""" + try: + rect = self.canvas.getBoundingClientRect() + return float(e.clientX) - float(rect.left), float(e.clientY) - float(rect.top) + except Exception: + return float(e.offsetX), float(e.offsetY) + + def _pointer_pos(self, e): + """Framebuffer pixels for host scaling / LVGL fingers.""" + x, y = self._local_xy(e) + # Fingers must be panel-normalized (SDL contract); mouse stays CSS and + # HostEventsDevice applies ``touch_scale``. + if self._is_touch(e) and self._display is not None: + try: + return self._display.map_pointer(x, y) + except Exception: + pass + return self._map_pos(x, y) + + def _finger_id(self, e): + try: + return int(e.pointerId) + except Exception: + return 0 + + def _on_pointer_down(self, e): + self._focus_canvas() + try: + self.canvas.setPointerCapture(e.pointerId) + except Exception: + pass + pos = self._pointer_pos(e) + if self._is_touch(e): + # Multipoint path for LVGL gestures (VirtualDevices tracks fingers). + self._queue.append(events.Finger(events.FINGERDOWN, pos, self._finger_id(e), None)) + self._queue.append( + events.Button( + events.MOUSEBUTTONDOWN, + pos, + getattr(e, "button", 0) + 1, # DOM 0/1/2 -> SDL 1/2/3 + False, + None, + ) + ) + + def _on_pointer_up(self, e): + pos = self._pointer_pos(e) + if self._is_touch(e): + self._queue.append(events.Finger(events.FINGERUP, pos, self._finger_id(e), None)) + self._queue.append( + events.Button( + events.MOUSEBUTTONUP, + pos, + getattr(e, "button", 0) + 1, + False, + None, + ) + ) + + def _on_pointer_move(self, e): + pos = self._pointer_pos(e) + if self._is_touch(e): + self._queue.append(events.Finger(events.FINGERMOTION, pos, self._finger_id(e), None)) + return + self._queue.append( + events.Motion( + events.MOUSEMOTION, + pos, + self._map_rel(e.movementX, e.movementY), + _buttons_tuple(e.buttons), + False, + None, + ) + ) + + def _on_wheel(self, e): + try: + e.preventDefault() + except Exception: + pass + # DOM deltaY > 0 means scrolling down; SDL/PyGame report up as positive. + x = -1 if e.deltaX < 0 else (1 if e.deltaX > 0 else 0) + y = 1 if e.deltaY < 0 else (-1 if e.deltaY > 0 else 0) + self._queue.append( + events.Wheel(events.MOUSEWHEEL, False, x, y, e.deltaX, e.deltaY, False, None) + ) + + def _on_contextmenu(self, e): + # Suppress the browser menu so right-click is usable as a button event. + try: + e.preventDefault() + except Exception: + pass + + ############### Keyboard ################ + + def _enqueue_key(self, type, keycode, mod): + self._queue.append(events.Key(type, keys.keyname(keycode), keycode, mod, 0, None)) + + def _suppress_browser_scroll(self, e, keycode): + if dom_key_scrolls_page(keycode): + try: + e.preventDefault() + except Exception: + pass + + def _on_keydown(self, e): + keycode = key_to_keycode(e.key, e.location) + self._suppress_browser_scroll(e, keycode) + if e.repeat: # ignore auto-repeat + return + self._pressed.add(keycode) + mod = enrich_mod(mod_mask(e.ctrlKey, e.shiftKey, e.altKey, e.metaKey), self._pressed) + self._enqueue_key(events.KEYDOWN, keycode, mod) + + def _on_keyup(self, e): + keycode = key_to_keycode(e.key, e.location) + self._suppress_browser_scroll(e, keycode) + self._pressed.discard(keycode) + mod = enrich_mod(mod_mask(e.ctrlKey, e.shiftKey, e.altKey, e.metaKey), self._pressed) + self._enqueue_key(events.KEYUP, keycode, mod) + + ############### Gamepad ################ + + def _poll_gamepads(self): + if navigator is None: + return + try: + pads = navigator.getGamepads() + except Exception: + return + for i in range(pads.length): + pad = pads[i] + if not pad: + continue + gid = pad.index + + axes = pad.axes + cur_axes = [float(axes[a]) for a in range(axes.length)] + prev_axes = self._gp_axes.get(gid, [0.0] * len(cur_axes)) + for a in range(len(cur_axes)): + prev = prev_axes[a] if a < len(prev_axes) else 0.0 + if cur_axes[a] != prev: + self._queue.append( + events.JoyAxisMotion(events.JOYAXISMOTION, gid, a, cur_axes[a]) + ) + self._gp_axes[gid] = cur_axes + + btns = pad.buttons + cur_btns = [bool(btns[b].pressed) for b in range(btns.length)] + prev_btns = self._gp_buttons.get(gid, [False] * len(cur_btns)) + for b in range(len(cur_btns)): + prev = prev_btns[b] if b < len(prev_btns) else False + if cur_btns[b] != prev: + if cur_btns[b]: + self._queue.append(events.JoyButtonDown(events.JOYBUTTONDOWN, gid, b)) + else: + self._queue.append(events.JoyButtonUp(events.JOYBUTTONUP, gid, b)) + self._gp_buttons[gid] = cur_btns diff --git a/lib/displaydev/psdisplay.py b/lib/displaydev/psdisplay.py index 23657176..74b10eae 100644 --- a/lib/displaydev/psdisplay.py +++ b/lib/displaydev/psdisplay.py @@ -15,37 +15,16 @@ except ImportError: create_proxy = lambda x: x -from displaydev import color_rgb -from displaydev._desktop import DesktopDisplay -from displaydev._domkeys import ( - dom_key_scrolls_page, - enrich_mod, - key_to_keycode, - mod_mask, -) -import events +from displaydev._webcanvas import CanvasDisplay +from displaydev._webinput import WebInput, _buttons_tuple # noqa: F401 (re-exported) import keys -try: # Gamepad polling is optional and only available in a browser. - from js import navigator -except ImportError: - navigator = None - def log(*args): console.log(*args) -def _buttons_tuple(buttons): - """Convert a DOM ``buttons`` bitmask to an (left, middle, right) tuple.""" - return ( - 1 if buttons & 1 else 0, # left - 1 if buttons & 4 else 0, # middle - 1 if buttons & 2 else 0, # right - ) - - -class PSDevices: +class PSDevices(WebInput): """ Unified input for a PyScript canvas, registered as an appdev HOST device. @@ -82,8 +61,8 @@ class PSDevices: """ def __init__(self, id, display=None): + super().__init__(display) self.canvas = document.getElementById(id) - self._display = display # Clean up any existing PSDevices listeners attached to this canvas old_devices = getattr(self.canvas, "_ps_devices", None) @@ -100,12 +79,6 @@ def __init__(self, id, display=None): except Exception: pass - self._queue = [] - self._pressed = set() - - # Gamepad state keyed by gamepad index: (axes list, pressed-bool list). - self._gp_axes = {} - self._gp_buttons = {} # Proxy functions are required for javascript self._proxies = { @@ -136,213 +109,8 @@ def deinit(self): if hasattr(self, "_queue"): self._queue.clear() - def _focus_canvas(self): - """Move keyboard focus to the canvas so keys reach the game, not the page.""" - try: - self.canvas.focus() - except Exception: - pass - - def read(self): - """ - Returns queued input events for an appdev device. - - Polls connected gamepads (if any) and returns all events received since - the last call. - - Returns: - list or None: The events, or None if there were none. - """ - self._poll_gamepads() - if not self._queue: - return None - queued = self._queue - self._queue = [] - return queued - - ############### Pointer (mouse / touch / pen) ################ - - def _is_touch(self, e): - try: - return e.pointerType != "mouse" - except Exception: - return False - - def _map_pos(self, x, y): - try: - return (int(x), int(y)) - except Exception: - return (int(float(x)), int(float(y))) - def _map_rel(self, dx, dy): - try: - return (int(dx), int(dy)) - except Exception: - return (int(float(dx)), int(float(dy))) - - def _local_xy(self, e): - """Element-local CSS pixels (prefer ``client*`` - rect for synthetics).""" - try: - rect = self.canvas.getBoundingClientRect() - return float(e.clientX) - float(rect.left), float(e.clientY) - float(rect.top) - except Exception: - return float(e.offsetX), float(e.offsetY) - - def _pointer_pos(self, e): - """Framebuffer pixels for host scaling / LVGL fingers.""" - x, y = self._local_xy(e) - # Fingers must be panel-normalized (SDL contract); mouse stays CSS and - # HostEventsDevice applies ``touch_scale``. - if self._is_touch(e) and self._display is not None: - try: - return self._display.map_pointer(x, y) - except Exception: - pass - return self._map_pos(x, y) - - def _finger_id(self, e): - try: - return int(e.pointerId) - except Exception: - return 0 - - def _on_pointer_down(self, e): - self._focus_canvas() - try: - self.canvas.setPointerCapture(e.pointerId) - except Exception: - pass - pos = self._pointer_pos(e) - if self._is_touch(e): - # Multipoint path for LVGL gestures (VirtualDevices tracks fingers). - self._queue.append(events.Finger(events.FINGERDOWN, pos, self._finger_id(e), None)) - self._queue.append( - events.Button( - events.MOUSEBUTTONDOWN, - pos, - getattr(e, "button", 0) + 1, # DOM 0/1/2 -> SDL 1/2/3 - False, - None, - ) - ) - - def _on_pointer_up(self, e): - pos = self._pointer_pos(e) - if self._is_touch(e): - self._queue.append(events.Finger(events.FINGERUP, pos, self._finger_id(e), None)) - self._queue.append( - events.Button( - events.MOUSEBUTTONUP, - pos, - getattr(e, "button", 0) + 1, - False, - None, - ) - ) - - def _on_pointer_move(self, e): - pos = self._pointer_pos(e) - if self._is_touch(e): - self._queue.append(events.Finger(events.FINGERMOTION, pos, self._finger_id(e), None)) - return - self._queue.append( - events.Motion( - events.MOUSEMOTION, - pos, - self._map_rel(e.movementX, e.movementY), - _buttons_tuple(e.buttons), - False, - None, - ) - ) - - def _on_wheel(self, e): - try: - e.preventDefault() - except Exception: - pass - # DOM deltaY > 0 means scrolling down; SDL/PyGame report up as positive. - x = -1 if e.deltaX < 0 else (1 if e.deltaX > 0 else 0) - y = 1 if e.deltaY < 0 else (-1 if e.deltaY > 0 else 0) - self._queue.append( - events.Wheel(events.MOUSEWHEEL, False, x, y, e.deltaX, e.deltaY, False, None) - ) - - def _on_contextmenu(self, e): - # Suppress the browser menu so right-click is usable as a button event. - try: - e.preventDefault() - except Exception: - pass - - ############### Keyboard ################ - - def _enqueue_key(self, type, keycode, mod): - self._queue.append(events.Key(type, keys.keyname(keycode), keycode, mod, 0, None)) - - def _suppress_browser_scroll(self, e, keycode): - if dom_key_scrolls_page(keycode): - try: - e.preventDefault() - except Exception: - pass - - def _on_keydown(self, e): - keycode = key_to_keycode(e.key, e.location) - self._suppress_browser_scroll(e, keycode) - if e.repeat: # ignore auto-repeat - return - self._pressed.add(keycode) - mod = enrich_mod(mod_mask(e.ctrlKey, e.shiftKey, e.altKey, e.metaKey), self._pressed) - self._enqueue_key(events.KEYDOWN, keycode, mod) - - def _on_keyup(self, e): - keycode = key_to_keycode(e.key, e.location) - self._suppress_browser_scroll(e, keycode) - self._pressed.discard(keycode) - mod = enrich_mod(mod_mask(e.ctrlKey, e.shiftKey, e.altKey, e.metaKey), self._pressed) - self._enqueue_key(events.KEYUP, keycode, mod) - - ############### Gamepad ################ - - def _poll_gamepads(self): - if navigator is None: - return - try: - pads = navigator.getGamepads() - except Exception: - return - for i in range(pads.length): - pad = pads[i] - if not pad: - continue - gid = pad.index - - axes = pad.axes - cur_axes = [float(axes[a]) for a in range(axes.length)] - prev_axes = self._gp_axes.get(gid, [0.0] * len(cur_axes)) - for a in range(len(cur_axes)): - prev = prev_axes[a] if a < len(prev_axes) else 0.0 - if cur_axes[a] != prev: - self._queue.append( - events.JoyAxisMotion(events.JOYAXISMOTION, gid, a, cur_axes[a]) - ) - self._gp_axes[gid] = cur_axes - - btns = pad.buttons - cur_btns = [bool(btns[b].pressed) for b in range(btns.length)] - prev_btns = self._gp_buttons.get(gid, [False] * len(cur_btns)) - for b in range(len(cur_btns)): - prev = prev_btns[b] if b < len(prev_btns) else False - if cur_btns[b] != prev: - if cur_btns[b]: - self._queue.append(events.JoyButtonDown(events.JOYBUTTONDOWN, gid, b)) - else: - self._queue.append(events.JoyButtonUp(events.JOYBUTTONUP, gid, b)) - self._gp_buttons[gid] = cur_btns - - -class PSDisplay(DesktopDisplay): +class PSDisplay(CanvasDisplay): """Emulate a display on a PyScript HTML canvas. Args: @@ -355,162 +123,17 @@ class PSDisplay(DesktopDisplay): needs_refresh (bool): True — ``appdev.App`` drives periodic ``show()``. """ - needs_refresh = True quit_chord = (keys.K_AC_BACK, 0) def __init__(self, id, width=None, height=None, *, quiet=False): self._canvas_id = id - self._canvas = document.getElementById(id) - self._vis_ctx = self._canvas.getContext("2d") - self._buffer = None - self._buf_ctx = None - self._width = width or self._canvas.width - self._height = height or self._canvas.height - self._requires_byteswap = False - self._rotation = 0 - self.color_depth = 16 - - super().__init__(quiet=quiet) + super().__init__(document.getElementById(id), width, height, quiet=quiet) # Per-canvas input; drain is the appdev.App host_read callable. self._ps_devices = PSDevices(id, self) self.get_events = self._ps_devices.read - ############### Required API Methods ################ - - def init(self) -> None: - """ - Initializes the display instance. Called by __init__ and rotation setter. - """ - self._canvas.width = self.width - self._canvas.height = self.height - if self._buffer is None: - self._buffer = document.createElement("canvas") - self._buf_ctx = self._buffer.getContext("2d") - self._buffer.width = self.width - self._buffer.height = self.height - if getattr(self, "_rgba_lut", None) is None: - lut = bytearray(65536 * 4) - for v in range(65536): - lo, hi = v & 0xFF, v >> 8 - k = v << 2 - lut[k] = (hi & 0xF8) | ((hi >> 5) & 0x07) - lut[k + 1] = ((hi << 5) & 0xE0) | ((lo >> 3) & 0x1F) - lut[k + 2] = ((lo << 3) & 0xF8) | ((lo >> 2) & 0x07) - lut[k + 3] = 255 - self._rgba_lut = lut - # Match PGDisplay / SDLDisplay / JNDisplay: default full-height scroll region - # so vscsad() works before an explicit set_vscroll/vscrdef. - self._reset_vscroll() - - def fill_rect(self, x, y, w, h, c): - """ - Fills a rectangle with the given color. - - Args: - x (int): The x-coordinate of the top-left corner of the rectangle. - y (int): The y-coordinate of the top-left corner of the rectangle. - w (int): The width of the rectangle. - h (int): The height of the rectangle. - c (int): The color to fill the rectangle with. - - Returns: - (tuple): A tuple containing the x, y, w, h values - """ - r, g, b = color_rgb(c) - self._buf_ctx.fillStyle = f"rgb({r},{g},{b})" - self._buf_ctx.fillRect(x, y, w, h) - self.render((x, y, w, h)) - return (x, y, w, h) - - def blit_rect(self, buf, x, y, w, h): - """ - Blits a buffer to the display. - - Args: - buf (bytearray): The buffer to blit. - x (int): The x-coordinate of the top-left corner of the buffer. - y (int): The y-coordinate of the top-left corner of the buffer. - w (int): The width of the buffer. - h (int): The height of the buffer. - - Returns: - (tuple): A tuple containing the x, y, w, h values - """ - BPP = self.color_depth // 8 - if x < 0 or y < 0 or x + w > self.width or y + h > self.height: - raise ValueError("The provided x, y, w, h values are out of range") - if len(buf) != w * h * BPP: - raise ValueError("The source buffer is not the correct size") - lut = self._rgba_lut - if lut is None: - self.init() - lut = self._rgba_lut - img_data = self._buf_ctx.createImageData(w, h) - data = img_data.data - j = 0 - for i in range(0, len(buf), BPP): - k = (buf[i] | (buf[i + 1] << 8)) << 2 - data[j] = lut[k] - data[j + 1] = lut[k + 1] - data[j + 2] = lut[k + 2] - data[j + 3] = lut[k + 3] - j += 4 - self._buf_ctx.putImageData(img_data, x, y) - self.render((x, y, w, h)) - return (x, y, w, h) - - def pixel(self, x, y, c): - """ - Sets a pixel to the given color. - - Args: - x (int): The x-coordinate of the pixel. - y (int): The y-coordinate of the pixel. - c (int): The color to set the pixel to. - - Returns: - (tuple): A tuple containing the x, y, w & h values. - """ - return self.fill_rect(x, y, 1, 1, c) - - ############### Scrolling (ILI9341-style, like SDLDisplay / PGDisplay) ################ - - def _scroll_changed(self) -> None: - """Repaint immediately; this backend has no deferred-render tick.""" - self.render() - - def render(self, render_rect=None) -> None: - """Copy the offscreen buffer to the visible canvas, applying vertical scroll.""" - buf = self._buffer - vis = self._vis_ctx - w, h = self.width, self.height - y_start = self.vscsad() - if not y_start: - if render_rect is not None: - x, y, rw, rh = render_rect - vis.drawImage(buf, x, y, rw, rh, x, y, rw, rh) - else: - vis.drawImage(buf, 0, 0, w, h, 0, 0, w, h) - return - - tfa = self._tfa - vsa = self._vsa - bfa = self._bfa - if tfa > 0: - vis.drawImage(buf, 0, 0, w, tfa, 0, 0, w, tfa) - - vsa_top_height = vsa + tfa - y_start - vis.drawImage(buf, 0, y_start, w, vsa_top_height, 0, tfa, w, vsa_top_height) - - vsa_btm_height = vsa - vsa_top_height - vis.drawImage(buf, 0, tfa, w, vsa_btm_height, 0, tfa + vsa_top_height, w, vsa_btm_height) - - if bfa > 0: - vis.drawImage(buf, 0, tfa + vsa, w, bfa, 0, tfa + vsa, w, bfa) - - def show(self, _timer=None) -> None: - """Present the offscreen canvas (alias of :meth:`render`).""" - self.render() + def _new_buffer(self, width, height): + return document.createElement("canvas") @property def touch_scale(self): diff --git a/lib/displaydev/workerdisplay.py b/lib/displaydev/workerdisplay.py new file mode 100644 index 00000000..674fa276 --- /dev/null +++ b/lib/displaydev/workerdisplay.py @@ -0,0 +1,160 @@ +# SPDX-FileCopyrightText: 2026 Brad Barnett +# +# SPDX-License-Identifier: MIT + +""" +displaydev.workerdisplay + +A display for Python running in a Web Worker: it draws on an +``OffscreenCanvas`` the page transferred to the worker, and takes its input +from events the page relays. + +Running the app in a worker leaves the page's main thread free, so a long +redraw never holds up anything the page has to do on time (Web MIDI, audio +hand-offs, input). The page side is a few lines of JavaScript: + +.. code-block:: javascript + + const off = canvas.transferControlToOffscreen(); + worker.postMessage({type: "canvas", canvas: off}, [off]); + // then, for every pointer, wheel and key event on the canvas: + worker.postMessage({type: "input", event: { + type: e.type, // pointerdown, pointerup, pointercancel, + // pointermove, wheel, keydown, keyup + x: e.offsetX, y: e.offsetY, // CSS pixels inside the canvas + movementX: e.movementX, movementY: e.movementY, + button: e.button, buttons: e.buttons, + pointerType: e.pointerType, pointerId: e.pointerId, + deltaX: e.deltaX, deltaY: e.deltaY, + key: e.key, location: e.location, repeat: e.repeat, + ctrlKey: e.ctrlKey, shiftKey: e.shiftKey, altKey: e.altKey, metaKey: e.metaKey, + }}); + // and whenever the canvas's CSS size changes: + worker.postMessage({type: "resize", width: rect.width, height: rect.height}); + +In the worker, hand each ``input`` event to :func:`feed` and each +``resize`` to :meth:`WorkerDisplay.set_css_size`. The events come out of +``display_drv.get_events`` exactly as :class:`PSDisplay` produces them, so +``appdev`` and LVGL's ``display_driver`` work unchanged. The page should +call ``preventDefault()`` itself on ``wheel``, ``contextmenu`` and the keys +that scroll a page; a worker can't. + +Gamepads aren't available in a worker. +""" + +from displaydev._webcanvas import CanvasDisplay +from displaydev._webinput import WebInput +import keys + +_current = None + + +class WorkerDevices(WebInput): + """Input for a :class:`WorkerDisplay`, fed by events the page relays.""" + + _HANDLERS = { + "pointerdown": "_on_pointer_down", + "pointerup": "_on_pointer_up", + "pointercancel": "_on_pointer_up", + "pointermove": "_on_pointer_move", + "wheel": "_on_wheel", + "keydown": "_on_keydown", + "keyup": "_on_keyup", + } + + def feed(self, event): + """Queue one relayed browser event (a JS object or a ``dict``).""" + if isinstance(event, dict): + event = _Event(event) + name = self._HANDLERS.get(str(event.type)) + if name: + getattr(self, name)(event) + + def _local_xy(self, e): + return float(e.x), float(e.y) + + def _poll_gamepads(self): + pass + + +class _Event: + """Attribute access over a plain ``dict`` (tests, or a converted JS object).""" + + def __init__(self, d): + self.__dict__.update(d) + + def __getattr__(self, name): + raise AttributeError(name) + + +class WorkerDisplay(CanvasDisplay): + """Emulate a display on an ``OffscreenCanvas``, from a Web Worker. + + Args: + canvas: The ``OffscreenCanvas`` the page transferred to this worker. + width (int, optional): Panel width; defaults to the canvas width. + height (int, optional): Panel height; defaults to the canvas height. + quiet (bool): Suppress init chatter when True. + """ + + quit_chord = (keys.K_AC_BACK, 0) + + def __init__(self, canvas, width=None, height=None, *, quiet=False): + global _current + self._css_size = None + super().__init__(canvas, width, height, quiet=quiet) + self._devices = WorkerDevices(self) + self.get_events = self._devices.read + self.feed = self._devices.feed + _current = self + + def _new_buffer(self, width, height): + from js import OffscreenCanvas + + return OffscreenCanvas.new(width, height) + + def set_css_size(self, width, height): + """Tell the display how big the page shows the canvas, in CSS pixels.""" + self._css_size = (float(width), float(height)) + + def _pointer_scale(self): + """Framebuffer pixels per CSS pixel (1.0 until the page says otherwise).""" + if not self._css_size: + return 1.0, 1.0 + cw, ch = self._css_size + if cw <= 0 or ch <= 0: + return 1.0, 1.0 + return float(self.width) / cw, float(self.height) / ch + + @property + def touch_scale(self): + """CSS pixels per framebuffer pixel, the same contract as ``PSDisplay``.""" + sx, _sy = self._pointer_scale() + return 1.0 / sx if sx else 1.0 + + @touch_scale.setter + def touch_scale(self, value): + # DisplayDriver.__init__ assigns a default; the page's layout decides. + pass + + def map_pointer(self, local_x, local_y): + """Map canvas-local CSS pixels to framebuffer ``(x, y)``.""" + sx, sy = self._pointer_scale() + return (int(float(local_x) * sx), int(float(local_y) * sy)) + + def map_pointer_rel(self, dx, dy): + """Map a pointer delta in CSS pixels to framebuffer pixels.""" + sx, sy = self._pointer_scale() + return (int(float(dx) * sx), int(float(dy) * sy)) + + +def feed(event): + """Hand a relayed ``input`` event to the most recent :class:`WorkerDisplay`.""" + if _current is not None: + _current.feed(event) + + +def resize(width, height): + """Hand a relayed ``resize`` to the most recent :class:`WorkerDisplay`.""" + if _current is not None: + _current.set_css_size(width, height) diff --git a/mip-split.toml b/mip-split.toml index 76787852..25afa9b9 100644 --- a/mip-split.toml +++ b/mip-split.toml @@ -30,10 +30,13 @@ [displaydev] # SDL, pygame, Windows GDI, Jupyter, PyScript, the browser canvas, Android. -# `_desktop` and `_domkeys` are shared helpers for those and nothing else. +# `_desktop`, `_domkeys`, `_webcanvas` and `_webinput` are shared helpers for +# those and nothing else. host-only = [ "_desktop", "_domkeys", + "_webcanvas", + "_webinput", "androidsdl", "jndisplay", "pgdisplay", @@ -41,6 +44,7 @@ host-only = [ "sdldisplay", "wasmdisplay", "windisplay", + "workerdisplay", ] [audiodev] diff --git a/pydevices-desktop.toml b/pydevices-desktop.toml index ad9ea30c..afa19b03 100644 --- a/pydevices-desktop.toml +++ b/pydevices-desktop.toml @@ -44,6 +44,8 @@ "https://raw.githubusercontent.com/PyDevices/pydevices/main/lib/displaydev/__init__.py" = "/lib/displaydev/__init__.py" "https://raw.githubusercontent.com/PyDevices/pydevices/main/lib/displaydev/_desktop.py" = "/lib/displaydev/_desktop.py" "https://raw.githubusercontent.com/PyDevices/pydevices/main/lib/displaydev/_domkeys.py" = "/lib/displaydev/_domkeys.py" +"https://raw.githubusercontent.com/PyDevices/pydevices/main/lib/displaydev/_webcanvas.py" = "/lib/displaydev/_webcanvas.py" +"https://raw.githubusercontent.com/PyDevices/pydevices/main/lib/displaydev/_webinput.py" = "/lib/displaydev/_webinput.py" "https://raw.githubusercontent.com/PyDevices/pydevices/main/lib/displaydev/androidsdl.py" = "/lib/displaydev/androidsdl.py" "https://raw.githubusercontent.com/PyDevices/pydevices/main/lib/displaydev/auto.py" = "/lib/displaydev/auto.py" "https://raw.githubusercontent.com/PyDevices/pydevices/main/lib/displaydev/busdisplay.py" = "/lib/displaydev/busdisplay.py" @@ -52,6 +54,7 @@ "https://raw.githubusercontent.com/PyDevices/pydevices/main/lib/displaydev/pgdisplay.py" = "/lib/displaydev/pgdisplay.py" "https://raw.githubusercontent.com/PyDevices/pydevices/main/lib/displaydev/pixeldisplay.py" = "/lib/displaydev/pixeldisplay.py" "https://raw.githubusercontent.com/PyDevices/pydevices/main/lib/displaydev/psdisplay.py" = "/lib/displaydev/psdisplay.py" +"https://raw.githubusercontent.com/PyDevices/pydevices/main/lib/displaydev/workerdisplay.py" = "/lib/displaydev/workerdisplay.py" "https://raw.githubusercontent.com/PyDevices/pydevices/main/lib/displaydev/sdldisplay.py" = "/lib/displaydev/sdldisplay.py" "https://raw.githubusercontent.com/PyDevices/pydevices/main/lib/displaydev/wasmdisplay.py" = "/lib/displaydev/wasmdisplay.py" "https://raw.githubusercontent.com/PyDevices/pydevices/main/lib/displaydev/windisplay.py" = "/lib/displaydev/windisplay.py" diff --git a/scripts/validate_board_mip_installers.py b/scripts/validate_board_mip_installers.py index 8106912b..eaf1d1be 100644 --- a/scripts/validate_board_mip_installers.py +++ b/scripts/validate_board_mip_installers.py @@ -14,6 +14,7 @@ def main() -> None: "board_configs/jndisplay/package.json", "board_configs/pgdisplay/package.json", "board_configs/psdisplay/package.json", + "board_configs/workerdisplay/package.json", "board_configs/sdldisplay/linux_kms/package.json", "board_configs/windisplay/package.json", } diff --git a/tests/test_workerdisplay.py b/tests/test_workerdisplay.py new file mode 100644 index 00000000..c6aea680 --- /dev/null +++ b/tests/test_workerdisplay.py @@ -0,0 +1,125 @@ +# SPDX-License-Identifier: MIT +"""WorkerDisplay: an OffscreenCanvas in a Web Worker, with input the page relays. + +The relayed events must come out of ``get_events`` exactly as PSDisplay's DOM +listeners produce them, and drawing must go through one JavaScript call per +blit, never one per pixel. +""" + +import pathlib +import sys +import types +import unittest +from unittest import mock + +_LIB = pathlib.Path(__file__).resolve().parent.parent / "lib" + + +def _fake_canvas(width, height): + canvas = mock.Mock() + canvas.width, canvas.height = width, height + return canvas + + +class _Recorder: + """Stands in for the JS function ``Function.new`` builds.""" + + def __init__(self): + self.calls = [] + + def __call__(self, *args): + self.calls.append(args) + + +class TestWorkerDisplay(unittest.TestCase): + def setUp(self): + self.blit = _Recorder() + js = types.ModuleType("js") + js.OffscreenCanvas = mock.Mock() + js.OffscreenCanvas.new = lambda w, h: _fake_canvas(w, h) + js.Function = mock.Mock() + js.Function.new = lambda *a: self.blit + ffi = types.ModuleType("pyodide.ffi") + ffi.to_js = lambda mv: bytes(mv) + pyodide = types.ModuleType("pyodide") + pyodide.ffi = ffi + self._mods = mock.patch.dict(sys.modules, {"js": js, "pyodide": pyodide, "pyodide.ffi": ffi}) + self._mods.start() + self._path = mock.patch.object(sys, "path", [str(_LIB)] + sys.path) + self._path.start() + for name in ("displaydev.workerdisplay", "displaydev._webcanvas", "displaydev._webinput"): + sys.modules.pop(name, None) + from displaydev import _webcanvas, workerdisplay + + _webcanvas._blit565 = None + self.wd = workerdisplay + self.events = __import__("events") + self.display = workerdisplay.WorkerDisplay(_fake_canvas(640, 480), 640, 480, quiet=True) + + def tearDown(self): + for name in ("displaydev.workerdisplay", "displaydev._webcanvas", "displaydev._webinput"): + sys.modules.pop(name, None) + self._path.stop() + self._mods.stop() + + def _drain(self): + return self.display.get_events() or [] + + def test_blit_is_one_call_with_the_whole_buffer(self): + buf = bytearray(range(256)) * 2 # 16 x 16 pixels... 512 bytes = 256 px + self.display.blit_rect(buf, 8, 4, 16, 16) + self.assertEqual(len(self.blit.calls), 1) + ctx, src, x, y, w, h = self.blit.calls[0] + self.assertIs(ctx, self.display._buf_ctx) + self.assertEqual((src, x, y, w, h), (bytes(buf), 8, 4, 16, 16)) + + def test_blit_checks_bounds_and_size(self): + with self.assertRaises(ValueError): + self.display.blit_rect(bytearray(8), 638, 0, 4, 1) + with self.assertRaises(ValueError): + self.display.blit_rect(bytearray(7), 0, 0, 2, 2) + + def test_mouse_events_keep_css_pixels(self): + self.wd.feed({"type": "pointerdown", "x": 10, "y": 20, "button": 0, "pointerType": "mouse"}) + self.wd.feed({"type": "pointermove", "x": 12, "y": 21, "movementX": 2, "movementY": 1, + "buttons": 1, "pointerType": "mouse"}) + self.wd.feed({"type": "pointerup", "x": 12, "y": 21, "button": 0, "pointerType": "mouse"}) + got = self._drain() + E = self.events + self.assertEqual([e.type for e in got], [E.MOUSEBUTTONDOWN, E.MOUSEMOTION, E.MOUSEBUTTONUP]) + self.assertEqual(got[0].pos, (10, 20)) + self.assertEqual(got[0].button, 1) + self.assertEqual(got[1].rel, (2, 1)) + + def test_touch_maps_to_framebuffer_pixels(self): + # The page shows the 640-pixel canvas 320 CSS pixels wide. + self.wd.resize(320, 240) + self.assertAlmostEqual(self.display.touch_scale, 0.5) + self.wd.feed({"type": "pointerdown", "x": 100, "y": 50, "button": 0, + "pointerType": "touch", "pointerId": 7}) + got = self._drain() + E = self.events + self.assertEqual(got[0].type, E.FINGERDOWN) + self.assertEqual(got[0].pos, (200, 100)) + self.assertEqual(got[0].finger_id, 7) + + def test_keys_wheel_and_repeat(self): + self.wd.feed({"type": "keydown", "key": "a", "location": 0, "repeat": False, + "ctrlKey": False, "shiftKey": False, "altKey": False, "metaKey": False}) + self.wd.feed({"type": "keydown", "key": "a", "location": 0, "repeat": True, + "ctrlKey": False, "shiftKey": False, "altKey": False, "metaKey": False}) + self.wd.feed({"type": "keyup", "key": "a", "location": 0, "repeat": False, + "ctrlKey": False, "shiftKey": False, "altKey": False, "metaKey": False}) + self.wd.feed({"type": "wheel", "deltaX": 0, "deltaY": 120}) + self.wd.feed({"type": "contextmenu"}) + E = self.events + self.assertEqual([e.type for e in self._drain()], [E.KEYDOWN, E.KEYUP, E.MOUSEWHEEL]) + + def test_board_config_shape(self): + self.assertTrue(self.display.needs_refresh) + self.assertEqual((self.display.width, self.display.height), (640, 480)) + self.assertIsNone(self.display.get_events()) + + +if __name__ == "__main__": + unittest.main() From 830742388ad6d97648743e106ed2d670404a5640 Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Thu, 15 Oct 2026 02:16:18 -0500 Subject: [PATCH 2/4] docs: the one-call blit, and Python in a Web Worker --- docs/displaydev-internals.md | 4 ++-- docs/pyscript.md | 11 +++++++++++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/docs/displaydev-internals.md b/docs/displaydev-internals.md index e1a7b24c..fc872c94 100644 --- a/docs/displaydev-internals.md +++ b/docs/displaydev-internals.md @@ -150,7 +150,7 @@ Backends use several patterns to map **565 API input** to **native storage**: | Pattern | Where | Description | |---------|-------|-------------| | **Bitwise expand** | `color_rgb()` | Per-color 565 → `(r,g,b)`; fills and single pixels | -| **LUT-assisted loop** | **PSDisplay** | 65536 × 4 byte table; blit loop indexes LUT → RGBA for `putImageData` | +| **One JavaScript call** | **PSDisplay**, **WorkerDisplay** | The whole RGB565 buffer goes to JavaScript once and is expanded to RGBA there for `putImageData` (`_webcanvas.blit565`); a per-pixel loop through the Pyodide bridge took about 2 s for a 1280x720 frame | | **Python pixel loop** | **PGDisplay** blit, **JNDisplay** blit | Per pixel: read 2 bytes → `color_rgb` → `set_at` / PIL `point` | | **Zero-copy passthrough** | **SDLDisplay** at 16 bpp | `SDL_UpdateTexture` with 565 pitch — buffer format matches texture | | **Raw 565 pack** | **BusDisplay**, **FBDisplay** | `(c & 0xFFFF).to_bytes(2, …)` — no expand | @@ -169,7 +169,7 @@ Not yet centralized in `displaydev`, but useful for future shared helpers: | **SDLDisplay** (16) | RGB565 texture | Zero-copy upload | Throughput on large blits; matches app buffers | | **SDLDisplay** fill | Same texture | `color_rgb` → SDL RGB draw | Draw API expects 8-bit RGB | | **PGDisplay** | Pygame surface (default 16) | Per-pixel `set_at` loop | Simple; partial `renderRect`; room to switch to `frombuffer`+`blit` | -| **PSDisplay** | Canvas RGBA | LUT → `ImageData` | Browser API requires RGBA bytes; large blits | +| **PSDisplay**, **WorkerDisplay** | Canvas RGBA | One JS call → `ImageData` | Browser API requires RGBA bytes; the bridge is the cost, so cross it once | | **JNDisplay** | PIL RGB | Loop → `pixel` | Present is PNG-bound; blit speed secondary | | **PixelDisplay** | RGB888 strip buf | 565 in → `color_rgb` → inner 888 | Tiny grids; standard DisplayDriver API | diff --git a/docs/pyscript.md b/docs/pyscript.md index 16213489..8066a5b8 100644 --- a/docs/pyscript.md +++ b/docs/pyscript.md @@ -21,3 +21,14 @@ part of this launcher. Use [`bin/wasm.py`](../bin/wasm.py) and the [direct WebAssembly guide](wasm.md) for MicroPython in a browser. + +## Python in a Web Worker + +A long redraw on the page's main thread holds up everything else the page +does, input and Web MIDI included. To keep the main thread free, run your +Python in a worker and draw with `displaydev.workerdisplay.WorkerDisplay`: +the page transfers its canvas to the worker as an `OffscreenCanvas` and +relays pointer, wheel and key events. `board_configs/workerdisplay` sets it +up; the module docstring has the few lines of page-side JavaScript and the +event format. LVGL's `display_driver` and `appdev` work on it unchanged. + From c215934cd8dcfb7f7d193117714479f16e820db0 Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Thu, 15 Oct 2026 02:21:41 -0500 Subject: [PATCH 3/4] multimer: Pyodide always has its browser event loop, in a Web Worker too Without a pyscript module, a Pyodide Web Worker fell through to wake sources that never fire there, so timers (LVGL's display_driver loop among them) armed and never ran. --- lib/multimer/_dispatch.py | 5 +++++ tests/test_multimer.py | 11 +++++++++++ 2 files changed, 16 insertions(+) diff --git a/lib/multimer/_dispatch.py b/lib/multimer/_dispatch.py index 6a1d7ac0..b283a9f7 100644 --- a/lib/multimer/_dispatch.py +++ b/lib/multimer/_dispatch.py @@ -679,6 +679,11 @@ def _load_source(name): def _async_owned_host(): """True on hosts whose lifecycle is an asyncio loop we did not start.""" + # Pyodide always runs on the browser's event loop (its webloop), with or + # without PyScript: in a Web Worker there is no pyscript module, and the + # other sources never wake there. + if sys.platform == "emscripten": + return True try: import pyscript # noqa: F401 diff --git a/tests/test_multimer.py b/tests/test_multimer.py index 63d11708..c3371f12 100644 --- a/tests/test_multimer.py +++ b/tests/test_multimer.py @@ -15,6 +15,7 @@ import threading import time import unittest +from unittest import mock import _env # noqa: F401 @@ -497,5 +498,15 @@ def test_unknown_source_fails_loud(self): self.assertIn("unknown multimer source", p.stdout) + +class TestPyodideHost(unittest.TestCase): + """Pyodide runs on the browser's event loop, PyScript or not (a Web Worker).""" + + def test_emscripten_is_an_asyncio_host(self): + from multimer import _dispatch + + with mock.patch.object(_dispatch.sys, "platform", "emscripten"): + self.assertTrue(_dispatch._async_owned_host()) + if __name__ == "__main__": unittest.main() From 123e3d99ddeac0283091705a64498fac7ccdc23e Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Thu, 15 Oct 2026 02:26:32 -0500 Subject: [PATCH 4/4] PSDisplay and WorkerDisplay say needs_refresh themselves; keep the generated TOML sorted --- lib/displaydev/psdisplay.py | 1 + lib/displaydev/workerdisplay.py | 1 + pydevices-desktop.toml | 2 +- tests/test_needs_refresh.py | 2 +- 4 files changed, 4 insertions(+), 2 deletions(-) diff --git a/lib/displaydev/psdisplay.py b/lib/displaydev/psdisplay.py index 74b10eae..6456e7f0 100644 --- a/lib/displaydev/psdisplay.py +++ b/lib/displaydev/psdisplay.py @@ -123,6 +123,7 @@ class PSDisplay(CanvasDisplay): needs_refresh (bool): True — ``appdev.App`` drives periodic ``show()``. """ + needs_refresh = True quit_chord = (keys.K_AC_BACK, 0) def __init__(self, id, width=None, height=None, *, quiet=False): diff --git a/lib/displaydev/workerdisplay.py b/lib/displaydev/workerdisplay.py index 674fa276..9639eaa3 100644 --- a/lib/displaydev/workerdisplay.py +++ b/lib/displaydev/workerdisplay.py @@ -97,6 +97,7 @@ class WorkerDisplay(CanvasDisplay): quiet (bool): Suppress init chatter when True. """ + needs_refresh = True quit_chord = (keys.K_AC_BACK, 0) def __init__(self, canvas, width=None, height=None, *, quiet=False): diff --git a/pydevices-desktop.toml b/pydevices-desktop.toml index afa19b03..a06a69cb 100644 --- a/pydevices-desktop.toml +++ b/pydevices-desktop.toml @@ -54,10 +54,10 @@ "https://raw.githubusercontent.com/PyDevices/pydevices/main/lib/displaydev/pgdisplay.py" = "/lib/displaydev/pgdisplay.py" "https://raw.githubusercontent.com/PyDevices/pydevices/main/lib/displaydev/pixeldisplay.py" = "/lib/displaydev/pixeldisplay.py" "https://raw.githubusercontent.com/PyDevices/pydevices/main/lib/displaydev/psdisplay.py" = "/lib/displaydev/psdisplay.py" -"https://raw.githubusercontent.com/PyDevices/pydevices/main/lib/displaydev/workerdisplay.py" = "/lib/displaydev/workerdisplay.py" "https://raw.githubusercontent.com/PyDevices/pydevices/main/lib/displaydev/sdldisplay.py" = "/lib/displaydev/sdldisplay.py" "https://raw.githubusercontent.com/PyDevices/pydevices/main/lib/displaydev/wasmdisplay.py" = "/lib/displaydev/wasmdisplay.py" "https://raw.githubusercontent.com/PyDevices/pydevices/main/lib/displaydev/windisplay.py" = "/lib/displaydev/windisplay.py" +"https://raw.githubusercontent.com/PyDevices/pydevices/main/lib/displaydev/workerdisplay.py" = "/lib/displaydev/workerdisplay.py" "https://raw.githubusercontent.com/PyDevices/pydevices/main/lib/events.py" = "/lib/events.py" "https://raw.githubusercontent.com/PyDevices/pydevices/main/utils/frame_recorder.py" = "/lib/frame_recorder.py" "https://raw.githubusercontent.com/PyDevices/pydevices/main/lib/keys.py" = "/lib/keys.py" diff --git a/tests/test_needs_refresh.py b/tests/test_needs_refresh.py index bd930fad..1d6e9f51 100644 --- a/tests/test_needs_refresh.py +++ b/tests/test_needs_refresh.py @@ -32,7 +32,7 @@ def test_hosted_backends_need_refresh(self): self.assertFalse(DisplayDriver.needs_refresh) - for module_name in ("sdldisplay", "pgdisplay", "jndisplay", "psdisplay"): + for module_name in ("sdldisplay", "pgdisplay", "jndisplay", "psdisplay", "workerdisplay"): self.assertTrue( _module_sets_needs_refresh(module_name), module_name,