Files
slate32/test/ui_layout_test.cpp
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

390 lines
12 KiB
C++

// 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;
}