Chapter 04 · Graphics

Drawing to the screen

On the web you describe a page and a browser, an OS, a GPU and an HDMI cable do the actual drawing. This board has none of those — no browser, no GPU, no compositor, no HDMI. The app puts every pixel on the glass itself. This chapter is the visual guide: how a screen normally gets drawn, and exactly how this app does it instead.

The big picture

Where's the HDMI?

There isn't one — and that changes everything about how you draw. Three pipelines, same goal. Follow each column top to bottom, and watch where the cyan box (the place the finished picture actually lives) sits relative to the cable.

A · The web page you ship a description YOU your code HTML · CSS · JS — no pixels yet ENGINE browser layout · style · paint FRAMEBUFFER GPU + compositor the picture lives here, in RAM LINK HDMI cable every pixel, 60×/s — ≈3 Gbit/s GLASS dumb panel shows a frame, forgets it B · The desktop app you draw, the OS delivers YOU your code draw calls: rects, text, images TOOLKIT UI framework turns widgets into pixels FRAMEBUFFER GPU + compositor your window + everyone else's LINK HDMI cable every pixel, 60×/s — ≈3 Gbit/s GLASS dumb panel shows a frame, forgets it C · This gadget you own every pixel YOU dash.py hand-placed rows · budget bar text("Today", 8, 20) DRIVER st7789py.py glyphs & fills → RGB565 rects set window + pixel stream LINK SPI wire @ 40 MHz 3 pins out of the ESP32-S3 only when something changes FRAMEBUFFER ST7789 GRAM the only copy of the picture self-refresh, ~60 Hz, no code GLASS 240×135 TFT remembers until overwritten
the code you write machinery in between where the picture lives
The punchline

In pipelines A and B the picture lives inside the computer, so a cable must re-send all of it, 60 times a second, forever — a static desktop still costs ≈3 Gbit/s of HDMI traffic. In pipeline C the framebuffer sits on the far side of the wire, inside the display controller itself. Send nothing and the picture simply stays. The app only ever transmits changes, and the panel handles its own refresh.

0GPUs · compositors · HDMI
64,800 Bone full frame on the wire
≈13 msfull repaint @ 40 MHz SPI
512 Bbiggest pixel buffer in RAM

That last number is the strangest one: the full picture never exists in the ESP32's memory. A whole frame would be 240 × 135 × 2 bytes = 64,800 — but the driver streams everything through scratch buffers of at most 512 bytes. The only complete copy of what you see is in the panel's own RAM. (The SPI bus here is even wired without a MISO line — the display is a one-way street we write to and never read back.)

The mechanism

A panel with a photographic memory

The glass has a chip bonded onto it — the Sitronix ST7789 — with enough RAM (GRAM) for 240×320 pixels and a tiny state machine. Drawing means one thing: writing bytes into that RAM through a three-command protocol. The controller repaints the liquid crystal from GRAM on its own, about 60 times a second, powered but unbothered by our code.

ST7789 GRAM — on-panel memory sized for 240×320; our glass shows a window into it unused margin (hidden) visible 240×135 · offset (40, 53) pixels stream in; the controller walks the draw window itself 1 · CASET — pick the columns (x0…x1) 2 · RASET — pick the rows (y0…y1) 3 · RAMWR — stream w×h pixels, no coordinates needed

Every draw in the entire app is this one move: declare a rectangle, then pour pixels into it. After RAMWR, the controller advances its own write cursor left→right, top→bottom inside the window — the ESP32 sends raw color data with no addresses attached.

The abridged heart of st7789py.py:

def _set_window(self, x0, y0, x1, y1):
    # our glass is a window into the bigger GRAM,
    # hence the (40, 53) offset in landscape
    self._write(CASET, pack(">HH", x0+40, x1+40))
    self._write(RASET, pack(">HH", y0+53, y1+53))
    self._write(RAMWR)              # "pixels follow"

def fill_rect(self, x, y, w, h, color):
    self._set_window(x, y, x+w-1, y+h-1)
    pixel = pack(">H", color)      # 2 bytes, RGB565
    for chunk in chunks(pixel * (w*h), 512):
        self.spi.write(chunk)       # fire and forget

Everything is a rectangle

Every primitive the dash uses reduces to _set_window + a stream. That's the whole graphics API:

Call in dash.pyWindow it opensBytes streamed
pixel(x, y, c)1 × 12
hline(x, y, w, c)w × 12·w
fill_rect(x, y, w, h, c)w × h2·w·h — one pixel, repeated
text(font, s, x, y, fg, bg)16 × 8, twice per glyph256 per strip
fill(c) — clear the screen240 × 13564,800

Even outlines and diagonals obey the rule: rect() is four skinny filled rectangles, and line() is Bresenham stepping — a fresh 1×1 window per pixel, which is why diagonal lines are slow and the dash's layout uses none.

The vocabulary

Colors are 16 bits, letters are 32 bytes

No color names, no font files. A color is one 16-bit number in RGB565 encoding — 5 bits red, 6 green (your eye resolves green best), 5 blue — two bytes on the wire per pixel:

bit 15 bit 0 red × 5 green × 6 blue × 5 65,536 colors · 2 bytes per pixel
def color565(r, g, b):        # 0–255 each → one 16-bit number
    return (r & 0xF8) << 8 | (g & 0xFC) << 3 | b >> 3

