Projects Embedded

MicroUI — OLED Interface Library

A header-only immediate-mode UI library for 128x64 SSD1306 OLEDs: widgets, transitions, and full menu navigation from one rotary encoder in under 8 KB of flash.

Shipped since Mar 2025 #embedded#oled#ssd1306#cpp
7.4 KB
Flash footprint
1 KB
Framebuffer
12
Built-in widgets
9 ms
Full redraw

MicroUI exists because every OLED menu I wrote before it was the same 400 lines of switch statements, copy-pasted between projects and slightly broken in each one. The libraries I tried were either retained-mode object trees that ate RAM I didn’t have on an ATmega328, or thin wrappers that still made me write the navigation logic myself. I wanted the ergonomics of Dear ImGui at 1/1000th the budget.

Immediate mode on 1 KB

The whole display state is a 1 KB framebuffer — 128x64 pixels, one bit each, laid out in the SSD1306’s native page format so flushing is a straight memcpy per page. There are no widget objects. Every frame, the sketch calls functions like ui.button("Save") or ui.slider("Bright", val, 0, 255), which draw immediately and return whether they were activated. Focus is just an integer index the encoder increments; whichever widget’s draw call matches the index renders inverted. No heap, no callbacks, no dangling state between screens.

Dirty-rect updates

A full framebuffer flush over 400 kHz I2C takes about 22 ms — too slow for smooth encoder scrolling. MicroUI tracks a dirty rectangle per 8-pixel page and only transmits the changed column span:

void flush() {
  for (uint8_t p = 0; p < 8; p++) {
    if (dirty_[p].x1 < dirty_[p].x0) continue;   // page untouched
    setWindow(p, dirty_[p].x0, dirty_[p].x1);
    write(&fb_[p * 128 + dirty_[p].x0], dirty_[p].x1 - dirty_[p].x0 + 1);
    dirty_[p] = {127, 0};                        // reset to empty
  }
}

Typical menu interaction touches one or two pages, so a frame flushes in under 3 ms. The catch I hit early: screen transitions dirty everything anyway, so transitions temporarily disable tracking rather than paying the bookkeeping cost for a guaranteed full flush.

API design was the hard part

The first API took a screen ID and a widget list — basically retained mode wearing a trench coat. It died the moment I needed a widget that appeared conditionally. Version two went fully immediate: your screen is a function, control flow is your UI logic, and an if statement is how you hide a widget. That single decision deleted about a third of the codebase and made the encoder navigation almost free, since the focus index only has to count how many focusable calls happened this frame.

It now runs in four of my other projects unchanged, which was the actual goal.

Development timeline

  1. 2025-03

    First pixels

    Raw framebuffer and page-addressing driver working over I2C.

  2. 2025-04

    Immediate-mode core

    Widget calls, focus handling, and encoder input landed.

  3. 2025-06

    Dirty rects

    Partial updates cut typical frame transfer time from 22 ms to under 3 ms.

  4. 2025-08

    1.0 shipped

    Header-only release published with docs and three example sketches.