Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions board_configs/workerdisplay/board_config.py
Original file line number Diff line number Diff line change
@@ -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())
74 changes: 74 additions & 0 deletions board_configs/workerdisplay/board_peripherals.py
Original file line number Diff line number Diff line change
@@ -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)
19 changes: 19 additions & 0 deletions board_configs/workerdisplay/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
4 changes: 2 additions & 2 deletions docs/displaydev-internals.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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 |

Expand Down
11 changes: 11 additions & 0 deletions docs/pyscript.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

8 changes: 8 additions & 0 deletions lib/displaydev/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
184 changes: 184 additions & 0 deletions lib/displaydev/_webcanvas.py
Original file line number Diff line number Diff line change
@@ -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()
Loading
Loading