_DIM = color565(90, 90, 90)   # dash.py mixes its own grays & orange

Text works the same way — there is no font engine, so the app ships a bitmap font: vga2_bold_16x16.py, a PC-BIOS typeface where every character is a 16×16 grid of bits packed into 32 bytes. To draw a glyph, the driver expands each bit into a foreground or background pixel and blits it as two 16×8 strips. These are the actual bytes for $ — the most-drawn glyph on this dashboard:

# font.FONT, 32 bytes at ord('$')·32
03 c0  ■ 0000001111000000
03 c0  ■ 0000001111000000
07 e0  ■ 0000011111100000
1e 78  ■ 0001111001111000
3c 3c  ■ 0011110000111100
3c 00  ■ 0011110000000000
1e 00  ■ 0001111000000000
07 e0  ■ 0000011111100000
00 78  ■ 0000000001111000
00 3c  ■ 0000000000111100
3c 3c  ■ 0011110000111100
1e 78  ■ 0001111001111000
07 e0  ■ 0000011111100000
03 c0  ■ 0000001111000000
03 c0  ■ 0000001111000000
00 00  ■ 0000000000000000
bits → pixels. Each 1 becomes a foreground pixel, each 0 a background pixel — 256 color bytes per 16×8 strip, blitted through the same set-window move as everything else.
Detail that bites

Because every glyph paints its own background, drawing new text over old text cleanly replaces it — no clearing needed. But the driver silently drops any glyph that would cross the right edge, so dash.py pre-clips long strings and marks them with a trailing ~ (see _clip()).

Our layout engine

No CSS — a ruler and constants

A web page reflows; this screen is surveyed. The font is 16×16, so the 240×135 panel is a grid of 15 columns × 8 rows, and every coordinate in dash.py is a hand-picked constant. This is the per-key dash with its actual coordinates:

0 8 104 224 240 0 20 38 56 76 96 112 135 warp Today $0.35 Week $1.20 Month $4.50 Used $0.35 $20.00 @ 18:51 1/3

Reading the rulers: x=8 is the text gutter, x=104 (_VALUE_X, the cyan guide) starts the value column — labels are ≤5 glyphs, so they always fit to its left and $9999.99 still fits to its right. x=224 parks the 12 px Wi-Fi dot in the corner. Rows sit at y=20/38/56 — 16 px of glyph plus 2 px of air — with hairline rules at y=18 and y=74, the budget bar at y=96, and the footer at y=112.

And the render pass is nothing more than those constants in order — abridged from render():

def render(tft, view, updated, wifi_ok, ...):
    tft.fill(st7789.BLACK)                                    # wipe the canvas
    tft.text(font, header, 8, 0, WHITE, BLACK)                # key name
    tft.fill_rect(224, 2, 12, 12, GREEN if wifi_ok else RED)  # Wi-Fi dot
    tft.hline(0, 18, 240, _DIM)                               # rule
    _row(tft, "Today", view["today"], 20)
    _row(tft, "Week",  view["week"],  38)
    _row(tft, "Month", view["month"], 56)
    tft.hline(0, 74, 240, _DIM)                               # rule
    ...                                                       # Used row @ 76
    _budget_bar(tft, view["used_frac"], view["budget"], 8, 96, 224, 10)
    tft.text(font, "@ " + updated, 8, 112, _DIM, BLACK)      # footer

The web habits, translated

Building a web pageDrawing this dash
CSS + a layout engine decide positionsYou decide: x=8, _VALUE_X=104, rows 18 px apart
Responsive reflow on any screenOne screen, ever: 15 glyph columns × 8 rows
Components / a DOM treeFour render functions: render, render_account, render_loading, render_error
Browser repaints when state changesmain.py calls a render function when it has new data
requestAnimationFrame, 60 fpsOne repaint per refresh — every 60 seconds by default
Text wraps; overflow scrolls_clip() truncates and appends ~
Web fonts, kerning, shapingOne vendored bitmap font, 16×16 cells, ASCII
GPU compositing, vsyncThe ST7789 refreshes itself from GRAM
Why full redraws?

Each refresh clears the whole screen and redraws — the simplest correct strategy, and at one repaint per minute the brief blank is invisible in practice. Since text paints its own background, a fancier version could update numbers in place with zero flicker — that's a great first exercise when you remix dash.py.

The trade

What you give up, what you get

Gone

The layout engine

Nothing flows, wraps, or centers itself. Every glyph is where a constant in dash.py put it — and moving a row means re-doing arithmetic.

Gone

Typography

One font, one size, no kerning, ASCII only. Want bold 24 px? You vendor another bitmap font module and spend the flash space.

Gone

Animation for free

No vsync, no requestAnimationFrame. Motion means re-blitting rectangles yourself, racing a 40 MHz wire from interpreted Python.

Gained

Determinism

Same bytes in, same pixels out — no user agents, no zoom levels. The renderer is unit-tested against a fake TFT because its output is exactly predictable.

Gained

Absurdly small footprint

The frame never exists in MCU RAM: 512-byte scratch buffers and the panel's own GRAM do all the remembering.

Gained

A stack you can read

Your code to lit glass is ~1,000 lines of vendored Python — no browser, no OS, no driver blobs. Every pixel is explainable.

Go deeper

Bookmark these

Back · Chapter 03← Tour the app