Skip to content

[M2-UI-01] UI widget tree (retained, screen-space) - #90

Merged
offdev merged 2 commits into
masterfrom
feat/m2-ui-01-ui-widget-tree
Oct 11, 2026
Merged

offdev merged 2 commits into
masterfrom
feat/m2-ui-01-ui-widget-tree

Conversation

@offdev

@offdev offdev commented Oct 11, 2026

Copy link
Copy Markdown
Owner

M2-UI-01 — UI widget tree (retained, screen-space)

The retained-data half of the UI work: laige::render::UiTree (header-only, no GL, no new dependency) — the pool-backed retained tree of the ADR 0006 widget set (panel, button, text, image, list, slider, input — exactly the M2-DEC-02 list), 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; the deterministic preorder tab order), and the per-frame bounded pointer-event list (the ADR 0006 events; the first-maxEvents overflow policy). pointerHit() is the FR-4.4 UI-first routing hook. No rendering (M2-UI-02), no sim coupling (ARCH-009).

  • API contract: docs/api/ui_widgets.md (new; incl. the Performance section)
  • Verify: ctest -R ui_tree — 24 tests / 6 suites (green in all six canonical trees)
  • No budgets.json entry (composite 50k render-CPU budget, M2-PERF-01); laige-api.json regenerated last (api-real-tree green)

- 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).
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).
@offdev
offdev merged commit e9a93f1 into master Oct 11, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant