Files
slate32/src/ui/paint.h
T
evan f7c5cc09ba feat(ui)!: move the widget tree into C++
A node was a Lua table of ~625 bytes, of which 21 keys pushed it over a
power-of-two hash boundary and eight were style copies inheritance had
splattered down from its parent. A 400 node screen cost ~250 KB and could not
coexist with wifi's buffers.

The tree now lives in src/ui/layout.h as a 16 byte struct in a flat arena, and
splits by lifetime: Node holds what hit testing and repainting need forever,
Spec holds what only measure/place read and is dropped when layout ends. Style
is sparse and resolved by walking parents, so a node naming no colours costs
nothing. Re-layout rebuilds from Lua rather than retaining the inputs.

    401 nodes:  8218 B steady, 21050 B peak
           Lua: ~250000 B steady

sdcard/lib/ui.lua stays the toolkit and keeps every constructor signature, but
returns integer handles: 627 lines to 374. Composition, the palette and custom
painters are still Lua on the SD card; only primitives now need a reflash.

BREAKING CHANGE: ui constructors return handles, not tables. Use
ui.setText(id, text) and keep per-node app data in a table keyed by id.
2026-08-02 18:48:48 -04:00

185 lines
7.4 KiB
C++

#pragma once
// Painting the node tree. Needs the panel, so unlike layout.h this is not host-testable;
// keep anything that can be decided without pixels on the other side of that line.
//
// Repainting follows the Lua original: a dirty node paints itself and dirties its
// children, because a parent's fill lands on top of whatever they drew. Nothing tracks
// sub-regions -- a widget that wants to repaint part of itself is a CUSTOM node and does
// it through the gui bindings, which is what the on-screen keyboard already does.
#include <TFT_eSPI.h>
#include "../gfx/round_rect.h"
#include "layout.h"
namespace ui {
constexpr int MAX_SPAN = 480; // longest panel edge, so one row buffer covers any shape
// One primitive draws the whole surface: fill (solid or vertical gradient) and border
// derive from the same distance field, so they cannot disagree at the corners the way
// two separate rounded-rect algorithms did. The panel has no alpha, so edge pixels are
// blended against `surface`, the colour of whatever sits underneath.
inline void drawRoundRect(TFT_eSPI& tft, int x, int y, int w, int h, float radius,
uint16_t surface, bool hasFill, uint16_t top, uint16_t bottom,
bool hasBorder, uint16_t border) {
if (w <= 0 || h <= 0 || w > MAX_SPAN) return;
float halfWidth = w * 0.5f, halfHeight = h * 0.5f;
if (radius < 0.0f) radius = 0.0f;
float limit = (w < h ? w : h) / 2.0f;
if (radius > limit) radius = limit;
static uint16_t span[MAX_SPAN];
// pushImage sends the buffer verbatim, but the panel wants each colour big-endian.
bool previousSwap = tft.getSwapBytes();
tft.setSwapBytes(true);
for (int row = 0; row < h; row++) {
uint16_t fill = hasFill ? gfx::lerp565(top, bottom, row, h - 1) : 0;
float py = row + 0.5f - halfHeight;
for (int column = 0; column < w; column++) {
float distance =
gfx::roundRectDistance(column + 0.5f - halfWidth, py, halfWidth, halfHeight, radius);
float outer = gfx::coverage(distance);
// The border is the ring between the shape and the same shape inset by its width.
float inner = hasBorder ? gfx::coverage(distance + 1.0f) : outer;
uint16_t pixel = surface;
if (hasFill) pixel = gfx::blend565(pixel, fill, inner);
if (hasBorder) pixel = gfx::blend565(pixel, border, outer - inner);
span[column] = pixel;
}
tft.pushImage(x, y + row, w, 1, span);
}
tft.setSwapBytes(previousSwap);
}
// A CUSTOM node paints through Lua, so the walk needs a way back. One dispatcher for the
// whole tree rather than a reference per node: the Lua side already keys its painters by
// node id and can look one up faster than the registry can hand it over.
typedef void (*CustomPainter)(void* context, uint16_t id, int x, int y, int w, int h);
class Painter {
public:
Painter(TFT_eSPI& tft, Tree& tree) : tft(tft), tree(tree) {}
CustomPainter custom = nullptr;
void* context = nullptr;
void draw(uint16_t id) {
Node& n = tree.nodes[id];
if (n.flags & DIRTY) {
paint(id);
n.flags &= ~DIRTY;
for (uint16_t c = tree.nodes[id].first; c != NONE; c = tree.nodes[c].next) {
tree.nodes[c].flags |= DIRTY;
}
}
for (uint16_t c = tree.nodes[id].first; c != NONE; c = tree.nodes[c].next) draw(c);
}
private:
TFT_eSPI& tft;
Tree& tree;
// What a node sits on, which is not what it fills. Derived rather than stored, because
// a node cannot be told what is behind it: a dialog layer paints nothing, so its card
// blends into the dimmed content two levels up, not into the lit palette the layer
// hands its children. Nothing filling means the panel, cleared to the root's colour.
uint16_t surfaceOf(uint16_t id) const {
for (uint16_t n = tree.nodes[id].parent; n != NONE; n = tree.nodes[n].parent) {
const Style* style = tree.styleOf(n);
if (style && (style->set & S_FILL)) return style->fill;
}
uint16_t root = id;
while (tree.nodes[root].parent != NONE) root = tree.nodes[root].parent;
return tree.inherited(root, S_BG).bg;
}
void paint(uint16_t id) {
const Node& n = tree.nodes[id];
switch (n.type) {
case BUTTON:
paintButton(id);
break;
case TEXT:
paintText(id);
break;
case CUSTOM:
// Cleared first, because a custom painter draws what it wants and nothing knows
// what it drew last time. The keyboard's number page is narrower than its letter
// page, and without this the wider row's outer keys survive the repaint.
tft.fillRect(n.x, n.y, n.w, n.h, tree.inherited(id, S_BG).bg);
if (custom) custom(context, id, n.x, n.y, n.w, n.h);
break;
default:
paintBox(id);
break;
}
}
// A bordered box paints its own background as a rounded rect. Filling a square first
// would leave corners outside the border, which is invisible against a matching
// surface and obvious against any other.
void paintBox(uint16_t id) {
const Node& n = tree.nodes[id];
const Style* own = tree.styleOf(id);
bool hasBorder = own && (own->set & S_BORDER);
bool hasFill = own && (own->set & S_FILL);
if (!hasBorder) {
if (hasFill) tft.fillRect(n.x, n.y, n.w, n.h, own->fill);
return;
}
uint16_t fill = hasFill ? own->fill : tree.inherited(id, S_BG).bg;
drawRoundRect(tft, n.x, n.y, n.w, n.h, tree.inherited(id, S_RADIUS).radius, surfaceOf(id),
true, fill, fill, true, own->border);
}
void paintButton(uint16_t id) {
const Node& n = tree.nodes[id];
bool pressed = (n.flags & PRESSED) != 0;
const Style& face = tree.inherited(id, pressed ? S_PRESSED : S_FACE);
uint16_t top = pressed ? face.pressTop : face.faceTop;
uint16_t bottom = pressed ? face.pressBottom : face.faceBottom;
drawRoundRect(tft, n.x, n.y, n.w, n.h, tree.inherited(id, S_RADIUS).radius, surfaceOf(id),
true, top, bottom, true, tree.inherited(id, S_FG).fg);
}
// Glyphs over a button are transparent: an opaque fill is one flat colour, which
// matches only the single row of the gradient it was taken from. The face is repainted
// whenever it changes, so the label has nothing to erase.
void paintText(uint16_t id) {
const Node& n = tree.nodes[id];
const char* label = tree.label(id);
if (!label) return;
uint16_t parent = n.parent;
bool onButton = parent != NONE && tree.nodes[parent].type == BUTTON;
bool pressed = onButton && (tree.nodes[parent].flags & PRESSED);
tft.setTextSize(tree.inherited(id, S_SIZE).size);
// A text node usually fills its parent's width, so alignment is inside its own box.
int x = n.x;
Align align = tree.inherited(id, S_TEXT_ALIGN).textAlign;
if (align == CENTER) {
x += (n.w - static_cast<int>(tft.textWidth(label))) / 2;
} else if (align == END) {
x += n.w - static_cast<int>(tft.textWidth(label));
}
if (pressed) {
tft.setTextColor(tree.inherited(id, S_PRESS_FG).pressFg);
} else if (onButton) {
tft.setTextColor(tree.inherited(id, S_FG).fg);
} else {
// The whole box is cleared, not just the glyphs: a label replaced by a shorter one
// would otherwise leave the tail of the old text standing next to the new.
uint16_t bg = tree.inherited(id, S_BG).bg;
tft.fillRect(n.x, n.y, n.w, n.h, bg);
tft.setTextColor(tree.inherited(id, S_FG).fg, bg);
}
tft.drawString(label, x, n.y);
}
};
} // namespace ui