Display backend internals
This document explains how pydevices-examples display drivers relate to each other: the shared 565 API contract, the two-stage draw/present model used by desktop and browser simulators, color conversion strategies, and why each backend chose its implementation pattern.
For a shorter “which driver do I pick?” guide, see Displays. For chip wiring and board configs, see Board configs and Display interfaces.
The DisplayDriver API contract
Application and example code assume a 16-bit RGB565 surface:
display_drv.color_depth == 16(bits per pixel)- Color literals like
0xFFFF,0xF800, and helpers likecolor565(r, g, b) - Scratch buffers:
FrameBuffer(..., RGB565)andBPP = display_drv.color_depth // 8(2 bytes per pixel) blit_rectsource buffers sizedw * h * 2blit_transparentkey colors as 2-byte 565 values
Every concrete backend must implement the drawing
and lifecycle methods below. Application code,
pygraphics, and examples assume they exist — not only
show() and quit():
| Method | Role |
|---|---|
blit_rect(buffer, x, y, w, h) |
Copy a 565 buffer region into the driver's logical framebuffer |
fill_rect(x, y, w, h, color) |
Fill a rectangle with a 565 color |
pixel(x, y, color) |
Set one pixel (often implemented via a 1×1
blit_rect) |
show() |
Present the logical framebuffer (immediate on MCU buses; batched on
SDL/pygame until show() on desktop) |
quit(code=0, force=False) |
Release native resources; Runtime calls this on app
quit |
Optional but common: deinit() (called from
quit()), scroll helpers (vscroll,
set_vscroll), blit_transparent, and
needs_refresh for runtime-driven presentation on hosted
backends.
BusDisplay, FBDisplay, and most TFT paths are 565 end-to-end — the API matches the hardware framebuffer.
Other backends store pixels in a deeper native
format (RGB888 strip buffer, PIL RGB, canvas RGBA, SDL texture
at 24/32 bpp). They still expose the 565 API and
convert at draw time via color_rgb (or backend-specific
blit paths).
Color encoding trap
565 and 888 use different integer layouts:
| Meaning | RGB565 int | RGB888 int |
|---|---|---|
| White | 0xFFFF |
0xFFFFFF |
| Red | 0xF800 |
0xFF0000 |
RGB888 unpack (used by PixelFramebuffer and
pygraphics.RGB888) treats the int as 0xRRGGBB.
Passing 0xFFFF (565 white) through that path yields cyan
(R=0, G=255, B=255), not white.
The shared expand helper is color_rgb()
in displaydev/__init__.py: 565 int or 2-byte little-endian
slice → (r, g, b) with 5/6/5 bit expansion. Tests live in
tests/test_color.py.
Two-stage architecture: logical GRAM + present
Five simulators — SDLDisplay, PGDisplay, WinDisplay, PSDisplay, JNDisplay — mimic an ILI9341-style panel:
App draw API Present path
───────────── ──────────────
fill_rect / blit_rect → self._buffer (logical GRAM)
render() (scroll bands, scale, …)
show() (flip / widget / canvas)
self._buffer— offscreen memory at logicalwidth × height(PG calls this “the LCD’s internal memory”).- Draw methods — write only into that buffer.
render()— composes the buffer to the visible surface, including vertical scroll (top fixed / scroll area / bottom fixed).show()— pushes frames to the OS window, browser canvas, or Jupyter widget.
Scroll emulation requires this split: you cannot
draw directly on the window and still implement vscroll /
set_vscroll correctly. See pydevices_demo
for the redraw-at-vscroll=0 rule.
PixelDisplay is different: there is no ILI9341
scroll model. Drawing updates an RGB888 grid buffer; show()
diff-flushes to the LED strip.
Vertical scroll (shared by SDL / PG / PS / JN)
All four implement the same band compositing in
render():
- TFA — top fixed area (pinned rows)
- VSA — vertical scroll area (content that moves)
- BFA — bottom fixed area
display_drv.set_vscroll(tfa, bfa) and the
vscroll property map to the controller-style
vscsad address. On SDLDisplay,
render() uses multiple SDL_RenderCopy bands (a
single full-frame copy was disabled due to platform issues).
PGDisplay and PSDisplay use the same
four-step blit/drawImage layout when scrolled.
Rotation
| Backend | Buffer rotation |
|---|---|
| SDLDisplay | _rotation_helper — new SDL texture +
SDL_RenderCopyEx |
| PGDisplay | _rotation_helper — pg.transform.rotate on
_buffer |
| PSDisplay | _rotation tracked; no
_rotation_helper (surface dims swap only) |
| JNDisplay | Same as PS — property only, no pixel rotation |
| PixelDisplay | rotation on inner framebuf (grid wiring), not
LCD-style |
Full rotation matters for SDL/PG desktop LCD simulation. Browser and notebook backends document that rotation reshapes the surface but does not rotate pointer coordinates — see Displays — Browser / notebook.
Scaling
| Backend | Mechanism |
|---|---|
| PGDisplay | Constructor scale; pg.transform.scale_by
at present; touch_scale = scale |
| SDLDisplay | Window size width * scale;
SDL_RenderSetLogicalSize for logical coords |
| PSDisplay | CSS layout vs canvas pixel size; _pointer_scale() /
touch_scale |
| JNDisplay | 1:1 (touch_scale = 1.0) |
| PixelDisplay | N/A (tiny physical grid) |
Why each backend exists (runtime)
| Backend | Typical runtime | Why it exists |
|---|---|---|
| SDLDisplay | CPython, MicroPython Unix, CircuitPython Unix | Native SDL2 / usdl2; default on MP Unix |
| WinDisplay | Windows CPython | Native HWND via uwin32; preferred by
AutoDisplay on win32 |
| PGDisplay | CPython desktop | Easier install on Windows; avoids some SDL glitches on Chromebooks;
fallback after WinDisplay |
| PSDisplay | PyScript | HTML Canvas 2D; no SDL/pygame in the browser |
| JNDisplay | Jupyter | ipywidgets / PNG refresh; interactive
JNDevices |
| PixelDisplay | MCU / CP | NeoPixel / DotStar grids via
displaydev.pixeldisplay |
CircuitPython Unix SDLDisplay forces
software rendering when accelerated GL cannot attach
rotated render targets (see comment in sdldisplay.py).
MicroPython SDL show() may defer present
on MemoryError when the heap is locked during scroll
rendering.
Color conversion toolbox
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 |
| 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 |
Not yet centralized in displaydev, but useful for future
shared helpers:
frombuffer+ blit (Pygame) —pg.image.frombuffer(buf, (w,h), "RGB565")thendest.blit(src, (x,y))for 16-in / 16-stored without a Python loop- Row expand — convert one row of 565 to RGB/RGBA, bulk-write
- Component mini-LUTs — small 5/6/5-bit tables (~128 bytes) instead of 256 KB
Per-backend blit strategy (today)
| Backend | Native sink | Blit path | Rationale |
|---|---|---|---|
| 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 |
| 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 |
SDL vs Pygame “fast path”
SDL at color_depth=16 is true
16-in / 16-stored for blits: the app’s 565 buffer is
uploaded directly into a RGB565 render-target texture.
Pygame can match that intent (not the same
mechanism) by keeping a 16-bit surface and using
frombuffer + blit once per
blit_rect call instead of set_at per pixel.
The current PG implementation still expands each pixel to RGB for
set_at, which quantizes back to 565 on a 16-bit surface —
correct but slow at 320×480.
LUT size and MCUs
PSDisplay allocates 65536 × 4 ≈ 256
KB for _rgba_lut — appropriate in PyScript,
not on typical MCUs (e.g. RP2040 has 264 KB SRAM
total).
For PixelDisplay (8×4, 12×6, …), a
color_rgb loop over tens or hundreds of
pixels is negligible. A full 565→888 LUT (~192 KB for 3-byte entries)
would cost more RAM than the grid itself.
PixelDisplay specifics
Addressable LED boards wire:
_pixel_framebuf = PixelFramebuffer(...) # internal; RGB888 grid + strip map
display_drv = PixelDisplay(_pixel_framebuf)Use display_drv for all app drawing.
_pixel_framebuf is prefixed to discourage bypassing the
DisplayDriver API (see Board
configs — Pixel configs).
PixelDisplay exposes the usual 565
DisplayDriver API (color_depth=16).
The inner PixelFramebuffer stays RGB888
for the strip; fill_rect, pixel, and
blit_rect expand via color_rgb before writing
the inner buffer.
MicroPython uses
displaydev.pixeldisplay.PixelFramebuffer; CircuitPython
uses Adafruit adafruit_pixel_framebuf behind the same
PixelDisplay wrapper.
Hardware drivers (brief)
These follow the 565 API without a separate “present” stage in the same sense:
| Class | Storage | Notes |
|---|---|---|
| BusDisplay | Panel GRAM via SPI/I80 | Optional byteswap; true 565 |
| FBDisplay | CircuitPython framebuf | RAM mirror + refresh() |
Consolidation direction
Goals discussed for displaydev maintenance:
- One API — all
DisplayDriverinstances reportcolor_depth=16and accept 565 colors and blit buffers. - Shared conversion helpers in
displaydev/__init__.py—color_rgb(exists), plus swappable loop vs LUT blit writers for benchmarking. - Keep backend-specific fast paths where they matter:
- SDL: zero-copy 565 blit
- PS: LUT for RGBA canvas
- PG: consider
frombuffer+blitfor 565 surfaces - PixelDisplay: loop expand (tiny grids)
- Lazy LUT — build only on CPython / desktop unless explicitly enabled, so MCUs never allocate 256 KB silently.
Internal buffer format may remain 565 (SDL), RGB (JN), RGBA (PS), or RGB888 (strip) as long as the public contract stays 565.
Related reading
- Displays — pick a driver, input, scroll overview
- pydevices_demo — scroll bands and redraw rules
- Architecture — how
board_configwires drivers - Display interfaces — hardware taxonomy
tests/test_color.py—color_rgb/color565contract tests