#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 #include #include #include #include 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 nodes; std::vector 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(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(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 labelAt; std::vector labels; std::vector> 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(w != UNKNOWN ? w : along); self.h = static_cast(h != UNKNOWN ? h : across); } else { self.w = static_cast(w != UNKNOWN ? w : across); self.h = static_cast(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(x); self.y = static_cast(y); self.w = static_cast(w); self.h = static_cast(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