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.