Architecture

A small stack of modules, a single drawing strategy, and one narrow door to the hardware.

Layers

Each module only calls the ones below it. The core is the only module that sends data to the port, so everything above it stays hardware-independent and can run against the PC model unchanged.

Layered architecture: application, widgets and demo, text, shapes and images, core, then the two port functions and the bus and controller
Layers of stm32-st7789-gfx. Dashed boxes are code you own: the application and the port.
ModuleHeaderRole
Portgfx_port.hTwo functions you implement: gfx_port_cmd() and gfx_port_data().
Coregfx_core.hWindow, burst, clipping, rectangles, colour blending, gradients, shared types.
Shapesgfx_shapes.hLines, rectangles, rounded rectangles, circles, rings, arcs, triangles, polygons, integer maths.
Textgfx_text.hUTF-8 decoding, glyph lookup, anti-aliased drawing, text boxes, integer to string.
Imagesgfx_image.hRGB565 images, 4-bit opacity icons, 1-bit bitmaps.
Fonts, iconsgfx_fonts.h, gfx_icons.hGenerated const data: six fonts and 32 icons.
Widgetsgfx_widgets.hStateless drawing functions that use the active theme.
Demo, self-testgfx_demo.h, gfx_selftest.hSeven demo pages and nine bring-up patterns. Optional.

Window and burst

The ST7789 keeps a write window: a rectangle of its frame memory. Once the window is set, each pixel written after the RAMWR command lands at the next position inside the window, row by row. The library exploits that everywhere.

Window and burst: clip, set the window with 3 commands and 8 parameters, then stream the pixels
Setting the window costs 11 bus writes: three commands and eight parameters.

The cost model is simple:

  • Pixel by pixel: 12 bus writes per pixel (CASET 1 + 4, RASET 1 + 4, RAMWR 1 + 1).
  • Window and burst: 11 writes to set the window, then 1 write per pixel. A filled w x h rectangle costs w x h + 11 writes.

A full-screen fill is therefore 57,611 writes instead of 691,200. Shapes that are not rectangles, such as circles and arcs, are painted as one run per row, so they pay 11 writes per row instead of 12 per pixel.

Bar chart on a logarithmic axis comparing bus writes of window and burst against pixel by pixel for ten drawing operations
Measured on the PC model with make bench.

Clipping

gfx_clip_rect() trims a rectangle to the screen and reports whether anything is left. Every public primitive clips before it touches the port, so drawing partly or completely off screen is safe and never sends a pixel outside the window. The host tests count window violations and require zero.

Text

Fonts are proportional bitmaps with 4 bits of opacity per pixel, two pixels per byte. When you give a background colour, the library builds a 16-entry colour table between background and text and draws each glyph as one window the size of its cell, so the glyph and its background go out in a single burst. Without a background there is nothing to blend with, so the _t variants paint only pixels at 50 percent opacity or more, run by run.

Strings are UTF-8. The decoder understands one- and two-byte sequences up to U+00FF and maps anything else to ?.

Shapes without floating point

  • Lines use Bresenham, grouped into horizontal or vertical runs so each run is one burst.
  • Circles and rings use an integer square root and the test dx2 + dy2 ≤ r2 + r, which gives rounder results than the plain radius test.
  • Arcs compare each pixel with the start and end directions using cross products, with 0 degrees pointing up and angles growing clockwise.
  • Polygons are filled with the even-odd rule on a grid of 1/16 pixel, so concave shapes such as stars work. Thick lines are rotated rectangles plus a circle at each end.
  • Sine and cosine come from a 91-entry table in Q14 fixed point.

Widgets

Widgets keep no state. You pass the value, and when it changes you call the function again. They draw over a flat colour from the active theme so text and icons blend exactly. Because they only call the layers below, they behave the same on the board and on the PC model.

The port

The whole hardware surface is two functions. After CASET or RASET, gfx_port_data() carries 8-bit parameters in the low byte; after RAMWR it carries 16-bit RGB565 pixels. See Write the port for FMC, SPI and 8-bit examples.

Design decisions

DecisionReason
Integer arithmetic onlyRuns on cores without an FPU, gives identical pixels on every host, and makes golden hashes stable.
No dynamic memory, no global drawing state beyond the active themePredictable memory use, nothing to initialise or free.
Two-function portKeeps the library independent of HAL versions, buses and boards.
Text with a background fills the whole glyph cellOne window per glyph is far cheaper than a window per run, and anti-aliasing needs a known background anyway.
Fixed 240 x 240 geometryKeeps clipping and widgets simple. See the known limits.
Generated data in the repositoryFonts and icons are produced by scripts in tools/ and committed, so a build needs no Python and no font files.

Single-header build

tools/make_single_header.py concatenates the headers and sources into single_header/lcd_gfx.h, in the style of the stb libraries. The declarations are always visible; the implementation is compiled only where GFX_IMPLEMENTATION is defined. A test links one translation unit with the implementation and another without it, runs the whole suite on that build, and checks that every demo page hashes to the same value as the multi-file build.