f7c5cc09ba
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.
185 lines
7.4 KiB
C++
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
|