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/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. + 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..6456e7f0 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: @@ -360,157 +128,13 @@ class PSDisplay(DesktopDisplay): 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..9639eaa3 --- /dev/null +++ b/lib/displaydev/workerdisplay.py @@ -0,0 +1,161 @@ +# 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. + """ + + needs_refresh = 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/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/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..a06a69cb 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" @@ -55,6 +57,7 @@ "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/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_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() 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, 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()