git.lucas.co / cce-ui
GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git

docs/rfc-owning-registry.md (10.2K)

  1 # RFC: a registry that owns its widgets
  2 
  3 Status: done (opened and closed 2026-10-08). Each phase below carries its DONE note.
  4 
  5 ## Why
  6 
  7 `UiContext`'s tree does not own its widgets. The app owns them (struct fields, `Vec`s of
  8 `Owned<Adapted<X>>`) and registers raw pointers; the context dispatches through those pointers,
  9 and every accessor checks a liveness token before it resolves one. Since 2026-10-08 that is
 10 sound and checked by Miri (`widget::owned`), but one rule is left to discipline rather than the
 11 compiler: **a `&mut` reached through the registry must not overlap one taken through the
 12 `Owned`.** An app that holds `&mut self.x` across a `UiContext` call that reaches `x`, or a
 13 widget that reaches itself through the context while the context is dispatching into it, aliases
 14 two `&mut`s to one widget.
 15 
 16 The end state that makes the compiler enforce it: the context OWNS every registered widget and
 17 lends it out. The app keeps a typed handle; to touch a widget it borrows the context
 18 (`ui.get_mut(h)`), so the borrow checker refuses any overlap with another context call; and the
 19 context, while it is inside a widget, has that widget out on loan, so nothing — not the widget
 20 itself, reaching back through the context — can reach it a second time.
 21 
 22 ## Design
 23 
 24 - **`Handle<W>`** — `Copy`, typed, a `WidgetId` and a marker. Stable for the widget's life;
 25   a removed widget's handle resolves to `None`.
 26 - **Owned slots.** `UiContext::insert(w) -> Handle<W>` moves the widget into a heap allocation
 27   the tree keeps (the same raw-root shape `Owned` uses, never a `Box` held across accesses), with
 28   the allocation's `TypeId` for typed access and its liveness token. `get(h)` / `get_mut(h)` hand
 29   out `&W` / `&mut W` borrowed from the context; `remove(h)` gives the widget back by value;
 30   dropping the context drops every widget it owns.
 31 - **Lending.** Every call the context makes INTO a widget — events, ticks, focus changes,
 32   `mark_dirty`, popover and context-menu actions — goes through `UiContext::lend(id, |w, ctx|
 33   ..)`, which marks the slot lent for the call. While lent, nothing in the context resolves it
 34   (`get_ptr`, `children_ptrs`, `iter_registered`, `get_widget[_mut]`, `get(h)`, `lend` itself
 35   all answer `None`/skip it), so a re-entrant reach is a visible miss, never an alias. Lending
 36   applies to the legacy pointer entries too, which closes the self-reach hole for apps that have
 37   not moved yet.
 38 - **Hosts that place and paint a widget** take a handle: `render_widget_h(pc, h, rect, ctx)`,
 39   `Form::widget_h`, `set_focused_h`, `link_handles`… — each lends the widget for its call.
 40 - **Embedded children** (a tree list's search box, a paginator's menu): the parent holds handles
 41   and reaches its children through the context it is handed (`EventCtx`, `paint_ui`'s `&UiContext`).
 42   The parent is out on loan while it runs, its children are not.
 43 
 44 ## Phases
 45 
 46 1. **Owned slots, handles and lending in the toolkit.** `Handle`, `insert` / `get` / `get_mut` /
 47    `remove` / `lend`; the lent flag in the tree and every resolver honouring it; the context's
 48    own calls into widgets routed through `lend`. Both kinds of entry coexist: an app migrates
 49    field by field. Tests, Miri included.
 50    **DONE (2026-10-08).** `widget::Handle`; `UiContext::{insert, get, get_mut, remove, lend,
 51    lend_h, register_popover_id}` and `ctx[h]`; `WidgetTree::{insert_owned, owned_root,
 52    take_owned, set_lent}` (owned slots keep the raw-root shape `Owned` uses; `clear_all`
 53    keeps them). Every call the context makes into a widget that hands it the context —
 54    routed events, drag start / update / end, grabs, keys to the focused widget, ticks,
 55    FocusIn / FocusOut, the focused widget's context action, the popover-close sweep — goes
 56    through `lend`; dispatch works by id (`propagate_event_impl`, `find_hovered_scrollable`).
 57    Lending covers the pointer entries too, so a widget that reached itself through the
 58    context mid-event now misses instead of aliasing. Host helpers: `render_widget_h`,
 59    `Form::widget_h`. Tests in `widget::handle` (CI's Miri job runs them beside
 60    `widget::owned`); the 604 existing tests pass unchanged.
 61 2. **The demo app** (`src/main.rs`) on handles — the reference every client copies.
 62    **DONE (2026-10-08).** Twelve widgets inserted in `create`; `register_roots` and the
 63    first-frame registration went. A shadow session driven through a click, the toggle,
 64    typing, the theme dropdown and the Options dialog (open, pick, OK; and Escape) is
 65    identical to the pixel to the pointer-registry build at every step.
 66 3. **Every app**, one crate at a time, by widget count: the smallest first.
 67    **DONE (2026-10-08).** cce-text-editor, cce-list, cce-notes, cce-weather,
 68    cce-authenticator, cce-color-editor, cce-cloud, cce-designer, cce-graph,
 69    cce-display-manager, cce-fonts, cce-files, cce-secrets, cce-mail, cce-data-editor,
 70    cce-relief, cce-gallery and cce-system-interface hold `Handle`s; each was driven in a
 71    shadow session against its pointer-registry build and matched to the pixel, but for live
 72    data and one explained case (cce-fonts' preview: the runner now shapes it before the
 73    app does, with the font system the glyph pass draws with). Toolkit additions on the
 74    way: `focus_id` / `unfocus_id`, `Group::widget_w_h`, `Handle::none` (and `Default`) for
 75    values built off-thread, and `insert` registering a tick receiver as `register_widget`
 76    did — missing, an inserted tree list never filtered. The move also retired a real bug:
 77    cce-files' prompt focused a stack-local `TextBox` by pointer and then moved it into its
 78    box, and opening a second prompt corrupted the heap ("double free or corruption"); it
 79    now inserts first and focuses by id. Left on the pointer path: the toolkit's embedded
 80    children (the designer dialog's dropdown and colour selectors, a ramp's preset
 81    dropdown, a tree list's fields), which are phase 4, and widgets tests build on the stack.
 82 4. **The toolkit's embedded children** on handles.
 83    **DONE (2026-10-08).** `widget::Embedded<W>`: a child a composite holds by value
 84    until the composite is inserted, when `UiContext::insert` calls the new
 85    `WidgetHost::attach_embedded` (the adapter's `Layout::register_embedded_children`) and the
 86    child moves into the context under its own id; `UiContext::remove` calls
 87    `release_embedded` first, so a composite leaves with its children in it. A composite's
 88    `set_rect` has no context, so it keeps the rect it was given and places its context-held
 89    children in `register_embedded_children`, which runs on insert, every layout and every
 90    tick. Done: **Paginator** (its strip, linked as before; the page is pushed down when
 91    `set_selected_page` asks and otherwise taken from the strip, since the router reaches the
 92    linked strip before the paginator — a per-tick push undid a tab clicked on the strip, which
 93    the gallery's A/B caught; its `container_children` raw-pointer channel is gone) and
 94    **TreeList** (search box, add-key button and popover box, the rename editor while a rename
 95    is up; the search query is kept by the tree for `rebuild_tree`, the field geometry is one
 96    pure function, and the fields paint through `paint_ui`). Shadow A/B of the data editor and
 97    the gallery against the build before: identical. **Ramp** never registers its fields at
 98    all: the focus record names the field that has the keyboard (`focus_field` claims its id),
 99    the ramp routes keys to it and walks them on Tab, and its tick unfocuses a field the record
100    no longer names — the pointer registration it made while a field was focused is gone, and
101    ColorRamp already worked so. A closed dropdown takes Enter only when the record names it,
102    which is why the record names the field rather than the ramp. **The designer dialog's
103    dropdown** is an `Embedded` the dialog attaches when it is inserted; the host opens it by
104    id (`claim_focus`, which tells the dropdown nothing, so its trigger wears no focus ring as
105    before) and reaches it with `lend_h`. Its colour selectors were paint stamps held in
106    `Owned` boxes and never registered; they are bare `Adapted`s. Shadow A/B of cce-ramp, the
107    gallery's Ramp child (focus, Tab, Enter, a pick, a click on the line) and the designer
108    dialog (open, pick by pointer and by keyboard, Escape): identical. Nothing in the toolkit
109    or the apps registers a widget by pointer any more but tests that build widgets on the stack.
110 5. **Delete the pointer path**: `Owned`, `register_host` / `register_widget` / `set_focused_ptr`
111    and the other `unsafe fn`s, `Liveness`, `stable_target`. The tree holds owned slots only.
112    **DONE (2026-10-08).** Gone: `Owned`, `register_host`, `register_embedded`,
113    `register_widget`, `unregister_widget`, `set_focused` / `set_focused_ptr`, `focus_widget` /
114    `unfocus_widget` (use `focus_id` / `unfocus_id`), `register_popover` (`register_popover_id`),
115    `link_parent_child`, `WidgetTree::register`, `Liveness` and `Widget::live`,
116    `WidgetHost::stable_target`, and the container-children raw-pointer channel
117    (`Layout::container_children` / `child_visible`, `Input::hits_through_children`,
118    `WidgetHost::is_child_visible`), which nothing implemented any more. `show_context_menu(_rows)`
119    take the target's id; `handle_right_click` takes the widget by reference, and a widget asking
120    for the menu mid-event (`EventCtx::open_context_menu`) has it opened by the adapter once it is
121    done, so `EventCtx` carries no pointer to its host. `Adapted::set_parent` takes an id;
122    `Layout::arrange_children` no host pointer; `render_widget` no longer registers. The tree
123    holds widgets only (`Entry { id, slot, lent }`), and its pointer accessors are crate-private;
124    apps that used them (the data editor, mail, graph and system interface's popover paint, the
125    gallery's parent check, the designer's child walk) use `get_widget`, `widgets()`,
126    `parent_id` and `child_ids`. `take_owned` leaves a removed widget's children as roots where
127    it dropped its subtree (which dropped a linked child the context owned with its parent).
128    Every test that registered a stack widget inserts it instead. Shadow A/B
129    against the phase-4 build: the data editor's tree-list, search-box and editor menus (open,
130    Collapse, Select All), cce-files' breadcrumb menu, the gallery's text-box and breadcrumb
131    right-clicks, the designer's views and its dialog dropdown — identical to the pixel.