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.
This commit is contained in:
@@ -0,0 +1,389 @@
|
||||
// Run: make test-cpp
|
||||
// The rect and hit assertions from test/ui_layout.lua, against the C++ node arena.
|
||||
// Panel and font match test/fake_device.lua so the numbers are the same ones.
|
||||
|
||||
#include "../src/ui/layout.h"
|
||||
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
|
||||
using namespace ui;
|
||||
|
||||
static const int PANEL_W = 320, PANEL_H = 480;
|
||||
static const int CHAR_W = 6, FONT_H = 8;
|
||||
|
||||
static int failures = 0;
|
||||
|
||||
static void check(const char* what, const std::string& got, const std::string& want) {
|
||||
if (got == want) return;
|
||||
printf("\n %s: got %s, want %s", what, got.c_str(), want.c_str());
|
||||
failures++;
|
||||
}
|
||||
|
||||
static void checkTrue(const char* what, bool ok) {
|
||||
if (ok) return;
|
||||
printf("\n %s", what);
|
||||
failures++;
|
||||
}
|
||||
|
||||
static std::string rect(const Tree& tree, uint16_t id) {
|
||||
const Node& n = tree.nodes[id];
|
||||
char buffer[64];
|
||||
snprintf(buffer, sizeof(buffer), "%d,%d %dx%d", n.x, n.y, n.w, n.h);
|
||||
return buffer;
|
||||
}
|
||||
|
||||
// Builders -----------------------------------------------------------------
|
||||
|
||||
static Spec boxSpec() { return Spec(); }
|
||||
|
||||
static Spec textSpec(const char* label) {
|
||||
Spec spec;
|
||||
spec.intrinsicW = static_cast<int16_t>(strlen(label) * CHAR_W);
|
||||
spec.intrinsicH = FONT_H;
|
||||
return spec;
|
||||
}
|
||||
|
||||
static uint16_t addText(Tree& tree, uint16_t parent, const char* label) {
|
||||
uint16_t id = tree.add(parent, textSpec(label), TEXT);
|
||||
tree.setLabel(id, label);
|
||||
return id;
|
||||
}
|
||||
|
||||
// Mirrors ui.button: pad 8 all round, contents centred on the cross axis.
|
||||
static uint16_t addButton(Tree& tree, uint16_t parent, const char* label, bool interactive = true) {
|
||||
Spec spec;
|
||||
spec.padT = spec.padR = spec.padB = spec.padL = 8;
|
||||
spec.align = CENTER;
|
||||
uint16_t id = tree.add(parent, spec, BUTTON, interactive ? INTERACTIVE : 0);
|
||||
addText(tree, id, label);
|
||||
return id;
|
||||
}
|
||||
|
||||
// Mirrors ui.screen: the root is the panel, so it must measure that way too.
|
||||
static uint16_t addRoot(Tree& tree, Spec spec = Spec()) {
|
||||
spec.w = Size::fill();
|
||||
spec.h = Size::fill();
|
||||
return tree.add(NONE, spec);
|
||||
}
|
||||
|
||||
static bool run(Tree& tree, uint16_t root) {
|
||||
return tree.layout(root, 0, 0, PANEL_W, PANEL_H);
|
||||
}
|
||||
|
||||
// Cases --------------------------------------------------------------------
|
||||
|
||||
static void flowStacksChildren() {
|
||||
Tree tree;
|
||||
Spec spec;
|
||||
spec.padT = spec.padR = spec.padB = spec.padL = 12;
|
||||
spec.gap = 8;
|
||||
uint16_t root = addRoot(tree, spec);
|
||||
uint16_t title = addText(tree, root, "settings");
|
||||
uint16_t first = addButton(tree, root, "one");
|
||||
uint16_t second = addButton(tree, root, "two");
|
||||
|
||||
checkTrue("flow laid out", run(tree, root));
|
||||
check("title", rect(tree, title), "12,12 296x8");
|
||||
// Button height is text plus its own padding: 8 + 8 + 8 = 24.
|
||||
check("first button", rect(tree, first), "12,28 296x24");
|
||||
check("second button", rect(tree, second), "12,60 296x24");
|
||||
}
|
||||
|
||||
static void fractionsResolveAgainstContent() {
|
||||
Tree tree;
|
||||
Spec spec;
|
||||
spec.padT = spec.padR = spec.padB = spec.padL = 10;
|
||||
uint16_t root = addRoot(tree, spec);
|
||||
|
||||
Spec halfSpec = boxSpec();
|
||||
halfSpec.w = Size::fraction(500);
|
||||
halfSpec.h = Size::px(40);
|
||||
uint16_t half = tree.add(root, halfSpec);
|
||||
|
||||
Spec fixedSpec = boxSpec();
|
||||
fixedSpec.w = Size::px(100);
|
||||
fixedSpec.h = Size::px(40);
|
||||
uint16_t fixed = tree.add(root, fixedSpec);
|
||||
|
||||
checkTrue("fractions laid out", run(tree, root));
|
||||
check("half", rect(tree, half), "10,10 150x40");
|
||||
check("fixed", rect(tree, fixed), "10,50 100x40");
|
||||
}
|
||||
|
||||
static void rowCentresOnCrossAxis() {
|
||||
Tree tree;
|
||||
uint16_t root = addRoot(tree);
|
||||
|
||||
Spec rowSpec;
|
||||
rowSpec.gap = 6;
|
||||
rowSpec.align = CENTER;
|
||||
rowSpec.h = Size::px(40);
|
||||
uint16_t row = tree.add(root, rowSpec, BOX, ROW);
|
||||
|
||||
Spec tallSpec;
|
||||
tallSpec.w = Size::px(40);
|
||||
tallSpec.h = Size::px(40);
|
||||
uint16_t tall = tree.add(row, tallSpec);
|
||||
|
||||
Spec shortSpec;
|
||||
shortSpec.w = Size::px(40);
|
||||
shortSpec.h = Size::px(10);
|
||||
uint16_t shortBox = tree.add(row, shortSpec);
|
||||
|
||||
checkTrue("row laid out", run(tree, root));
|
||||
check("tall", rect(tree, tall), "0,0 40x40");
|
||||
check("short", rect(tree, shortBox), "46,15 40x10");
|
||||
}
|
||||
|
||||
// justify distributes the main axis: "between" pushes the last child to the far edge,
|
||||
// which is how a header keeps a title left and a clock right without arithmetic.
|
||||
static void justifyBetweenSpreads() {
|
||||
Tree tree;
|
||||
Spec rootSpec;
|
||||
rootSpec.padT = rootSpec.padR = rootSpec.padB = rootSpec.padL = 10;
|
||||
uint16_t root = addRoot(tree, rootSpec);
|
||||
|
||||
Spec rowSpec;
|
||||
rowSpec.justify = BETWEEN;
|
||||
uint16_t row = tree.add(root, rowSpec, BOX, ROW);
|
||||
|
||||
Spec leftSpec;
|
||||
leftSpec.w = Size::px(40);
|
||||
leftSpec.h = Size::px(10);
|
||||
uint16_t left = tree.add(row, leftSpec);
|
||||
|
||||
Spec rightSpec;
|
||||
rightSpec.w = Size::px(60);
|
||||
rightSpec.h = Size::px(10);
|
||||
uint16_t right = tree.add(row, rightSpec);
|
||||
|
||||
checkTrue("between laid out", run(tree, root));
|
||||
check("left", rect(tree, left), "10,10 40x10");
|
||||
check("right", rect(tree, right), "250,10 60x10");
|
||||
}
|
||||
|
||||
// The other modes shift the whole run rather than spreading it.
|
||||
static void justifyEndShiftsRun() {
|
||||
Tree tree;
|
||||
uint16_t root = addRoot(tree);
|
||||
|
||||
Spec rowSpec;
|
||||
rowSpec.gap = 10;
|
||||
rowSpec.justify = END;
|
||||
uint16_t row = tree.add(root, rowSpec, BOX, ROW);
|
||||
|
||||
Spec aSpec;
|
||||
aSpec.w = Size::px(40);
|
||||
aSpec.h = Size::px(10);
|
||||
uint16_t a = tree.add(row, aSpec);
|
||||
|
||||
Spec bSpec;
|
||||
bSpec.w = Size::px(60);
|
||||
bSpec.h = Size::px(10);
|
||||
uint16_t b = tree.add(row, bSpec);
|
||||
|
||||
checkTrue("end laid out", run(tree, root));
|
||||
check("a", rect(tree, a), "210,0 40x10");
|
||||
check("b", rect(tree, b), "260,0 60x10");
|
||||
}
|
||||
|
||||
// One child cannot be spread against anything, so "between" degrades to "start".
|
||||
static void betweenWithOneChild() {
|
||||
Tree tree;
|
||||
uint16_t root = addRoot(tree);
|
||||
|
||||
Spec rowSpec;
|
||||
rowSpec.justify = BETWEEN;
|
||||
uint16_t row = tree.add(root, rowSpec, BOX, ROW);
|
||||
|
||||
Spec onlySpec;
|
||||
onlySpec.w = Size::px(40);
|
||||
onlySpec.h = Size::px(10);
|
||||
uint16_t only = tree.add(row, onlySpec);
|
||||
|
||||
checkTrue("lone child laid out", run(tree, root));
|
||||
check("only", rect(tree, only), "0,0 40x10");
|
||||
}
|
||||
|
||||
// The root always fills the screen, so alignment there spans the whole panel.
|
||||
static void alignCentresOnTheRoot() {
|
||||
Tree tree;
|
||||
Spec rootSpec;
|
||||
rootSpec.align = CENTER;
|
||||
uint16_t root = addRoot(tree, rootSpec);
|
||||
|
||||
Spec loneSpec;
|
||||
loneSpec.w = Size::px(40);
|
||||
loneSpec.h = Size::px(40);
|
||||
uint16_t lone = tree.add(root, loneSpec);
|
||||
|
||||
checkTrue("centred root laid out", run(tree, root));
|
||||
check("lone", rect(tree, lone), "140,0 40x40");
|
||||
}
|
||||
|
||||
// A fraction inside an auto-sized parent is an error, not a silent zero.
|
||||
static void fractionInsideAutoParentFails() {
|
||||
Tree tree;
|
||||
uint16_t root = addRoot(tree);
|
||||
uint16_t autoBox = tree.add(root, boxSpec());
|
||||
|
||||
Spec childSpec;
|
||||
childSpec.w = Size::px(20);
|
||||
childSpec.h = Size::fraction(500);
|
||||
tree.add(autoBox, childSpec);
|
||||
|
||||
checkTrue("fraction inside an auto parent must fail loudly", !run(tree, root));
|
||||
}
|
||||
|
||||
// Hit testing: deepest interactive node wins over its tappable ancestor.
|
||||
static void hitPrefersTheDeepestNode() {
|
||||
Tree tree;
|
||||
Spec outerSpec;
|
||||
outerSpec.padT = outerSpec.padR = outerSpec.padB = outerSpec.padL = 20;
|
||||
outerSpec.w = Size::fill();
|
||||
outerSpec.h = Size::fill();
|
||||
uint16_t outer = tree.add(NONE, outerSpec, BOX, INTERACTIVE);
|
||||
uint16_t inner = addButton(tree, outer, "inner");
|
||||
|
||||
checkTrue("hit tree laid out", run(tree, outer));
|
||||
checkTrue("inner button should win inside its rect", tree.hit(outer, 160, 30) == inner);
|
||||
checkTrue("the container claims its own padding", tree.hit(outer, 2, 2) == outer);
|
||||
checkTrue("outside the tree is a miss", tree.hit(outer, -50, -50) == NONE);
|
||||
}
|
||||
|
||||
// A dialog is an ordinary node the app includes, placed absolutely so it covers the flow
|
||||
// and swallows the taps that would otherwise reach what is underneath it.
|
||||
static void dialogCoversAndCaptures() {
|
||||
Tree tree;
|
||||
uint16_t root = addRoot(tree);
|
||||
|
||||
Spec behindSpec;
|
||||
behindSpec.padT = behindSpec.padR = behindSpec.padB = behindSpec.padL = 12;
|
||||
uint16_t behind = tree.add(root, behindSpec);
|
||||
uint16_t buried = addButton(tree, behind, "do not press");
|
||||
|
||||
Spec layerSpec;
|
||||
layerSpec.absolute = true;
|
||||
layerSpec.atX = Size::px(0);
|
||||
layerSpec.atY = Size::px(0);
|
||||
layerSpec.w = Size::fill();
|
||||
layerSpec.h = Size::fill();
|
||||
layerSpec.align = CENTER;
|
||||
layerSpec.justify = CENTER;
|
||||
uint16_t layer = tree.add(root, layerSpec, BOX, CAPTURE);
|
||||
|
||||
Spec cardSpec;
|
||||
cardSpec.w = Size::fraction(850);
|
||||
cardSpec.padT = cardSpec.padR = cardSpec.padB = cardSpec.padL = 16;
|
||||
cardSpec.gap = 12;
|
||||
uint16_t card = tree.add(layer, cardSpec);
|
||||
addText(tree, card, "forget network?");
|
||||
|
||||
Spec buttonsSpec;
|
||||
buttonsSpec.gap = 8;
|
||||
buttonsSpec.justify = END;
|
||||
uint16_t buttons = tree.add(card, buttonsSpec, BOX, ROW);
|
||||
addButton(tree, buttons, "cancel");
|
||||
uint16_t confirm = addButton(tree, buttons, "forget");
|
||||
|
||||
checkTrue("dialog laid out", run(tree, root));
|
||||
check("layer", rect(tree, layer), "0,0 320x480");
|
||||
|
||||
const Node& cardNode = tree.nodes[card];
|
||||
checkTrue("card is 0.85 of the panel", cardNode.w == 272);
|
||||
checkTrue("card is centred horizontally", cardNode.x == 24);
|
||||
checkTrue("card is centred vertically", cardNode.y == (PANEL_H - cardNode.h) / 2);
|
||||
|
||||
checkTrue("the dialog was tapped through", tree.hit(root, 60, 24) == layer);
|
||||
checkTrue("the dialog's own buttons still work",
|
||||
tree.hit(root, tree.nodes[confirm].x + 4, tree.nodes[confirm].y + 4) == confirm);
|
||||
(void)buried;
|
||||
}
|
||||
|
||||
// Labels share one arena. Overwriting in place matters because the clock repaints every
|
||||
// second at a fixed width, and an append per tick would grow without bound.
|
||||
static void labelsShareOneArena() {
|
||||
Tree tree;
|
||||
uint16_t root = addRoot(tree);
|
||||
uint16_t clock = addText(tree, root, "12:00:00");
|
||||
uint16_t other = addText(tree, root, "wifi");
|
||||
|
||||
size_t before = tree.footprint();
|
||||
for (int i = 0; i < 100; i++) tree.setLabel(clock, "12:00:01");
|
||||
checkTrue("a same-width label must not grow the arena", tree.footprint() == before);
|
||||
check("clock label", tree.label(clock), "12:00:01");
|
||||
check("neighbour label", tree.label(other), "wifi");
|
||||
|
||||
tree.setLabel(clock, "a much longer label");
|
||||
check("grown label", tree.label(clock), "a much longer label");
|
||||
check("neighbour survived the growth", tree.label(other), "wifi");
|
||||
checkTrue("a node with no label reports none", tree.label(root) == nullptr);
|
||||
}
|
||||
|
||||
// Styling is inheritance rather than copies: the root carries the palette and a node
|
||||
// that names nothing costs nothing, which is what keeps the arena at 16 bytes a node.
|
||||
static void styleInheritsFromTheNearestAncestor() {
|
||||
Tree tree;
|
||||
uint16_t root = addRoot(tree);
|
||||
uint16_t middle = tree.add(root, boxSpec());
|
||||
uint16_t leaf = tree.add(middle, boxSpec());
|
||||
|
||||
Style& palette = tree.styleFor(root);
|
||||
palette.fg = 0x0001;
|
||||
palette.bg = 0x0002;
|
||||
palette.set = S_FG | S_BG;
|
||||
|
||||
Style& override_ = tree.styleFor(leaf);
|
||||
override_.fg = 0x0003;
|
||||
override_.set = S_FG;
|
||||
|
||||
checkTrue("a leaf inherits what it does not state", tree.inherited(leaf, S_BG).bg == 0x0002);
|
||||
checkTrue("a leaf keeps what it does state", tree.inherited(leaf, S_FG).fg == 0x0003);
|
||||
checkTrue("an unstyled node inherits both", tree.inherited(middle, S_FG).fg == 0x0001);
|
||||
checkTrue("an unset role falls back rather than guessing",
|
||||
tree.inherited(leaf, S_BORDER).border == Style().border);
|
||||
checkTrue("only styled nodes cost anything", tree.styleOf(middle) == nullptr);
|
||||
}
|
||||
|
||||
// The point of the split arena: what a screen costs once its layout pass is over.
|
||||
static void reportFootprint() {
|
||||
Tree tree;
|
||||
uint16_t root = addRoot(tree);
|
||||
for (int i = 0; i < 200; i++) addButton(tree, root, "item");
|
||||
size_t built = tree.nodes.size();
|
||||
checkTrue("footprint tree laid out", run(tree, root));
|
||||
|
||||
size_t scratch = built * sizeof(Spec);
|
||||
tree.dropScratch();
|
||||
size_t persistent = tree.footprint();
|
||||
printf("\n sizeof(Node)=%zu sizeof(Spec)=%zu", sizeof(Node), sizeof(Spec));
|
||||
printf("\n %zu nodes: %zu B steady, %zu B peak (+%zu B scratch)", built, persistent,
|
||||
persistent + scratch, scratch);
|
||||
|
||||
checkTrue("a Node must stay at 16 bytes", sizeof(Node) == 16);
|
||||
}
|
||||
|
||||
int main() {
|
||||
flowStacksChildren();
|
||||
fractionsResolveAgainstContent();
|
||||
rowCentresOnCrossAxis();
|
||||
justifyBetweenSpreads();
|
||||
justifyEndShiftsRun();
|
||||
betweenWithOneChild();
|
||||
alignCentresOnTheRoot();
|
||||
fractionInsideAutoParentFails();
|
||||
hitPrefersTheDeepestNode();
|
||||
dialogCoversAndCaptures();
|
||||
labelsShareOneArena();
|
||||
styleInheritsFromTheNearestAncestor();
|
||||
reportFootprint();
|
||||
|
||||
if (failures) {
|
||||
printf("\n%d failure(s)\n", failures);
|
||||
return 1;
|
||||
}
|
||||
printf("\n ok\n");
|
||||
return 0;
|
||||
}
|
||||
Reference in New Issue
Block a user