Display backend internals

August 20, 2026 · View on GitHub

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-drivers.md.

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 like color565(r, g, b)
  • Scratch buffers: FrameBuffer(..., RGB565) and BPP = display_drv.color_depth // 8 (2 bytes per pixel)
  • blit_rect source buffers sized w * h * 2
  • blit_transparent key 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():

MethodRole
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; appdev.App calls this on app quit

Optional but common: deinit() (called from quit()), scroll helpers (vscroll, set_vscroll), blit_transparent, and needs_refresh for app-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:

MeaningRGB565 intRGB888 int
White0xFFFF0xFFFFFF
Red0xF8000xFF0000

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)
  1. self._buffer — offscreen memory at logical width × height (PG calls this “the LCD’s internal memory”).
  2. Draw methods — write only into that buffer.
  3. render() — composes the buffer to the visible surface, including vertical scroll (top fixed / scroll area / bottom fixed).
  4. 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

BackendBuffer rotation
SDLDisplay_rotation_helper — new SDL texture + SDL_RenderCopyEx
PGDisplay_rotation_helperpg.transform.rotate on _buffer
PSDisplay_rotation tracked; no _rotation_helper (surface dims swap only)
JNDisplaySame as PS — property only, no pixel rotation
PixelDisplayrotation 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

BackendMechanism
PGDisplayConstructor scale; pg.transform.scale_by at present; touch_scale = scale
SDLDisplayWindow size width * scale; SDL_RenderSetLogicalSize for logical coords
PSDisplayCSS layout vs canvas pixel size; _pointer_scale() / touch_scale
JNDisplay1:1 (touch_scale = 1.0)
PixelDisplayN/A (tiny physical grid)

Why each backend exists (interpreter)

BackendTypical interpreterWhy it exists
SDLDisplayCPython, MicroPython Unix, CircuitPython UnixNative SDL2 / usdl2; default on MP Unix
WinDisplayWindows CPythonNative HWND via uwin32; preferred by AutoDisplay on win32
PGDisplayCPython desktopEasier install on Windows; avoids some SDL glitches on Chromebooks; fallback after WinDisplay
PSDisplayPyScriptHTML Canvas 2D; no SDL/pygame in the browser
JNDisplayJupyteripywidgets / PNG refresh; interactive JNDevices
PixelDisplayMCU / CPNeoPixel / 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:

PatternWhereDescription
Bitwise expandcolor_rgb()Per-color 565 → (r,g,b); fills and single pixels
LUT-assisted loopPSDisplay65536 × 4 byte table; blit loop indexes LUT → RGBA for putImageData
Python pixel loopPGDisplay blit, JNDisplay blitPer pixel: read 2 bytes → color_rgbset_at / PIL point
Zero-copy passthroughSDLDisplay at 16 bppSDL_UpdateTexture with 565 pitch — buffer format matches texture
Raw 565 packBusDisplay, 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") then dest.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)

BackendNative sinkBlit pathRationale
SDLDisplay (16)RGB565 textureZero-copy uploadThroughput on large blits; matches app buffers
SDLDisplay fillSame texturecolor_rgb → SDL RGB drawDraw API expects 8-bit RGB
PGDisplayPygame surface (default 16)Per-pixel set_at loopSimple; partial renderRect; room to switch to frombuffer+blit
PSDisplayCanvas RGBALUT → ImageDataBrowser API requires RGBA bytes; large blits
JNDisplayPIL RGBLoop → pixelPresent is PNG-bound; blit speed secondary
PixelDisplayRGB888 strip buf565 in → color_rgb → inner 888Tiny 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:

ClassStorageNotes
BusDisplayPanel GRAM via SPI/I80Optional byteswap; true 565
FBDisplayCircuitPython framebufRAM mirror + refresh()

Consolidation direction

Goals discussed for displaydev maintenance:

  1. One API — all DisplayDriver instances report color_depth=16 and accept 565 colors and blit buffers.
  2. Shared conversion helpers in displaydev/__init__.pycolor_rgb (exists), plus swappable loop vs LUT blit writers for benchmarking.
  3. Keep backend-specific fast paths where they matter:
    • SDL: zero-copy 565 blit
    • PS: LUT for RGBA canvas
    • PG: consider frombuffer+blit for 565 surfaces
    • PixelDisplay: loop expand (tiny grids)
  4. 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.

  • Displays — pick a driver, input, scroll overview
  • pydevices_demo.py — scroll bands and redraw rules
  • Architecture — how board_config wires drivers
  • tests/test_color.pycolor_rgb / color565 contract tests