bf286eefa6
The controller and the WiFi driver each need a large aggregate allocation, which a live app sitting on garbage can deny - that is why a failed connect often succeeded on retry. Both bindings now collect before initializing, and the tree reserves its node and spec capacity so a build does not reallocate into a tight heap.
470 lines
14 KiB
C++
470 lines
14 KiB
C++
#pragma once
|
|
|
|
// The widget tree: structure, block-flow layout, labels and style, over a flat
|
|
// node arena. Free of Arduino headers so test/ui_layout_test.cpp can exercise
|
|
// it on the host. Ported from the Lua toolkit's Component:measure/place, whose
|
|
// semantics the tests still describe.
|
|
//
|
|
// The tree is split across two arenas because the halves have different
|
|
// lifetimes. A Node holds what hit testing and repainting need forever: 16
|
|
// bytes. A Spec holds what only measure() and place() read -- requested sizes,
|
|
// padding, gap, alignment -- and is dropped when the pass ends. Re-layout
|
|
// rebuilds from Lua rather than retaining ~20 bytes per node against a rotation
|
|
// nobody measures in milliseconds.
|
|
|
|
#include <cstddef>
|
|
#include <cstdint>
|
|
#include <cstring>
|
|
#include <utility>
|
|
#include <vector>
|
|
|
|
namespace esp32lua {
|
|
namespace ui {
|
|
|
|
constexpr uint16_t NONE = 0xFFFF;
|
|
|
|
// Distinguishes "no constraint" from a real zero, which a plain int cannot: an
|
|
// auto-sized parent genuinely has no width to hand down, and a child asking for
|
|
// a fraction of it is an error rather than a zero-width silence.
|
|
constexpr int UNKNOWN = INT32_MIN;
|
|
|
|
enum Type : uint8_t { BOX, TEXT, BUTTON, CUSTOM };
|
|
|
|
enum Flag : uint8_t {
|
|
ROW = 1 << 0, // main axis is horizontal
|
|
CAPTURE = 1 << 1, // swallows the taps its children missed
|
|
INTERACTIVE = 1 << 2, // has an on_press
|
|
DIRTY = 1 << 3,
|
|
PRESSED = 1 << 4,
|
|
};
|
|
|
|
enum SizeMode : uint8_t { AUTO, PX, FRACTION, FILL };
|
|
enum Align : uint8_t { START, CENTER, END, BETWEEN };
|
|
|
|
// Which roles a style states for itself. Anything unset is answered by the
|
|
// nearest ancestor that does state it, so styling stays in one place and a node
|
|
// that names no colours costs no bytes at all.
|
|
enum StyleField : uint16_t {
|
|
S_COLOR = 1 << 0,
|
|
// The background a node offers its descendants to draw text on, which is not
|
|
// the same as the fill it paints: a dialog layer hands the lit palette down
|
|
// while painting nothing itself. What is physically behind a node is derived
|
|
// at paint time, never set.
|
|
S_BG = 1 << 1,
|
|
S_FILL = 1 << 2,
|
|
S_BORDER = 1 << 3,
|
|
S_FACE = 1 << 4,
|
|
S_PRESSED_FACE = 1 << 5,
|
|
S_PRESSED_COLOR = 1 << 6,
|
|
S_FOCUS_COLOR = 1 << 7,
|
|
S_RADIUS = 1 << 8,
|
|
S_FONT = 1 << 9,
|
|
S_TEXT_STYLE = 1 << 10,
|
|
};
|
|
|
|
// Colours are whatever gui.color() returned, so the panel's own encoding.
|
|
struct Style {
|
|
int32_t color = 0;
|
|
int32_t bg = 0;
|
|
int32_t fill = 0;
|
|
int32_t border = 0;
|
|
int32_t face = 0;
|
|
int32_t pressedFace = 0;
|
|
int32_t pressedColor = 0;
|
|
int32_t focusColor = 0;
|
|
int32_t font = 0;
|
|
int32_t textStyle = 0;
|
|
uint8_t radius = 6;
|
|
uint16_t set = 0;
|
|
};
|
|
|
|
// FRACTION is per-mille rather than a float: 0.85 of 320 is 272 either way, and
|
|
// the firmware has no business rounding differently to the host.
|
|
struct Size {
|
|
SizeMode mode = AUTO;
|
|
int16_t value = 0;
|
|
|
|
static Size make(SizeMode mode, int16_t value) {
|
|
Size size;
|
|
size.mode = mode;
|
|
size.value = value;
|
|
return size;
|
|
}
|
|
static Size px(int16_t v) { return make(PX, v); }
|
|
static Size fraction(int16_t permille) { return make(FRACTION, permille); }
|
|
static Size fill() { return make(FILL, 0); }
|
|
};
|
|
|
|
// Persistent. w/h hold the measured size between measure() and place(), and the
|
|
// final rect afterwards, because the two are never needed at once.
|
|
struct Node {
|
|
int16_t x = 0, y = 0, w = 0, h = 0;
|
|
uint16_t first = NONE, next = NONE, parent = NONE;
|
|
uint8_t type = BOX;
|
|
uint8_t flags = 0;
|
|
};
|
|
|
|
// Scratch. Lives only for the duration of a build plus its layout pass.
|
|
struct Spec {
|
|
Size w, h;
|
|
Size atX, atY;
|
|
bool absolute = false;
|
|
int16_t intrinsicW = 0,
|
|
intrinsicH = 0; // content size of a leaf, e.g. a text run
|
|
uint8_t padT = 0, padR = 0, padB = 0, padL = 0;
|
|
uint8_t gap = 0;
|
|
Align align = START;
|
|
Align justify = START;
|
|
uint16_t last = NONE; // tail of the child list, so append is not a walk
|
|
};
|
|
|
|
// Resistive panels land a few pixels off, so a hit box is larger than what was
|
|
// painted.
|
|
constexpr int SLOP = 4;
|
|
|
|
constexpr uint16_t NO_LABEL = 0xFFFF;
|
|
|
|
class Tree {
|
|
public:
|
|
std::vector<Node> nodes;
|
|
std::vector<Spec> specs;
|
|
const char* error = nullptr;
|
|
uint16_t focus = NONE;
|
|
|
|
// Growth doubling needs the old and new buffers live at once, which is the
|
|
// allocation that fails first on a tight heap. One reservation covers a
|
|
// typical screen so a build reallocates only if it genuinely gets large.
|
|
static constexpr size_t RESERVE = 64;
|
|
|
|
void reset() {
|
|
focus = NONE;
|
|
nodes.clear();
|
|
specs.clear();
|
|
nodes.reserve(RESERVE);
|
|
specs.reserve(RESERVE);
|
|
labelAt.clear();
|
|
labels.clear();
|
|
styles.clear();
|
|
error = nullptr;
|
|
}
|
|
|
|
uint16_t add(uint16_t parent, const Spec& spec, uint8_t type = BOX,
|
|
uint8_t flags = 0) {
|
|
// A tree whose scratch has been dropped cannot be extended: its layout
|
|
// inputs are gone, so building again is a new screen by definition.
|
|
// Self-healing rather than advisory, because the alternative is a spec list
|
|
// that no longer indexes the nodes.
|
|
if (specs.size() != nodes.size())
|
|
reset();
|
|
uint16_t id = static_cast<uint16_t>(nodes.size());
|
|
nodes.push_back(Node());
|
|
specs.push_back(spec);
|
|
labelAt.push_back(NO_LABEL);
|
|
nodes[id].type = type;
|
|
nodes[id].flags = flags;
|
|
nodes[id].parent = parent;
|
|
if (parent != NONE)
|
|
attach(parent, id);
|
|
return id;
|
|
}
|
|
|
|
// Lua evaluates inner constructors first, so a child exists before the box
|
|
// that holds it. Adopting afterwards is what lets each spec table be garbage
|
|
// the moment its node is created, instead of a whole screen's worth of them
|
|
// living until the end of a build.
|
|
void attach(uint16_t parent, uint16_t child) {
|
|
nodes[child].parent = parent;
|
|
uint16_t tail = specs[parent].last;
|
|
if (tail == NONE) {
|
|
nodes[parent].first = child;
|
|
} else {
|
|
nodes[tail].next = child;
|
|
}
|
|
specs[parent].last = child;
|
|
}
|
|
|
|
bool layout(uint16_t root, int x, int y, int w, int h) {
|
|
error = nullptr;
|
|
measure(root, w, h);
|
|
if (error)
|
|
return false;
|
|
place(root, x, y, w, h);
|
|
return error == nullptr;
|
|
}
|
|
|
|
// Deepest interactive node wins, so a tappable child beats its tappable
|
|
// parent. The list is singly linked, so "last match walking forward" stands
|
|
// in for "first match walking backward"; they name the same node.
|
|
uint16_t hit(uint16_t id, int px, int py) const {
|
|
const Node& n = nodes[id];
|
|
if (px < n.x - SLOP || px >= n.x + n.w + SLOP)
|
|
return NONE;
|
|
if (py < n.y - SLOP || py >= n.y + n.h + SLOP)
|
|
return NONE;
|
|
|
|
uint16_t found = NONE;
|
|
for (uint16_t c = n.first; c != NONE; c = nodes[c].next) {
|
|
uint16_t inner = hit(c, px, py);
|
|
if (inner != NONE)
|
|
found = inner;
|
|
}
|
|
if (found != NONE)
|
|
return found;
|
|
if (n.flags & CAPTURE)
|
|
return id;
|
|
return (n.flags & INTERACTIVE) ? id : NONE;
|
|
}
|
|
|
|
// Layout inputs are dead once place() has run. Callers drop them here rather
|
|
// than carrying ~20 bytes a node for the lifetime of a screen.
|
|
void dropScratch() {
|
|
specs.clear();
|
|
specs.shrink_to_fit();
|
|
}
|
|
|
|
// Labels share one NUL-separated arena, so a text node costs two bytes plus
|
|
// its characters rather than a string object.
|
|
//
|
|
// ponytail: a longer label appends and abandons the old bytes. The clock
|
|
// repaints every second at a fixed width, which overwrites in place, so the
|
|
// arena only grows when a label genuinely gets longer. Compact on rebuild if
|
|
// some app proves otherwise.
|
|
void setLabel(uint16_t id, const char* text) {
|
|
size_t length = strlen(text);
|
|
uint16_t at = labelAt[id];
|
|
if (at != NO_LABEL && strlen(&labels[at]) >= length) {
|
|
memcpy(&labels[at], text, length + 1);
|
|
return;
|
|
}
|
|
labelAt[id] = static_cast<uint16_t>(labels.size());
|
|
labels.insert(labels.end(), text, text + length + 1);
|
|
}
|
|
|
|
const char* label(uint16_t id) const {
|
|
uint16_t at = labelAt[id];
|
|
return at == NO_LABEL ? nullptr : &labels[at];
|
|
}
|
|
|
|
size_t footprint() const {
|
|
return nodes.size() * sizeof(Node) + labelAt.size() * sizeof(uint16_t) +
|
|
labels.size() + styles.size() * sizeof(styles[0]);
|
|
}
|
|
|
|
// Styles are sparse because inheritance means almost every node states
|
|
// nothing: a screen's root carries the palette and a handful of nodes
|
|
// override one role. Ids are handed out in increasing order during a build,
|
|
// so appends keep the list sorted and lookup is a binary search.
|
|
Style& styleFor(uint16_t id) {
|
|
size_t low = 0, high = styles.size();
|
|
while (low < high) {
|
|
size_t mid = (low + high) / 2;
|
|
if (styles[mid].first < id) {
|
|
low = mid + 1;
|
|
} else {
|
|
high = mid;
|
|
}
|
|
}
|
|
if (low < styles.size() && styles[low].first == id)
|
|
return styles[low].second;
|
|
return styles.insert(styles.begin() + low, std::make_pair(id, Style()))
|
|
->second;
|
|
}
|
|
|
|
// The nearest style at or above `id` that states `field`, or a default one if
|
|
// nothing does. Returning the whole style lets a caller read the pair a role
|
|
// comes in.
|
|
const Style& inherited(uint16_t id, uint16_t field) const {
|
|
static const Style fallback;
|
|
for (uint16_t n = id; n != NONE; n = nodes[n].parent) {
|
|
const Style* style = styleOf(n);
|
|
if (style && (style->set & field))
|
|
return *style;
|
|
}
|
|
return fallback;
|
|
}
|
|
|
|
const Style* styleOf(uint16_t id) const {
|
|
size_t low = 0, high = styles.size();
|
|
while (low < high) {
|
|
size_t mid = (low + high) / 2;
|
|
if (styles[mid].first < id) {
|
|
low = mid + 1;
|
|
} else {
|
|
high = mid;
|
|
}
|
|
}
|
|
return (low < styles.size() && styles[low].first == id)
|
|
? &styles[low].second
|
|
: nullptr;
|
|
}
|
|
|
|
private:
|
|
std::vector<uint16_t> labelAt;
|
|
std::vector<char> labels;
|
|
std::vector<std::pair<uint16_t, Style>> styles;
|
|
|
|
void fail(const char* message) {
|
|
if (!error)
|
|
error = message;
|
|
}
|
|
|
|
int resolve(Size size, int span) {
|
|
switch (size.mode) {
|
|
case AUTO:
|
|
return UNKNOWN;
|
|
case PX:
|
|
return size.value;
|
|
case FILL:
|
|
if (span == UNKNOWN) {
|
|
fail("fill inside an auto-sized parent");
|
|
return 0;
|
|
}
|
|
return span;
|
|
case FRACTION:
|
|
if (span == UNKNOWN) {
|
|
fail("fraction inside an auto-sized parent");
|
|
return 0;
|
|
}
|
|
return span * size.value / 1000;
|
|
}
|
|
return UNKNOWN;
|
|
}
|
|
|
|
void measure(uint16_t id, int availW, int availH) {
|
|
// By value, and re-index after recursion: measuring a child can push nodes,
|
|
// and a vector that grows moves every reference taken before it.
|
|
const Spec s = specs[id];
|
|
const bool row = (nodes[id].flags & ROW) != 0;
|
|
int w = resolve(s.w, availW);
|
|
int h = resolve(s.h, availH);
|
|
|
|
int innerW = w != UNKNOWN
|
|
? w - s.padL - s.padR
|
|
: (availW != UNKNOWN ? availW - s.padL - s.padR : UNKNOWN);
|
|
// Height is only handed down when this node was given one. A parent sized
|
|
// by its content cannot tell a child what fraction of it to take.
|
|
int innerH = h != UNKNOWN ? h - s.padT - s.padB : UNKNOWN;
|
|
|
|
int main = 0, cross = 0, count = 0;
|
|
for (uint16_t c = nodes[id].first; c != NONE; c = nodes[c].next) {
|
|
measure(c, innerW, innerH);
|
|
// Absolutely placed children are measured, because they still need a
|
|
// size, but they take no part in the flow their siblings share. The Lua
|
|
// original counted them here and excluded them in place(); nothing
|
|
// depended on the disagreement.
|
|
if (specs[c].absolute)
|
|
continue;
|
|
const Node& child = nodes[c];
|
|
if (count)
|
|
main += s.gap;
|
|
if (row) {
|
|
main += child.w;
|
|
if (child.h > cross)
|
|
cross = child.h;
|
|
} else {
|
|
main += child.h;
|
|
if (child.w > cross)
|
|
cross = child.w;
|
|
}
|
|
count++;
|
|
}
|
|
if (count == 0) {
|
|
main = row ? s.intrinsicW : s.intrinsicH;
|
|
cross = row ? s.intrinsicH : s.intrinsicW;
|
|
}
|
|
|
|
Node& self = nodes[id];
|
|
int along = main + (row ? s.padL + s.padR : s.padT + s.padB);
|
|
int across = cross + (row ? s.padT + s.padB : s.padL + s.padR);
|
|
if (row) {
|
|
self.w = static_cast<int16_t>(w != UNKNOWN ? w : along);
|
|
self.h = static_cast<int16_t>(h != UNKNOWN ? h : across);
|
|
} else {
|
|
self.w = static_cast<int16_t>(w != UNKNOWN ? w : across);
|
|
self.h = static_cast<int16_t>(h != UNKNOWN ? h : along);
|
|
}
|
|
}
|
|
|
|
void place(uint16_t id, int x, int y, int w, int h) {
|
|
const Spec s = specs[id];
|
|
bool row = (nodes[id].flags & ROW) != 0;
|
|
{
|
|
Node& self = nodes[id];
|
|
self.x = static_cast<int16_t>(x);
|
|
self.y = static_cast<int16_t>(y);
|
|
self.w = static_cast<int16_t>(w);
|
|
self.h = static_cast<int16_t>(h);
|
|
self.flags |= DIRTY;
|
|
}
|
|
|
|
int cx = x + s.padL, cy = y + s.padT;
|
|
int cw = w - s.padL - s.padR, ch = h - s.padT - s.padB;
|
|
|
|
// Main-axis distribution, CSS justify-content minus the modes nothing asks
|
|
// for. Absolutely placed children take no part in the flow.
|
|
int flowing = 0, used = 0;
|
|
for (uint16_t c = nodes[id].first; c != NONE; c = nodes[c].next) {
|
|
if (specs[c].absolute)
|
|
continue;
|
|
flowing++;
|
|
used += row ? nodes[c].w : nodes[c].h;
|
|
}
|
|
if (flowing > 1)
|
|
used += (flowing - 1) * s.gap;
|
|
int slack = (row ? cw : ch) - used;
|
|
if (slack < 0)
|
|
slack = 0;
|
|
|
|
int offset = 0, spread = 0;
|
|
if (s.justify == END) {
|
|
offset = slack;
|
|
} else if (s.justify == CENTER) {
|
|
offset = slack / 2;
|
|
} else if (s.justify == BETWEEN && flowing > 1) {
|
|
spread = slack / (flowing - 1);
|
|
}
|
|
|
|
for (uint16_t c = nodes[id].first; c != NONE; c = nodes[c].next) {
|
|
const Spec cs = specs[c];
|
|
int childW = nodes[c].w, childH = nodes[c].h;
|
|
// Cross axis fills the parent unless the child asked for a size, like CSS
|
|
// blocks.
|
|
if (row) {
|
|
if (cs.h.mode == AUTO)
|
|
childH = ch;
|
|
} else {
|
|
if (cs.w.mode == AUTO)
|
|
childW = cw;
|
|
}
|
|
|
|
int px, py;
|
|
if (cs.absolute) {
|
|
px = cx + resolve(cs.atX, cw);
|
|
py = cy + resolve(cs.atY, ch);
|
|
} else if (row) {
|
|
px = cx + offset;
|
|
py = cy;
|
|
if (s.align == CENTER) {
|
|
py += (ch - childH) / 2;
|
|
} else if (s.align == END) {
|
|
py += ch - childH;
|
|
}
|
|
offset += childW + s.gap + spread;
|
|
} else {
|
|
px = cx;
|
|
py = cy + offset;
|
|
if (s.align == CENTER) {
|
|
px += (cw - childW) / 2;
|
|
} else if (s.align == END) {
|
|
px += cw - childW;
|
|
}
|
|
offset += childH + s.gap + spread;
|
|
}
|
|
place(c, px, py, childW, childH);
|
|
}
|
|
}
|
|
};
|
|
|
|
} // namespace ui
|
|
} // namespace esp32lua
|