From 360e4c83b3e76d4cb7cc8d43b4f93c53f9ca6f3b Mon Sep 17 00:00:00 2001 From: Pascal Severin Date: Sun, 11 Oct 2026 14:10:23 +0200 Subject: [PATCH 1/2] [M2-UI-01] UI widget tree (retained, screen-space) - New laige::render UI module (public header-only laige/render/ui_widgets.h; no new .cpp, no GL calls, no new dependency): the retained-data half of the UI work. - UiTree (move-only; Options{maxWidgets, maxEvents, screenWidth, screenHeight}; screen dims validated in [1, 2^20]): the pool-backed retained tree of the ADR 0006 widget set -- exactly panel, button, text, image, list, slider, input, in the ADR's canonical order (the M2-DEC-02 Verify clause: the ADR widget list == the implemented set). UiWidgetId = laige::PoolHandle (stable generation-checked ids; kInvalidUiWidgetId never live; a stale id reads as nullptr, never UB). Two allocations at create only (the widget pool -- maxWidgets INCLUDING the root -- and the per-frame event array); the widget budget failure is BudgetExhausted + one rate-limited Warn ui_tree/widget_budget. - Layout (layout()): one iterative preorder pass (the parent always before its children -- a 65k-deep chain is safe), exact integer math, deterministic (RENDER-003). The root is the layout-only screen container (never pointer-hit, never focusable, never removable). A child's rect = its anchor point in the parent's content box (the rect inset by its padding, clamped to 0 x 0 when the padding is wider) + its offset - its anchor offset in its own size (the nine UiAnchor values). A list stacks its children top-to-bottom in insertion order at the content's left edge (the children's anchors/offsets ignored), shifted up by the normalized scroll in [0, totalContentHeight - contentHeight]. Computed coordinates saturate into +/- (2^21 - 1) (kUiMaxCoordPx: a widget that far offscreen renders nothing -- the M2-UI-02 cull; the int32 storage stays exact). A node's rect is zero until the next layout (a zero-rect node contains no pointer point). - Focus: the focusable kinds are button, slider, input (ADR 0006). focus()/focusWidget/focusNext/focusPrev over the deterministic preorder traversal (wraparound; from unfocused, next -> first / prev -> last); a pointer press on a focusable widget takes the focus; removing the focused widget (or an ancestor) clears it. No keyboard events here (action routing is M3-INPUT-04). - Frame protocol + events: layout() then handlePointer(UiPointerState {x, y, pressed}) (or beginFrame() for a pointerless frame). handlePointer hit-tests the topmost node (the LAST node in traversal order whose half-open rect contains the point; the root excluded) and delivers, in order: leave-then-enter PointerLeave/PointerEnter on a top change; PointerPress + focus + the slider drag start on press; PointerRelease + ButtonPressed on release iff the press started on that same button (a press that moved off the button emits no ButtonPressed); the press-owner slider keeps tracking on pressed frames (value = min + t*(max-min), t clamped to [0, 1] over the slider's width -- a zero-width slider takes no input). The per-frame event list is bounded: the first maxEvents events are kept, a later one dropped + counted in droppedEventsTotal() + one rate-limited Warn ui_tree/event_overflow (the LOG-004 facade windows the repeats). The ADR 0006 event set: pointer enter/leave/press/release, ButtonPressed, SliderChanged (only the pointer drag emits it -- setSliderValue clamps and stays silent), InputText (from setInputText; a no-op for the same string handle). - FR-4.4 hook: pointerHit() = the topmost node under the last processed pointer position (a hit = the UI consumed the frame's pointer input -- the UI-first routing hook). No sim coupling (ARCH-009: presentation state, never mutates sim); owner-thread-only (CONC-001). - Tests: tests/laige-render/ui_tree_tests.cpp (new CTest entry ui_tree = the step's Verify command; 24 tests / 6 suites; GL-free -- no context, runs in every tree incl. ASan/TSan): UiTreeCreate (the validation matrix, the no-log failure contract, the stopped state, the two-alloc create, the move-only semantics), UiTreeCrud (the seven kinds, the kind/parent/geometry/slider domain validation, the widget budget + warn, the sibling-chain structure, the subtree removal + LIFO slot recycling, the setter failures), UiTreeLayout (the nine-anchor goldens against hand-computed rects, offsets, container padding (incl. padding wider than the container), list stacking + scroll normalization (incl. the over-scroll clamp), negative rects + coordinate saturation, the zero-rect-before-layout + idempotence), UiTreeFocus (the focusable kinds only, the deterministic tab order with wraparound, the pointer-press focus, the removal clears), UiTreeEvents (the scripted pointer sequences against hand-computed per-frame event goldens: the hover transitions, the button click (incl. the drag-off no-press), the slider drag across frames, the press-owner tracking across widgets, the input text events, the zero-size widgets, the event overflow), UiTreeZeroAlloc (1 000 frames of layout + pointer + placement + input = 0 heap blocks, the machine-greppable ui-tree-zeroalloc line). - Docs (DOC-006/007): NEW docs/api/ui_widgets.md (the full contract incl. the DOC-004 Performance section); docs/README.md API index row; src/laige-render/README.md status; docs/concepts/coordinates.md (section 4.13 the UI widget tree coordinate section + the section 5 conversion row + the Related link); roadmap M2-UI-01 box checked + the Progress Board M2 25/33 (total 71/194) + the change log row. - Verification: all six canonical trees build warning-free and ctest -R ui_tree green in each (24/24); tools/laige-include-lint + tools/laige-determinism-lint OK; laige-api.json regenerated LAST (api-real-tree green); no budgets.json entry (the per-frame UI cost is part of the composite 50k render-CPU budget, M2-PERF-01 -- the pin count stays 17). No rendering (the UI draw pass is M2-UI-02). --- docs/README.md | 9 + docs/api/ui_widgets.md | 268 +++ docs/concepts/coordinates.md | 40 + laige-api.json | 135 ++ roadmap/M2-rendering-2.5d.md | 22 +- roadmap/README.md | 5 +- src/laige-render/README.md | 42 +- .../include/laige/render/ui_widgets.h | 1555 +++++++++++++++++ tests/laige-render/CMakeLists.txt | 37 +- tests/laige-render/ui_tree_tests.cpp | 1184 +++++++++++++ 10 files changed, 3288 insertions(+), 9 deletions(-) create mode 100644 docs/api/ui_widgets.md create mode 100644 src/laige-render/include/laige/render/ui_widgets.h create mode 100644 tests/laige-render/ui_tree_tests.cpp diff --git a/docs/README.md b/docs/README.md index 41fe214..d985c69 100644 --- a/docs/README.md +++ b/docs/README.md @@ -373,6 +373,15 @@ still to land. the identical M2-TEXT-01 metrics (the width match, tolerance 0), and the deterministic packing (same font + options → identical bytes). + - [UI widget tree](api/ui_widgets.md) — + `laige::render::UiTree` (M2-UI-01, the retained-data half of the + UI work): the pool-backed retained tree of the ADR 0006 widget + set (panel, button, text, image, list, slider, input), the + stable generation-checked widget ids, the deterministic per-frame + layout pass (the nine anchors, the list stacking + scroll, the + coordinate saturation), the focus system (button / slider / + input), and the per-frame bounded pointer-event list (the FR-4.4 + UI-first routing hook through `pointerHit`). ## Guides diff --git a/docs/api/ui_widgets.md b/docs/api/ui_widgets.md new file mode 100644 index 0000000..16aeeee --- /dev/null +++ b/docs/api/ui_widgets.md @@ -0,0 +1,268 @@ +# The retained UI widget tree (`laige::render` ui_widgets) + +The retained-widget-tree half of the UI work (M2-UI-01): a +pool-backed tree of the ADR 0006 widget set — exactly **panel, +button, text, image, list, slider, input**, in that canonical order — +with stable generation-checked widget ids, a per-frame layout pass, +a focus system, and a per-frame bounded list of pointer events. This +step is the **retained data only**: no rendering (the draw pass lands +with M2-UI-02), no simulation coupling (ARCH-009: the tree is +presentation state, owned by the game's UI update, and never mutates +authoritative state). + +The widget set, the focusable kinds (button, slider, input), the +container kinds (panel, list), and the per-kind events are the ADR +0006 decisions ([docs/decisions/0006-ui-scope.md](../decisions/0006-ui-scope.md)); +this document is the shipped contract for the tree's storage, layout, +frame protocol, and events. + +Public header: +`src/laige-render/include/laige/render/ui_widgets.h` (header-only — no +new `.cpp`, no GL calls, no new dependency). Unit suite: +`ctest -R ui_tree` (`tests/laige-render/ui_tree_tests.cpp`): the create +validation + stopped state + move semantics, the tree CRUD (the seven +kinds, the child-list structure, the domain validation, the widget +budget, the subtree removal + LIFO slot recycling), the layout +goldens (the nine-anchor matrix, offsets, padding, list stacking + +scroll, saturation, zero-rect-before-layout), the focus system (the +deterministic tab order, the pointer-press focus, the removal clears), +the scripted pointer sequences (enter/leave/press/release, the button +click, the slider drag, the input text, the event overflow), and the +1 000-frame zero-allocation loop. + +## The API + +```cpp +// One frame's raw pointer state (the FR-4.4 hook input). +struct UiPointerState { + float x{0.0f}; // screen px, y down + float y{0.0f}; + bool pressed{false}; // the primary button +}; + +// The per-frame events (the ADR 0006 event column). +enum class UiEventKind : std::uint8_t { + PointerEnter = 0, PointerLeave = 1, + PointerPress = 2, PointerRelease = 3, + ButtonPressed = 4, // a press + release on the same Button + SliderChanged = 5, // sliderValue carries the new value + InputText = 6, // text carries the new string handle +}; +struct UiEvent { + UiEventKind kind{}; + UiWidgetId widget{}; // kInvalidUiWidgetId = no widget + float sliderValue{0.0f}; + StringRef text{}; +}; + +class UiTree { // move-only + public: + struct Options { + std::uint32_t maxWidgets{1024}; // INCLUDING the root + std::uint32_t maxEvents{64}; + std::int32_t screenWidth{1280}; + std::int32_t screenHeight{720}; + }; + [[nodiscard]] static laige::Result + create(Options options) noexcept; + + [[nodiscard]] bool valid() const noexcept; + [[nodiscard]] UiWidgetId root() const noexcept; + [[nodiscard]] const UiNode* node(UiWidgetId id) noexcept; // null-safe + [[nodiscard]] bool isValid(UiWidgetId id) const noexcept; + [[nodiscard]] std::uint32_t widgetCount() const noexcept; + [[nodiscard]] laige::PoolStats widgetStats() const noexcept; + + [[nodiscard]] laige::Result + addChild(const UiWidgetSpec& spec) noexcept; + [[nodiscard]] laige::Status remove(UiWidgetId id) noexcept; + + [[nodiscard]] laige::Status layout() noexcept; + void beginFrame() noexcept; + [[nodiscard]] laige::Status handlePointer(const UiPointerState& ptr) noexcept; + [[nodiscard]] UiWidgetId pointerHit() const noexcept; + [[nodiscard]] std::span events() const noexcept; + [[nodiscard]] std::uint64_t droppedEventsTotal() const noexcept; + + [[nodiscard]] UiWidgetId focus() const noexcept; + [[nodiscard]] laige::Status focusWidget(UiWidgetId id) noexcept; + void focusNext() noexcept; + void focusPrev() noexcept; + + [[nodiscard]] laige::Status setPlacement(UiWidgetId id, UiAnchor anchor, + std::int32_t x, std::int32_t y, + std::int32_t width, std::int32_t height) + noexcept; + [[nodiscard]] laige::Status setSliderValue(UiWidgetId id, float value) + noexcept; + [[nodiscard]] laige::Status setScroll(UiWidgetId id, std::int32_t scroll) + noexcept; + [[nodiscard]] laige::Status setInputText(UiWidgetId id, StringRef text) + noexcept; +}; +``` + +`UiWidgetId` is the pool handle (`laige::PoolHandle`: slot index + +generation). `kInvalidUiWidgetId` is `{0, 0}` — never a live id. +`UiWidgetSpec` carries the kind, the parent (the invalid id = the +root), the layout spec (anchor, x, y, width, height, padding), and the +kind-specific payload (`opaque` for panel; `label`, `image`, +`atlasId`, `tint` for button/image; `item` for text — the M2-TEXT-02 +`TextItem`; `scroll` for list; `sliderMin/Max/Value` for slider; +`text` for input). + +## The layout model + +`layout()` computes every node's rect in one iterative preorder pass +(the parent always before its children — no recursion, a 65k-deep +chain is safe), exact integer math: + +- The **root** is the layout-only screen container: rect + `(0, 0, screenWidth, screenHeight)`. It never receives pointer + events, is never focusable, and is never removable. +- A **panel/leaf child** of a container C is placed by its anchor + point in C's content box (C's rect inset by its padding, clamped to + a 0 x 0 box when the padding is wider than the rect), plus its + offset, minus the anchor offset in its own size: + `rect = (anchorPoint + offset) − anchorOffsetInOwnSize`. + Concretely, for content `(cx, cy, cw, ch)` and a child of size + `(w, h)` at `(x, y)`: the anchor point is `cx + {0, cw/2, cw} × + {0, ch/2, ch}` for the column × row, and the anchor offset is + `{0, w/2, w} × {0, h/2, h}` — e.g. `Center` places the child's + CENTER at the content center; `TopRight` places its top-right + corner at the content's top-right corner. +- A **list** stacks its children top-to-bottom in insertion order at + the content's left edge, IGNORING each child's anchor and offsets. + The list's `scroll` (px, y down) shifts the stack up; `layout()` + normalizes it into `[0, max(0, totalContentHeight − contentHeight)]` + and writes the normalized value back (a list whose content is + shorter than its rect never scrolls). +- Computed coordinates are saturated into `±(2^21 − 1)` + (`kUiMaxCoordPx`): a node pushed that far offscreen renders nothing + (the M2-UI-02 cull), and the saturation keeps the int32 storage + exact. +- A node's rect is **zero until the next `layout()`** — a fresh + `addChild` and every `setPlacement` zero the node's rect. Zero-rect + nodes contain no pointer point. +- The layout is deterministic (RENDER-003): the same tree + specs give + the same rects on every platform (integer math only). + +The spec domains (validated at the boundary, first failure wins, +`InvalidArgument`, no log): width/height/padding/scroll in +`[0, 2^20]`; x/y in `±2^20`; the screen dimensions in `[1, 2^20]`; +slider `min < max` (a NaN is rejected) with the initial value clamped +into `[min, max]` (a NaN snaps to min). + +## The frame protocol and pointer events + +The game calls, once per frame, on its owner thread: + +1. `layout()` — recompute the rects (O(n)). +2. `handlePointer(ptr)` — process the frame's raw pointer state + (O(n), zero allocation). + ...or `beginFrame()` for a pointerless (keyboard-only) frame, which + only resets the event list. +3. Read `events()` (a span valid until the next frame boundary) and + `pointerHit()`. + +`handlePointer` resets the frame's event list, hit-tests the +**topmost** node — the LAST node in traversal order whose rect +contains the pointer point, half-open containment (`left ≤ x < +right`, `top ≤ y < bottom`; the root excluded), then delivers, in +order: + +- **Leave-then-enter**: when the topmost hit changes, `PointerLeave` + for the previous top (if any) then `PointerEnter` for the new one. +- **Press** (released → pressed): `PointerPress` on the top; a + focusable top takes the focus; a slider top starts the drag (the + value is set from the pointer x and `SliderChanged` is emitted when + it changes). The press owner is recorded even when the top is empty. +- **Release** (pressed → released): `PointerRelease` on the top; + `ButtonPressed` follows it **iff the press started on that same + Button** (a press that moved off the button emits no `ButtonPressed` + — a drag off the button is not a click). +- **Drag** (pressed → pressed): the press owner — if a slider — keeps + tracking the pointer x (the value clamps at the rect's edges). + +The slider value comes from the pointer x over the slider's width: +`value = min + t · (max − min)` with `t = (x − rectX) / width` clamped +to `[0, 1]` (a zero-width slider takes no input — no division). +`setSliderValue` is the programmatic path: it clamps (NaN → min) and +emits **no** `SliderChanged` (only the pointer drag does). + +**Overflow policy**: a frame keeps its first `Options::maxEvents` +events; a later event of the frame is dropped, counted in +`droppedEventsTotal()`, and logged once per dropped event as a +rate-limited Warn `ui_tree/event_overflow` (the facade windows the +repeats, LOG-004). The default 64 is 9x the ~7 events a frame emits +in the documented model. + +## The focus system + +The focusable kinds are **button, slider, input** (ADR 0006). The +traversal order is the deterministic preorder (parent before +children, children in insertion order). `focusNext()` / +`focusPrev()` step through the focusable widgets in that order with +wraparound; from an unfocused state, `focusNext` takes the first and +`focusPrev` the last. `focusWidget(id)` sets the focus explicitly +(tab key / editor selection). A pointer press on a focusable widget +takes the focus; a press on a non-focusable widget leaves it. +Removing the focused widget — or any ancestor of it — clears the +focus. The focus state is read by `focus()`; this step delivers no +keyboard events (the action routing lands with M3-INPUT-04). + +## Storage and ownership + +The tree owns two allocations made at `create` (the set-up path): the +fixed-capacity widget pool (`laige::Pool`) and the event +array. The widget budget is `maxWidgets` INCLUDING the root node +(user widgets: `maxWidgets − 1`). Widget ids are stable generation- +checked handles (CPP-007): `remove` destroys the subtree in +post-order and the pool's LIFO recycling reuses the slots with a +generation bump, so every stale id is detected by +`isValid`/`node` (a stale id reads as `nullptr`, never UB). `remove` +does not free memory (the pool is fixed at create). The tree is +move-only; the moved-from tree is stopped (`valid()` false, every +id-taking operation fails `InvalidArgument`, the reads are empty). +All operations run on the owner thread (CONC-001); there is no +concurrent access contract because there is no shared state. + +## Performance + +- **Allocation**: zero on every per-frame path (`layout`, + `handlePointer`, `beginFrame`, the focus/tab operations, the + setters, `events`, `pointerHit`) — proven by the 1 000-frame + zero-allocation loop in `ctest -R ui_tree` (PERF-003). The two + allocations happen once, at `create` (PERF-008: the pool never + grows; a budget overrun fails `BudgetExhausted` instead of + allocating). +- **Complexity**: `layout` and `handlePointer` are O(n) over the live + widgets (single iterative passes; the hit test is a full preorder + scan — fine at the declared scene UI budget, below); `addChild` is + O(1) typical (O(parent's child tail) worst case — the insertion + append); `remove` is O(subtree size); `focusNext`/`focusPrev` are + O(n) (one or two passes); the reads are O(1). +- **Memory**: one `UiNode` per widget (~240 B — the kind, the three + structural links, the layout spec, the computed rect, and the + kind-specific payload) in one contiguous pool block, plus the event + array (`maxEvents × 16 B`). The 65 536-widget cap is ~15 MB + (S-6). +- **Hot-path traps**: the rects are only valid after a `layout` of + the current frame — calling `handlePointer` before `layout` (or + after a `setPlacement` without a relayout) hit-tests zero rects + (total, but not what you want). Every `addChild`/`setPlacement` + zeros one node's rect — relayout that frame. The event span from + `events()` is invalidated by the next `handlePointer`/`beginFrame` + — consume it in the same frame. Dropping events is observable: + `droppedEventsTotal()` and the `ui_tree/event_overflow` warn. + +## Misuse warning + +Do not store `UiNode*` pointers across frames — the pool slots are +recycled (the LIFO order means a removed node's slot can be reused by +the next `addChild` of another subtree). Store `UiWidgetId` handles +and re-read through `node()` / `isValid()`. Do not read kind-specific +fields without checking the kind first (a button's `sliderValue` is +uninitialized domain — the spec's default). The tree is presentation +state: never drive simulation from it (ARCH-009), and keep its owner +thread the game's UI update thread. diff --git a/docs/concepts/coordinates.md b/docs/concepts/coordinates.md index fc6b8c0..06acb6a 100644 --- a/docs/concepts/coordinates.md +++ b/docs/concepts/coordinates.md @@ -460,6 +460,40 @@ presentation-only (ARCH-009): never sim state, never in the sim hash or replay; the string bytes come from the scene's string table (PRD §10.4 — no per-frame `std::string`). +### 4.13 The UI widget tree: screen-space retained layout (M2-UI-01) + +The UI widget tree ([`ui_widgets.md`](../api/ui_widgets.md)) lives in +**SCREEN space, y DOWN** — the UI pass's coordinate system, not +world or sim space. The root node is the layout-only screen +container with the rect `(0, 0, screenWidth, screenHeight)` +(`Options::screenWidth/screenHeight`, the documented domain +`[1, 2^20]` px); it never receives pointer events. A widget's +laid-out rect is computed from its parent's **content box** (the +parent's rect inset by its padding, clamped to 0 x 0) by the +widget's anchor point + its offset minus its anchor offset in its +own size (the nine `UiAnchor` values — `TopLeft` places its +top-left corner on the content's top-left, `Center` places its +center on the content center, `BottomRight` its bottom-right +corner on the content's bottom-right, and so on). The spec +domains: width/height/padding/scroll in `[0, 2^20]`, x/y in +`±2^20`; the computed coordinates are saturated into +`±(2^21 − 1)` (`kUiMaxCoordPx`) — a widget pushed that far off +screen renders nothing (the M2-UI-02 cull), and the saturation +keeps the int32 storage exact. A **list** stacks its children +top-to-bottom in insertion order at the content's left edge (the +children's anchors and offsets are ignored), shifted up by the +list's `scroll` px, which `layout()` normalizes into +`[0, totalContentHeight − contentHeight]`. The layout is +deterministic (RENDER-003: integer math only, same tree + specs → +same rects on every platform), and a node's rect is zero until +the next `layout()` (a fresh `addChild` or `setPlacement` zeros +it). The pointer hit test is half-open containment of the pointer +point in the laid-out rects (`left ≤ x < right`, `top ≤ y < +bottom`), topmost = the last containing node in traversal order. +The UI tree is presentation-only (ARCH-009): it consumes raw +pointer state (the FR-4.4 hook — `UiPointerState` in the same +screen px, y down) and never mutates simulation state. + ## 5. Conversion rules (the module boundaries, RENDER-006) | Conversion | Direction | Owner | Status | @@ -474,6 +508,7 @@ or replay; the string bytes come from the scene's string table | Camera position → parallax offset | camera (x, y) + layer def → world-space offset (the exact formula) + wrap quads | `laige-render` (`ParallaxLayers::worldOffsetAt` / `declareTo`, M2-PAR-01) | **Shipped (M2-PAR-01)** | | Particle state → sprite items | particle (pos, depth, size, faded tint) → declared sprites (one batch per emitter set, the key from the particle's depth) | `laige-render` (`declareParticles` / `particleDepthToStepHeight` — consumes `ParticleSystem::liveParticles()`, read-only after the sim phase, M2-PART-02) | **Shipped (M2-PART-02)** | | Text item → glyph quads | string handle + metrics + anchor (screen px, y down) → declared glyph quads (one per ink glyph; the UI z through `depthOverride`; the mode's cell — bitmap ink or SDF padded) | `laige-render` (`measureText` / `declareText` — consumes `GlyphAtlas::glyph` / `SdfGlyphAtlas::glyph` + the string table, M2-TEXT-02/03) | **Shipped (M2-TEXT-02 + M2-TEXT-03)** | +| UI widget spec → laid-out rect | widget spec (anchor, offsets, size; the parent's content box) → computed rect (screen px, y down; the coordinate saturation; the list stacking + scroll) | `laige-render` (`UiTree::layout` / `UiTree::handlePointer` — the retained tree + pointer hit test, M2-UI-01) | **Shipped (M2-UI-01)** | ### 5.1 The isometric grid picking (M2-ISO-03) @@ -596,6 +631,11 @@ state → same keys → same order, every frame (RENDER-003). contract: `declareText` (the O(glyphs) string → glyph-quad pass — screen space y down, the exact wrap model, the white-on-alpha upload) (M2-TEXT-02). +- [`api/ui_widgets.md`](../api/ui_widgets.md) — the retained UI + widget tree contract: `UiTree` (the pool-backed widget tree — + screen space y down, the nine anchors, the layout pass, the + focus, the per-frame pointer events, the FR-4.4 routing hook) + (M2-UI-01). - [`api/matrices.md`](../api/matrices.md) — the matrix builders and NDC conventions (M2-GL-03). - [`decisions/0005-iso-default.md`](../decisions/0005-iso-default.md) — diff --git a/laige-api.json b/laige-api.json index 3b10f3a..9b8b797 100644 --- a/laige-api.json +++ b/laige-api.json @@ -32,6 +32,7 @@ "src/laige-render/include/laige/render/sprite_renderer.h", "src/laige-render/include/laige/render/text_items.h", "src/laige-render/include/laige/render/tilemap.h", + "src/laige-render/include/laige/render/ui_widgets.h", "src/laige-sim/include/laige/sim/archetype.h", "src/laige-sim/include/laige/sim/component.h", "src/laige-sim/include/laige/sim/config.h", @@ -1096,6 +1097,140 @@ {"name": "laige::render::TileMap::AnimSlot::tick", "kind": "variable", "header": "src/laige-render/include/laige/render/tilemap.h", "line": 607, "signature": "std::uint32_t tick{0}", "summary": null, "budget": null, "experimental": false}, {"name": "laige::render::TileMap::AnimSlot::valid", "kind": "variable", "header": "src/laige-render/include/laige/render/tilemap.h", "line": 608, "signature": "bool valid{false}", "summary": null, "budget": null, "experimental": false}, {"name": "laige::render::TileMap::AnimSlot::SpriteUvRect", "kind": "variable", "header": "src/laige-render/include/laige/render/tilemap.h", "line": 610, "signature": "std::unique_ptr frameUv", "summary": "The precomputed frame UVs (frameCount rects — setAnimation).", "budget": null, "experimental": false}, + {"name": "laige::render::kUiWidgetKindCount", "kind": "variable", "header": "src/laige-render/include/laige/render/ui_widgets.h", "line": 349, "signature": "inline constexpr std::uint32_t kUiWidgetKindCount = 7", "summary": "The seven ADR 0006 widget kinds, in the ADR's canonical order (the M2-DEC-02 Verify clause: the implemented set is exactly this).", "budget": null, "experimental": false}, + {"name": "laige::render::kUiMaxDimPx", "kind": "variable", "header": "src/laige-render/include/laige/render/ui_widgets.h", "line": 355, "signature": "inline constexpr std::uint32_t kUiMaxDimPx = 1u << 20", "summary": "The screen-pixel dimension / offset domain: 2^20 px (a 20-bit screen dimension — far beyond any UI, and < 2^24 so every coordinate inside the domain is exact in float, the M2-SPRITE-03 float-exact domain).", "budget": null, "experimental": false}, + {"name": "laige::render::kUiMaxCoordPx", "kind": "variable", "header": "src/laige-render/include/laige/render/ui_widgets.h", "line": 361, "signature": "inline constexpr std::int32_t kUiMaxCoordPx = (1 << 21) - 1", "summary": "The computed-rect coordinate saturation bound (2^21 − 1): a node pushed that far offscreen renders nothing (the M2-UI-02 cull); the saturation keeps the int32 storage exact (the header's layout domain section).", "budget": null, "experimental": false}, + {"name": "laige::render::kUiTreeMaxWidgets", "kind": "variable", "header": "src/laige-render/include/laige/render/ui_widgets.h", "line": 365, "signature": "inline constexpr std::uint32_t kUiTreeMaxWidgets = 65536", "summary": "The widget-pool budget domain: [2 (the root + one widget), 65536] (65536 x ~240 B/node ~= 15 MB — the declared scene UI budget, S-6).", "budget": null, "experimental": false}, + {"name": "laige::render::kUiDefaultMaxWidgets", "kind": "variable", "header": "src/laige-render/include/laige/render/ui_widgets.h", "line": 368, "signature": "inline constexpr std::uint32_t kUiDefaultMaxWidgets = 1024", "summary": "The documented default (a typical game UI: a menu, a settings panel, a HUD — a few hundred widgets at most).", "budget": null, "experimental": false}, + {"name": "laige::render::kUiTreeMaxEvents", "kind": "variable", "header": "src/laige-render/include/laige/render/ui_widgets.h", "line": 372, "signature": "inline constexpr std::uint32_t kUiTreeMaxEvents = 4096", "summary": "The per-frame event-list domain: [1, 4096] (a frame emits at most ~7 events in the documented model; 64 is 9x headroom).", "budget": null, "experimental": false}, + {"name": "laige::render::kUiDefaultMaxEvents", "kind": "variable", "header": "src/laige-render/include/laige/render/ui_widgets.h", "line": 373, "signature": "inline constexpr std::uint32_t kUiDefaultMaxEvents = 64", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::kUiDefaultScreenWidth", "kind": "variable", "header": "src/laige-render/include/laige/render/ui_widgets.h", "line": 377, "signature": "inline constexpr std::int32_t kUiDefaultScreenWidth = 1280", "summary": "The documented default screen (the M2-GOLD-01 reference resolution).", "budget": null, "experimental": false}, + {"name": "laige::render::kUiDefaultScreenHeight", "kind": "variable", "header": "src/laige-render/include/laige/render/ui_widgets.h", "line": 378, "signature": "inline constexpr std::int32_t kUiDefaultScreenHeight = 720", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::UiWidgetKind", "kind": "enum", "header": "src/laige-render/include/laige/render/ui_widgets.h", "line": 386, "signature": "enum class UiWidgetKind : std::uint8_t", "summary": "The seven ADR 0006 widget kinds, in the ADR's canonical order. The enumerator values are stable (PRD §9.4: additive only).", "budget": null, "experimental": false}, + {"name": "laige::render::UiWidgetKind::Panel", "kind": "enumerator", "header": "src/laige-render/include/laige/render/ui_widgets.h", "line": 389, "signature": "Panel = 0", "summary": "The structural container (children; the opaque-background option — the M2-UI-02 occlusion-culling target).", "budget": null, "experimental": false}, + {"name": "laige::render::UiWidgetKind::Button", "kind": "enumerator", "header": "src/laige-render/include/laige/render/ui_widgets.h", "line": 391, "signature": "Button = 1", "summary": "The press action (the menus / options primitive); focusable.", "budget": null, "experimental": false}, + {"name": "laige::render::UiWidgetKind::Text", "kind": "enumerator", "header": "src/laige-render/include/laige/render/ui_widgets.h", "line": 393, "signature": "Text = 2", "summary": "The static text/label (the M2-TEXT-02 TextItem value).", "budget": null, "experimental": false}, + {"name": "laige::render::UiWidgetKind::Image", "kind": "enumerator", "header": "src/laige-render/include/laige/render/ui_widgets.h", "line": 395, "signature": "Image = 3", "summary": "The fixed UI-atlas sub-rect (static art and decoration).", "budget": null, "experimental": false}, + {"name": "laige::render::UiWidgetKind::List", "kind": "enumerator", "header": "src/laige-render/include/laige/render/ui_widgets.h", "line": 398, "signature": "List = 4", "summary": "The bounded scrollable container (children stacked in insertion order); the `scroll` px state.", "budget": null, "experimental": false}, + {"name": "laige::render::UiWidgetKind::Slider", "kind": "enumerator", "header": "src/laige-render/include/laige/render/ui_widgets.h", "line": 400, "signature": "Slider = 5", "summary": "The continuous value in [min, max]; focusable.", "budget": null, "experimental": false}, + {"name": "laige::render::UiWidgetKind::Input", "kind": "enumerator", "header": "src/laige-render/include/laige/render/ui_widgets.h", "line": 403, "signature": "Input = 6", "summary": "The single-line text entry (the M3-INPUT-04 keyboard consumer); focusable.", "budget": null, "experimental": false}, + {"name": "laige::render::uiWidgetIsFocusable", "kind": "function", "header": "src/laige-render/include/laige/render/ui_widgets.h", "line": 408, "signature": "[[nodiscard]] inline bool uiWidgetIsFocusable(UiWidgetKind kind) noexcept", "summary": "True for the focusable kinds (the ADR 0006 focus row): Button, Slider, Input. O(1).", "budget": null, "experimental": false}, + {"name": "laige::render::uiWidgetIsContainer", "kind": "function", "header": "src/laige-render/include/laige/render/ui_widgets.h", "line": 415, "signature": "[[nodiscard]] inline bool uiWidgetIsContainer(UiWidgetKind kind) noexcept", "summary": "True for the container kinds (the kinds that may have children): Panel, List. O(1).", "budget": null, "experimental": false}, + {"name": "laige::render::UiAnchor", "kind": "enum", "header": "src/laige-render/include/laige/render/ui_widgets.h", "line": 426, "signature": "enum class UiAnchor : std::uint8_t", "summary": "The node's anchor: which point of the node is placed at the parent's anchor point (the header's layout section). The canonical 3 x 3 grid, row-major (Top, Center, Bottom x Left, Center, Right).", "budget": null, "experimental": false}, + {"name": "laige::render::UiAnchor::TopLeft", "kind": "enumerator", "header": "src/laige-render/include/laige/render/ui_widgets.h", "line": 427, "signature": "TopLeft = 0", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::UiAnchor::TopCenter", "kind": "enumerator", "header": "src/laige-render/include/laige/render/ui_widgets.h", "line": 428, "signature": "TopCenter = 1", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::UiAnchor::TopRight", "kind": "enumerator", "header": "src/laige-render/include/laige/render/ui_widgets.h", "line": 429, "signature": "TopRight = 2", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::UiAnchor::CenterLeft", "kind": "enumerator", "header": "src/laige-render/include/laige/render/ui_widgets.h", "line": 430, "signature": "CenterLeft = 3", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::UiAnchor::Center", "kind": "enumerator", "header": "src/laige-render/include/laige/render/ui_widgets.h", "line": 431, "signature": "Center = 4", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::UiAnchor::CenterRight", "kind": "enumerator", "header": "src/laige-render/include/laige/render/ui_widgets.h", "line": 432, "signature": "CenterRight = 5", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::UiAnchor::BottomLeft", "kind": "enumerator", "header": "src/laige-render/include/laige/render/ui_widgets.h", "line": 433, "signature": "BottomLeft = 6", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::UiAnchor::BottomCenter", "kind": "enumerator", "header": "src/laige-render/include/laige/render/ui_widgets.h", "line": 434, "signature": "BottomCenter = 7", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::UiAnchor::BottomRight", "kind": "enumerator", "header": "src/laige-render/include/laige/render/ui_widgets.h", "line": 435, "signature": "BottomRight = 8", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::anchorPointH", "kind": "function", "header": "src/laige-render/include/laige/render/ui_widgets.h", "line": 440, "signature": "[[nodiscard]] inline std::int64_t anchorPointH(UiAnchor anchor, std::int64_t extent) noexcept", "summary": "The anchor's HORIZONTAL point inside a content extent (Left -> 0, Center -> extent/2 integer floor, Right -> extent). Exact int64.", "budget": null, "experimental": false}, + {"name": "laige::render::anchorPointV", "kind": "function", "header": "src/laige-render/include/laige/render/ui_widgets.h", "line": 461, "signature": "[[nodiscard]] inline std::int64_t anchorPointV(UiAnchor anchor, std::int64_t extent) noexcept", "summary": "The anchor's VERTICAL point inside a content extent (Top -> 0, Center -> extent/2 integer floor, Bottom -> extent). Exact int64.", "budget": null, "experimental": false}, + {"name": "laige::render::anchorOffsetH", "kind": "function", "header": "src/laige-render/include/laige/render/ui_widgets.h", "line": 482, "signature": "[[nodiscard]] inline std::int64_t anchorOffsetH(UiAnchor anchor, std::int64_t w) noexcept", "summary": "The HORIZONTAL offset of the node's anchor point from its top-left (Left -> 0, Center -> w/2 integer floor, Right -> w). Exact int64.", "budget": null, "experimental": false}, + {"name": "laige::render::anchorOffsetV", "kind": "function", "header": "src/laige-render/include/laige/render/ui_widgets.h", "line": 503, "signature": "[[nodiscard]] inline std::int64_t anchorOffsetV(UiAnchor anchor, std::int64_t h) noexcept", "summary": "The VERTICAL offset of the node's anchor point from its top-left (Top -> 0, Center -> h/2 integer floor, Bottom -> h). Exact int64.", "budget": null, "experimental": false}, + {"name": "laige::render::saturateCoord", "kind": "function", "header": "src/laige-render/include/laige/render/ui_widgets.h", "line": 524, "signature": "[[nodiscard]] inline std::int32_t saturateCoord(std::int64_t v) noexcept", "summary": "The computed-rect coordinate saturation (the header's layout domain section): exact, total, no failure.", "budget": null, "experimental": false}, + {"name": "laige::render::UiWidgetId", "kind": "alias", "header": "src/laige-render/include/laige/render/ui_widgets.h", "line": 540, "signature": "using UiWidgetId = laige::PoolHandle", "summary": "The stable, generation-checked widget id (the M0-CORE-05 pool handle): valid while the widget is live; a destroyed widget's id is rejected by the generation bump and is never valid again (a 2^32 free wrap of one slot is the documented, effectively unreachable exception — the Pool::isValid contract). The default id {0, 0} is never valid.", "budget": null, "experimental": false}, + {"name": "laige::render::kInvalidUiWidgetId", "kind": "variable", "header": "src/laige-render/include/laige/render/ui_widgets.h", "line": 544, "signature": "inline constexpr UiWidgetId kInvalidUiWidgetId{0, 0}", "summary": "The invalid widget id (never a live handle — the Pool reserves generation 0).", "budget": null, "experimental": false}, + {"name": "laige::render::UiNode", "kind": "struct", "header": "src/laige-render/include/laige/render/ui_widgets.h", "line": 556, "signature": "struct UiNode", "summary": "One retained widget: the structural links (the tree's own — never assigned directly, the header's misuse warnings), the layout spec + the computed rect (screen px, y down), and the kind-specific payload. A plain value type (no ownership; the StringRefs / the TextItem font pointers are NON-OWNING scene handles — the header's ownership section).", "budget": null, "experimental": false}, + {"name": "laige::render::UiNode::kind", "kind": "variable", "header": "src/laige-render/include/laige/render/ui_widgets.h", "line": 558, "signature": "UiWidgetKind kind{UiWidgetKind::Panel}", "summary": "The widget kind (ADR 0006 — immutable after creation).", "budget": null, "experimental": false}, + {"name": "laige::render::UiNode::parent", "kind": "variable", "header": "src/laige-render/include/laige/render/ui_widgets.h", "line": 562, "signature": "UiWidgetId parent{kInvalidUiWidgetId}", "summary": "The structural links (managed by addChild / remove — the tree's own invariants): the parent (kInvalidUiWidgetId for the root), the first child, and the next sibling (kInvalidUiWidgetId = none).", "budget": null, "experimental": false}, + {"name": "laige::render::UiNode::firstChild", "kind": "variable", "header": "src/laige-render/include/laige/render/ui_widgets.h", "line": 563, "signature": "UiWidgetId firstChild{kInvalidUiWidgetId}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::UiNode::nextSibling", "kind": "variable", "header": "src/laige-render/include/laige/render/ui_widgets.h", "line": 564, "signature": "UiWidgetId nextSibling{kInvalidUiWidgetId}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::UiNode::anchor", "kind": "variable", "header": "src/laige-render/include/laige/render/ui_widgets.h", "line": 567, "signature": "UiAnchor anchor{UiAnchor::TopLeft}", "summary": "The layout spec (the header's layout section; screen px, y down): the anchor, the anchor offsets, and the node size.", "budget": null, "experimental": false}, + {"name": "laige::render::UiNode::x", "kind": "variable", "header": "src/laige-render/include/laige/render/ui_widgets.h", "line": 568, "signature": "std::int32_t x{0}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::UiNode::y", "kind": "variable", "header": "src/laige-render/include/laige/render/ui_widgets.h", "line": 569, "signature": "std::int32_t y{0}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::UiNode::width", "kind": "variable", "header": "src/laige-render/include/laige/render/ui_widgets.h", "line": 570, "signature": "std::int32_t width{0}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::UiNode::height", "kind": "variable", "header": "src/laige-render/include/laige/render/ui_widgets.h", "line": 571, "signature": "std::int32_t height{0}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::UiNode::padding", "kind": "variable", "header": "src/laige-render/include/laige/render/ui_widgets.h", "line": 573, "signature": "std::int32_t padding{0}", "summary": "The container padding, px, all four sides (Panel / List only).", "budget": null, "experimental": false}, + {"name": "laige::render::UiNode::rectX", "kind": "variable", "header": "src/laige-render/include/laige/render/ui_widgets.h", "line": 577, "signature": "std::int32_t rectX{0}", "summary": "The computed rect (layout() fills it; screen px, y down, half- open). Zero before the first layout (a zero rect contains no pointer point).", "budget": null, "experimental": false}, + {"name": "laige::render::UiNode::rectY", "kind": "variable", "header": "src/laige-render/include/laige/render/ui_widgets.h", "line": 578, "signature": "std::int32_t rectY{0}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::UiNode::rectW", "kind": "variable", "header": "src/laige-render/include/laige/render/ui_widgets.h", "line": 579, "signature": "std::int32_t rectW{0}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::UiNode::rectH", "kind": "variable", "header": "src/laige-render/include/laige/render/ui_widgets.h", "line": 580, "signature": "std::int32_t rectH{0}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::UiNode::opaque", "kind": "variable", "header": "src/laige-render/include/laige/render/ui_widgets.h", "line": 583, "signature": "bool opaque{false}", "summary": "Panel: the opaque-background option (the M2-UI-02 occlusion target).", "budget": null, "experimental": false}, + {"name": "laige::render::UiNode::label", "kind": "variable", "header": "src/laige-render/include/laige/render/ui_widgets.h", "line": 586, "signature": "StringRef label{}", "summary": "Button: the label's string-table handle (PRD §10.4 — the scene's StringTable must outlive the frame that renders it).", "budget": null, "experimental": false}, + {"name": "laige::render::UiNode::image", "kind": "variable", "header": "src/laige-render/include/laige/render/ui_widgets.h", "line": 588, "signature": "SpriteUvRect image{}", "summary": "Button / Image: the art's UV sub-rect in the UI atlas.", "budget": null, "experimental": false}, + {"name": "laige::render::UiNode::atlasId", "kind": "variable", "header": "src/laige-render/include/laige/render/ui_widgets.h", "line": 591, "signature": "std::uint32_t atlasId{0}", "summary": "Button / Image: the UI atlas's texture id (the scene's bindAtlas id — M2-SPRITE-02).", "budget": null, "experimental": false}, + {"name": "laige::render::UiNode::tint", "kind": "variable", "header": "src/laige-render/include/laige/render/ui_widgets.h", "line": 593, "signature": "SpriteTint tint{}", "summary": "Button / Image: the multiplicative tint (M2-SPRITE-02).", "budget": null, "experimental": false}, + {"name": "laige::render::UiNode::item", "kind": "variable", "header": "src/laige-render/include/laige/render/ui_widgets.h", "line": 598, "signature": "TextItem item{}", "summary": "Text: the text block (the M2-TEXT-02 value — the string handle, the glyph path + font pointer, the scale, the alignment, the max-width wrap). The widget's rect is the spec's width/height (the game sizes it at set-up, e.g. from measureText).", "budget": null, "experimental": false}, + {"name": "laige::render::UiNode::scroll", "kind": "variable", "header": "src/laige-render/include/laige/render/ui_widgets.h", "line": 601, "signature": "std::int32_t scroll{0}", "summary": "List: the scroll offset, px, y down (layout() normalizes it into the reachable range).", "budget": null, "experimental": false}, + {"name": "laige::render::UiNode::sliderMin", "kind": "variable", "header": "src/laige-render/include/laige/render/ui_widgets.h", "line": 605, "signature": "float sliderMin{0.0f}", "summary": "Slider: the value domain [min, max] (min < max — the addChild validation) and the current value (the pointer drag + setSliderValue write it; presentation-only float, ARCH-009).", "budget": null, "experimental": false}, + {"name": "laige::render::UiNode::sliderMax", "kind": "variable", "header": "src/laige-render/include/laige/render/ui_widgets.h", "line": 606, "signature": "float sliderMax{0.0f}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::UiNode::sliderValue", "kind": "variable", "header": "src/laige-render/include/laige/render/ui_widgets.h", "line": 607, "signature": "float sliderValue{0.0f}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::UiNode::text", "kind": "variable", "header": "src/laige-render/include/laige/render/ui_widgets.h", "line": 610, "signature": "StringRef text{}", "summary": "Input: the current text's string-table handle (setInputText writes it + emits the InputText event).", "budget": null, "experimental": false}, + {"name": "laige::render::UiWidgetSpec", "kind": "struct", "header": "src/laige-render/include/laige/render/ui_widgets.h", "line": 676, "signature": "struct UiWidgetSpec", "summary": "The value the game DECLARES for one widget (S-5: declaration, not draw). A plain value: the kind + the shared layout fields, then the kind-specific payload (which kind reads which field — the UiNode field comments above). The structural links are NOT part of the spec (the tree maintains them); `parent` names the parent (the invalid id = the tree root).", "budget": null, "experimental": false}, + {"name": "laige::render::UiWidgetSpec::kind", "kind": "variable", "header": "src/laige-render/include/laige/render/ui_widgets.h", "line": 678, "signature": "UiWidgetKind kind{UiWidgetKind::Panel}", "summary": "The widget kind (the ADR 0006 seven — validated at addChild).", "budget": null, "experimental": false}, + {"name": "laige::render::UiWidgetSpec::parent", "kind": "variable", "header": "src/laige-render/include/laige/render/ui_widgets.h", "line": 680, "signature": "UiWidgetId parent{kInvalidUiWidgetId}", "summary": "The parent widget id (kInvalidUiWidgetId = the tree root).", "budget": null, "experimental": false}, + {"name": "laige::render::UiWidgetSpec::anchor", "kind": "variable", "header": "src/laige-render/include/laige/render/ui_widgets.h", "line": 683, "signature": "UiAnchor anchor{UiAnchor::TopLeft}", "summary": "The layout spec (the header's layout section — the same domains as the UiNode fields above).", "budget": null, "experimental": false}, + {"name": "laige::render::UiWidgetSpec::x", "kind": "variable", "header": "src/laige-render/include/laige/render/ui_widgets.h", "line": 684, "signature": "std::int32_t x{0}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::UiWidgetSpec::y", "kind": "variable", "header": "src/laige-render/include/laige/render/ui_widgets.h", "line": 685, "signature": "std::int32_t y{0}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::UiWidgetSpec::width", "kind": "variable", "header": "src/laige-render/include/laige/render/ui_widgets.h", "line": 686, "signature": "std::int32_t width{0}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::UiWidgetSpec::height", "kind": "variable", "header": "src/laige-render/include/laige/render/ui_widgets.h", "line": 687, "signature": "std::int32_t height{0}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::UiWidgetSpec::padding", "kind": "variable", "header": "src/laige-render/include/laige/render/ui_widgets.h", "line": 688, "signature": "std::int32_t padding{0}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::UiWidgetSpec::opaque", "kind": "variable", "header": "src/laige-render/include/laige/render/ui_widgets.h", "line": 690, "signature": "bool opaque{false}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::UiWidgetSpec::label", "kind": "variable", "header": "src/laige-render/include/laige/render/ui_widgets.h", "line": 692, "signature": "StringRef label{}", "summary": "Button: the label's string-table handle.", "budget": null, "experimental": false}, + {"name": "laige::render::UiWidgetSpec::image", "kind": "variable", "header": "src/laige-render/include/laige/render/ui_widgets.h", "line": 694, "signature": "SpriteUvRect image{}", "summary": "Button / Image: the art's UV sub-rect + atlas id + tint.", "budget": null, "experimental": false}, + {"name": "laige::render::UiWidgetSpec::atlasId", "kind": "variable", "header": "src/laige-render/include/laige/render/ui_widgets.h", "line": 695, "signature": "std::uint32_t atlasId{0}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::UiWidgetSpec::tint", "kind": "variable", "header": "src/laige-render/include/laige/render/ui_widgets.h", "line": 696, "signature": "SpriteTint tint{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::UiWidgetSpec::item", "kind": "variable", "header": "src/laige-render/include/laige/render/ui_widgets.h", "line": 698, "signature": "TextItem item{}", "summary": "Text: the text block (the M2-TEXT-02 value).", "budget": null, "experimental": false}, + {"name": "laige::render::UiWidgetSpec::scroll", "kind": "variable", "header": "src/laige-render/include/laige/render/ui_widgets.h", "line": 700, "signature": "std::int32_t scroll{0}", "summary": "List: the initial scroll offset, px.", "budget": null, "experimental": false}, + {"name": "laige::render::UiWidgetSpec::sliderMin", "kind": "variable", "header": "src/laige-render/include/laige/render/ui_widgets.h", "line": 703, "signature": "float sliderMin{0.0f}", "summary": "Slider: the value domain + the initial value (min < max required; the value is clamped into [min, max] — a NaN snaps to min).", "budget": null, "experimental": false}, + {"name": "laige::render::UiWidgetSpec::sliderMax", "kind": "variable", "header": "src/laige-render/include/laige/render/ui_widgets.h", "line": 704, "signature": "float sliderMax{0.0f}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::UiWidgetSpec::sliderValue", "kind": "variable", "header": "src/laige-render/include/laige/render/ui_widgets.h", "line": 705, "signature": "float sliderValue{0.0f}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::UiWidgetSpec::text", "kind": "variable", "header": "src/laige-render/include/laige/render/ui_widgets.h", "line": 707, "signature": "StringRef text{}", "summary": "Input: the initial text's string-table handle.", "budget": null, "experimental": false}, + {"name": "laige::render::UiPointerState", "kind": "struct", "header": "src/laige-render/include/laige/render/ui_widgets.h", "line": 718, "signature": "struct UiPointerState", "summary": "One frame's raw pointer state: the pointer position (screen px, y down) + the primary button's pressed state. The game reads the raw input (GLFW / the platform layer) and hands this value to handlePointer once per frame.", "budget": null, "experimental": false}, + {"name": "laige::render::UiPointerState::x", "kind": "variable", "header": "src/laige-render/include/laige/render/ui_widgets.h", "line": 719, "signature": "float x{0.0f}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::UiPointerState::y", "kind": "variable", "header": "src/laige-render/include/laige/render/ui_widgets.h", "line": 720, "signature": "float y{0.0f}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::UiPointerState::pressed", "kind": "variable", "header": "src/laige-render/include/laige/render/ui_widgets.h", "line": 721, "signature": "bool pressed{false}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::UiEventKind", "kind": "enum", "header": "src/laige-render/include/laige/render/ui_widgets.h", "line": 730, "signature": "enum class UiEventKind : std::uint8_t", "summary": "The seven event kinds (the ADR 0006 event table). The enumerator values are stable (PRD §9.4: additive only).", "budget": null, "experimental": false}, + {"name": "laige::render::UiEventKind::PointerEnter", "kind": "enumerator", "header": "src/laige-render/include/laige/render/ui_widgets.h", "line": 733, "signature": "PointerEnter = 0", "summary": "The pointer entered the widget's rect (the topmost hit changed to this widget).", "budget": null, "experimental": false}, + {"name": "laige::render::UiEventKind::PointerLeave", "kind": "enumerator", "header": "src/laige-render/include/laige/render/ui_widgets.h", "line": 736, "signature": "PointerLeave = 1", "summary": "The pointer left the widget's rect (the topmost hit changed away from this widget).", "budget": null, "experimental": false}, + {"name": "laige::render::UiEventKind::PointerPress", "kind": "enumerator", "header": "src/laige-render/include/laige/render/ui_widgets.h", "line": 738, "signature": "PointerPress = 2", "summary": "The pointer pressed while on this widget.", "budget": null, "experimental": false}, + {"name": "laige::render::UiEventKind::PointerRelease", "kind": "enumerator", "header": "src/laige-render/include/laige/render/ui_widgets.h", "line": 740, "signature": "PointerRelease = 3", "summary": "The pointer released while on this widget.", "budget": null, "experimental": false}, + {"name": "laige::render::UiEventKind::ButtonPressed", "kind": "enumerator", "header": "src/laige-render/include/laige/render/ui_widgets.h", "line": 743, "signature": "ButtonPressed = 4", "summary": "A press + release on this Button (a click — the ADR 0006 \"button pressed\" event).", "budget": null, "experimental": false}, + {"name": "laige::render::UiEventKind::SliderChanged", "kind": "enumerator", "header": "src/laige-render/include/laige/render/ui_widgets.h", "line": 745, "signature": "SliderChanged = 5", "summary": "The slider drag changed this slider's value (`sliderValue`).", "budget": null, "experimental": false}, + {"name": "laige::render::UiEventKind::InputText", "kind": "enumerator", "header": "src/laige-render/include/laige/render/ui_widgets.h", "line": 747, "signature": "InputText = 6", "summary": "The input's text changed (`text` — the new string handle).", "budget": null, "experimental": false}, + {"name": "laige::render::UiEvent", "kind": "struct", "header": "src/laige-render/include/laige/render/ui_widgets.h", "line": 753, "signature": "struct UiEvent", "summary": "One delivered event: the kind, the widget it was delivered to, and the kind-specific payload (sliderValue for SliderChanged, text for InputText — the other kinds leave them at their defaults).", "budget": null, "experimental": false}, + {"name": "laige::render::UiEvent::kind", "kind": "variable", "header": "src/laige-render/include/laige/render/ui_widgets.h", "line": 754, "signature": "UiEventKind kind{UiEventKind::PointerEnter}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::UiEvent::widget", "kind": "variable", "header": "src/laige-render/include/laige/render/ui_widgets.h", "line": 755, "signature": "UiWidgetId widget{kInvalidUiWidgetId}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::UiEvent::sliderValue", "kind": "variable", "header": "src/laige-render/include/laige/render/ui_widgets.h", "line": 756, "signature": "float sliderValue{0.0f}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::UiEvent::text", "kind": "variable", "header": "src/laige-render/include/laige/render/ui_widgets.h", "line": 757, "signature": "StringRef text{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::UiTree", "kind": "class", "header": "src/laige-render/include/laige/render/ui_widgets.h", "line": 773, "signature": "class UiTree", "summary": "The retained widget tree: the pool-backed node store, the layout pass, the focus system, and the per-frame pointer-event list (the preamble: the model, the layout model, the frame protocol, the pointer events, the focus, the storage, the ownership, and the failure contracts).", "budget": null, "experimental": false}, + {"name": "laige::render::UiTree::Options", "kind": "struct", "header": "src/laige-render/include/laige/render/ui_widgets.h", "line": 779, "signature": "struct Options", "summary": "The tree budgets (the declared scene UI budget, S-6, API-006): the widget pool capacity (INCLUDING the root node — the user widget budget is maxWidgets − 1), the per-frame event-list capacity, and the screen rect (the root's rect).", "budget": null, "experimental": false}, + {"name": "laige::render::UiTree::Options::maxWidgets", "kind": "variable", "header": "src/laige-render/include/laige/render/ui_widgets.h", "line": 780, "signature": "std::uint32_t maxWidgets{kUiDefaultMaxWidgets}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::UiTree::Options::maxEvents", "kind": "variable", "header": "src/laige-render/include/laige/render/ui_widgets.h", "line": 781, "signature": "std::uint32_t maxEvents{kUiDefaultMaxEvents}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::UiTree::Options::screenWidth", "kind": "variable", "header": "src/laige-render/include/laige/render/ui_widgets.h", "line": 782, "signature": "std::int32_t screenWidth{kUiDefaultScreenWidth}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::UiTree::Options::screenHeight", "kind": "variable", "header": "src/laige-render/include/laige/render/ui_widgets.h", "line": 783, "signature": "std::int32_t screenHeight{kUiDefaultScreenHeight}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::UiTree::UiTree", "kind": "constructor", "header": "src/laige-render/include/laige/render/ui_widgets.h", "line": 788, "signature": "UiTree() noexcept : nodes_(laige::Pool::Options{0})", "summary": "The stopped state (default / moved-from): valid() false, every id-taking operation fails InvalidArgument, the reads are empty.", "budget": null, "experimental": false}, + {"name": "laige::render::UiTree::create", "kind": "method", "header": "src/laige-render/include/laige/render/ui_widgets.h", "line": 794, "signature": "[[nodiscard]] static laige::Result create(Options options) noexcept", "summary": "The set-up path (scene load): the two backing allocations (the widget pool + the event array) + the root node (the layout-only screen container — it never receives pointer events).", "budget": "O(maxWidgets); two allocations (setup path only)", "experimental": false}, + {"name": "laige::render::UiTree::UiTree", "kind": "constructor", "header": "src/laige-render/include/laige/render/ui_widgets.h", "line": 797, "signature": "UiTree(const UiTree&) = delete", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::UiTree::operator=", "kind": "method", "header": "src/laige-render/include/laige/render/ui_widgets.h", "line": 798, "signature": "UiTree& operator=(const UiTree&) = delete", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::UiTree::UiTree", "kind": "constructor", "header": "src/laige-render/include/laige/render/ui_widgets.h", "line": 802, "signature": "UiTree(UiTree&& o) noexcept : nodes_(std::move(o.nodes_)), events_(std::move(o.events_)), eventCapacity_(o.eventCapacity_), eventCount_(o.eventCount_), droppedEventsTotal_(o.droppedEventsTotal_), screenWidth_(o.screenWidth_), screenHeight_(o.screenHeight_), rootId_(o.rootId_), pointerHit_(o.pointerHit_), prevTop_(o.prevTop_), pressOwner_(o.pressOwner_), focus_(o.focus_), prevPressed_(o.prevPressed_)", "summary": "The move (the house pattern — the moved-from tree is stopped: the pool's move zeroes the source's capacity, the scalars are zeroed so the source reports the stopped state).", "budget": null, "experimental": false}, + {"name": "laige::render::UiTree::operator=", "kind": "method", "header": "src/laige-render/include/laige/render/ui_widgets.h", "line": 831, "signature": "UiTree& operator=(UiTree&& o) noexcept", "summary": "The move assignment (the members are moved DIRECTLY — delegating through a temporary would recurse: the temporary's own move-assignment constructs yet another temporary).", "budget": null, "experimental": false}, + {"name": "laige::render::UiTree::~UiTree", "kind": "destructor", "header": "src/laige-render/include/laige/render/ui_widgets.h", "line": 860, "signature": "~UiTree() noexcept = default", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::UiTree::valid", "kind": "method", "header": "src/laige-render/include/laige/render/ui_widgets.h", "line": 863, "signature": "[[nodiscard]] bool valid() const noexcept", "summary": "True iff the tree is live (create succeeded).", "budget": null, "experimental": false}, + {"name": "laige::render::UiTree::root", "kind": "method", "header": "src/laige-render/include/laige/render/ui_widgets.h", "line": 869, "signature": "[[nodiscard]] UiWidgetId root() const noexcept", "summary": "The root node id (the layout-only screen container — never receives pointer events, never focusable, never removable).", "budget": null, "experimental": false}, + {"name": "laige::render::UiTree::node", "kind": "method", "header": "src/laige-render/include/laige/render/ui_widgets.h", "line": 874, "signature": "[[nodiscard]] const UiNode* node(UiWidgetId id) noexcept", "summary": "The null-safe node read: nullptr for a stale/invalid id or the stopped state (the total read — never UB). O(1). (Non-const: the Pool API has no const element read — the pools.h pattern.)", "budget": null, "experimental": false}, + {"name": "laige::render::UiTree::isValid", "kind": "method", "header": "src/laige-render/include/laige/render/ui_widgets.h", "line": 879, "signature": "[[nodiscard]] bool isValid(UiWidgetId id) const noexcept", "summary": "Generation-checked liveness (CPP-007). O(1).", "budget": null, "experimental": false}, + {"name": "laige::render::UiTree::widgetCount", "kind": "method", "header": "src/laige-render/include/laige/render/ui_widgets.h", "line": 885, "signature": "[[nodiscard]] std::uint32_t widgetCount() const noexcept", "summary": "The live widget count (the root included; 0 in the stopped state). O(1).", "budget": null, "experimental": false}, + {"name": "laige::render::UiTree::widgetStats", "kind": "method", "header": "src/laige-render/include/laige/render/ui_widgets.h", "line": 890, "signature": "[[nodiscard]] laige::PoolStats widgetStats() const noexcept", "summary": "The widget pool's accounting snapshot (PRD §10.4). O(1).", "budget": null, "experimental": false}, + {"name": "laige::render::UiTree::addChild", "kind": "method", "header": "src/laige-render/include/laige/render/ui_widgets.h", "line": 903, "signature": "[[nodiscard]] laige::Result addChild(const UiWidgetSpec& spec) noexcept", "summary": "Declares one widget under `spec.parent` (the invalid id = the root): the validation (first failure wins, InvalidArgument, no log — the header's failure section), the pool create (the BudgetExhausted + one rate-limited Warn `ui_tree/widget_budget`), and the child-list append (insertion order — the traversal / draw order, RENDER-003). The new node's rect is zero until the next layout(). allocation on the success path", "budget": "O(parent's child tail) worst case, O(1) typical; no", "experimental": false}, + {"name": "laige::render::UiTree::remove", "kind": "method", "header": "src/laige-render/include/laige/render/ui_widgets.h", "line": 914, "signature": "[[nodiscard]] laige::Status remove(UiWidgetId id) noexcept", "summary": "Destroys the subtree rooted at `id` (post-order: every node of the subtree is unlinked + destroyed; the pool's LIFO recycling reuses the slots, the generation bumps invalidate every stale id). The root is not removable; a stale/invalid id fails. A removed focused widget (or a subtree containing the focus) clears the focus. The slots are NOT freed (the pool is fixed at create — the recycled slots serve the next addChild).", "budget": "O(subtree size); no allocation", "experimental": false}, + {"name": "laige::render::UiTree::layout", "kind": "method", "header": "src/laige-render/include/laige/render/ui_widgets.h", "line": 922, "signature": "[[nodiscard]] laige::Status layout() noexcept", "summary": "Computes every node's rect from the layout spec (the header's layout model): one preorder pass, exact integer math, the coordinate saturation, the list stacking + scroll normalization. The rect is valid from this call until the next spec change / addChild (the frame protocol calls this every frame).", "budget": "O(n) (n = the live widget count); no allocation", "experimental": false}, + {"name": "laige::render::UiTree::beginFrame", "kind": "method", "header": "src/laige-render/include/laige/render/ui_widgets.h", "line": 927, "signature": "void beginFrame() noexcept", "summary": "The pointerless frame boundary: resets the frame's event list (a keyboard-only frame — the game's setInputText events of this frame do not stack onto the previous frame's list).", "budget": null, "experimental": false}, + {"name": "laige::render::UiTree::handlePointer", "kind": "method", "header": "src/laige-render/include/laige/render/ui_widgets.h", "line": 939, "signature": "[[nodiscard]] laige::Status handlePointer(const UiPointerState& ptr) noexcept", "summary": "Processes one frame's pointer state (the header's frame protocol + pointer-events sections): resets the event list, hit-tests the topmost containing node (the root excluded — layout-only), delivers this frame's events (leave/enter, press/release, the button press, the slider drag, the focus-on-pointer-press), and records the pointer hit (pointerHit() — the FR-4.4 routing hook). The rects must be current (layout() this frame).", "budget": "O(n) (n = the live widget count); no allocation", "experimental": false}, + {"name": "laige::render::UiTree::pointerHit", "kind": "method", "header": "src/laige-render/include/laige/render/ui_widgets.h", "line": 945, "signature": "[[nodiscard]] UiWidgetId pointerHit() const noexcept", "summary": "The topmost node under the LAST processed pointer position (the invalid id when the pointer is over empty screen space or no pointer was processed yet). The FR-4.4 UI-first routing hook: a hit means the UI consumed the frame's pointer input.", "budget": null, "experimental": false}, + {"name": "laige::render::UiTree::events", "kind": "method", "header": "src/laige-render/include/laige/render/ui_widgets.h", "line": 949, "signature": "[[nodiscard]] std::span events() const noexcept", "summary": "The frame's event list (valid until the next handlePointer / beginFrame — the header's frame protocol). O(1); no copy.", "budget": null, "experimental": false}, + {"name": "laige::render::UiTree::droppedEventsTotal", "kind": "method", "header": "src/laige-render/include/laige/render/ui_widgets.h", "line": 958, "signature": "[[nodiscard]] std::uint64_t droppedEventsTotal() const noexcept", "summary": "The cumulative count of events dropped by the per-frame overflow policy (0 = healthy). O(1).", "budget": null, "experimental": false}, + {"name": "laige::render::UiTree::focus", "kind": "method", "header": "src/laige-render/include/laige/render/ui_widgets.h", "line": 963, "signature": "[[nodiscard]] UiWidgetId focus() const noexcept", "summary": "The focused widget (the invalid id when nothing is focused).", "budget": null, "experimental": false}, + {"name": "laige::render::UiTree::focusWidget", "kind": "method", "header": "src/laige-render/include/laige/render/ui_widgets.h", "line": 968, "signature": "[[nodiscard]] laige::Status focusWidget(UiWidgetId id) noexcept", "summary": "Sets the focus explicitly (tab / editor selection): an invalid/stale id or a non-focusable kind fails InvalidArgument (no log).", "budget": null, "experimental": false}, + {"name": "laige::render::UiTree::focusNext", "kind": "method", "header": "src/laige-render/include/laige/render/ui_widgets.h", "line": 974, "signature": "void focusNext() noexcept", "summary": "Tab forward: the next focusable widget in the traversal order, wrapping from the last to the first; from unfocused, the first focusable; a no-op when there are no focusable widgets.", "budget": "O(n) (one or two preorder passes); no allocation", "experimental": false}, + {"name": "laige::render::UiTree::focusPrev", "kind": "method", "header": "src/laige-render/include/laige/render/ui_widgets.h", "line": 980, "signature": "void focusPrev() noexcept", "summary": "Tab backward: the previous focusable widget in the traversal order, wrapping from the first to the last; from unfocused, the last focusable; a no-op when there are no focusable widgets.", "budget": "O(n) (one preorder pass); no allocation", "experimental": false}, + {"name": "laige::render::UiTree::setPlacement", "kind": "method", "header": "src/laige-render/include/laige/render/ui_widgets.h", "line": 986, "signature": "[[nodiscard]] laige::Status setPlacement(UiWidgetId id, UiAnchor anchor, std::int32_t x, std::int32_t y, std::int32_t width, std::int32_t height) noexcept", "summary": "Changes a widget's layout spec (the anchor + the offsets + the size — the header's layout domains): an invalid/stale id or an out-of-domain value fails InvalidArgument (no log). The node's rect is zero until the next layout().", "budget": null, "experimental": false}, + {"name": "laige::render::UiTree::setSliderValue", "kind": "method", "header": "src/laige-render/include/laige/render/ui_widgets.h", "line": 995, "signature": "[[nodiscard]] laige::Status setSliderValue(UiWidgetId id, float value) noexcept", "summary": "Sets a slider's value (clamped into [min, max] — a NaN snaps to min): an invalid/stale id or a non-slider kind fails InvalidArgument (no log). The programmatic set emits NO SliderChanged (only the pointer drag does — the header's pointer-events section).", "budget": null, "experimental": false}, + {"name": "laige::render::UiTree::setScroll", "kind": "method", "header": "src/laige-render/include/laige/render/ui_widgets.h", "line": 1002, "signature": "[[nodiscard]] laige::Status setScroll(UiWidgetId id, std::int32_t y) noexcept", "summary": "Sets a list's scroll offset, px, y down (domain [0, kUiMaxDimPx]; layout() normalizes it into the reachable range): an invalid/stale id, a non-list kind, or an out-of-domain value fails InvalidArgument (no log).", "budget": null, "experimental": false}, + {"name": "laige::render::UiTree::setInputText", "kind": "method", "header": "src/laige-render/include/laige/render/ui_widgets.h", "line": 1011, "signature": "[[nodiscard]] laige::Status setInputText(UiWidgetId id, StringRef text) noexcept", "summary": "Sets an input's text (the scene's string-table handle — PRD §10.4): a no-op (no event) when the handle is unchanged; otherwise the node's text is updated and an InputText event is appended to the frame's event list (the overflow policy applies). An invalid/stale id or a non-input kind fails InvalidArgument (no log).", "budget": null, "experimental": false}, {"name": "laige::kMaxArchetypes", "kind": "variable", "header": "src/laige-sim/include/laige/sim/archetype.h", "line": 184, "signature": "inline constexpr std::uint32_t kMaxArchetypes = 256", "summary": "The engine-level cap on distinct component sets (archetypes) per world (CORE-005: a named engine constant, the kMaxComponentTypes precedent — a game's component-set vocabulary is orders of magnitude smaller than its entity count; raising it is an ADR, not a knob).", "budget": null, "experimental": false}, {"name": "laige::kMaxArchetypeComponents", "kind": "variable", "header": "src/laige-sim/include/laige/sim/archetype.h", "line": 189, "signature": "inline constexpr std::uint32_t kMaxArchetypeComponents = 32", "summary": "The engine-level cap on components per entity (per archetype) (CORE-005). A 33rd distinct component on one entity fails addComponent with BudgetExhausted.", "budget": null, "experimental": false}, {"name": "laige::kInitialArchetypeRows", "kind": "variable", "header": "src/laige-sim/include/laige/sim/archetype.h", "line": 194, "signature": "inline constexpr std::uint32_t kInitialArchetypeRows = 16", "summary": "Rows reserved when an archetype is created (or the world's entity capacity, when smaller). Growth doubles from here (see the header preamble, \"Reserve policy\").", "budget": null, "experimental": false}, diff --git a/roadmap/M2-rendering-2.5d.md b/roadmap/M2-rendering-2.5d.md index 6d40642..2890e50 100644 --- a/roadmap/M2-rendering-2.5d.md +++ b/roadmap/M2-rendering-2.5d.md @@ -356,7 +356,7 @@ if M2 slips, and its status is recorded in M2-EXIT-01. per-frame cost is part of the composite 50k render-CPU budget, M2-PERF-01). -- [ ] **M2-UI-01 · UI widget tree (retained, screen-space)** +- [x] **M2-UI-01 · UI widget tree (retained, screen-space)** - **Refs:** FR-2.8 (panel, button, text, image, list, slider, input), FR-4.4 (UI input routing base) - **Depends:** M2-DEC-02, M2-TEXT-02, M2-SPRITE-02 - **Scope:** @@ -366,6 +366,26 @@ if M2 slips, and its status is recorded in M2-EXIT-01. - Unit tests: tree CRUD + layout golden checks; focus traversal order deterministic; event delivery for scripted pointer sequences. - **Verify:** `ctest -R ui_tree` green. - **Size:** ~400 lines + tests (largest UI step; split into layout / focus if needed) + - **Shipped:** M2-UI-01 (`laige/render/ui_widgets.h`, + header-only, no GL calls, no new dependency): `UiTree` is a + pool-backed retained tree of the ADR 0006 set (panel, button, + text, image, list, slider, input — exactly the confirmed widget + list, M2-DEC-02), with stable generation-checked widget ids + (`laige::PoolHandle`), the documented layout model (the nine + anchors against the padded content box, the list stacking in + insertion order with the normalized scroll, the coordinates + saturated into ±(2^21 − 1)), the focus system (button / slider / + input focusable; the deterministic preorder tab order; the + pointer-press focus; the removal clears), and the per-frame + bounded pointer-event list (enter/leave/press/release, + `ButtonPressed` on the same-widget press + release, + `SliderChanged` on the pointer drag, `InputText` from + `setInputText`; the first-`maxEvents` overflow policy + the + rate-limited `ui_tree/event_overflow` warn). `pointerHit()` is + the FR-4.4 UI-first routing hook (a hit = the UI consumed the + frame's pointer input). Two allocations at `create` only; every + per-frame path is allocation-free and owner-thread-only (the + 1 000-frame zero-allocation loop in `ctest -R ui_tree`). - [ ] **M2-UI-02 · UI rendering pass** - **Refs:** FR-2.8 (separate render pass, screen-space) diff --git a/roadmap/README.md b/roadmap/README.md index f0a455c..573ee45 100644 --- a/roadmap/README.md +++ b/roadmap/README.md @@ -157,7 +157,7 @@ Updated in the same PR that closes steps. "Done" = box checked + Verify green. |---|---|---|---| | M0 | 22 | 22 | ✅ complete (2026-09-13, M0-EXIT-01) | | M1 | 25 | 25 | ✅ complete (M1-EXIT-01, 2026-09-25) | -| M2 | 33 | 24 | ⬜ in progress | +| M2 | 33 | 25 | ⬜ in progress | | M3 | 36 | 0 | ⬜ not started | | M4 | 12 | 0 | ⬜ not started | | M5 | 21 | 0 | ⬜ not started | @@ -165,7 +165,7 @@ Updated in the same PR that closes steps. "Done" = box checked + Verify green. | M7 | 15 | 0 | ⬜ not started | | M8 | 8 | 0 | ⬜ not started | | M9 | 6 | 0 | ⬜ proposals only | -| **Total** | **194** | **70** | | +| **Total** | **194** | **71** | | --- @@ -251,6 +251,7 @@ One line per completed (or split/renumbered) step. | 2026-10-10 | M2-TEXT-02 | `—` / PR (open) | Text items in the UI pass (FR-2.8 — the text-item half of the bitmap P0 text; M2-TEXT-02 scope, nothing else): **API** — NEW header-only `laige::render` text module (public header `include/laige/render/text_items.h`, no new `.cpp`, no GL calls): `StringTable` (the scene's text store — PRD §10.4, no per-frame `std::string` in the path): u32 code points in a fixed pool (two budgeted allocations at `create` — header table + code-point pool; defaults 4096 strings / 16 384 code points, both capped at 2^24 via `kStringTableMax*`), `intern` idempotent (same bytes return the first interned handle — the layout pass's shared-string identity), pool-bound `BudgetExhausted` + one rate-limited Warn `string_table/table_full` (fields `limit` = `"strings"`/`"code_points"`, `capacity`), stopped table rejects with `InvalidArgument` no log, move-only with the moved-from table stopped (explicit move zeroes the source); `TextItem` (the block: `StringRef` text, non-owning `GlyphAtlas*`, anchor `x`/`y` = the first line's baseline, integer `scale` in [1, 16], `SpriteTint` color, per-line `TextAlignment` left/center/right, `maxWidth` in 0 or [1, 2^20] px, the hand-typed UI z `depthKey`, atlasId/materialId/blend); `measureText` the no-wrap O(glyphs) width probe; `declareText` the declaration — the shared `detail::nextLine` walk (word wrap, one collapsed space per inter-word gap, the character split for over-wide words, the over-wide single character taking its own line, leading/trailing spaces excluded, all widths at the scale) used TWICE: the pre-check (any line wider than `kTextMaxLineWidthPx` fails the call `InvalidArgument` with NOTHING declared) then the emit (one `SpriteItem` per ink glyph — the centered quad at the ink center, `scale` = the ink size, the atlas cell's uv rect `(atlasX/W, atlasY/H, (atlasX+w)/W, (atlasY+h)/H)`, `depthOverride = true` (the G-R11 escape hatch, counted + warned at build); a first failed `batcher.add` fails the call, declared items stay); `expandGlyphAtlasRgba8` fills the caller's W·H·4 span with the white-on-alpha RGBA8 upload (the M2-SPRITE-02 `bindAtlas` input); **tests** — `tests/laige-render` CTest entry `text_items` (the step's Verify command; 19 tests / 6 suites; `LAIGE_TEST_FONT_PATH` env var on the entry + the unfiltered entry + BOTH sanitizer blocks): `TextStringTable` (create validation no-log, intern identity + dedup, the two pool bounds + the cumulative warn count, stopped/moved-from, the zero-alloc create), `TextMeasure` (width goldens against the committed Vera test font — measured advances at the runtime metrics, no-wrap), `TextLayout` (the exact wrap model: word wrap, the collapsed spaces, the character split, the over-wide character, empty/whitespace, alignment offsets), `TextDeclare` (the per-glyph quad goldens — pos/scale/uv/depthOverride against hand-computed values, the group + order contract, the failure paths — null/stopped font, invalid handle, scale/maxWidth domain, the screen-domain pre-check declares nothing, the batcher overflow drop-oldest), `TextZeroAlloc` (1 000 frames of a wrapped 2x-scale declare = 0 heap blocks, machine-greppable line), `TextSmoke` (the offscreen GL smoke — the white-on-alpha upload through the real M2-SPRITE-02 submit stage: a 64² frame, the M + a glyphs at hand-computed positions, the exact pixel checks — opaque texels → (255,255,255,255), transparent texels → the clear, semi-transparent texels → rgb exactly the atlas alpha + alpha within ±1 of the documented SRC_ALPHA/ONE_MINUS_SRC_ALPHA blend on the alpha channel; `GTEST_SKIP` on no GL, as the sprite_draw smoke); **budget** — no `budgets.json` entry (the per-frame text cost is part of the composite 50k render-CPU budget, M2-PERF-01; the entry count stays 17); **docs** (DOC-006/007) — NEW `docs/api/text_items.md` (the full contract incl. the DOC-004 Performance section), `docs/api/font.md` (the M2-TEXT-02 references updated to shipped + cross-links), `src/laige-render/include/laige/render/font.h` (the header comments), `docs/concepts/coordinates.md` (§4.12 the text coordinate section + the §5 conversion row + the Related link), `docs/README.md` (the API index row), `src/laige-render/README.md` (the status paragraph); **local verification** — the canonical `build` tree warning-clean, `ctest -R text_items` green (19/19) + full ctest 117/118 with only `api-real-tree` pending the manifest regen; `laige-api.json` regenerated LAST; `tools/laige-include-lint` + `tools/laige-determinism-lint` OK; the five remaining trees verified (prior counts + 1 each); Progress Board M2 23/33 (total 69/194) | | 2026-10-10 | M2-TEXT-03 | `—` / PR (open) | SDF glyph atlas + SDF text (FR-2.8 SDF P1; M2-TEXT-03 scope, nothing else): **API** — NEW `laige::render::SdfGlyphAtlas` (public header `include/laige/render/font_sdf.h`, implementation `font_sdf.cpp`; reuses the pinned `stb_truetype` vendor boundary — ADR 0009, no new dependency): `SdfGlyphAtlas::create(fontBytes, Options{atlasWidth, atlasHeight, fontSize, glyphFirst, glyphLast, sdfRadius})` converts the font bytes + glyph range ONCE at scene set-up into a fixed-size 8-bit SIGNED DISTANCE FIELD atlas: per glyph, the 1x ink's 0.5-coverage pixels (alpha > 127 — `kSdfInsideCut`) are the inside set of a two-pass 8-neighborhood distance transform (the Felice & Nienhuis 8SDD — CORE-014: forward pass left/up-left/up/up-right, backward pass the reverse, orthogonal 1.0 / diagonal 1.414213562373095, float32, INF 1e30) over the glyph's PADDED cell (the ink grown by the radius `r ∈ [1, 16]` — `kSdfDefaultRadius` = 8, `kSdfMaxRadius` = 16 — on all four sides), quantized to `byte = clamp(128 + lround(127·d/r), 0, 255)` with d positive inside: 128 = the 0.5-coverage contour, the field exact within `r` px (byte 255/1 at exactly `|d| = r`), clamped to 0/255 beyond (the corners saturate to 0 — the documented threshold edge behavior); options validate first (radius domain, dims, range — `InvalidArgument` no log), the untrusted-font boundary is the M2-TEXT-01 guard (`MalformedInput` + one Error `font/font_invalid`), a padded cell not fitting the shelf → `BudgetExhausted` + one Error `font/atlas_full` (capacity pre-checked over ALL glyphs before any rasterization), success → one Info `font/sdf_atlas_created`; move-only, stopped default state; the metrics are the M2-TEXT-01 `GlyphMetrics` UNCHANGED (the text layout is identical on both paths — the width match is EXACT, tolerance 0, pinned by the parity + width tests against the committed Vera test font), `atlasX/atlasY` are the PADDED cell's origin (the ink sits at `+r/+r`), shelf packing deterministic (same font + options → identical atlas bytes — the machine-greppable `font-sdf-determinism` FNV-1a line), `glyph(code)` the M2-TEXT-01 fallback (total — out-of-range → the U+0020 slot else the empty metric; stopped → nullptr); **text pass** — `TextItem` gained `FontRenderMode mode` (Bitmap = 0 the default, Sdf = 1 — the documented scene-config key) + non-owning `const SdfGlyphAtlas* sdfFont`; the shared declare walk now runs per glyph path: the SDF quads cover the PADDED cell (size `(w+2r)·scale × (h+2r)·scale`, left `lineX0 + pen + (bearingX − r)·scale`, top `baseline − (bearingY + r)·scale`, the uv the padded-cell rect) while the pen still advances by the ink's `advance·scale` (wrap + layout unchanged); the `measureText` SDF overload returns the same width (tolerance 0); `expandSdfGlyphAtlasRgba8` (white-on-alpha upload of the field, the `bindSdfAtlas` input); **renderer** — `SpriteRenderer::bindSdfAtlas(atlasId, w, h, rgba, threshold)` (same upload contract as `bindAtlas` + the per-atlas shader MODE + threshold ∈ [0, 1], the 0.5-coverage contour at 0.5; the mode travels with the texture — a re-bind replaces it; the submit publishes `uSdf`/`uSdfThreshold` at each group's texture bind) and the fragment shader's SDF branch (the M2-SPRITE-02 pattern, extended): `aa = max(length(vec2(dFdx(t.a), dFdy(t.a))), 1/255)` then `smoothstep(uSdfThreshold − aa, uSdfThreshold + aa, t.a)` — the screen-space derivative AA rule, a band exactly one screen pixel wide AT ANY ZOOM (the P1 crispness motivation, FR-2.8); **tests** — NEW `tests/laige-render/font_sdf_tests.cpp` (new CTest entry `font_sdf` = the step's Verify command; 17 tests / 9 suites; `LAIGE_TEST_FONT_PATH` on the entry + the unfiltered entry + both sanitizer blocks): `FontSdfCreate` (the validation matrix incl. the radius 0/17 bounds, the untrusted-font boundary — 3 Errors `font_invalid`, the padded-cell capacity guard on 8×8 / 32×32 atlases — 2 Errors `atlas_full`, the success introspection + 1 Info, the move-only stopped state), `FontSdfMetrics` (metrics parity with the bitmap atlas for every code + the padded cell fit; the exact width match — "Ma"@1, "Hello World"@1/@2, "W"@4, empty + whitespace-only — tolerance 0), `FontSdfDeterminism` (two creates byte-identical + the `font-sdf-determinism` FNV-1a line; radius 8 vs 4 diverges), `FontSdfThreshold` (the inside/outside boundary — every ink texel: bitmap alpha > 127 ⟺ SDF byte ≥ 128; the corner saturation — both padded-cell corners byte 0 for every ink glyph; the byte-exact oracle test — the 'a' field vs a brute-force double min-Euclidean distance to the opposite set within one quantization byte), `FontSdfFallback` (out-of-range → the space slot; a narrow range's missing space → the empty metric; stopped → nullptr), `FontSdfExpand` (the white-on-alpha bytes; wrong-size + stopped → `InvalidArgument`), `FontSdfZeroAlloc` (the 1000-lookup zero-alloc window), `FontSdfSmoke` (the offscreen SDF frame — 64² headless, `bindSdfAtlas` + an SDF-mode TextItem "Ma" through the real `SpriteRenderer::submit`: 1 draw call / 2 instances, the deep-inside texel (field ≥ 128 + 2·127/r) renders exactly (255,255,255,255), the padded-cell corner renders exactly the clear color, the edge scan finds the anti-aliased band pixel (0 < rgb < 255, r==g==b) — the threshold edge behavior visible in a rendered frame; `GTEST_SKIP`s where no usable OpenGL 3.3 environment); **docs** — NEW `docs/api/font_sdf.md` (the field model, the layout, the 8SDD algorithm, the threshold edge behavior, the width match, the upload, ownership/perf/misuse, DOC-004); `docs/api/text_items.md` (the `FontRenderMode` key, the padded-cell quads, the width match, the SDF upload, the SDF example), `docs/api/sprite_renderer.md` (`bindSdfAtlas` + the SDF fragment branch), `docs/api/font.md` (the shipped SDF ref), `docs/README.md` index, `src/laige-render/README.md` status, `docs/concepts/coordinates.md` §4.12 + the §5 conversion row; **local verification** — all six canonical trees build warning-free and run 277/277 CPU tests (the GL smoke suites excluded by explicit suite filter — the GPU local-execution policy: `FontSdfSmoke` is CI-verified only, the Linux merge lane's offscreen Mesa path); `laige-api.json` regenerated LAST (new public header — `api-real-tree` green); `tools/laige-include-lint` + `tools/laige-determinism-lint` OK; no `budgets.json` entry (the atlas builds once at set-up; the per-frame cost is part of the composite 50k render-CPU budget, M2-PERF-01 — the pin count stays 17); Progress Board M2 24/33 (total 70/194) | +| 2026-10-11 | M2-UI-01 | `—` / PR (open) | UI widget tree (retained, screen-space; M2-UI-01 scope, nothing else): **API** — NEW header-only `laige::render` UI module (public header `include/laige/render/ui_widgets.h`, no new `.cpp`, no GL calls, no new dependency): `UiTree` (move-only; `create(Options{maxWidgets, maxEvents, screenWidth, screenHeight})` — the two backing allocations: the fixed widget pool (`maxWidgets` INCLUDING the root) + the per-frame event array; screen dims validated in `[1, 2^20]`): a retained widget tree of the ADR 0006 set — exactly `panel`, `button`, `text`, `image`, `list`, `slider`, `input`, in the ADR's canonical order (the M2-DEC-02 Verify clause: widget list in ADR == widget list implemented) — `UiWidgetId` = `laige::PoolHandle` (stable generation-checked ids, `kInvalidUiWidgetId` never live, a stale id reads as `nullptr`, never UB); `addChild(spec)` (validation first-failure-wins `InvalidArgument` no log: kind in the seven, parent live-or-root, geometry domains w/h/pad/scroll in `[0, 2^20]`, x/y in `±2^20`, slider `min < max` with NaN rejected and the value clamped; the pool create → `BudgetExhausted` + one rate-limited Warn `ui_tree/widget_budget` (field `capacity`); the child-list append in insertion order = the traversal / draw order); `remove(id)` (iterative post-order — every node of the subtree unlinked + destroyed exactly once, a 65k-deep chain safe; the pool's LIFO recycling reuses the slots with a generation bump; the root / a stale id → `InvalidArgument`; a removed focused widget clears the focus); **layout** — `layout()` one iterative preorder pass (the parent always before its children), exact integer math, deterministic (RENDER-003): the root = the layout-only screen container `(0, 0, w, h)` (never pointer-hit, never focusable, never removable); a child's rect = its anchor point in the parent's CONTENT box (the rect inset by the padding, clamped to 0 x 0 when the padding is wider) + its offset − its anchor offset in its own size (the nine `UiAnchor` values — `TopLeft`/`Center`/`BottomRight` etc.); a **list** stacks its children top-to-bottom in insertion order at the content's left edge (the children's anchors/offsets ignored), shifted up by the normalized `scroll` ∈ `[0, totalContentHeight − contentHeight]`; computed coordinates saturated into `±(2^21 − 1)` (`kUiMaxCoordPx` — offscreen widgets render nothing, the M2-UI-02 cull; the int32 storage stays exact); a node's rect is zero until the next `layout()` (zero-rect nodes contain no pointer point); **focus** — the focusable kinds are button / slider / input (ADR 0006); `focus()` / `focusWidget(id)` / `focusNext()` / `focusPrev()` (the deterministic preorder tab order with wraparound; from unfocused, next → first / prev → last; a pointer press on a focusable takes the focus; no keyboard events here — action routing is M3-INPUT-04); **frame protocol + events** — `layout()` then `handlePointer(UiPointerState{x, y, pressed})` (or `beginFrame()` for a pointerless frame — resets the event list); `handlePointer` hit-tests the TOPMOST node (the LAST node in traversal order whose half-open rect `left ≤ x < right, top ≤ y < bottom` contains the point, the root excluded) and delivers, in order: leave-then-enter `PointerLeave`/`PointerEnter` on a top change; on press `PointerPress` + focus-on-focusable + the slider drag start; on release `PointerRelease` + `ButtonPressed` iff the press started on that SAME button (a press that moved off the button emits no `ButtonPressed`); on a pressed frame the press-owner slider keeps tracking (`value = min + t·(max−min)`, `t` clamped `[0, 1]` over the slider's width — a zero-width slider takes no input); the per-frame list is bounded: the first `maxEvents` events kept, a later one dropped + counted in `droppedEventsTotal()` + one rate-limited Warn `ui_tree/event_overflow` (the LOG-004 facade windows the repeats); `events()` span valid until the next frame boundary; `setSliderValue` (clamps, NaN → min, NO event — only the pointer drag emits), `setPlacement`, `setScroll` (layout normalizes), `setInputText` (the `InputText` event — a no-op for the same handle); **FR-4.4 hook** — `pointerHit()` = the topmost node under the last processed pointer position (a hit = the UI consumed the frame's pointer input — UI-first routing); **ownership** — owner-thread-only (CONC-001), no sim coupling (ARCH-009: presentation state, never mutates sim), two allocations at `create` only; **tests** — `tests/laige-render/ui_tree_tests.cpp` (new CTest entry `ui_tree` = the step's Verify command; 24 tests / 6 suites; TSan entry with `halt_on_error=1`; GL-free — no context, passes in every tree incl. ASan/TSan): `UiTreeCreate` (the validation matrix + the no-log failure contract, the stopped state, the two-alloc create, the move-only stopped source), `UiTreeCrud` (the seven kinds + the kind-specific payloads, the parent/kind/geometry/slider domain validation, the widget budget + the `widget_budget` Warn, the sibling-chain structure, the subtree removal + LIFO slot recycling (the generation bump), the setter failures), `UiTreeLayout` (the nine-anchor goldens against hand-computed rects, the offsets, the container padding (incl. padding wider than the container → the 0 x 0 content at its inset origin), the list stacking + scroll normalization (incl. the over-scroll clamp and the content-shorter-than-rect case), the negative rects + the coordinate saturation (both axes at the 2^20 screen bound), the zero-rect-before-layout + idempotence), `UiTreeFocus` (the focusable kinds only, the deterministic tab order (wraparound both directions, the nested preorder), the pointer-press focus + the removal clears), `UiTreeEvents` (the scripted pointer sequences — the exact per-frame event lists against hand-computed goldens: the hover leave-then-enter, the button click (press + release on the same spot → `ButtonPressed`; the drag-off → no `ButtonPressed`), the slider drag (the value from the pointer x across the frames, no hover events while pressed), the press-owner tracking across widgets, the input text events (the new-handle rule, the no-op same handle), the zero-size widgets contain nothing, the event overflow (the exact drop order + the 2 Warns + the cumulative count)), `UiTreeZeroAlloc` (1 000 frames of layout + pointer + placement + input = 0 heap blocks, the machine-greppable `ui-tree-zeroalloc` line); **docs** (DOC-006/007) — NEW `docs/api/ui_widgets.md` (the full contract: the API sketch, the layout model, the frame protocol + pointer events, the focus, the storage/ownership, the failure table, the DOC-004 Performance section, the misuse warning), `docs/README.md` (the API index row), `src/laige-render/README.md` (the status paragraph), `docs/concepts/coordinates.md` (§4.13 the UI widget tree coordinate section + the §5 conversion row + the Related link); **local verification** — all six canonical trees build warning-free and `ctest -R ui_tree` green in each (24/24); `tools/laige-include-lint` + `tools/laige-determinism-lint` OK; `laige-api.json` regenerated LAST (the new public header — `api-real-tree` green); no `budgets.json` entry (the per-frame cost is part of the composite 50k render-CPU budget, M2-PERF-01 — the pin count stays 17); no rendering (the UI draw pass is M2-UI-02); Progress Board M2 25/33 (total 71/194) | --- diff --git a/src/laige-render/README.md b/src/laige-render/README.md index e8dbdf5..4cff33a 100644 --- a/src/laige-render/README.md +++ b/src/laige-render/README.md @@ -684,5 +684,45 @@ distance oracle, the fallback, the upload bytes, the zero-alloc lookup window, and the offscreen SDF GL smoke that `GTEST_SKIP`s where no usable OpenGL 3.3 environment). -The remaining M2 steps (M2-UI-01, M2-SCENE-01, M2-PERF-01) land in +M2-UI-01 landed the retained UI widget tree — the retained-data half +of the UI work: `laige::render::UiTree` (public header +`include/laige/render/ui_widgets.h`, header-only, no GL, no new +dependency) is a pool-backed retained tree of the ADR 0006 widget +set — exactly panel, button, text, image, list, slider, input, in +the ADR's canonical order — with stable generation-checked widget +ids (`laige::PoolHandle`, `kInvalidUiWidgetId` never live). The +layout pass is one deterministic preorder pass per frame (the nine +anchor points against the container's padded content box, the list +stacking in insertion order with the normalized scroll, the computed +coordinates saturated into ±(2^21 − 1) — the documented model in the +header). The focus system tracks one focused widget (the focusable +kinds are button, slider, input) with a deterministic preorder tab +order (wraparound; a pointer press on a focusable widget takes the +focus; a removal clears it). The frame protocol processes one raw +`UiPointerState` per frame into a bounded list of the ADR 0006 +events (pointer enter/leave/press/release, `ButtonPressed` on a +same-widget press + release, `SliderChanged` on the pointer drag, +`InputText` from `setInputText`), with the first-`maxEvents` +overflow policy (a dropped event counts in +`droppedEventsTotal()` + one rate-limited Warn +`ui_tree/event_overflow`). `pointerHit()` is the FR-4.4 UI-first +routing hook: a hit means the UI consumed the frame's pointer input. +The tree owns two allocations at `create` (the fixed-capacity widget +pool — `maxWidgets` INCLUDING the root — and the event array); every +per-frame path is allocation-free and owner-thread-only. No +rendering (the UI draw pass lands with M2-UI-02), no simulation +coupling (ARCH-009), no standalone `budgets.json` entry (the per- +frame cost is part of the composite 50k render-CPU budget, +M2-PERF-01). API contract in +[docs/api/ui_widgets.md](../docs/api/ui_widgets.md), tests under +[tests/laige-render](../tests/laige-render) (CTest entry `ui_tree` — +the create validation + stopped state + move semantics, the tree +CRUD (the seven kinds, the domain validation, the widget budget, the +subtree removal + LIFO slot recycling), the layout goldens (the +nine-anchor matrix, padding, list stacking + scroll, saturation), +the focus system, the scripted pointer sequences (the button click, +the slider drag, the input text, the event overflow), and the 1 000- +frame zero-allocation loop). + +The remaining M2 steps (M2-UI-02, M2-SCENE-01, M2-PERF-01) land in later steps. diff --git a/src/laige-render/include/laige/render/ui_widgets.h b/src/laige-render/include/laige/render/ui_widgets.h new file mode 100644 index 0000000..cfcc7d4 --- /dev/null +++ b/src/laige-render/include/laige/render/ui_widgets.h @@ -0,0 +1,1555 @@ +// laige-render UI widget tree (M2-UI-01): the retained, screen-space +// widget tree of FR-2.8 — the widget half of the M2 UI scope. +// +// FR-2.8: "Text rendering ...; immediate-mode-style UI widget tree +// (panel, button, text, image, list, slider, input) with a retained +// mode for menus; UI is a separate render pass, screen-space." The +// retained mode lands here: the seven-widget set confirmed by ADR +// 0006 (the M2-DEC-02 D-UI decision), a pool-backed node store with +// stable widget ids, the anchor + box layout model, the focus system +// (focusable widgets, tab/pointer focus), and the bounded per-frame +// pointer-event list. The FR-4.4 UI-first input routing hook is the +// raw pointer state: this step publishes the pointer hit + the focus +// handle; the action-based routing (a typed key goes to the focused +// input before the world) lands with M3-INPUT-04. +// +// M2-UI-01 carries NO rendering: the tree is presentation state read +// by the M2-UI-02 UI render pass (widgets render as batched quads + +// text through the M2-SPRITE-02 path, screen space, identity camera). +// No simulation coupling (ARCH-009): the tree never mutates sim state +// — it emits the per-frame event list, and the game routes those +// events. +// +// UiWidgetKind The seven ADR 0006 widget kinds +// UiAnchor The nine anchor positions (the layout model) +// UiWidgetId The stable, generation-checked widget id +// UiNode One retained widget (the pool element) +// UiWidgetSpec The value the game declares for one widget +// UiPointerState One frame's raw pointer state +// UiEventKind The seven event kinds +// UiEvent One delivered event (the per-frame list) +// UiTree The retained tree (layout, focus, events) +// +// --------------------------------------------------------------------------- +// The model (documented units) +// --------------------------------------------------------------------------- +// +// All layout values are SCREEN PIXELS, screen space, y DOWN — the +// UI pass's coordinate system (the M2-UI-02 identity screen matrix +// maps (x, y) to NDC; nothing world-space leaks in, RENDER-006). The +// root node's rect is the screen rect (`UiTree::Options` +// screenWidth/Height, default 1280 x 720 — the M2-GOLD-01 reference +// resolution). +// +// The widget set is EXACTLY the seven ADR 0006 widgets, in the +// ADR's canonical order (the M2-DEC-02 Verify clause: "widget list in +// ADR == widget list implemented in M2-UI-01"): +// +// Panel structural container; opaque-background option (the +// M2-UI-02 occlusion-culling target); container of children +// Button press action (the menus / options primitive); label, +// art rect, tint; focusable +// Text static text/label: the M2-TEXT-02 `TextItem` value (the +// string-table handle, the glyph path, the scale, the +// alignment, the max-width wrap); focusable: no +// Image fixed UI-atlas sub-rect (static art and decoration) +// List bounded scrollable container: children stacked +// top-to-bottom in insertion order; container of children +// Slider continuous value in [min, max]; focusable +// Input single-line text entry (the M3-INPUT-04 keyboard +// consumer); content is a string-table handle; focusable +// +// Only `Panel` and `List` may have children (a container); only +// `Button`, `Slider`, and `Input` are focusable. The other fields of +// the spec / node are kind-specific (the `UiWidgetSpec` field +// comments name which kind reads which). +// +// --------------------------------------------------------------------------- +// The layout model (anchor + box layout) +// --------------------------------------------------------------------------- +// +// Each node carries: an `anchor` (one of the nine positions), the +// anchor offsets `x` / `y` (screen px, y down), and the node size +// `width` / `height` (screen px). Containers (`Panel`, `List`) +// additionally carry `padding` (px, all four sides). `layout()` +// computes every node's `rect` (screen px, y down, half-open +// [x, x+w) x [y, y+h)) from the parent's CONTENT rect: +// +// content rect of P (Px + P.pad, Py + P.pad) x +// max(0, P.w - 2·P.pad) x max(0, P.h - 2·P.pad) +// anchor point (Cx + anchorH(anchor, Cw), +// Cy + anchorV(anchor, Ch)) +// anchorH/V: Left/Top -> 0, Center -> extent/2 +// (integer floor), Right/Bottom -> extent +// node rect (anchor point + (x, y) - the node's ANCHOR +// POINT in its own size): the anchor says which +// point of the node sits at the anchor point: +// TopLeft/Top/... offset (0, 0), *Center* / +// Center* offset (w/2, 0) / (0, h/2), +// Center (w/2, h/2), *Right/Bottom* (w, h) +// +// So a TopLeft-anchored node sits at (anchor point + offset); a +// Center-anchored node is CENTERED on (anchor point + offset); a +// TopRight node's right edge sits at the anchor point + x. All +// layout arithmetic is exact integer (int64) math (the only floats +// are the presentation conversion to render quads, ARCH-009). +// +// The `List` is the one stacking container: its children are laid +// out top-to-bottom in INSERTION ORDER from the content top, each +// child at its own size (the child's anchor / x / y are IGNORED in +// a list — a row's indent is a nested panel's job), and the list's +// `scroll` (px, y down) shifts the whole stack up. `layout()` +// normalizes `scroll` into the reachable range +// [0, max(0, contentHeight − contentH)] (a list shorter than its +// content scrolls to the bottom; a taller list never scrolls). +// +// Domain: a node's spec fields are bounded (width/height/padding/ +// scroll in [0, kUiMaxDimPx], x/y in [-kUiMaxDimPx, kUiMaxDimPx] — +// 2^20 px, far beyond any screen). Computed rect coordinates are +// SATURATED to +/- kUiMaxCoordPx (2^21 − 1): a node pushed that far +// offscreen renders nothing anyway (the M2-UI-02 cull) and the +// saturation keeps the int32 storage exact — the total-function +// contract of the iso-picking saturation (no per-frame failure). +// +// A node's rect is VALID only after `layout()`: a node created or +// re-placed after the last `layout()` has a zero rect (it contains +// no pointer point until the next layout — the documented frame +// protocol below calls layout() every frame). +// +// --------------------------------------------------------------------------- +// The frame protocol + the pointer events (FR-4.4 hook) +// --------------------------------------------------------------------------- +// +// Per rendered frame (the game's main-thread update, before the +// M2-UI-02 UI pass declares the frame's quads): +// +// layout() O(n): every node's rect (above) +// handlePointer(ptr) O(n): one frame's pointer state — resets +// the event list, hit-tests, and delivers +// this frame's events (below); exactly ONE +// of handlePointer / beginFrame per frame +// (game input handling) e.g. setInputText on the focused input +// events() read the frame's event list +// +// `beginFrame()` is the pointerless variant (a keyboard-only frame): +// it resets the event list so `setInputText` events of this frame do +// not stack onto the previous frame's list. +// +// The pointer HIT is the TOPMOST containing node: the LAST node in +// the tree's traversal order (preorder, children in insertion order +// — the M2-UI-02 draw order, back-to-front, RENDER-003) whose rect +// contains the point (half-open, so a point exactly on a node's +// right or bottom edge is OUTSIDE that node — the iso-picking +// boundary rule). The root is LAYOUT-ONLY: it never receives pointer +// events (a pointer over empty screen space hits NOTHING — the +// world gets the input). An opaque panel in front of a button +// therefore occludes the pointer to the button, exactly as it +// occludes the render (the ADR 0006 occlusion-culling target). +// +// The per-frame events (the ADR 0006 event column, delivered to the +// topmost hit node only; a frame emits, in this order): +// +// PointerLeave the previous frame's topmost node N != this frame's +// top (N leaves first, then the enter — the +// deterministic order) +// PointerEnter this frame's topmost node N != the previous frame's +// PointerPress the pointer transitions not-pressed -> pressed +// WHILE on node N (the pointer is outside every node +// -> no press event; the press is still tracked for +// the release) +// PointerRelease the pointer transitions pressed -> not-pressed +// WHILE on node N +// ButtonPressed a PointerRelease on a Button that ALSO received +// the frame's PointerPress (press and release on the +// same button — a click; the press moves off the +// button before the release -> no ButtonPressed) +// SliderChanged the slider drag (below) changes the value +// InputText `setInputText` changed the focused-or-not input's +// text (programmatic — the M3-INPUT-04 keyboard +// path will route typed keys here) +// +// The SLIDER DRAG: a pointer press ON a slider records the slider +// as the press owner and immediately sets the value from the press +// x (SliderChanged when it moves); while the pointer stays pressed +// the slider keeps tracking the pointer x (even when the pointer +// leaves the slider's rect — the standard drag; the value is +// clamped to [min, max]); the release ends the drag. The value from +// x: `min + clamp((x - rectX) / width, 0, 1) * (max - min)` (a +// zero-width slider takes no pointer input — no division). +// +// The FR-4.4 UI-FIRST ROUTING HOOK (raw pointer state, M2): +// `pointerHit()` returns the topmost node under the LAST processed +// pointer position (the invalid id when the pointer is over empty +// screen space). The game checks it before routing the pointer to +// the world: a hit means the UI consumed the frame's pointer input. +// `focus()` is the keyboard half of the hook: the focused input is +// the widget that consumes typed keys (M3-INPUT-04 routes the keys; +// M2's `setInputText` is the programmatic path). +// +// --------------------------------------------------------------------------- +// The focus system +// --------------------------------------------------------------------------- +// +// Focusable widgets: `Button`, `Slider`, `Input` (the ADR 0006 focus +// row; `Panel` / `Text` / `Image` / `List` are never focused). +// +// focus() the focused widget (the invalid id when none) +// focusWidget(id) explicit focus (tab / editor selection): invalid, +// stale, or non-focusable -> InvalidArgument +// focusNext() tab forward: the next focusable widget in the +// traversal order (the deterministic preorder); +// from the last, wraps to the first; from +// unfocused, the first focusable +// focusPrev() tab backward (the previous focusable, wrapping to +// the last; from unfocused, the last focusable) +// (pointer) a PointerPress ON a focusable widget takes the +// focus (pointer focus — the standard UI behavior) +// (removal) removing a focused widget (or a subtree that +// contains it) clears the focus +// +// The traversal order is the DETERMINISTIC preorder (parent before +// children, children in insertion order) — the same order the +// M2-UI-02 pass draws in and the hit test uses. +// +// --------------------------------------------------------------------------- +// Storage (CORE-005, PERF-004) +// --------------------------------------------------------------------------- +// +// widget pool Pool (the M0-CORE-05 stable-handle pool — +// generation-checked ids, CPP-007; LIFO slot +// recycling; no per-operation allocation) +// event list one UiEvent[maxEvents] array (the frame's events) +// +// `UiTree::create` performs the two backing allocations (setup +// path); EVERY per-frame and per-operation path (addChild, remove, +// layout, handlePointer, the focus ops, the setters) allocates +// nothing (PERF-003 — the zero-allocation proof test). +// +// --------------------------------------------------------------------------- +// Ownership, threading, failure (CORE-009, CONC-001, CORE-008) +// --------------------------------------------------------------------------- +// +// One owner: the game's UI update thread (the same single-owner +// pattern as the SpriteBatcher and the StringTable). The render +// thread never touches the tree: the M2-UI-02 UI pass declares the +// frame's UI quads from the tree's rect/payload state in the cull/ +// batch stage (the PresentationSnapshot pattern, ARCH-009). The tree +// is NOT thread-safe by design (CONC-001). +// +// The tree is move-only (the house pattern): the moved-from tree is +// stopped (capacity 0). Every `StringRef` stored in a node (the +// button label, the text item's text, the input text) is a +// NON-OWNING handle into the scene's StringTable (PRD §10.4 — the +// table and its handles must outlive every frame that reads the +// tree, the TextItem.font lifetime contract). The `TextItem.item` +// of a Text widget carries its own font pointer (non-owning, the +// scene's glyph atlas). +// +// Failure (no silent failure, CORE-008; first failure wins): +// +// - create: maxWidgets outside [2, kUiTreeMaxWidgets], maxEvents +// outside [1, kUiTreeMaxEvents], or a screen dimension outside +// [1, kUiMaxDimPx] -> InvalidArgument (no allocation, no log); +// - addChild: stopped tree, a kind outside the seven-widget set, +// an invalid / non-container parent, an out-of-domain spec +// field, or a slider with min >= max (a NaN min/max fails the +// comparison) -> InvalidArgument (no log); the widget budget -> +// BudgetExhausted + one rate-limited Warn `ui_tree/widget_budget` +// (the facade windows the repeats, LOG-004); +// - remove: stopped tree, the root, or a stale/invalid id -> +// InvalidArgument (no log); +// - layout / handlePointer / the setters / focusWidget: stopped +// tree or a stale/invalid id (a setter's kind mismatch, a +// non-focusable focusWidget target, an out-of-domain value) -> +// InvalidArgument (no log); +// - event overflow: a frame that emits more than maxEvents events +// keeps the FIRST maxEvents (the later ones are dropped) + one +// rate-limited Warn `ui_tree/event_overflow` per dropped event +// (the cumulative count in droppedEventsTotal()). +// +// --------------------------------------------------------------------------- +// Performance (PERF-002/003/004, DOC-004) +// --------------------------------------------------------------------------- +// +// create O(maxWidgets): the two backing allocations. Setup +// path only. +// addChild O(subtree-tail walk of the parent's children in the +// worst case — O(1) for the common append): one pool +// create. No allocation; the budget Warn is a cold, +// rate-limited path (LOG-003). +// remove O(subtree size): every node of the subtree is +// unlinked + destroyed exactly once. No allocation. +// layout O(n): one preorder pass over the n live widgets. No +// allocation, no logging. +// handlePointer O(n): one preorder hit test + a constant number of +// transitions. No allocation; the overflow Warn is a +// cold, rate-limited path. +// focusNext / focusPrev O(n) each (one or two preorder passes). +// events() O(1); the list is the frame's array (no copy). +// +// Call site: once per frame per UI scene, in the game's UI update +// (before the M2-UI-02 UI pass) — never in the simulation tick +// (ARCH-002). The per-frame UI cost is part of the composite 50k +// render-CPU budget (PRD §8.1 — measured with M2-PERF-01; no +// standalone budgets.json entry for this step). +// +// --------------------------------------------------------------------------- +// Misuse warnings +// --------------------------------------------------------------------------- +// +// - Do not mutate the structural fields (`parent`, `firstChild`, +// `nextSibling`) of a node directly: they are the tree's own +// invariants (addChild / remove maintain them). The layout / +// payload fields have the documented domains; a spec violation at +// add time is a caller bug the validation rejects. +// - Do not call handlePointer without layout() first: a node added or +// re-placed since the last layout has a zero rect and contains no +// pointer point (the frame protocol calls layout() every frame). +// - Do not hold a widget id past its remove(): the pool's +// generation check invalidates it (CPP-007) — stale ids fail the +// null-safe reads and every operation that takes an id. +// - Do not point a node's StringRef / TextItem at a dead table or +// atlas: the scene's StringTable and glyph atlases must outlive +// every frame that reads the tree (PRD §10.4, the TextItem.font +// contract). +// - Do not expect per-widget hover state: only the TOPMOST hit node +// receives enter/leave/press/release (a node underneath an opaque +// panel gets nothing while occluded — the documented model). +// - Do not expect programmatic slider changes to emit +// SliderChanged: only the pointer drag emits it (setSliderValue is +// the silent set — the M2-UI-02 render reads the value). +// - Do not read the event list across handlePointer calls: each +// handlePointer / beginFrame starts the frame's list fresh. +// +// Canonical narrative: docs/concepts/coordinates.md §4.13 (ARCH-008); +// API contract: docs/api/ui_widgets.md. + +#pragma once + +#include +#include +#include +#include + +#include "laige/errors.h" +#include "laige/logging.h" +#include "laige/pools.h" +#include "laige/render/sprite_batcher.h" +#include "laige/render/text_items.h" +#include "laige/result.h" + +namespace laige::render { + +// --------------------------------------------------------------------------- +// Named constants (CORE-005) +// --------------------------------------------------------------------------- + +// The seven ADR 0006 widget kinds, in the ADR's canonical order +// (the M2-DEC-02 Verify clause: the implemented set is exactly this). +inline constexpr std::uint32_t kUiWidgetKindCount = 7; + +// The screen-pixel dimension / offset domain: 2^20 px (a 20-bit +// screen dimension — far beyond any UI, and < 2^24 so every +// coordinate inside the domain is exact in float, the M2-SPRITE-03 +// float-exact domain). +inline constexpr std::uint32_t kUiMaxDimPx = 1u << 20; + +// The computed-rect coordinate saturation bound (2^21 − 1): a node +// pushed that far offscreen renders nothing (the M2-UI-02 cull); +// the saturation keeps the int32 storage exact (the header's layout +// domain section). +inline constexpr std::int32_t kUiMaxCoordPx = (1 << 21) - 1; + +// The widget-pool budget domain: [2 (the root + one widget), 65536] +// (65536 x ~240 B/node ~= 15 MB — the declared scene UI budget, S-6). +inline constexpr std::uint32_t kUiTreeMaxWidgets = 65536; +// The documented default (a typical game UI: a menu, a settings +// panel, a HUD — a few hundred widgets at most). +inline constexpr std::uint32_t kUiDefaultMaxWidgets = 1024; + +// The per-frame event-list domain: [1, 4096] (a frame emits at most +// ~7 events in the documented model; 64 is 9x headroom). +inline constexpr std::uint32_t kUiTreeMaxEvents = 4096; +inline constexpr std::uint32_t kUiDefaultMaxEvents = 64; + +// The documented default screen (the M2-GOLD-01 reference +// resolution). +inline constexpr std::int32_t kUiDefaultScreenWidth = 1280; +inline constexpr std::int32_t kUiDefaultScreenHeight = 720; + +// --------------------------------------------------------------------------- +// The widget kinds (ADR 0006 — the canonical order) +// --------------------------------------------------------------------------- + +// The seven ADR 0006 widget kinds, in the ADR's canonical order. The +// enumerator values are stable (PRD §9.4: additive only). +enum class UiWidgetKind : std::uint8_t { + // The structural container (children; the opaque-background option + // — the M2-UI-02 occlusion-culling target). + Panel = 0, + // The press action (the menus / options primitive); focusable. + Button = 1, + // The static text/label (the M2-TEXT-02 TextItem value). + Text = 2, + // The fixed UI-atlas sub-rect (static art and decoration). + Image = 3, + // The bounded scrollable container (children stacked in insertion + // order); the `scroll` px state. + List = 4, + // The continuous value in [min, max]; focusable. + Slider = 5, + // The single-line text entry (the M3-INPUT-04 keyboard consumer); + // focusable. + Input = 6, +}; + +// True for the focusable kinds (the ADR 0006 focus row): Button, +// Slider, Input. O(1). +[[nodiscard]] inline bool uiWidgetIsFocusable(UiWidgetKind kind) noexcept { + return kind == UiWidgetKind::Button || kind == UiWidgetKind::Slider || + kind == UiWidgetKind::Input; +} + +// True for the container kinds (the kinds that may have children): +// Panel, List. O(1). +[[nodiscard]] inline bool uiWidgetIsContainer(UiWidgetKind kind) noexcept { + return kind == UiWidgetKind::Panel || kind == UiWidgetKind::List; +} + +// --------------------------------------------------------------------------- +// The nine anchor positions (the layout model) +// --------------------------------------------------------------------------- + +// The node's anchor: which point of the node is placed at the +// parent's anchor point (the header's layout section). The canonical +// 3 x 3 grid, row-major (Top, Center, Bottom x Left, Center, Right). +enum class UiAnchor : std::uint8_t { + TopLeft = 0, + TopCenter = 1, + TopRight = 2, + CenterLeft = 3, + Center = 4, + CenterRight = 5, + BottomLeft = 6, + BottomCenter = 7, + BottomRight = 8, +}; + +// The anchor's HORIZONTAL point inside a content extent (Left -> 0, +// Center -> extent/2 integer floor, Right -> extent). Exact int64. +[[nodiscard]] inline std::int64_t anchorPointH(UiAnchor anchor, + std::int64_t extent) noexcept { + switch (anchor) { + case UiAnchor::TopLeft: + case UiAnchor::CenterLeft: + case UiAnchor::BottomLeft: + return 0; + case UiAnchor::TopCenter: + case UiAnchor::Center: + case UiAnchor::BottomCenter: + return extent / 2; + case UiAnchor::TopRight: + case UiAnchor::CenterRight: + case UiAnchor::BottomRight: + return extent; + } + return 0; // unreachable: the enum domain is exhaustive +} + +// The anchor's VERTICAL point inside a content extent (Top -> 0, +// Center -> extent/2 integer floor, Bottom -> extent). Exact int64. +[[nodiscard]] inline std::int64_t anchorPointV(UiAnchor anchor, + std::int64_t extent) noexcept { + switch (anchor) { + case UiAnchor::TopLeft: + case UiAnchor::TopCenter: + case UiAnchor::TopRight: + return 0; + case UiAnchor::CenterLeft: + case UiAnchor::Center: + case UiAnchor::CenterRight: + return extent / 2; + case UiAnchor::BottomLeft: + case UiAnchor::BottomCenter: + case UiAnchor::BottomRight: + return extent; + } + return 0; // unreachable: the enum domain is exhaustive +} + +// The HORIZONTAL offset of the node's anchor point from its top-left +// (Left -> 0, Center -> w/2 integer floor, Right -> w). Exact int64. +[[nodiscard]] inline std::int64_t anchorOffsetH(UiAnchor anchor, + std::int64_t w) noexcept { + switch (anchor) { + case UiAnchor::TopLeft: + case UiAnchor::CenterLeft: + case UiAnchor::BottomLeft: + return 0; + case UiAnchor::TopCenter: + case UiAnchor::Center: + case UiAnchor::BottomCenter: + return w / 2; + case UiAnchor::TopRight: + case UiAnchor::CenterRight: + case UiAnchor::BottomRight: + return w; + } + return 0; // unreachable: the enum domain is exhaustive +} + +// The VERTICAL offset of the node's anchor point from its top-left +// (Top -> 0, Center -> h/2 integer floor, Bottom -> h). Exact int64. +[[nodiscard]] inline std::int64_t anchorOffsetV(UiAnchor anchor, + std::int64_t h) noexcept { + switch (anchor) { + case UiAnchor::TopLeft: + case UiAnchor::TopCenter: + case UiAnchor::TopRight: + return 0; + case UiAnchor::CenterLeft: + case UiAnchor::Center: + case UiAnchor::CenterRight: + return h / 2; + case UiAnchor::BottomLeft: + case UiAnchor::BottomCenter: + case UiAnchor::BottomRight: + return h; + } + return 0; // unreachable: the enum domain is exhaustive +} + +// The computed-rect coordinate saturation (the header's layout domain +// section): exact, total, no failure. +[[nodiscard]] inline std::int32_t saturateCoord(std::int64_t v) noexcept { + if (v < -static_cast(kUiMaxCoordPx)) return kUiMaxCoordPx; + if (v > static_cast(kUiMaxCoordPx)) return kUiMaxCoordPx; + return static_cast(v); +} + +// --------------------------------------------------------------------------- +// The stable widget id (CPP-007) +// --------------------------------------------------------------------------- + +// The stable, generation-checked widget id (the M0-CORE-05 pool +// handle): valid while the widget is live; a destroyed widget's id is +// rejected by the generation bump and is never valid again (a 2^32 +// free wrap of one slot is the documented, effectively unreachable +// exception — the Pool::isValid contract). The default id {0, 0} is +// never valid. +using UiWidgetId = laige::PoolHandle; + +// The invalid widget id (never a live handle — the Pool reserves +// generation 0). +inline constexpr UiWidgetId kInvalidUiWidgetId{0, 0}; + +// --------------------------------------------------------------------------- +// One retained widget (the pool element) +// --------------------------------------------------------------------------- + +// One retained widget: the structural links (the tree's own — never +// assigned directly, the header's misuse warnings), the layout spec +// + the computed rect (screen px, y down), and the kind-specific +// payload. A plain value type (no ownership; the StringRefs / the +// TextItem font pointers are NON-OWNING scene handles — the header's +// ownership section). +struct UiNode { + // The widget kind (ADR 0006 — immutable after creation). + UiWidgetKind kind{UiWidgetKind::Panel}; + // The structural links (managed by addChild / remove — the tree's + // own invariants): the parent (kInvalidUiWidgetId for the root), + // the first child, and the next sibling (kInvalidUiWidgetId = none). + UiWidgetId parent{kInvalidUiWidgetId}; + UiWidgetId firstChild{kInvalidUiWidgetId}; + UiWidgetId nextSibling{kInvalidUiWidgetId}; + // The layout spec (the header's layout section; screen px, y down): + // the anchor, the anchor offsets, and the node size. + UiAnchor anchor{UiAnchor::TopLeft}; + std::int32_t x{0}; + std::int32_t y{0}; + std::int32_t width{0}; + std::int32_t height{0}; + // The container padding, px, all four sides (Panel / List only). + std::int32_t padding{0}; + // The computed rect (layout() fills it; screen px, y down, half- + // open). Zero before the first layout (a zero rect contains no + // pointer point). + std::int32_t rectX{0}; + std::int32_t rectY{0}; + std::int32_t rectW{0}; + std::int32_t rectH{0}; + // Panel: the opaque-background option (the M2-UI-02 occlusion + // target). + bool opaque{false}; + // Button: the label's string-table handle (PRD §10.4 — the scene's + // StringTable must outlive the frame that renders it). + StringRef label{}; + // Button / Image: the art's UV sub-rect in the UI atlas. + SpriteUvRect image{}; + // Button / Image: the UI atlas's texture id (the scene's bindAtlas + // id — M2-SPRITE-02). + std::uint32_t atlasId{0}; + // Button / Image: the multiplicative tint (M2-SPRITE-02). + SpriteTint tint{}; + // Text: the text block (the M2-TEXT-02 value — the string handle, + // the glyph path + font pointer, the scale, the alignment, the + // max-width wrap). The widget's rect is the spec's width/height + // (the game sizes it at set-up, e.g. from measureText). + TextItem item{}; + // List: the scroll offset, px, y down (layout() normalizes it into + // the reachable range). + std::int32_t scroll{0}; + // Slider: the value domain [min, max] (min < max — the addChild + // validation) and the current value (the pointer drag + + // setSliderValue write it; presentation-only float, ARCH-009). + float sliderMin{0.0f}; + float sliderMax{0.0f}; + float sliderValue{0.0f}; + // Input: the current text's string-table handle (setInputText + // writes it + emits the InputText event). + StringRef text{}; +}; + +namespace detail { + +// The slider-value clamp (the header's pointer-events section): into +// [min, max]; a NaN snaps to min (total on all floats — the min/max +// domain is min < max, guaranteed by the addChild validation). +[[nodiscard]] inline float uiClampSliderValue(float min, float max, + float v) noexcept { + if (!(v >= min) && !(v <= max)) return min; + if (v < min) return min; + if (v > max) return max; + return v; +} + +// The tree's preorder walk (parent before children, children in +// insertion order — the deterministic traversal / draw order, +// RENDER-003, the header's focus section): iterative (no recursion — +// a 65k-deep chain must not overflow the stack), O(n), no allocation. +// Note: the Pool API has no const element read (pools.h), so the +// walk takes the pool non-const (a walk of a logically const tree is +// a non-mutating operation — the visitor MUST NOT mutate the tree +// mid-walk: the walk follows the structural links it visits). +template +inline void uiTreePreorder(laige::Pool& nodes, + UiWidgetId root, F&& visit) noexcept { + UiWidgetId cur = root; + for (;;) { + const UiNode* n = nodes.get(cur); + visit(cur, n); + if (nodes.isValid(n->firstChild)) { + cur = n->firstChild; + continue; + } + // No children: climb to the nearest ancestor with a live next + // sibling (or end at the root). + UiWidgetId up = cur; + UiWidgetId next = kInvalidUiWidgetId; + for (;;) { + const UiNode* un = nodes.get(up); + if (nodes.isValid(un->nextSibling)) { + next = un->nextSibling; + break; + } + const UiWidgetId p = un->parent; + if (p == kInvalidUiWidgetId) break; // the root: no next + up = p; + } + if (next == kInvalidUiWidgetId) break; + cur = next; + } +} + +} // namespace detail + +// --------------------------------------------------------------------------- +// The widget declaration (the addChild input) +// --------------------------------------------------------------------------- + +// The value the game DECLARES for one widget (S-5: declaration, not +// draw). A plain value: the kind + the shared layout fields, then the +// kind-specific payload (which kind reads which field — the UiNode +// field comments above). The structural links are NOT part of the +// spec (the tree maintains them); `parent` names the parent (the +// invalid id = the tree root). +struct UiWidgetSpec { + // The widget kind (the ADR 0006 seven — validated at addChild). + UiWidgetKind kind{UiWidgetKind::Panel}; + // The parent widget id (kInvalidUiWidgetId = the tree root). + UiWidgetId parent{kInvalidUiWidgetId}; + // The layout spec (the header's layout section — the same domains + // as the UiNode fields above). + UiAnchor anchor{UiAnchor::TopLeft}; + std::int32_t x{0}; + std::int32_t y{0}; + std::int32_t width{0}; + std::int32_t height{0}; + std::int32_t padding{0}; // Panel / List only + // Panel: the opaque-background option. + bool opaque{false}; + // Button: the label's string-table handle. + StringRef label{}; + // Button / Image: the art's UV sub-rect + atlas id + tint. + SpriteUvRect image{}; + std::uint32_t atlasId{0}; + SpriteTint tint{}; + // Text: the text block (the M2-TEXT-02 value). + TextItem item{}; + // List: the initial scroll offset, px. + std::int32_t scroll{0}; + // Slider: the value domain + the initial value (min < max required; + // the value is clamped into [min, max] — a NaN snaps to min). + float sliderMin{0.0f}; + float sliderMax{0.0f}; + float sliderValue{0.0f}; + // Input: the initial text's string-table handle. + StringRef text{}; +}; + +// --------------------------------------------------------------------------- +// One frame's raw pointer state (FR-4.4 hook) +// --------------------------------------------------------------------------- + +// One frame's raw pointer state: the pointer position (screen px, y +// down) + the primary button's pressed state. The game reads the raw +// input (GLFW / the platform layer) and hands this value to +// handlePointer once per frame. +struct UiPointerState { + float x{0.0f}; + float y{0.0f}; + bool pressed{false}; +}; + +// --------------------------------------------------------------------------- +// The per-frame events (the ADR 0006 event column) +// --------------------------------------------------------------------------- + +// The seven event kinds (the ADR 0006 event table). The enumerator +// values are stable (PRD §9.4: additive only). +enum class UiEventKind : std::uint8_t { + // The pointer entered the widget's rect (the topmost hit changed to + // this widget). + PointerEnter = 0, + // The pointer left the widget's rect (the topmost hit changed away + // from this widget). + PointerLeave = 1, + // The pointer pressed while on this widget. + PointerPress = 2, + // The pointer released while on this widget. + PointerRelease = 3, + // A press + release on this Button (a click — the ADR 0006 + // "button pressed" event). + ButtonPressed = 4, + // The slider drag changed this slider's value (`sliderValue`). + SliderChanged = 5, + // The input's text changed (`text` — the new string handle). + InputText = 6, +}; + +// One delivered event: the kind, the widget it was delivered to, and +// the kind-specific payload (sliderValue for SliderChanged, text for +// InputText — the other kinds leave them at their defaults). +struct UiEvent { + UiEventKind kind{UiEventKind::PointerEnter}; + UiWidgetId widget{kInvalidUiWidgetId}; + float sliderValue{0.0f}; + StringRef text{}; +}; + +// --------------------------------------------------------------------------- +// The retained UI tree (M2-UI-01) +// --------------------------------------------------------------------------- + +// The retained widget tree: the pool-backed node store, the layout +// pass, the focus system, and the per-frame pointer-event list (the +// preamble: the model, the layout model, the frame protocol, the +// pointer events, the focus, the storage, the ownership, and the +// failure contracts). +// +// Move-only (CORE-009): the tree owns the widget pool + the event +// array; it is created by create() and handed to its owner (the +// game's UI update). +class UiTree { + public: + // The tree budgets (the declared scene UI budget, S-6, API-006): + // the widget pool capacity (INCLUDING the root node — the user + // widget budget is maxWidgets − 1), the per-frame event-list + // capacity, and the screen rect (the root's rect). + struct Options { + std::uint32_t maxWidgets{kUiDefaultMaxWidgets}; + std::uint32_t maxEvents{kUiDefaultMaxEvents}; + std::int32_t screenWidth{kUiDefaultScreenWidth}; + std::int32_t screenHeight{kUiDefaultScreenHeight}; + }; + + // The stopped state (default / moved-from): valid() false, every + // id-taking operation fails InvalidArgument, the reads are empty. + UiTree() noexcept : nodes_(laige::Pool::Options{0}) {} + + // The set-up path (scene load): the two backing allocations (the + // widget pool + the event array) + the root node (the layout-only + // screen container — it never receives pointer events). + // @budget O(maxWidgets); two allocations (setup path only) + [[nodiscard]] static laige::Result + create(Options options) noexcept; + + UiTree(const UiTree&) = delete; + UiTree& operator=(const UiTree&) = delete; + // The move (the house pattern — the moved-from tree is stopped: + // the pool's move zeroes the source's capacity, the scalars are + // zeroed so the source reports the stopped state). + UiTree(UiTree&& o) noexcept + : nodes_(std::move(o.nodes_)), + events_(std::move(o.events_)), + eventCapacity_(o.eventCapacity_), + eventCount_(o.eventCount_), + droppedEventsTotal_(o.droppedEventsTotal_), + screenWidth_(o.screenWidth_), + screenHeight_(o.screenHeight_), + rootId_(o.rootId_), + pointerHit_(o.pointerHit_), + prevTop_(o.prevTop_), + pressOwner_(o.pressOwner_), + focus_(o.focus_), + prevPressed_(o.prevPressed_) { + o.eventCapacity_ = 0; + o.eventCount_ = 0; + o.droppedEventsTotal_ = 0; + o.screenWidth_ = 0; + o.screenHeight_ = 0; + o.rootId_ = kInvalidUiWidgetId; + o.pointerHit_ = kInvalidUiWidgetId; + o.prevTop_ = kInvalidUiWidgetId; + o.pressOwner_ = kInvalidUiWidgetId; + o.focus_ = kInvalidUiWidgetId; + o.prevPressed_ = false; + } + // The move assignment (the members are moved DIRECTLY — delegating + // through a temporary would recurse: the temporary's own + // move-assignment constructs yet another temporary). + UiTree& operator=(UiTree&& o) noexcept { + if (this != &o) { + nodes_ = std::move(o.nodes_); + events_ = std::move(o.events_); + eventCapacity_ = o.eventCapacity_; + eventCount_ = o.eventCount_; + droppedEventsTotal_ = o.droppedEventsTotal_; + screenWidth_ = o.screenWidth_; + screenHeight_ = o.screenHeight_; + rootId_ = o.rootId_; + pointerHit_ = o.pointerHit_; + prevTop_ = o.prevTop_; + pressOwner_ = o.pressOwner_; + focus_ = o.focus_; + prevPressed_ = o.prevPressed_; + o.eventCapacity_ = 0; + o.eventCount_ = 0; + o.droppedEventsTotal_ = 0; + o.screenWidth_ = 0; + o.screenHeight_ = 0; + o.rootId_ = kInvalidUiWidgetId; + o.pointerHit_ = kInvalidUiWidgetId; + o.prevTop_ = kInvalidUiWidgetId; + o.pressOwner_ = kInvalidUiWidgetId; + o.focus_ = kInvalidUiWidgetId; + o.prevPressed_ = false; + } + return *this; + } + ~UiTree() noexcept = default; + + // True iff the tree is live (create succeeded). + [[nodiscard]] bool valid() const noexcept { + return nodes_.capacity() != 0; + } + + // The root node id (the layout-only screen container — never + // receives pointer events, never focusable, never removable). + [[nodiscard]] UiWidgetId root() const noexcept { return rootId_; } + + // The null-safe node read: nullptr for a stale/invalid id or the + // stopped state (the total read — never UB). O(1). (Non-const: the + // Pool API has no const element read — the pools.h pattern.) + [[nodiscard]] const UiNode* node(UiWidgetId id) noexcept { + return nodes_.get(id); + } + + // Generation-checked liveness (CPP-007). O(1). + [[nodiscard]] bool isValid(UiWidgetId id) const noexcept { + return nodes_.isValid(id); + } + + // The live widget count (the root included; 0 in the stopped + // state). O(1). + [[nodiscard]] std::uint32_t widgetCount() const noexcept { + return nodes_.inUse(); + } + + // The widget pool's accounting snapshot (PRD §10.4). O(1). + [[nodiscard]] laige::PoolStats widgetStats() const noexcept { + return nodes_.stats(); + } + + // Declares one widget under `spec.parent` (the invalid id = the + // root): the validation (first failure wins, InvalidArgument, no + // log — the header's failure section), the pool create (the + // BudgetExhausted + one rate-limited Warn + // `ui_tree/widget_budget`), and the child-list append (insertion + // order — the traversal / draw order, RENDER-003). The new node's + // rect is zero until the next layout(). + // @budget O(parent's child tail) worst case, O(1) typical; no + // allocation on the success path + [[nodiscard]] laige::Result + addChild(const UiWidgetSpec& spec) noexcept; + + // Destroys the subtree rooted at `id` (post-order: every node of + // the subtree is unlinked + destroyed; the pool's LIFO recycling + // reuses the slots, the generation bumps invalidate every stale + // id). The root is not removable; a stale/invalid id fails. A + // removed focused widget (or a subtree containing the focus) + // clears the focus. The slots are NOT freed (the pool is fixed at + // create — the recycled slots serve the next addChild). + // @budget O(subtree size); no allocation + [[nodiscard]] laige::Status remove(UiWidgetId id) noexcept; + + // Computes every node's rect from the layout spec (the header's + // layout model): one preorder pass, exact integer math, the + // coordinate saturation, the list stacking + scroll normalization. + // The rect is valid from this call until the next spec change / + // addChild (the frame protocol calls this every frame). + // @budget O(n) (n = the live widget count); no allocation + [[nodiscard]] laige::Status layout() noexcept; + + // The pointerless frame boundary: resets the frame's event list + // (a keyboard-only frame — the game's setInputText events of this + // frame do not stack onto the previous frame's list). + void beginFrame() noexcept { + if (valid()) eventCount_ = 0; + } + + // Processes one frame's pointer state (the header's frame protocol + // + pointer-events sections): resets the event list, hit-tests the + // topmost containing node (the root excluded — layout-only), + // delivers this frame's events (leave/enter, press/release, the + // button press, the slider drag, the focus-on-pointer-press), and + // records the pointer hit (pointerHit() — the FR-4.4 routing + // hook). The rects must be current (layout() this frame). + // @budget O(n) (n = the live widget count); no allocation + [[nodiscard]] laige::Status handlePointer(const UiPointerState& ptr) noexcept; + + // The topmost node under the LAST processed pointer position (the + // invalid id when the pointer is over empty screen space or no + // pointer was processed yet). The FR-4.4 UI-first routing hook: + // a hit means the UI consumed the frame's pointer input. + [[nodiscard]] UiWidgetId pointerHit() const noexcept { return pointerHit_; } + + // The frame's event list (valid until the next handlePointer / + // beginFrame — the header's frame protocol). O(1); no copy. + [[nodiscard]] std::span events() const noexcept { + if (events_ == nullptr || eventCount_ == 0) { + return std::span{}; + } + return std::span(events_.get(), eventCount_); + } + + // The cumulative count of events dropped by the per-frame + // overflow policy (0 = healthy). O(1). + [[nodiscard]] std::uint64_t droppedEventsTotal() const noexcept { + return droppedEventsTotal_; + } + + // The focused widget (the invalid id when nothing is focused). + [[nodiscard]] UiWidgetId focus() const noexcept { return focus_; } + + // Sets the focus explicitly (tab / editor selection): an + // invalid/stale id or a non-focusable kind fails InvalidArgument + // (no log). + [[nodiscard]] laige::Status focusWidget(UiWidgetId id) noexcept; + + // Tab forward: the next focusable widget in the traversal order, + // wrapping from the last to the first; from unfocused, the first + // focusable; a no-op when there are no focusable widgets. + // @budget O(n) (one or two preorder passes); no allocation + void focusNext() noexcept; + + // Tab backward: the previous focusable widget in the traversal + // order, wrapping from the first to the last; from unfocused, the + // last focusable; a no-op when there are no focusable widgets. + // @budget O(n) (one preorder pass); no allocation + void focusPrev() noexcept; + + // Changes a widget's layout spec (the anchor + the offsets + the + // size — the header's layout domains): an invalid/stale id or an + // out-of-domain value fails InvalidArgument (no log). The node's + // rect is zero until the next layout(). + [[nodiscard]] laige::Status + setPlacement(UiWidgetId id, UiAnchor anchor, std::int32_t x, std::int32_t y, + std::int32_t width, std::int32_t height) noexcept; + + // Sets a slider's value (clamped into [min, max] — a NaN snaps to + // min): an invalid/stale id or a non-slider kind fails + // InvalidArgument (no log). The programmatic set emits NO + // SliderChanged (only the pointer drag does — the header's + // pointer-events section). + [[nodiscard]] laige::Status setSliderValue(UiWidgetId id, + float value) noexcept; + + // Sets a list's scroll offset, px, y down (domain [0, kUiMaxDimPx]; + // layout() normalizes it into the reachable range): an + // invalid/stale id, a non-list kind, or an out-of-domain value + // fails InvalidArgument (no log). + [[nodiscard]] laige::Status setScroll(UiWidgetId id, + std::int32_t y) noexcept; + + // Sets an input's text (the scene's string-table handle — PRD + // §10.4): a no-op (no event) when the handle is unchanged; + // otherwise the node's text is updated and an InputText event is + // appended to the frame's event list (the overflow policy applies). + // An invalid/stale id or a non-input kind fails InvalidArgument + // (no log). + [[nodiscard]] laige::Status setInputText(UiWidgetId id, + StringRef text) noexcept; + + private: + // The root node value (the layout-only screen container). + static UiNode rootNode(std::int32_t w, std::int32_t h) noexcept { + UiNode n; + n.kind = UiWidgetKind::Panel; + n.width = w; + n.height = h; + n.padding = 0; + n.rectX = 0; + n.rectY = 0; + n.rectW = w; + n.rectH = h; + return n; + } + + // The half-open pointer containment (the iso-picking boundary rule: + // the right / bottom edges are exclusive). The int32 -> float + // conversion is exact (the coordinates are < 2^24 — the header's + // layout domain). + static bool pointInRect(const UiNode& n, float x, float y) noexcept { + return n.rectW > 0 && n.rectH > 0 && + x >= static_cast(n.rectX) && + x < static_cast(n.rectX) + static_cast(n.rectW) && + y >= static_cast(n.rectY) && + y < static_cast(n.rectY) + static_cast(n.rectH); + } + + // The slider drag (the header's pointer-events section): the value + // from the pointer x (a zero-width slider takes no input — no + // division); the SliderChanged event on change. + void dragSlider(UiWidgetId id, float x) noexcept { + UiNode& n = nodes_.at(id); + if (n.width <= 0) return; + float t = (x - static_cast(n.rectX)) / + static_cast(n.width); + if (t < 0.0f) t = 0.0f; + if (t > 1.0f) t = 1.0f; + const float v = detail::uiClampSliderValue( + n.sliderMin, n.sliderMax, n.sliderMin + t * (n.sliderMax - n.sliderMin)); + if (v != n.sliderValue) { + n.sliderValue = v; + emitEvent(UiEvent{UiEventKind::SliderChanged, id, v, + StringRef{}}); + } + } + + // The frame-event append (the header's failure section): the first + // maxEvents events of a frame are kept; a later one is dropped + + // one rate-limited Warn `ui_tree/event_overflow` (the facade + // windows the repeats, LOG-004) + the cumulative count. + void emitEvent(const UiEvent& ev) noexcept { + if (eventCount_ == eventCapacity_) { + ++droppedEventsTotal_; + LAIGE_LOG_WARN("ui_tree", "event_overflow", + "Dropped a UI event beyond the frame event budget", + laige::log::field("maxEvents", eventCapacity_)); + return; + } + events_[eventCount_++] = ev; + } + + // Places one node's DIRECT children (the layout walk visits the + // parent before the children, so the node's rect is valid here — + // the children's own children are placed when the walk reaches + // them, no recursion): the anchor/offset placement for Panel / + // leaf containers, the stacking + scroll normalization for List. + void placeChildren(UiWidgetId id) noexcept { + UiNode& n = nodes_.at(id); + const std::int64_t pad = n.padding; + const std::int64_t cx = static_cast(n.rectX) + pad; + const std::int64_t cy = static_cast(n.rectY) + pad; + const std::int64_t cw = + (std::int64_t)n.rectW > 2 * pad ? (std::int64_t)n.rectW - 2 * pad + : 0; + const std::int64_t ch = + (std::int64_t)n.rectH > 2 * pad ? (std::int64_t)n.rectH - 2 * pad + : 0; + UiWidgetId child = n.firstChild; + if (n.kind == UiWidgetKind::List) { + // The total content height (the scroll-clamp domain — the + // header's layout model). + std::int64_t total = 0; + for (UiWidgetId c = child; c != kInvalidUiWidgetId; + c = nodes_.at(c).nextSibling) { + total += nodes_.at(c).height; + } + const std::int64_t maxScroll = total > ch ? total - ch : 0; + if (n.scroll > maxScroll) { + n.scroll = static_cast(maxScroll); + } + std::int64_t y = cy; + for (UiWidgetId c = child; c != kInvalidUiWidgetId; + c = nodes_.at(c).nextSibling) { + UiNode& cn = nodes_.at(c); + cn.rectX = saturateCoord(cx); + cn.rectY = saturateCoord(y - n.scroll); + cn.rectW = cn.width; + cn.rectH = cn.height; + y += cn.height; + } + } else { + for (UiWidgetId c = child; c != kInvalidUiWidgetId; + c = nodes_.at(c).nextSibling) { + UiNode& cn = nodes_.at(c); + const std::int64_t ax = cx + anchorPointH(cn.anchor, cw); + const std::int64_t ay = cy + anchorPointV(cn.anchor, ch); + const std::int64_t rx = + ax + cn.x - anchorOffsetH(cn.anchor, cn.width); + const std::int64_t ry = + ay + cn.y - anchorOffsetV(cn.anchor, cn.height); + cn.rectX = saturateCoord(rx); + cn.rectY = saturateCoord(ry); + cn.rectW = cn.width; + cn.rectH = cn.height; + } + } + } + + // The pointer hit test: the LAST node in the traversal order whose + // rect contains the point (the root excluded — layout-only, the + // header's pointer-events section). + UiWidgetId topmostAt(float x, float y) noexcept { + UiWidgetId best = kInvalidUiWidgetId; + detail::uiTreePreorder(nodes_, rootId_, + [this, x, y, &best](UiWidgetId id, + const UiNode* n) { + if (id == rootId_) return; + if (pointInRect(*n, x, y)) best = id; + }); + return best; + } + + laige::Pool nodes_; + std::unique_ptr events_; + std::uint32_t eventCapacity_{0}; + std::uint32_t eventCount_{0}; + std::uint64_t droppedEventsTotal_{0}; + std::int32_t screenWidth_{0}; + std::int32_t screenHeight_{0}; + UiWidgetId rootId_{kInvalidUiWidgetId}; + // The frame's pointer state (the header's pointer-events section): + // the last processed hit, the previous frame's topmost, the + // previous frame's pressed state, the press owner (the node the + // current press started on — the button-click / slider-drag + // state). + UiWidgetId pointerHit_{kInvalidUiWidgetId}; + UiWidgetId prevTop_{kInvalidUiWidgetId}; + UiWidgetId pressOwner_{kInvalidUiWidgetId}; + UiWidgetId focus_{kInvalidUiWidgetId}; + bool prevPressed_{false}; +}; + +// --------------------------------------------------------------------------- +// The implementations (the header's sections name each contract) +// --------------------------------------------------------------------------- + +inline laige::Result +UiTree::create(Options options) noexcept { + // The create-validation (first failure wins, InvalidArgument, no + // allocation, no log — the header's failure section). + if (options.maxWidgets < 2 || options.maxWidgets > kUiTreeMaxWidgets || + options.maxEvents < 1 || options.maxEvents > kUiTreeMaxEvents || + options.screenWidth < 1 || + options.screenWidth > static_cast(kUiMaxDimPx) || + options.screenHeight < 1 || + options.screenHeight > static_cast(kUiMaxDimPx)) { + return laige::Result::failure( + laige::ErrorCode::InvalidArgument); + } + UiTree t; + t.screenWidth_ = options.screenWidth; + t.screenHeight_ = options.screenHeight; + t.nodes_ = laige::Pool(laige::Pool::Options{ + options.maxWidgets}); + t.events_ = std::make_unique(options.maxEvents); + t.eventCapacity_ = options.maxEvents; + // The root (the layout-only screen container — the budget is >= 2, + // so this create cannot fail). + const auto slot = t.nodes_.create(rootNode(options.screenWidth, + options.screenHeight)); + if (slot.isError()) { + return laige::Result::failure(slot.error()); + } + t.rootId_ = slot.value(); + return laige::Result::success(std::move(t)); +} + +inline laige::Result +UiTree::addChild(const UiWidgetSpec& spec) noexcept { + if (!valid()) { + return laige::Result::failure( + laige::ErrorCode::InvalidArgument); + } + // The kind domain (the ADR 0006 seven — a garbage kind value fails + // too, the header's failure section). + if (static_cast(spec.kind) >= kUiWidgetKindCount) { + return laige::Result::failure( + laige::ErrorCode::InvalidArgument); + } + // The parent: the root, or a live container. + UiWidgetId parent = spec.parent; + if (parent != kInvalidUiWidgetId) { + const UiNode* p = nodes_.get(parent); + if (p == nullptr || !uiWidgetIsContainer(p->kind)) { + return laige::Result::failure( + laige::ErrorCode::InvalidArgument); + } + } + // The geometry domains (the header's layout domain section). + if (spec.width < 0 || spec.width > static_cast(kUiMaxDimPx) || + spec.height < 0 || + spec.height > static_cast(kUiMaxDimPx) || + spec.x < -static_cast(kUiMaxDimPx) || + spec.x > static_cast(kUiMaxDimPx) || + spec.y < -static_cast(kUiMaxDimPx) || + spec.y > static_cast(kUiMaxDimPx) || + spec.padding < 0 || + spec.padding > static_cast(kUiMaxDimPx) || + spec.scroll < 0 || + spec.scroll > static_cast(kUiMaxDimPx)) { + return laige::Result::failure( + laige::ErrorCode::InvalidArgument); + } + // The slider domain (min < max — a NaN min/max fails the + // comparison; the value is clamped, a NaN snaps to min). + float sliderValue = spec.sliderValue; + if (spec.kind == UiWidgetKind::Slider) { + if (!(spec.sliderMin < spec.sliderMax)) { + return laige::Result::failure( + laige::ErrorCode::InvalidArgument); + } + sliderValue = detail::uiClampSliderValue(spec.sliderMin, spec.sliderMax, + spec.sliderValue); + } + UiNode n; + n.kind = spec.kind; + n.anchor = spec.anchor; + n.x = spec.x; + n.y = spec.y; + n.width = spec.width; + n.height = spec.height; + n.padding = spec.padding; + n.opaque = spec.opaque; + n.label = spec.label; + n.image = spec.image; + n.atlasId = spec.atlasId; + n.tint = spec.tint; + n.item = spec.item; + n.scroll = spec.scroll; + n.sliderMin = spec.sliderMin; + n.sliderMax = spec.sliderMax; + n.sliderValue = sliderValue; + n.text = spec.text; + // The rect is zero until the next layout() (the header's layout + // domain section). + const auto slot = nodes_.create(n); + if (slot.isError()) { + // The widget budget (PERF-008 — the pool never grows; the owning + // system logs, the pools.h contract; LOG-004 windows the repeats). + LAIGE_LOG_WARN("ui_tree", "widget_budget", + "UI widget creation exceeded the tree widget budget", + laige::log::field("capacity", nodes_.capacity())); + return laige::Result::failure( + laige::ErrorCode::BudgetExhausted); + } + const UiWidgetId id = slot.value(); + // The child-list append (insertion order — the traversal / draw + // order, RENDER-003). + if (parent == kInvalidUiWidgetId) parent = rootId_; + UiNode& p = nodes_.at(parent); + nodes_.at(id).parent = parent; + if (p.firstChild == kInvalidUiWidgetId) { + p.firstChild = id; + } else { + UiWidgetId tail = p.firstChild; + while (nodes_.at(tail).nextSibling != kInvalidUiWidgetId) { + tail = nodes_.at(tail).nextSibling; + } + nodes_.at(tail).nextSibling = id; + } + return laige::Result::success(id); +} + +inline laige::Status UiTree::remove(UiWidgetId id) noexcept { + if (!valid() || id == rootId_ || !nodes_.isValid(id)) { + return laige::Status::failure(laige::ErrorCode::InvalidArgument); + } + // The iterative post-order (no recursion — a 65k-deep chain must not + // overflow the stack): descend to the leftmost live descendant, + // unlink + destroy the leaf, climb to its parent, repeat until the + // root of the subtree is removed. Every node of the subtree is + // unlinked + destroyed exactly once (O(subtree size)). + UiWidgetId cur = id; + for (;;) { + UiNode& n = nodes_.at(cur); + if (nodes_.isValid(n.firstChild)) { + cur = n.firstChild; + continue; + } + // cur is a leaf: unlink it from its parent's child list. + const UiWidgetId nextParent = n.parent; + UiNode& p = nodes_.at(nextParent); + if (p.firstChild == cur) { + p.firstChild = n.nextSibling; + } else { + // The cur's predecessor in the parent's child list (the + // first-child branch above handled the no-predecessor case). + UiWidgetId prev = kInvalidUiWidgetId; + UiWidgetId s = p.firstChild; + while (s != cur) { + prev = s; + s = nodes_.at(s).nextSibling; + } + nodes_.at(prev).nextSibling = n.nextSibling; + } + (void)nodes_.destroy(cur); // valid by construction: Status ok + if (cur == id) break; + cur = nextParent; + } + // The removed subtree may have held the focus / the press owner. + if (!nodes_.isValid(focus_)) focus_ = kInvalidUiWidgetId; + if (!nodes_.isValid(pressOwner_)) pressOwner_ = kInvalidUiWidgetId; + return laige::Status{}; +} + +inline laige::Status UiTree::layout() noexcept { + if (!valid()) { + return laige::Status::failure(laige::ErrorCode::InvalidArgument); + } + UiNode& root = nodes_.at(rootId_); + root.rectX = 0; + root.rectY = 0; + root.rectW = screenWidth_; + root.rectH = screenHeight_; + // The preorder pass (the parent is visited before its children, so + // every placement reads a valid parent rect — the header's layout + // model). + detail::uiTreePreorder(nodes_, rootId_, [this](UiWidgetId id, + const UiNode*) { + placeChildren(id); + }); + return laige::Status{}; +} + +inline laige::Status UiTree::handlePointer(const UiPointerState& ptr) noexcept { + if (!valid()) { + return laige::Status::failure(laige::ErrorCode::InvalidArgument); + } + // The frame boundary (the header's frame protocol): the event list + // is this frame's. + eventCount_ = 0; + const UiWidgetId top = topmostAt(ptr.x, ptr.y); + // The hover transitions (leave first, then enter — the + // deterministic order, the header's pointer-events section). + if (top != prevTop_) { + if (nodes_.isValid(prevTop_)) { + emitEvent(UiEvent{UiEventKind::PointerLeave, prevTop_, 0.0f, + StringRef{}}); + } + if (top != kInvalidUiWidgetId) { + emitEvent(UiEvent{UiEventKind::PointerEnter, top, 0.0f, + StringRef{}}); + } + } + // The press transitions (the button click + the slider drag — the + // header's pointer-events section). + if (!prevPressed_ && ptr.pressed) { + pressOwner_ = top; + if (top != kInvalidUiWidgetId) { + emitEvent(UiEvent{UiEventKind::PointerPress, top, 0.0f, + StringRef{}}); + const UiNode& n = nodes_.at(top); + if (uiWidgetIsFocusable(n.kind)) focus_ = top; + if (n.kind == UiWidgetKind::Slider) dragSlider(top, ptr.x); + } + } else if (prevPressed_ && !ptr.pressed) { + const UiWidgetId owner = pressOwner_; + pressOwner_ = kInvalidUiWidgetId; + if (top != kInvalidUiWidgetId) { + emitEvent(UiEvent{UiEventKind::PointerRelease, top, 0.0f, + StringRef{}}); + // The button click: a press + release on the SAME button + // (a press that moved off the button emits no ButtonPressed — + // the header's pointer-events section). + if (owner == top && nodes_.at(owner).kind == UiWidgetKind::Button) { + emitEvent(UiEvent{UiEventKind::ButtonPressed, top, 0.0f, + StringRef{}}); + } + } + } else if (prevPressed_ && ptr.pressed) { + // The drag frame: the press owner (a slider) keeps tracking. + if (nodes_.isValid(pressOwner_) && + nodes_.at(pressOwner_).kind == UiWidgetKind::Slider) { + dragSlider(pressOwner_, ptr.x); + } + } + prevTop_ = top; + prevPressed_ = ptr.pressed; + pointerHit_ = top; + return laige::Status{}; +} + +inline laige::Status UiTree::focusWidget(UiWidgetId id) noexcept { + if (!valid()) { + return laige::Status::failure(laige::ErrorCode::InvalidArgument); + } + const UiNode* n = nodes_.get(id); + if (n == nullptr || !uiWidgetIsFocusable(n->kind)) { + return laige::Status::failure(laige::ErrorCode::InvalidArgument); + } + focus_ = id; + return laige::Status{}; +} + +inline void UiTree::focusNext() noexcept { + if (!valid()) return; + // Pass 1: the first focusable AFTER the current focus (the + // traversal order — the deterministic tab order, the header's + // focus section). + bool passed = false; + UiWidgetId next = kInvalidUiWidgetId; + detail::uiTreePreorder(nodes_, rootId_, [&](UiWidgetId id, + const UiNode* n) { + if (!uiWidgetIsFocusable(n->kind)) return; + if (passed) { + if (next == kInvalidUiWidgetId) next = id; + } else if (id == focus_) { + passed = true; + } + }); + if (next != kInvalidUiWidgetId) { + focus_ = next; + return; + } + // The wrap (or the unfocused start): the FIRST focusable in the + // traversal order (a no-op when there are none). + detail::uiTreePreorder(nodes_, rootId_, [&](UiWidgetId id, + const UiNode* n) { + if (uiWidgetIsFocusable(n->kind) && next == kInvalidUiWidgetId) { + next = id; + } + }); + focus_ = next; +} + +inline void UiTree::focusPrev() noexcept { + if (!valid()) return; + // The previous focusable in the traversal order (wrapping to the + // last; from unfocused, the last — the header's focus section). + UiWidgetId lastBefore = kInvalidUiWidgetId; + UiWidgetId lastOverall = kInvalidUiWidgetId; + bool found = false; + detail::uiTreePreorder(nodes_, rootId_, [&](UiWidgetId id, + const UiNode* n) { + if (!uiWidgetIsFocusable(n->kind)) return; + lastOverall = id; + if (id == focus_) { + found = true; + } else if (!found) { + lastBefore = id; + } + }); + focus_ = (found && lastBefore != kInvalidUiWidgetId) ? lastBefore + : lastOverall; +} + +inline laige::Status +UiTree::setPlacement(UiWidgetId id, UiAnchor anchor, std::int32_t x, + std::int32_t y, std::int32_t width, + std::int32_t height) noexcept { + if (!valid()) { + return laige::Status::failure(laige::ErrorCode::InvalidArgument); + } + if (width < 0 || width > static_cast(kUiMaxDimPx) || + height < 0 || height > static_cast(kUiMaxDimPx) || + x < -static_cast(kUiMaxDimPx) || + x > static_cast(kUiMaxDimPx) || + y < -static_cast(kUiMaxDimPx) || + y > static_cast(kUiMaxDimPx)) { + return laige::Status::failure(laige::ErrorCode::InvalidArgument); + } + UiNode* n = nodes_.get(id); + if (n == nullptr) { + return laige::Status::failure(laige::ErrorCode::InvalidArgument); + } + n->anchor = anchor; + n->x = x; + n->y = y; + n->width = width; + n->height = height; + // The rect is zero until the next layout() (the header's layout + // domain section). + n->rectW = 0; + n->rectH = 0; + return laige::Status{}; +} + +inline laige::Status UiTree::setSliderValue(UiWidgetId id, float value) noexcept { + if (!valid()) { + return laige::Status::failure(laige::ErrorCode::InvalidArgument); + } + UiNode* n = nodes_.get(id); + if (n == nullptr || n->kind != UiWidgetKind::Slider) { + return laige::Status::failure(laige::ErrorCode::InvalidArgument); + } + n->sliderValue = detail::uiClampSliderValue(n->sliderMin, n->sliderMax, + value); + return laige::Status{}; +} + +inline laige::Status UiTree::setScroll(UiWidgetId id, + std::int32_t y) noexcept { + if (!valid()) { + return laige::Status::failure(laige::ErrorCode::InvalidArgument); + } + if (y < 0 || y > static_cast(kUiMaxDimPx)) { + return laige::Status::failure(laige::ErrorCode::InvalidArgument); + } + UiNode* n = nodes_.get(id); + if (n == nullptr || n->kind != UiWidgetKind::List) { + return laige::Status::failure(laige::ErrorCode::InvalidArgument); + } + n->scroll = y; // layout() normalizes into the reachable range + return laige::Status{}; +} + +inline laige::Status UiTree::setInputText(UiWidgetId id, + StringRef text) noexcept { + if (!valid()) { + return laige::Status::failure(laige::ErrorCode::InvalidArgument); + } + UiNode* n = nodes_.get(id); + if (n == nullptr || n->kind != UiWidgetKind::Input) { + return laige::Status::failure(laige::ErrorCode::InvalidArgument); + } + if (n->text.id == text.id) return laige::Status{}; // no-op: no event + n->text = text; + emitEvent(UiEvent{UiEventKind::InputText, id, 0.0f, text}); + return laige::Status{}; +} + +} // namespace laige::render diff --git a/tests/laige-render/CMakeLists.txt b/tests/laige-render/CMakeLists.txt index b343cda..b58cb10 100644 --- a/tests/laige-render/CMakeLists.txt +++ b/tests/laige-render/CMakeLists.txt @@ -184,7 +184,25 @@ # white-on-alpha RGBA8 upload through the M2-SPRITE-02 submit stage: # the draw-call count + exact pixel checks against the atlas bytes; the # smoke needs a usable OpenGL 3.3 environment and GTEST_SKIPs where -# absent) — pure data + batcher bookkeeping except the GL smoke. +# absent) — pure data + batcher bookkeeping except the GL smoke; and +# the retained UI widget tree (M2-UI-01) — the ADR 0006 widget set +# (ui_tree_tests.cpp's UiTree* suites: the create validation + the +# stopped state + the move semantics, the tree CRUD (the seven kinds, +# the child-list structure, the kind / parent / geometry / slider +# domain validation, the widget budget failure + one rate-limited +# warn, the subtree removal (the stale ids, the LIFO slot recycling), +# the root's non-removability), the layout goldens (the nine-anchor +# matrix against hand-computed rects, the offsets, the container +# padding, the list stacking + scroll normalization, the negative +# rects, the coordinate saturation, the zero-rect-before-layout, the +# layout idempotence), the focus system (the focusable kinds only, +# the deterministic tab traversal order, the wrap, the pointer-press +# focus, the removal clears the focus), the scripted pointer sequences +# (the enter/leave/press/release transitions, the button click, the +# slider drag, the input text event, the panel press, the zero-size +# widgets, the event overflow (the drop count + the warn)), and the +# 1 000-frame zero-allocation loop (PERF-003)) — pure retained data: +# no GL calls (the rendering lands with M2-UI-02). # # One executable per module (tests/README.md): laige-render_tests links # the module under test plus gtest_main. The unfiltered entry runs the @@ -212,8 +230,9 @@ # (`ctest -R tilemap_anim`), the `parallax` entry is the M2-PAR-01 # Verify command (`ctest -R parallax`), the `particle_render` entry # is the M2-PART-02 Verify command (`ctest -R -# particle_render`), and the `font` entry is the M2-TEXT-01 Verify -# command (`ctest -R font`), each selecting exactly its suites from +# particle_render`), the `font` entry is the M2-TEXT-01 Verify command +# (`ctest -R font`), and the `ui_tree` entry is the M2-UI-01 Verify +# command (`ctest -R ui_tree`), each selecting exactly its suites from # the shared executable. (The unanchored `ctest -R tilemap` regex also # selects the `tilemap_anim` entry — the M2-TILE-02 suites run under # the M2-TILE-01 command too; that is intended. Likewise the @@ -240,7 +259,7 @@ add_executable(laige-render_tests depth_sort_tests.cpp sprite_batcher_tests.cpp sprite_draw_tests.cpp sprite_frames_tests.cpp render_counters_tests.cpp tilemap_tests.cpp tilemap_anim_tests.cpp parallax_tests.cpp particle_render_tests.cpp - font_tests.cpp text_items_tests.cpp font_sdf_tests.cpp) + font_tests.cpp text_items_tests.cpp font_sdf_tests.cpp ui_tree_tests.cpp) laige_apply_engine_policy(laige-render_tests) target_link_libraries(laige-render_tests PRIVATE gtest_main laige-render) # The randomized suites draw through the test-only seed helper @@ -470,6 +489,14 @@ set_tests_properties(font_sdf PROPERTIES ENVIRONMENT "LAIGE_TEST_FONT_PATH=${CMAKE_SOURCE_DIR}/tests/laige-render/assets/vera.ttf") +# M2-UI-01: the step's Verify command is `ctest -R ui_tree`. The +# UiTree* suites are pure retained data (no GL calls, no font asset, +# no environment variables) — they run in every local tree and in CI. +# No budget gate: the step's roadmap scope has no standalone +# budgets.json entry (the per-frame UI cost is part of the composite +# 50k render-CPU budget, M2-PERF-01). +add_test(NAME ui_tree COMMAND laige-render_tests --gtest_filter=UiTree*) + if(NOT LAIGE_ASAN AND NOT LAIGE_TSAN AND CMAKE_SYSTEM_NAME STREQUAL "Linux") target_compile_definitions(laige-render_tests PRIVATE LAIGE_ISO_DEPTH_BUDGET=1) target_compile_definitions(laige-render_tests PRIVATE LAIGE_ISO_PICK_BUDGET=1) @@ -511,7 +538,7 @@ if(LAIGE_TSAN) iso_depth_key iso_depth_table camera iso_camera projection iso_picking depth_sort batcher sprite_draw sprite_frames render_counters tilemap tilemap_anim parallax particle_render font - text_items font_sdf PROPERTIES + text_items font_sdf ui_tree PROPERTIES ENVIRONMENT "TSAN_OPTIONS=halt_on_error=1;LAIGE_BUDGETS_PATH=${CMAKE_SOURCE_DIR}/budgets.json;LAIGE_TEST_FONT_PATH=${CMAKE_SOURCE_DIR}/tests/laige-render/assets/vera.ttf") endif() diff --git a/tests/laige-render/ui_tree_tests.cpp b/tests/laige-render/ui_tree_tests.cpp new file mode 100644 index 0000000..0170a94 --- /dev/null +++ b/tests/laige-render/ui_tree_tests.cpp @@ -0,0 +1,1184 @@ +// laige-render UI widget tree tests (M2-UI-01): the step's Verify +// command is `ctest -R ui_tree`. +// +// The contract pinned against: the create validation + the stopped +// state + the move semantics (UiTreeCreate), the tree CRUD — the +// seven ADR 0006 kinds, the child-list structure, the kind / parent / +// geometry / slider domain validation, the widget budget failure + +// one rate-limited warn, the subtree removal (the stale ids, the LIFO +// slot recycling, the generation bump), the root's non-removability +// (UiTreeCrud), the layout goldens — the nine-anchor matrix against +// hand-computed rects, the offsets, the container padding, the list +// stacking + scroll normalization, the negative rects, the +// coordinate saturation, the zero-rect-before-layout, the layout +// idempotence (UiTreeLayout), the focus system — the focusable kinds +// only, the deterministic tab traversal order (nested tree, the +// hand-computed focusable sequence), the wrap, the pointer-press +// focus, the removal clears the focus (UiTreeFocus), the scripted +// pointer sequences — the enter/leave/press/release transitions, the +// button click (press + release on the same button; the off-button +// click), the slider drag (the exact value goldens), the input text +// event, the panel press (no focus change), the zero-size widgets +// contain nothing, the event overflow (the drop count + the warn) +// (UiTreeEvents), and the 1 000-frame zero-allocation loop +// (PERF-003, UiTreeZeroAlloc). +// +// No GL anywhere: the step is the retained tree only (the rendering +// lands with M2-UI-02). No font asset, no environment variables, no +// budget gate: the step's roadmap scope has no standalone budgets.json +// entry (the per-frame UI cost is part of the composite 50k render-CPU +// budget, M2-PERF-01). + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "laige/alloc_watch.h" +#include "laige/errors.h" +#include "laige/logging.h" +#include "laige/render/ui_widgets.h" +#include "laige/result.h" + +namespace { + +using laige::ErrorCode; +using laige::Result; +using laige::Status; +using laige::render::UiAnchor; +using laige::render::UiEvent; +using laige::render::UiEventKind; +using laige::render::UiNode; +using laige::render::UiPointerState; +using laige::render::UiTree; +using laige::render::UiWidgetId; +using laige::render::UiWidgetKind; +using laige::render::UiWidgetSpec; +using laige::render::kInvalidUiWidgetId; +using laige::render::kUiWidgetKindCount; + +constexpr std::uint32_t kDefaultMaxWidgets = 128; +constexpr std::uint32_t kDefaultMaxEvents = 64; + +UiTree makeTree(std::uint32_t maxWidgets, std::uint32_t maxEvents, + std::int32_t w, std::int32_t h) { + auto r = UiTree::create(UiTree::Options{maxWidgets, maxEvents, w, h}); + if (!r.ok()) { + ADD_FAILURE() << "UiTree::create failed"; + std::abort(); + } + return std::move(r).takeValue(); +} + +UiTree makeDefaultTree() { + return makeTree(kDefaultMaxWidgets, kDefaultMaxEvents, 400, 300); +} + +UiWidgetId addWidget(UiTree& t, UiWidgetKind kind, UiAnchor anchor, + std::int32_t x, std::int32_t y, std::int32_t w, + std::int32_t h, UiWidgetId parent = kInvalidUiWidgetId, + std::int32_t padding = 0, bool opaque = false) { + UiWidgetSpec s; + s.kind = kind; + s.parent = parent; + s.anchor = anchor; + s.x = x; + s.y = y; + s.width = w; + s.height = h; + s.padding = padding; + s.opaque = opaque; + auto r = t.addChild(s); + if (!r.ok()) { + ADD_FAILURE() << "addChild failed"; + std::abort(); + } + return std::move(r).takeValue(); +} + +UiWidgetId addSlider(UiTree& t, std::int32_t x, std::int32_t y, + std::int32_t w, std::int32_t h, float min, float max, + float value) { + UiWidgetSpec s; + s.kind = UiWidgetKind::Slider; + s.x = x; + s.y = y; + s.width = w; + s.height = h; + s.sliderMin = min; + s.sliderMax = max; + s.sliderValue = value; + auto r = t.addChild(s); + if (!r.ok()) { + ADD_FAILURE() << "addSlider failed"; + std::abort(); + } + return std::move(r).takeValue(); +} + +void expectOk(const Status& s, const char* what) { + if (s.isError()) { + ADD_FAILURE() << what << " failed"; + } +} + +void frame(UiTree& t, float x, float y, bool pressed) { + UiPointerState ptr; + ptr.x = x; + ptr.y = y; + ptr.pressed = pressed; + expectOk(t.handlePointer(ptr), "handlePointer"); +} + +void layoutTree(UiTree& t) { + expectOk(t.layout(), "layout"); +} + +// One expected event (the kind-specific payload defaults to zero). +struct EventExpect { + UiEventKind kind{}; + UiWidgetId widget{kInvalidUiWidgetId}; + float sliderValue{0.0f}; + std::uint32_t textId{0}; + bool expectText{false}; +}; + +void expectEvents(const UiTree& t, std::vector want, + std::uint64_t dropped = 0) { + const std::span ev = t.events(); + EXPECT_EQ(want.size(), ev.size()); + std::size_t i = 0; + for (const auto& e : ev) { + if (i >= want.size()) break; + const EventExpect& w = want[i]; + EXPECT_EQ(w.kind, e.kind) << "event " << i << ": wrong kind"; + EXPECT_EQ(w.widget, e.widget) << "event " << i << ": wrong widget"; + if (w.kind == UiEventKind::SliderChanged) { + EXPECT_FLOAT_EQ(w.sliderValue, e.sliderValue) + << "event " << i << ": wrong slider value"; + } + if (w.expectText) { + EXPECT_EQ(w.textId, e.text.id) << "event " << i << ": wrong text id"; + } + ++i; + } + if (dropped != 0) { + EXPECT_EQ(dropped, t.droppedEventsTotal()); + } +} + +// Log capture (the sprite_batcher_tests MemorySink pattern — rate +// limiting OFF so the tests assert per-event counts, not the facade's +// LOG-004 window). +class MemorySink : public laige::log::Sink { + public: + struct Entry { + laige::log::Severity severity{}; + std::string subsystem; + std::string event; + std::string message; + std::vector> fields; + }; + + void emit(const laige::log::LogRecord& record) override { + Entry e; + e.severity = record.severity; + e.subsystem = record.subsystem; + e.event = record.event; + e.message = record.message; + for (const auto& f : record.fields) { + e.fields.emplace_back(std::string(f.name), f.value); + } + entries.push_back(std::move(e)); + } + void flush() override {} + + std::vector entries; +}; + +MemorySink* installCaptureSink() { + auto sink = std::make_unique(); + MemorySink* ptr = sink.get(); + laige::log::LoggerOptions opts; + opts.sink = std::move(sink); + opts.rateLimiting = false; + if (!laige::log::Logger::instance().init(std::move(opts)).ok()) { + ADD_FAILURE() << "Logger::init (capture sink) failed"; + std::abort(); + } + return ptr; +} + +std::size_t countEvents(const MemorySink& sink, std::string_view subsystem, + std::string_view event) { + std::size_t n = 0; + for (const auto& e : sink.entries) { + if (e.subsystem == subsystem && e.event == event) ++n; + } + return n; +} + +std::string fieldOf(const MemorySink::Entry& e, std::string_view key) { + for (const auto& [k, v] : e.fields) { + if (k == key) return v; + } + return std::string(); +} + +const MemorySink::Entry* firstEvent(const MemorySink& sink, + std::string_view event) { + for (const auto& e : sink.entries) { + if (e.event == event) return &e; + } + return nullptr; +} + +} // namespace + +// --------------------------------------------------------------------------- +// UiTreeCreate — the create validation, the stopped state, the move +// --------------------------------------------------------------------------- + +TEST(UiTreeCreate, ValidationMatrix) { + // maxWidgets: below 2 (the root needs a slot), above the cap. + EXPECT_EQ(ErrorCode::InvalidArgument, + UiTree::create(UiTree::Options{0, 64, 400, 300}).error()); + EXPECT_EQ(ErrorCode::InvalidArgument, + UiTree::create(UiTree::Options{1, 64, 400, 300}).error()); + EXPECT_EQ(ErrorCode::InvalidArgument, + UiTree::create(UiTree::Options{65537, 64, 400, 300}).error()); + // maxEvents: 0 (the list needs a slot), above the cap. + EXPECT_EQ(ErrorCode::InvalidArgument, + UiTree::create(UiTree::Options{1024, 0, 400, 300}).error()); + EXPECT_EQ(ErrorCode::InvalidArgument, + UiTree::create(UiTree::Options{1024, 4097, 400, 300}).error()); + // The screen dimensions: 0, above the domain. + EXPECT_EQ(ErrorCode::InvalidArgument, + UiTree::create(UiTree::Options{1024, 64, 0, 300}).error()); + EXPECT_EQ(ErrorCode::InvalidArgument, + UiTree::create(UiTree::Options{1024, 64, 400, 0}).error()); + EXPECT_EQ(ErrorCode::InvalidArgument, + UiTree::create(UiTree::Options{1024, 64, (1 << 20) + 1, 300}) + .error()); + EXPECT_EQ(ErrorCode::InvalidArgument, + UiTree::create(UiTree::Options{1024, 64, 400, (1 << 20) + 1}) + .error()); + // The domain boundaries themselves are valid. + EXPECT_TRUE(UiTree::create(UiTree::Options{2, 1, 1, 1}).ok()); + EXPECT_TRUE(UiTree::create(UiTree::Options{65536, 4096, 1 << 20, 1 << 20}) + .ok()); +} + +TEST(UiTreeCreate, SuccessIntrospection) { + UiTree t = makeTree(16, 64, 640, 480); + EXPECT_TRUE(t.valid()); + const UiWidgetId root = t.root(); + EXPECT_TRUE(t.isValid(root)); + const UiNode* r = t.node(root); + ASSERT_NE(nullptr, r); + EXPECT_EQ(UiWidgetKind::Panel, r->kind); + EXPECT_EQ(0, r->rectX); + EXPECT_EQ(0, r->rectY); + EXPECT_EQ(640, r->rectW); + EXPECT_EQ(480, r->rectH); + EXPECT_EQ(0, r->padding); + EXPECT_FALSE(r->opaque); + EXPECT_EQ(1u, t.widgetCount()); + const laige::PoolStats s = t.widgetStats(); + EXPECT_EQ(16u, s.capacity); + EXPECT_EQ(1u, s.inUse); + EXPECT_EQ(1u, s.peakInUse); + EXPECT_EQ(1ull, s.totalCreated); +} + +TEST(UiTreeCreate, StoppedState) { + UiTree t; + EXPECT_FALSE(t.valid()); + UiPointerState ptr; + ptr.x = 10.0f; + ptr.y = 10.0f; + EXPECT_EQ(ErrorCode::InvalidArgument, t.handlePointer(ptr).error()); + UiWidgetSpec spec; + spec.kind = UiWidgetKind::Button; + EXPECT_EQ(ErrorCode::InvalidArgument, t.addChild(spec).error()); + EXPECT_EQ(ErrorCode::InvalidArgument, t.remove(kInvalidUiWidgetId).error()); + EXPECT_EQ(ErrorCode::InvalidArgument, t.layout().error()); + EXPECT_EQ(ErrorCode::InvalidArgument, t.focusWidget(kInvalidUiWidgetId).error()); + EXPECT_EQ(ErrorCode::InvalidArgument, t.setInputText(kInvalidUiWidgetId, + laige::render::StringRef{1}).error()); + EXPECT_EQ(ErrorCode::InvalidArgument, t.setSliderValue(kInvalidUiWidgetId, + 1.0f).error()); + EXPECT_EQ(ErrorCode::InvalidArgument, t.setScroll(kInvalidUiWidgetId, 1).error()); + EXPECT_EQ(ErrorCode::InvalidArgument, + t.setPlacement(kInvalidUiWidgetId, UiAnchor::TopLeft, 0, 0, 1, 1).error()); + EXPECT_EQ(nullptr, t.node(kInvalidUiWidgetId)); + EXPECT_FALSE(t.isValid(kInvalidUiWidgetId)); + EXPECT_EQ(0u, t.widgetCount()); + EXPECT_EQ(0ull, t.droppedEventsTotal()); + EXPECT_TRUE(t.events().empty()); + EXPECT_EQ(kInvalidUiWidgetId, t.focus()); + EXPECT_EQ(kInvalidUiWidgetId, t.pointerHit()); + t.beginFrame(); // total: no crash, no state change + t.focusNext(); + t.focusPrev(); +} + +TEST(UiTreeCreate, MoveSemantics) { + UiTree t = makeDefaultTree(); + const UiWidgetId root = t.root(); + UiTree t2 = std::move(t); + EXPECT_TRUE(t2.valid()); + EXPECT_EQ(root, t2.root()); + EXPECT_TRUE(t2.isValid(root)); + EXPECT_FALSE(t.valid()); + EXPECT_EQ(0u, t.widgetCount()); + // The root's rect is intact on the target. + const UiNode* r = t2.node(root); + ASSERT_NE(nullptr, r); + EXPECT_EQ(400, r->rectW); + EXPECT_EQ(300, r->rectH); +} + +// --------------------------------------------------------------------------- +// UiTreeCrud — the seven kinds, the structure, the validation, the +// budget, the subtree removal, the LIFO recycling +// --------------------------------------------------------------------------- + +TEST(UiTreeCrud, AllSevenKindsUnderRoot) { + UiTree t = makeDefaultTree(); + const UiWidgetId root = t.root(); + const UiWidgetId p = addWidget(t, UiWidgetKind::Panel, UiAnchor::TopLeft, + 0, 0, 100, 100); + const UiWidgetId b = addWidget(t, UiWidgetKind::Button, UiAnchor::TopLeft, + 0, 0, 50, 20); + const UiWidgetId tx = addWidget(t, UiWidgetKind::Text, UiAnchor::TopLeft, + 0, 0, 80, 16); + const UiWidgetId im = addWidget(t, UiWidgetKind::Image, UiAnchor::TopLeft, + 0, 0, 32, 32); + const UiWidgetId li = addWidget(t, UiWidgetKind::List, UiAnchor::TopLeft, + 0, 0, 60, 120); + const UiWidgetId sl = addSlider(t, 0, 0, 100, 20, 0.0f, 10.0f, 3.0f); + const UiWidgetId in = addWidget(t, UiWidgetKind::Input, UiAnchor::TopLeft, + 0, 0, 120, 24); + // The child list: insertion order, the root's first child chain. + const UiNode* rp = t.node(root); + ASSERT_NE(nullptr, rp); + EXPECT_EQ(p, rp->firstChild); + EXPECT_EQ(b, t.node(p)->nextSibling); + EXPECT_EQ(tx, t.node(b)->nextSibling); + EXPECT_EQ(im, t.node(tx)->nextSibling); + EXPECT_EQ(li, t.node(im)->nextSibling); + EXPECT_EQ(sl, t.node(li)->nextSibling); + EXPECT_EQ(in, t.node(sl)->nextSibling); + EXPECT_EQ(kInvalidUiWidgetId, t.node(in)->nextSibling); + // Every child's parent is the root; the kinds + the slider clamp. + for (const UiWidgetId id : + {p, b, tx, im, li, sl, in}) { + EXPECT_EQ(root, t.node(id)->parent); + } + EXPECT_EQ(8u, t.widgetCount()); // the root + the seven + EXPECT_FLOAT_EQ(3.0f, t.node(sl)->sliderValue); + // The slider value clamps at creation (value 100 -> max 10; NaN -> min). + UiWidgetSpec over; + over.kind = UiWidgetKind::Slider; + over.width = 10; + over.height = 10; + over.sliderMin = 0.0f; + over.sliderMax = 10.0f; + over.sliderValue = 100.0f; + auto r = t.addChild(over); + ASSERT_TRUE(r.ok()); + EXPECT_FLOAT_EQ(10.0f, t.node(std::move(r).takeValue())->sliderValue); + UiWidgetSpec nan; + nan.kind = UiWidgetKind::Slider; + nan.width = 10; + nan.height = 10; + nan.sliderMin = 0.0f; + nan.sliderMax = 10.0f; + nan.sliderValue = std::nanf(""); + r = t.addChild(nan); + ASSERT_TRUE(r.ok()); + EXPECT_FLOAT_EQ(0.0f, t.node(std::move(r).takeValue())->sliderValue); +} + +TEST(UiTreeCrud, KindDomainAndParents) { + UiTree t = makeDefaultTree(); + // A kind outside the ADR 0006 seven (a garbage value too). + UiWidgetSpec bad; + bad.kind = static_cast(kUiWidgetKindCount); + EXPECT_EQ(ErrorCode::InvalidArgument, t.addChild(bad).error()); + bad.kind = static_cast(0xFF); + EXPECT_EQ(ErrorCode::InvalidArgument, t.addChild(bad).error()); + // A leaf parent: only Panel / List are containers. + const UiWidgetId btn = addWidget(t, UiWidgetKind::Button, UiAnchor::TopLeft, + 0, 0, 10, 10); + const UiWidgetId txt = addWidget(t, UiWidgetKind::Text, UiAnchor::TopLeft, + 0, 0, 10, 10); + UiWidgetSpec child; + child.kind = UiWidgetKind::Button; + child.parent = btn; + child.width = 5; + child.height = 5; + EXPECT_EQ(ErrorCode::InvalidArgument, t.addChild(child).error()); + child.parent = txt; + EXPECT_EQ(ErrorCode::InvalidArgument, t.addChild(child).error()); + // A stale parent (removed). + child.parent = kInvalidUiWidgetId; + auto r = t.addChild(child); + ASSERT_TRUE(r.ok()); + const UiWidgetId c = std::move(r).takeValue(); + expectOk(t.remove(c), "remove(c)"); + child.parent = c; // stale now + EXPECT_EQ(ErrorCode::InvalidArgument, t.addChild(child).error()); + // The root's non-removability + the stale remove. + EXPECT_EQ(ErrorCode::InvalidArgument, t.remove(t.root()).error()); + EXPECT_EQ(ErrorCode::InvalidArgument, t.remove(kInvalidUiWidgetId).error()); +} + +TEST(UiTreeCrud, GeometryDomain) { + UiTree t = makeDefaultTree(); + auto failDomain = [&t](std::int32_t x, std::int32_t y, std::int32_t w, + std::int32_t h, std::int32_t pad) { + UiWidgetSpec s; + s.kind = UiWidgetKind::Button; + s.x = x; + s.y = y; + s.width = w; + s.height = h; + s.padding = pad; + return t.addChild(s).error(); + }; + EXPECT_EQ(ErrorCode::InvalidArgument, failDomain(0, 0, -1, 10, 0)); + EXPECT_EQ(ErrorCode::InvalidArgument, failDomain(0, 0, 10, -1, 0)); + EXPECT_EQ(ErrorCode::InvalidArgument, failDomain(0, 0, (1 << 20) + 1, 10, 0)); + EXPECT_EQ(ErrorCode::InvalidArgument, failDomain(0, 0, 10, (1 << 20) + 1, 0)); + EXPECT_EQ(ErrorCode::InvalidArgument, failDomain(-((1 << 20) + 1), 0, 10, 10, 0)); + EXPECT_EQ(ErrorCode::InvalidArgument, failDomain(0, -((1 << 20) + 1), 10, 10, 0)); + EXPECT_EQ(ErrorCode::InvalidArgument, failDomain((1 << 20) + 1, 0, 10, 10, 0)); + EXPECT_EQ(ErrorCode::InvalidArgument, failDomain(0, 0, 10, 10, -1)); + EXPECT_EQ(ErrorCode::InvalidArgument, failDomain(0, 0, 10, 10, (1 << 20) + 1)); + // The domain boundaries are valid. + UiWidgetSpec ok; + ok.kind = UiWidgetKind::Button; + ok.x = -(1 << 20); + ok.width = 1 << 20; + ok.height = 1 << 20; + ok.padding = 1 << 20; + EXPECT_TRUE(t.addChild(ok).ok()); +} + +TEST(UiTreeCrud, SliderDomain) { + UiTree t = makeDefaultTree(); + auto failSlider = [&t](float min, float max) -> laige::ErrorCode { + UiWidgetSpec s; + s.kind = UiWidgetKind::Slider; + s.width = 10; + s.height = 10; + s.sliderMin = min; + s.sliderMax = max; + const auto r = t.addChild(s); + if (r.ok()) { + ADD_FAILURE() << "expected an InvalidArgument failure"; + return ErrorCode::Unknown; + } + return r.error(); + }; + EXPECT_EQ(ErrorCode::InvalidArgument, failSlider(5.0f, 5.0f)); // min == max + EXPECT_EQ(ErrorCode::InvalidArgument, failSlider(5.0f, 4.0f)); // min > max + EXPECT_EQ(ErrorCode::InvalidArgument, + failSlider(std::nanf(""), 5.0f)); // NaN min + EXPECT_EQ(ErrorCode::InvalidArgument, + failSlider(5.0f, std::nanf(""))); // NaN max + // A valid domain succeeds. + UiWidgetSpec ok; + ok.kind = UiWidgetKind::Slider; + ok.width = 10; + ok.height = 10; + ok.sliderMin = 0.0f; + ok.sliderMax = 1.0f; + ok.sliderValue = 0.25f; + EXPECT_TRUE(t.addChild(ok).ok()); +} + +// --------------------------------------------------------------------------- +// UiTreeLayout — the anchor goldens, the offsets, the padding, the list +// stacking + scroll, the saturation, the zero rect, the idempotence +// --------------------------------------------------------------------------- + +TEST(UiTreeLayout, NineAnchorGoldens) { + // Parent panel: rect (100, 50) x 200 x 100, no padding. Child 20 x 10. + UiTree t = makeDefaultTree(); + const UiWidgetId p = addWidget(t, UiWidgetKind::Panel, UiAnchor::TopLeft, + 100, 50, 200, 100); + struct Case { + UiAnchor anchor; + std::int32_t x; + std::int32_t y; + }; + const Case cases[] = { + {UiAnchor::TopLeft, 0, 0}, + {UiAnchor::TopCenter, 0, 0}, + {UiAnchor::TopRight, 0, 0}, + {UiAnchor::CenterLeft, 0, 0}, + {UiAnchor::Center, 0, 0}, + {UiAnchor::CenterRight, 0, 0}, + {UiAnchor::BottomLeft, 0, 0}, + {UiAnchor::BottomCenter, 0, 0}, + {UiAnchor::BottomRight, 0, 0}, + }; + // One child per anchor, in this order (the hand-computed goldens: + // the anchor point + the offset, minus the node's anchor point). + const std::int32_t goldens[][2] = { + {100, 50}, {190, 50}, {280, 50}, + {100, 95}, {190, 95}, {280, 95}, + {100, 140}, {190, 140}, {280, 140}, + }; + for (std::size_t i = 0; i < 9; ++i) { + const UiWidgetId c = addWidget(t, UiWidgetKind::Button, cases[i].anchor, + cases[i].x, cases[i].y, 20, 10, p); + layoutTree(t); + const UiNode* n = t.node(c); + EXPECT_EQ(goldens[i][0], n->rectX) << "anchor " << i; + EXPECT_EQ(goldens[i][1], n->rectY) << "anchor " << i; + EXPECT_EQ(20, n->rectW); + EXPECT_EQ(10, n->rectH); + EXPECT_EQ(p, n->parent); + } + // An offset shifts the node (TopLeft, x +5, y -3). + const UiWidgetId off = addWidget(t, UiWidgetKind::Button, UiAnchor::TopLeft, + 5, -3, 20, 10, p); + layoutTree(t); + EXPECT_EQ(105, t.node(off)->rectX); + EXPECT_EQ(47, t.node(off)->rectY); +} + +TEST(UiTreeLayout, ContainerPadding) { + // Panel (100, 50) x 200 x 100 with padding 10: content (110, 60) x + // 180 x 80. + UiTree t = makeDefaultTree(); + const UiWidgetId p = addWidget(t, UiWidgetKind::Panel, UiAnchor::TopLeft, + 100, 50, 200, 100, kInvalidUiWidgetId, 10); + const UiWidgetId a = addWidget(t, UiWidgetKind::Button, UiAnchor::TopLeft, + 0, 0, 20, 10, p); + const UiWidgetId b = addWidget(t, UiWidgetKind::Button, UiAnchor::TopRight, + 0, 0, 20, 10, p); + const UiWidgetId c = addWidget(t, UiWidgetKind::Button, UiAnchor::Center, + 0, 0, 20, 10, p); + layoutTree(t); + EXPECT_EQ(110, t.node(a)->rectX); + EXPECT_EQ(60, t.node(a)->rectY); + EXPECT_EQ(270, t.node(b)->rectX); // 110 + 180 - 20 + EXPECT_EQ(60, t.node(b)->rectY); + EXPECT_EQ(190, t.node(c)->rectX); // 110 + 90 - 10 + EXPECT_EQ(95, t.node(c)->rectY); // 60 + 40 - 5 + // A padding wider than the container: the content is 0 x 0 at its + // inset origin (10, 10); the TopLeft child lands on that origin. + UiTree t2 = makeDefaultTree(); + const UiWidgetId p2 = addWidget(t2, UiWidgetKind::Panel, UiAnchor::TopLeft, + 0, 0, 10, 10, kInvalidUiWidgetId, 10); + const UiWidgetId c2 = addWidget(t2, UiWidgetKind::Button, UiAnchor::TopLeft, + 0, 0, 4, 4, p2); + layoutTree(t2); + EXPECT_EQ(10, t2.node(c2)->rectX); // content origin (10, 10) + EXPECT_EQ(10, t2.node(c2)->rectY); +} + +TEST(UiTreeLayout, ListStackingAndScroll) { + // List (0, 0) x 100 x 50; children 100 x 40 + 100 x 40 (total 80 > + // 50 -> maxScroll 30). + UiTree t = makeDefaultTree(); + const UiWidgetId li = addWidget(t, UiWidgetKind::List, UiAnchor::TopLeft, + 0, 0, 100, 50); + const UiWidgetId a = addWidget(t, UiWidgetKind::Button, UiAnchor::TopLeft, + 0, 0, 100, 40, li); + const UiWidgetId b = addWidget(t, UiWidgetKind::Button, UiAnchor::TopLeft, + 0, 0, 100, 40, li); + // No scroll: stacked from the content top (the child anchor / + // offsets are ignored in a list). + layoutTree(t); + EXPECT_EQ(0, t.node(a)->rectX); + EXPECT_EQ(0, t.node(a)->rectY); + EXPECT_EQ(0, t.node(b)->rectX); + EXPECT_EQ(40, t.node(b)->rectY); + // A scroll within range shifts the stack up. + expectOk(t.setScroll(li, 5), "setScroll(5)"); + layoutTree(t); + EXPECT_EQ(-5, t.node(a)->rectY); + EXPECT_EQ(35, t.node(b)->rectY); + // An out-of-range scroll normalizes to maxScroll (30). + expectOk(t.setScroll(li, 1000), "setScroll(1000)"); + layoutTree(t); + EXPECT_EQ(30, t.node(li)->scroll); + EXPECT_EQ(-30, t.node(a)->rectY); + EXPECT_EQ(10, t.node(b)->rectY); + // A list shorter than its content never scrolls (total 45 < 50 -> + // maxScroll 0): a set scroll normalizes to 0. + UiTree t2 = makeDefaultTree(); + const UiWidgetId li2 = addWidget(t2, UiWidgetKind::List, UiAnchor::TopLeft, + 0, 0, 100, 50); + const UiWidgetId a2 = addWidget(t2, UiWidgetKind::Button, UiAnchor::TopLeft, + 0, 0, 100, 10, li2); + const UiWidgetId b2 = addWidget(t2, UiWidgetKind::Button, UiAnchor::TopLeft, + 0, 0, 100, 20, li2); + const UiWidgetId c2 = addWidget(t2, UiWidgetKind::Button, UiAnchor::TopLeft, + 0, 0, 100, 15, li2); + expectOk(t2.setScroll(li2, 7), "setScroll(7)"); + layoutTree(t2); + EXPECT_EQ(0, t2.node(li2)->scroll); + EXPECT_EQ(0, t2.node(a2)->rectY); + EXPECT_EQ(10, t2.node(b2)->rectY); + EXPECT_EQ(30, t2.node(c2)->rectY); +} + +TEST(UiTreeLayout, NegativeRectsAndSaturation) { + // A node extending outside the screen: the exact rect (the domain + // allows it — the render culls it, M2-UI-02). + UiTree t = makeDefaultTree(); // 400 x 300 + const UiWidgetId n = addWidget(t, UiWidgetKind::Image, UiAnchor::TopLeft, + -300, -200, 20, 10); + layoutTree(t); + EXPECT_EQ(-300, t.node(n)->rectX); + EXPECT_EQ(-200, t.node(n)->rectY); + // The saturation: a 2^20 x 2^20 screen, a zero-size child anchored + // BottomRight with x = 2^20, y = 2^20: both computed corners are + // 2^21 (one past the 2^21 - 1 bound) -> both saturate to the bound. + UiTree t2 = makeTree(16, 16, 1 << 20, 1 << 20); + const UiWidgetId s = addWidget(t2, UiWidgetKind::Button, UiAnchor::BottomRight, + 1 << 20, 1 << 20, 0, 0); + layoutTree(t2); + EXPECT_EQ((1 << 21) - 1, t2.node(s)->rectX); + EXPECT_EQ((1 << 21) - 1, t2.node(s)->rectY); +} + +TEST(UiTreeLayout, ZeroRectBeforeLayoutAndIdempotence) { + UiTree t = makeDefaultTree(); + const UiWidgetId b = addWidget(t, UiWidgetKind::Button, UiAnchor::TopLeft, + 10, 10, 20, 10); + // No layout yet: the rect is zero -> the node contains no pointer + // point. + UiPointerState ptr; + ptr.x = 15.0f; + ptr.y = 15.0f; + expectOk(t.handlePointer(ptr), "handlePointer"); + EXPECT_EQ(kInvalidUiWidgetId, t.pointerHit()); + EXPECT_TRUE(t.events().empty()); + EXPECT_EQ(0, t.node(b)->rectW); + // After layout: the rect is valid + the hit lands on the button. + expectOk(t.layout(), "layout"); + EXPECT_EQ(10, t.node(b)->rectX); + EXPECT_EQ(10, t.node(b)->rectY); + EXPECT_EQ(20, t.node(b)->rectW); + EXPECT_EQ(10, t.node(b)->rectH); + frame(t, 15.0f, 15.0f, false); + EXPECT_EQ(b, t.pointerHit()); + // Idempotence: a second layout leaves the rects unchanged. + const UiNode* before = t.node(b); + const std::int32_t bx = before->rectX; + const std::int32_t by = before->rectY; + expectOk(t.layout(), "layout"); + EXPECT_EQ(bx, t.node(b)->rectX); + EXPECT_EQ(by, t.node(b)->rectY); + // setPlacement zeros the rect until the next layout. + expectOk(t.setPlacement(b, UiAnchor::TopLeft, 20, 20, 30, 12), + "setPlacement"); + EXPECT_EQ(0, t.node(b)->rectW); + frame(t, 25.0f, 26.0f, false); + EXPECT_EQ(kInvalidUiWidgetId, t.pointerHit()); + expectOk(t.layout(), "layout"); + EXPECT_EQ(20, t.node(b)->rectX); + EXPECT_EQ(20, t.node(b)->rectY); + EXPECT_EQ(30, t.node(b)->rectW); + EXPECT_EQ(12, t.node(b)->rectH); +} + +// --------------------------------------------------------------------------- +// UiTreeCrud (continued) — the budget + the subtree removal + the LIFO +// recycling +// --------------------------------------------------------------------------- + +TEST(UiTreeCrud, WidgetBudgetAndWarn) { + MemorySink* sink = installCaptureSink(); + // Budget 3: the root + two user widgets; the third add fails. + UiTree t = makeTree(3, 16, 320, 200); + const UiWidgetId a = addWidget(t, UiWidgetKind::Button, UiAnchor::TopLeft, + 0, 0, 10, 10); + const UiWidgetId b = addWidget(t, UiWidgetKind::Button, UiAnchor::TopLeft, + 12, 0, 10, 10); + UiWidgetSpec c; + c.kind = UiWidgetKind::Button; + c.width = 10; + c.height = 10; + auto r = t.addChild(c); + EXPECT_EQ(ErrorCode::BudgetExhausted, r.error()); + EXPECT_EQ(3u, t.widgetCount()); // the root + the two: no growth + const MemorySink::Entry* e = firstEvent(*sink, "widget_budget"); + ASSERT_NE(nullptr, e); + EXPECT_EQ("ui_tree", e->subsystem); + EXPECT_EQ(laige::log::Severity::Warn, e->severity); + EXPECT_EQ("3", fieldOf(*e, "capacity")); + EXPECT_EQ(1ull, countEvents(*sink, "ui_tree", "widget_budget")); + (void)a; + (void)b; +} + +TEST(UiTreeCrud, SubtreeRemovalAndLifoRecycling) { + UiTree t = makeDefaultTree(); + const UiWidgetId root = t.root(); + // The tree: P -> {A -> {A1}, B} (P, A, A1, B are the subtree of P). + const UiWidgetId p = addWidget(t, UiWidgetKind::Panel, UiAnchor::TopLeft, + 0, 0, 100, 100); + const UiWidgetId a = addWidget(t, UiWidgetKind::Panel, UiAnchor::TopLeft, + 0, 0, 50, 50, p); + const UiWidgetId a1 = addWidget(t, UiWidgetKind::Button, UiAnchor::TopLeft, + 0, 0, 10, 10, a); + const UiWidgetId b = addWidget(t, UiWidgetKind::Button, UiAnchor::TopLeft, + 52, 0, 10, 10, p); + EXPECT_EQ(5u, t.widgetCount()); // the root + the four + expectOk(t.remove(p), "remove"); + // Every node of the subtree is stale (the generation bumps). + for (const UiWidgetId id : {p, a, a1, b}) { + EXPECT_FALSE(t.isValid(id)) << "stale id still valid"; + EXPECT_EQ(nullptr, t.node(id)); + } + EXPECT_EQ(1u, t.widgetCount()); // the root only + EXPECT_EQ(kInvalidUiWidgetId, t.node(root)->firstChild); + // The LIFO recycling: the post-order destruction order is A1, A, B, + // P -> the last freed slot (P's) is the next create's slot, with the + // generation bumped (the stale P handle is still invalid). + const UiWidgetId np = addWidget(t, UiWidgetKind::Button, UiAnchor::TopLeft, + 0, 0, 10, 10); + EXPECT_TRUE(t.isValid(np)); + EXPECT_EQ(p.index, np.index); + EXPECT_EQ(p.generation + 1, np.generation); + EXPECT_FALSE(t.isValid(p)); + EXPECT_EQ(2u, t.widgetCount()); + // Two more direct children under the root: the chain is + // np -> x -> y (np is the recycled slot from above). + const UiWidgetId x = addWidget(t, UiWidgetKind::Button, UiAnchor::TopLeft, + 0, 0, 10, 10); + const UiWidgetId y = addWidget(t, UiWidgetKind::Button, UiAnchor::TopLeft, + 12, 0, 10, 10); + EXPECT_EQ(np, t.node(root)->firstChild); + EXPECT_EQ(x, t.node(np)->nextSibling); + EXPECT_EQ(y, t.node(x)->nextSibling); + EXPECT_EQ(kInvalidUiWidgetId, t.node(y)->nextSibling); + // Removing the middle sibling splices it out of the chain. + expectOk(t.remove(x), "remove x"); + EXPECT_EQ(np, t.node(root)->firstChild); + EXPECT_EQ(y, t.node(np)->nextSibling); + EXPECT_EQ(kInvalidUiWidgetId, t.node(y)->nextSibling); +} + +// --------------------------------------------------------------------------- +// UiTreeFocus — the focusable kinds, the deterministic tab order, the +// pointer focus, the removal clears +// --------------------------------------------------------------------------- + +TEST(UiTreeFocus, FocusableKindsOnly) { + UiTree t = makeDefaultTree(); + const UiWidgetId p = addWidget(t, UiWidgetKind::Panel, UiAnchor::TopLeft, + 0, 0, 50, 50); + const UiWidgetId txt = addWidget(t, UiWidgetKind::Text, UiAnchor::TopLeft, + 52, 0, 50, 20); + const UiWidgetId im = addWidget(t, UiWidgetKind::Image, UiAnchor::TopLeft, + 52, 22, 10, 10); + const UiWidgetId li = addWidget(t, UiWidgetKind::List, UiAnchor::TopLeft, + 0, 52, 100, 50); + const UiWidgetId b = addWidget(t, UiWidgetKind::Button, UiAnchor::TopLeft, + 0, 0, 10, 10, p); + const UiWidgetId s = addSlider(t, 52, 22, 10, 10, 0.0f, 1.0f, 0.5f); + const UiWidgetId in = addWidget(t, UiWidgetKind::Input, UiAnchor::TopLeft, + 0, 0, 50, 20, li); + for (const UiWidgetId id : {p, txt, im, li}) { + EXPECT_EQ(ErrorCode::InvalidArgument, t.focusWidget(id).error()); + } + EXPECT_EQ(kInvalidUiWidgetId, t.focus()); + expectOk(t.focusWidget(b), "focusWidget(b)"); + EXPECT_EQ(b, t.focus()); + expectOk(t.focusWidget(s), "focusWidget(s)"); + EXPECT_EQ(s, t.focus()); + expectOk(t.focusWidget(in), "focusWidget(in)"); + EXPECT_EQ(in, t.focus()); + // A stale / invalid target fails. + EXPECT_EQ(ErrorCode::InvalidArgument, + t.focusWidget(kInvalidUiWidgetId).error()); + expectOk(t.remove(in), "remove(in)"); + EXPECT_EQ(ErrorCode::InvalidArgument, t.focusWidget(in).error()); +} + +TEST(UiTreeFocus, DeterministicTabOrder) { + // The tree (400 x 300): root -> {P, T, S, I}; P -> {B1, I1}. + // The preorder: root, P, B1, I1, T, S, I. + // The focusable sequence: B1, I1, S, I. + UiTree t = makeDefaultTree(); + const UiWidgetId p = addWidget(t, UiWidgetKind::Panel, UiAnchor::TopLeft, + 10, 10, 100, 50); + const UiWidgetId b1 = addWidget(t, UiWidgetKind::Button, UiAnchor::TopLeft, + 0, 0, 20, 10, p); + const UiWidgetId i1 = addWidget(t, UiWidgetKind::Input, UiAnchor::TopLeft, + 0, 12, 50, 20, p); + const UiWidgetId txt = addWidget(t, UiWidgetKind::Text, UiAnchor::TopLeft, + 120, 10, 50, 20); + const UiWidgetId s = addSlider(t, 120, 40, 50, 20, 0.0f, 1.0f, 0.5f); + const UiWidgetId in = addWidget(t, UiWidgetKind::Input, UiAnchor::TopLeft, + 180, 10, 50, 20); + (void)txt; + // focusNext from unfocused: the first focusable, then in order, + // wrapping from the last back to the first. + EXPECT_EQ(kInvalidUiWidgetId, t.focus()); + t.focusNext(); + EXPECT_EQ(b1, t.focus()); + t.focusNext(); + EXPECT_EQ(i1, t.focus()); + t.focusNext(); + EXPECT_EQ(s, t.focus()); + t.focusNext(); + EXPECT_EQ(in, t.focus()); + t.focusNext(); // the wrap + EXPECT_EQ(b1, t.focus()); + // focusPrev from the current (b1): wraps to the last (in), then back. + t.focusPrev(); + EXPECT_EQ(in, t.focus()); + t.focusPrev(); + EXPECT_EQ(s, t.focus()); + t.focusPrev(); + EXPECT_EQ(i1, t.focus()); + t.focusPrev(); + EXPECT_EQ(b1, t.focus()); + t.focusPrev(); // the wrap + EXPECT_EQ(in, t.focus()); + // focusPrev from unfocused: the last focusable (the removal of the + // focused input clears the focus first). + expectOk(t.focusWidget(in), "focusWidget(in)"); + expectOk(t.remove(in), "remove(in)"); // the removal clears the focus + EXPECT_EQ(kInvalidUiWidgetId, t.focus()); + t.focusPrev(); + EXPECT_EQ(s, t.focus()); +} + +TEST(UiTreeFocus, PointerPressTakesFocus) { + UiTree t = makeDefaultTree(); + const UiWidgetId b = addWidget(t, UiWidgetKind::Button, UiAnchor::TopLeft, + 10, 10, 50, 20); + // A non-focusable text (its handle is unused: the press targets its + // rect by coordinate). + addWidget(t, UiWidgetKind::Text, UiAnchor::TopLeft, 80, 10, 50, 20); + layoutTree(t); + // A press on the focusable button takes the focus. + frame(t, 15.0f, 15.0f, true); + EXPECT_EQ(b, t.focus()); + frame(t, 15.0f, 15.0f, false); + // A press on the non-focusable text leaves the focus unchanged. + frame(t, 85.0f, 15.0f, true); + EXPECT_EQ(b, t.focus()); + frame(t, 85.0f, 15.0f, false); + // A press on empty screen space leaves the focus unchanged. + frame(t, 300.0f, 250.0f, true); + EXPECT_EQ(b, t.focus()); + frame(t, 300.0f, 250.0f, false); + // The removal of the focused widget clears the focus. + expectOk(t.remove(b), "remove(b)"); + EXPECT_EQ(kInvalidUiWidgetId, t.focus()); +} + +// --------------------------------------------------------------------------- +// UiTreeEvents — the scripted pointer sequences +// --------------------------------------------------------------------------- + +namespace { + +// The shared event scene: B (10, 10) x 50 x 20, S (100, 10) x 100 x 20 +// (0..10), I (200, 10) x 50 x 20, P (0, 100) x 400 x 100. +struct EventScene { + UiTree t; + UiWidgetId b; + UiWidgetId s; + UiWidgetId i; + UiWidgetId p; +}; + +EventScene makeEventScene() { + EventScene sc; + sc.t = makeDefaultTree(); + sc.b = addWidget(sc.t, UiWidgetKind::Button, UiAnchor::TopLeft, 10, 10, 50, 20); + sc.s = addSlider(sc.t, 100, 10, 100, 20, 0.0f, 10.0f, 0.0f); + sc.i = addWidget(sc.t, UiWidgetKind::Input, UiAnchor::TopLeft, 200, 10, 50, 20); + sc.p = addWidget(sc.t, UiWidgetKind::Panel, UiAnchor::TopLeft, 0, 100, 400, + 100, kInvalidUiWidgetId, 0, true); + layoutTree(sc.t); + return sc; +} + +} // namespace + +TEST(UiTreeEvents, PointerSequence) { + EventScene sc = makeEventScene(); + UiTree& t = sc.t; + // f0: over empty screen space (the root is layout-only): no events. + frame(t, 0.0f, 0.0f, false); + expectEvents(t, {}); + EXPECT_EQ(kInvalidUiWidgetId, t.pointerHit()); + // f1: onto the button: enter. + frame(t, 15.0f, 15.0f, false); + expectEvents(t, {{UiEventKind::PointerEnter, sc.b}}); + EXPECT_EQ(sc.b, t.pointerHit()); + // f2: onto the slider: leave (the button) first, then enter. + frame(t, 150.0f, 15.0f, false); + expectEvents(t, {{UiEventKind::PointerLeave, sc.b}, + {UiEventKind::PointerEnter, sc.s}}); + EXPECT_EQ(sc.s, t.pointerHit()); + // f3: press on the slider: press + the value from x (t = 0.5 -> 5.0), + // and the focus. + frame(t, 150.0f, 15.0f, true); + expectEvents(t, {{UiEventKind::PointerPress, sc.s}, + {UiEventKind::SliderChanged, sc.s, 5.0f}}); + EXPECT_EQ(sc.s, t.focus()); + EXPECT_FLOAT_EQ(5.0f, t.node(sc.s)->sliderValue); + // f4: the drag frame (still pressed, inside the rect): the new + // value (t = 0.2 -> 2.0). + frame(t, 120.0f, 15.0f, true); + expectEvents(t, {{UiEventKind::SliderChanged, sc.s, 2.0f}}); + // f4b / f4c: drags toward the rect's edges (the clamped value is + // exact at the edges; in-rect frames emit no hover events). + frame(t, 100.0f, 15.0f, true); + expectEvents(t, {{UiEventKind::SliderChanged, sc.s, 0.0f}}); + frame(t, 199.0f, 15.0f, true); + expectEvents(t, {{UiEventKind::SliderChanged, sc.s, 9.9f}}); + // f5: release: the release only (no button press — it's a slider). + frame(t, 199.0f, 15.0f, false); + expectEvents(t, {{UiEventKind::PointerRelease, sc.s}}); + EXPECT_FLOAT_EQ(9.9f, t.node(sc.s)->sliderValue); + // f6: back to the button: leave (the slider) first, then enter. + frame(t, 15.0f, 15.0f, false); + expectEvents(t, {{UiEventKind::PointerLeave, sc.s}, + {UiEventKind::PointerEnter, sc.b}}); + // f7: press on the button: press + the focus. + frame(t, 15.0f, 15.0f, true); + expectEvents(t, {{UiEventKind::PointerPress, sc.b}}); + EXPECT_EQ(sc.b, t.focus()); + // f8: release on the same button: the release + the button click. + frame(t, 15.0f, 15.0f, false); + expectEvents(t, {{UiEventKind::PointerRelease, sc.b}, + {UiEventKind::ButtonPressed, sc.b}}); + // f9: press again, then f10: move off the button WHILE pressed + // (the leave / enter, no press event — the press is in progress). + frame(t, 15.0f, 15.0f, true); + expectEvents(t, {{UiEventKind::PointerPress, sc.b}}); + frame(t, 150.0f, 15.0f, true); + expectEvents(t, {{UiEventKind::PointerLeave, sc.b}, + {UiEventKind::PointerEnter, sc.s}}); + // f11: release off the button: the release only (no button click — + // the press started on the button, the release on the slider). + frame(t, 150.0f, 15.0f, false); + expectEvents(t, {{UiEventKind::PointerRelease, sc.s}}); + // f12: onto the panel: the enter (the panel is the topmost there — + // it occludes nothing behind it in this scene, but it takes the + // events of that rect). + frame(t, 350.0f, 150.0f, false); + expectEvents(t, {{UiEventKind::PointerLeave, sc.s}, + {UiEventKind::PointerEnter, sc.p}}); + EXPECT_EQ(sc.p, t.pointerHit()); + // f13: a press on the non-focusable panel: the press, no focus. + frame(t, 350.0f, 150.0f, true); + expectEvents(t, {{UiEventKind::PointerPress, sc.p}}); + EXPECT_EQ(sc.b, t.focus()); + frame(t, 350.0f, 150.0f, false); + expectEvents(t, {{UiEventKind::PointerRelease, sc.p}}); +} + +TEST(UiTreeEvents, ZeroSizeWidgetsContainNothing) { + UiTree t = makeDefaultTree(); + const UiWidgetId zb = addWidget(t, UiWidgetKind::Button, UiAnchor::TopLeft, + 10, 10, 0, 10); + const UiWidgetId zs = addSlider(t, 100, 10, 0, 20, 0.0f, 1.0f, 0.5f); + layoutTree(t); + // A zero-width rect is empty (half-open): no hit, no press, no + // slider input (no division by the zero width). + frame(t, 10.5f, 15.0f, true); + EXPECT_EQ(kInvalidUiWidgetId, t.pointerHit()); + expectEvents(t, {}); + EXPECT_FLOAT_EQ(0.5f, t.node(zs)->sliderValue); + frame(t, 10.5f, 15.0f, false); + expectEvents(t, {}); + (void)zb; +} + +TEST(UiTreeEvents, InputTextEvents) { + EventScene sc = makeEventScene(); + UiTree& t = sc.t; + // beginFrame: the pointerless frame boundary (no pointer this + // frame; the input events do not stack onto the previous list). + frame(t, 0.0f, 0.0f, false); + t.beginFrame(); + // A new text: one InputText event (the new handle). + expectOk(t.setInputText(sc.i, laige::render::StringRef{1}), + "setInputText"); + expectEvents(t, {{UiEventKind::InputText, sc.i, 0.0f, 1, true}}); + // The same handle again: a no-op (no event). + expectOk(t.setInputText(sc.i, laige::render::StringRef{1}), + "setInputText"); + expectEvents(t, {{UiEventKind::InputText, sc.i, 0.0f, 1, true}}); + // A different handle: the second event. + expectOk(t.setInputText(sc.i, laige::render::StringRef{2}), "setInputText"); + expectEvents(t, {{UiEventKind::InputText, sc.i, 0.0f, 1, true}, + {UiEventKind::InputText, sc.i, 0.0f, 2, true}}); + EXPECT_EQ(2u, t.node(sc.i)->text.id); + // The failure paths: a non-input kind, a stale id, the stopped tree. + EXPECT_EQ(ErrorCode::InvalidArgument, + t.setInputText(sc.b, laige::render::StringRef{3}).error()); + expectOk(t.remove(sc.i), "remove(sc.i)"); + EXPECT_EQ(ErrorCode::InvalidArgument, + t.setInputText(sc.i, laige::render::StringRef{3}).error()); + UiTree stopped; + EXPECT_EQ(ErrorCode::InvalidArgument, + stopped.setInputText(kInvalidUiWidgetId, + laige::render::StringRef{1}).error()); +} + +TEST(UiTreeEvents, EventOverflowDropsAndWarns) { + MemorySink* sink = installCaptureSink(); + // maxEvents 1: each frame keeps its first event; the rest are + // dropped (the cumulative count + one warn per dropped event). + UiTree t = makeTree(16, 1, 400, 300); + const UiWidgetId b = addWidget(t, UiWidgetKind::Button, UiAnchor::TopLeft, + 10, 10, 50, 20); + const UiWidgetId s = addSlider(t, 100, 10, 100, 20, 0.0f, 10.0f, 0.0f); + layoutTree(t); + // f1: enter (fits). + frame(t, 15.0f, 15.0f, false); + expectEvents(t, {{UiEventKind::PointerEnter, b}}, 0); + // f2: leave + enter: the leave fits, the enter is dropped. + frame(t, 150.0f, 15.0f, false); + expectEvents(t, {{UiEventKind::PointerLeave, b}}, 1); + // f3: press + slider change: the press fits, the change is dropped. + frame(t, 150.0f, 15.0f, true); + expectEvents(t, {{UiEventKind::PointerPress, s}}, 2); + EXPECT_EQ(2ull, t.droppedEventsTotal()); + EXPECT_EQ(2ull, countEvents(*sink, "ui_tree", "event_overflow")); + const MemorySink::Entry* e = firstEvent(*sink, "event_overflow"); + ASSERT_NE(nullptr, e); + EXPECT_EQ("ui_tree", e->subsystem); + EXPECT_EQ(laige::log::Severity::Warn, e->severity); + EXPECT_EQ("1", fieldOf(*e, "maxEvents")); +} + +// --------------------------------------------------------------------------- +// UiTreeCrud (final) — the setters +// --------------------------------------------------------------------------- + +TEST(UiTreeCrud, Setters) { + UiTree t = makeDefaultTree(); + const UiWidgetId b = addWidget(t, UiWidgetKind::Button, UiAnchor::TopLeft, + 0, 0, 10, 10); + const UiWidgetId s = addSlider(t, 0, 0, 100, 20, 0.0f, 10.0f, 5.0f); + const UiWidgetId li = addWidget(t, UiWidgetKind::List, UiAnchor::TopLeft, + 0, 0, 50, 50); + const UiWidgetId in = addWidget(t, UiWidgetKind::Input, UiAnchor::TopLeft, + 0, 0, 50, 20); + // setSliderValue: the clamp (100 -> 10; NaN -> 0), no event. + // (The pointer resets the frame over empty screen space — the + // widgets all occupy the origin corner.) + layoutTree(t); + expectOk(t.handlePointer(UiPointerState{200.0f, 200.0f, false}), + "handlePointer"); + expectOk(t.setSliderValue(s, 100.0f), "setSliderValue(100)"); + EXPECT_FLOAT_EQ(10.0f, t.node(s)->sliderValue); + expectOk(t.setSliderValue(s, std::nanf("")), "setSliderValue(NaN)"); + EXPECT_FLOAT_EQ(0.0f, t.node(s)->sliderValue); + expectOk(t.setSliderValue(s, 2.5f), "setSliderValue(2.5)"); + EXPECT_FLOAT_EQ(2.5f, t.node(s)->sliderValue); + EXPECT_TRUE(t.events().empty()); // the programmatic set: no event + // The kind / id failures. + EXPECT_EQ(ErrorCode::InvalidArgument, t.setSliderValue(b, 1.0f).error()); + EXPECT_EQ(ErrorCode::InvalidArgument, + t.setSliderValue(kInvalidUiWidgetId, 1.0f).error()); + // setScroll: the domain + the kind (layout normalizes the range). + expectOk(t.setScroll(li, 5), "setScroll(5)"); + EXPECT_EQ(5, t.node(li)->scroll); + EXPECT_EQ(ErrorCode::InvalidArgument, t.setScroll(li, -1).error()); + EXPECT_EQ(ErrorCode::InvalidArgument, + t.setScroll(li, (1 << 20) + 1).error()); + EXPECT_EQ(ErrorCode::InvalidArgument, t.setScroll(b, 1).error()); + EXPECT_EQ(ErrorCode::InvalidArgument, + t.setScroll(kInvalidUiWidgetId, 1).error()); + // setPlacement: the domain + the kind-independent geometry. + expectOk(t.setPlacement(b, UiAnchor::Center, 1, -2, 20, 12), + "setPlacement ok"); + EXPECT_EQ(UiAnchor::Center, t.node(b)->anchor); + EXPECT_EQ(1, t.node(b)->x); + EXPECT_EQ(-2, t.node(b)->y); + EXPECT_EQ(20, t.node(b)->width); + EXPECT_EQ(12, t.node(b)->height); + EXPECT_EQ(ErrorCode::InvalidArgument, + t.setPlacement(b, UiAnchor::Center, 0, 0, -1, 1).error()); + EXPECT_EQ(ErrorCode::InvalidArgument, + t.setPlacement(b, UiAnchor::Center, 0, 0, 1, (1 << 20) + 1).error()); + EXPECT_EQ(ErrorCode::InvalidArgument, + t.setPlacement(b, UiAnchor::Center, -(1 << 20) - 1, 0, 1, 1).error()); + EXPECT_EQ(ErrorCode::InvalidArgument, + t.setPlacement(kInvalidUiWidgetId, UiAnchor::Center, 0, 0, 1, 1).error()); + (void)in; +} + +// --------------------------------------------------------------------------- +// UiTreeZeroAlloc — the per-frame loop allocates nothing (PERF-003) +// --------------------------------------------------------------------------- + +TEST(UiTreeZeroAlloc, FrameLoopAllocatesNothing) { + constexpr std::uint32_t kFrames = 1000; + EventScene sc = makeEventScene(); + UiTree& t = sc.t; + const std::vector refs = {1, 2, 3}; + auto runFrames = [&]() { + for (std::uint32_t f = 0; f < kFrames; ++f) { + if (f % 32 == 0) { + // A re-placement (the rect invalidates + the layout recomputes). + if (t.setPlacement(sc.b, UiAnchor::TopLeft, 10, 10, 50, 20).isError()) { + std::fprintf(stderr, "UiTreeZeroAlloc: setPlacement failed\n"); + std::abort(); + } + } + if (t.layout().isError()) { + std::fprintf(stderr, "UiTreeZeroAlloc: layout failed\n"); + std::abort(); + } + // The pointer: a deterministic sweep across the widget row, a + // press cycle every 8 frames (the click + the slider drag). + UiPointerState ptr; + ptr.x = 50.0f + static_cast(f % 300); + ptr.y = 15.0f; + ptr.pressed = (f % 8) < 2; + if (t.handlePointer(ptr).isError()) { + std::fprintf(stderr, "UiTreeZeroAlloc: handlePointer failed\n"); + std::abort(); + } + if (f % 16 == 0) { + laige::render::StringRef ref; + ref.id = refs[(f / 16) % 3]; + if (t.setInputText(sc.i, ref).isError()) { + std::fprintf(stderr, "UiTreeZeroAlloc: setInputText failed\n"); + std::abort(); + } + } + } + }; + if (laige::allocWatchLive()) { + laige::allocWatchArm(); + runFrames(); + const laige::AllocWatchReading reading = laige::allocWatchRead(); + EXPECT_EQ(0ull, reading.allocs) + << kFrames << " frames of layout + pointer + input allocated " + << reading.allocs << " heap blocks on the loop thread"; + std::printf("ui-tree-zeroalloc frames=%u allocs=%llu\n", kFrames, + static_cast(reading.allocs)); + } else { + runFrames(); // sanitizer tree: the leak-free run covers it + } +} From 97aabb3cb09e4e239f5d9da5e6070e9e969ee9bb Mon Sep 17 00:00:00 2001 From: Pascal Severin Date: Sun, 11 Oct 2026 15:11:02 +0200 Subject: [PATCH 2/2] Raise the macOS CI job timeout from 10 to 15 minutes The shared GitHub macOS runners' tail latency exceeds the 10-min build budget: the M2-UI-01 PR lane (run 38138278150) saw three consecutive 10-min cancellations of the macOS arm64 job (11m25s in Build, 10m18s in Test, 10m32s in Build) while the identical build + test finished in 5m37s on the macOS Intel lane. Raise the two macOS build jobs in both lanes (PR + merge) to timeout-minutes: 15; the macOS exception is documented in ci.yml's build-budget header. Nothing else changes (no engine, test, or budget-gate impact). --- .github/workflows/ci-pull.yml | 16 +++++++++++----- .github/workflows/ci.yml | 21 +++++++++++++++++++-- 2 files changed, 30 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci-pull.yml b/.github/workflows/ci-pull.yml index 4ed5746..bf8228a 100644 --- a/.github/workflows/ci-pull.yml +++ b/.github/workflows/ci-pull.yml @@ -28,9 +28,11 @@ # longer a selector (Linux is verified locally); a PR carrying it gets # the no-label treatment. # -# Jobs, runner images, steps, and the 10-minute budget timeout are the -# same as in ci.yml (see its header for the full matrix documentation -# and the Windows multi-config note). +# Jobs, runner images, steps, and the build-budget timeouts are the +# same as in ci.yml — including the macOS exception (the two macOS +# jobs carry timeout-minutes: 15, the shared-runners tail latency), +# documented in ci.yml's header (the full matrix documentation and +# the Windows multi-config note). # # Sanitizer lanes (M0-CI-02): the linux-asan (LAIGE_ASAN=ON) and # linux-tsan (LAIGE_TSAN=ON) lanes no longer run on PRs (no Linux jobs @@ -116,7 +118,9 @@ jobs: name: macOS arm64 (AppleClang) if: contains(github.event.pull_request.labels.*.name, 'ci:macos') runs-on: macos-15 - timeout-minutes: 10 + # The shared macOS runners' tail latency exceeds the 10-min build + # budget (the macOS exception, documented in ci.yml's header). + timeout-minutes: 15 steps: - uses: actions/checkout@v4 - name: Configure @@ -130,7 +134,9 @@ jobs: name: macOS Intel (AppleClang) if: contains(github.event.pull_request.labels.*.name, 'ci:macos') runs-on: macos-14 - timeout-minutes: 10 + # The shared macOS runners' tail latency exceeds the 10-min build + # budget (the macOS exception, documented in ci.yml's header). + timeout-minutes: 15 steps: - uses: actions/checkout@v4 - name: Configure diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dd83bb9..35eddc2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -84,6 +84,19 @@ # include-lint job is a lint job, not a build, and carries its own # timeout-minutes: 5. # +# macOS exception: the two macOS build jobs carry timeout-minutes: 15 +# instead. The shared GitHub macOS runners' tail latency (runner +# preemption) exceeds the 10-min budget: the M2-UI-01 PR lane (run +# 38138278150) saw three consecutive 10-min cancellations of the +# macOS arm64 job (11m25s in Build, 10m18s in Test, 10m32s in Build) +# while the identical build + test finished in 5m37s on the macOS +# Intel lane. The 15-min cap gives the shared runners headroom without +# hiding a real hang (a job that runs 15 min is still well past the +# 10-min budget and gets investigated). Raised on user decision +# 2026-10-11; if macOS lanes keep hitting the 15-min wall, the +# evidence is persistent runner degradation, not a code regression — +# revisit the budget, never silently lower the cap back. +# # Windows note: CMake's default generator there is the multi-config # "Visual Studio 17 2022" generator, which ignores CMAKE_BUILD_TYPE and # would build every configuration from the canonical `cmake --build build @@ -342,7 +355,9 @@ jobs: macos-arm64: name: macOS arm64 (AppleClang) runs-on: macos-15 - timeout-minutes: 10 + # The shared macOS runners' tail latency exceeds the 10-min build + # budget (the macOS exception, documented in this file's header). + timeout-minutes: 15 steps: - uses: actions/checkout@v4 - name: Configure @@ -355,7 +370,9 @@ jobs: macos-intel: name: macOS Intel (AppleClang) runs-on: macos-14 - timeout-minutes: 10 + # The shared macOS runners' tail latency exceeds the 10-min build + # budget (the macOS exception, documented in this file's header). + timeout-minutes: 15 steps: - uses: actions/checkout@v4 - name: Configure