A lightweight, DOM-inspired UI framework for the M5Paper S3 e-ink display. Build touch-enabled interfaces with a familiar component tree model — no LVGL, no heavy dependencies.
中文文档: README_CN.md | 组件文档 | C++入门
M5Paper's 4.7" e-ink display (540×960, 16-level grayscale) needs careful refresh management. This framework handles:
- Smart rendering — only redraws dirty regions, config-then-draw pattern
- EPD modes — per-component refresh quality (QUALITY / TEXT / FAST / FASTEST)
- Touch handling — click, long-press, drag, swipe, double-click detection
- Theme system — light/dark themes with per-component color caching
- Task scheduler — non-blocking periodic tasks with lifecycle management
- Power saving — auto-sleep after 5 seconds of idle
#include <M5Unified.h>
#include "core.h"
#include "components.h"
class MyApp : public Node {
public:
MyApp() : Node(0, 0, 540, 960, "MyApp") {
add(new Rect(0, 0, 540, 960)); // background
add(new Label(20, 40, "Hello, World!")); // text
auto* btn = new Button(20, 145, 140, 50, "Click Me");
btn->onClick([this]() {
Serial.println("Clicked!");
});
add(btn);
add(new BatteryIcon()); // battery indicator
}
};
Framework app;
void setup() {
auto cfg = M5.config();
cfg.clear_display = false;
M5.begin(cfg);
delay(500);
app.begin();
app.addPage(new MyApp());
app.switchTo(app.getPage("MyApp"));
}
void loop() {
M5.update();
app.update();
delay(1);
}├── Core.h / Core.cpp # Framework core: Node tree, Theme, Touch, TaskPool
├── components.h / components.cpp # Built-in widgets
├── sketch_jun4a.ino # Full demo app (counter + theme + task pool)
├── examples/ # Minimal examples
│ ├── basic/ # "Hello World" on e-ink
│ ├── counter/ # Counter with buttons (no WiFi)
│ ├── refresh_test/ # EPD mode comparison
│ ├── sd_autocheck/ # SD card auto-detect
│ └── startwrite_test/ # Batch drawing performance
├── fonts/ # TrueType fonts converted to .vlw
├── docs/ # Detailed documentation (Chinese)
└── LICENSE
Every UI element is a Node. Pages are root nodes, widgets are children:
Page (Node)
├── Rect (background)
├── Label (title)
├── Button (click me)
├── ProgressBar
└── BatteryIcon
// ✅ config() computes everything once
void config() override {
_r = h / 2; // cache corner radius
_tx = (w - _tw) / 2; // cache text x offset
}
// ✅ draw() only uses cached values — zero compute
void draw() override {
M5.Display.fillRoundRect(x, y, w, h, _r, color);
M5.Display.drawString(_text.c_str(), _tx, _ty);
}Never compute in draw() — it's called on every frame. Do all measurement and color resolution in config().
The framework translates M5GFX raw touch data into structured events:
| Event | Trigger | Use Case |
|---|---|---|
TOUCH_CLICK |
Short press release (<300ms) | Button clicks |
TOUCH_LONG_CLICK |
Long press release (≥500ms) | Context menus |
TOUCH_LONG_HOLD |
Long press active (every frame) | Repeat actions |
TOUCH_DRAG |
Press + move | Sliders, scroll |
TOUCH_SWIPE |
Fast flick | Page switching |
TOUCH_DOUBLE_CLICK |
Rapid double-tap | Special actions |
Set touch_mask in your component's constructor to declare which events it handles.
Each component declares its refresh quality:
| Mode | Time | Quality | Use For |
|---|---|---|---|
EPD_QUALITY |
~450ms | Best, no ghosting | Final renders |
EPD_TEXT |
~450ms | Sharp text | Labels, text |
EPD_FAST |
~260ms | Minor ghosting | Buttons, UI (default) |
EPD_FASTEST |
~120ms | Washed out | Animations |
Non-blocking periodic tasks that follow page lifecycle:
// In your page constructor:
addTask("my_task", [this]() {
doSomething();
}, 1000); // runs every 1000ms
// Tasks are auto-registered on show(), auto-removed on hide()/del()| Component | Description |
|---|---|
Rect |
Solid / outlined rectangle (theme-aware background) |
Label |
Text with optional background fill |
Button |
Rounded button with click/long-press callbacks |
ProgressBar |
Horizontal progress bar (0–100) |
ListView |
Scrollable list with arrow buttons + scrollbar |
BatteryIcon |
Battery level + charging indicator (auto-refresh) |
Image |
JPG/PNG/BMP from SD card with fit modes |
GridSystem |
Grid coordinate calculator (not a Node) |
class MyWidget : public Node {
public:
MyWidget(int32_t x, int32_t y, int32_t w, int32_t h)
: Node(x, y, w, h, "MyWidget")
{
touch_mask = TOUCH_CLICK; // respond to clicks
epd_mode = EPD_FAST; // fast refresh
config(); // compute initial layout
}
void config() override {
// Cache colors, measurements from theme
_cached_color = theme().text_color;
}
void draw() override {
// Draw using cached values
M5.Display.fillRect(x, y, w, h, _cached_color);
}
void onTouch(const TouchEvent& ev) override {
if (ev.mode == TOUCH_CLICK) {
// Handle click
}
}
private:
uint32_t _cached_color;
};- M5Paper S3 (ESP32-S3, IT8951 EPD controller)
- M5Unified library (v0.1.x+)
- M5GFX library (included with M5Unified)
The framework uses 16-level grayscale colors (
_GRAY[0..15]) matching M5Paper's display capabilities.
- Install Arduino IDE with ESP32-S3 board support
- Install M5Unified via Library Manager
- Clone this repo into your Arduino sketches folder:
cd ~/Arduino git clone https://github.com/brejce/m5paper-ui.git
- Copy font file to SD card:
fonts/HarmonyOS_Sans_SC_Regular_28.vlw - Open
sketch_jun4a/sketch_jun4a.inoin Arduino IDE - Upload to M5Paper
For a minimal first test, start with examples/basic/basic.ino.
MIT — use freely in personal and commercial projects.
Pull requests welcome! For major changes, please open an issue first to discuss.
Built with ☕ by brejce