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

commitef2d668722214bfd5d6ec60580b087a38c52031a
parent6bd1571a7d
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-08 19:40
feat(context): the registry can own its widgets and lend them out by handle

docs/rfc-owning-registry.md, phases 1 and 2. `ctx.insert(w)` moves a widget
into the context and returns a typed, Copy `Handle<W>`; the app reaches it
through the context (`ctx[h]`, `get` / `get_mut`, `lend_h` for widget and
context together), so an app access that overlaps a context call is a borrow
error. `remove(h)` gives it back; dropping the context drops the rest;
`clear_hierarchy` keeps them. The tree keeps owned slots in the raw-root
shape `Owned` uses (`insert_owned`, `owned_root`, `take_owned`).

Every call the context makes into a widget that hands it the context --
routed events, drag start / update / end, grabs, keys to the focused widget,
ticks, FocusIn / FocusOut, the focused widget's context action, the popover
close sweep -- now goes through `lend`, which takes the widget out of reach
for the call: a widget reaching itself through the context mid-event gets
None, for owned and pointer entries alike. Dispatch works by id.

Helpers for handle holders: render_widget_h, Form::widget_h,
register_popover_id. The demo app is on handles. Tests in widget::handle,
added to CI's Miri job; the 604 existing tests pass unchanged, and a shadow
session through the demo's click, toggle, typing, dropdown and dialog is
identical to the pixel to the pointer-registry build.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

 .github/workflows/ci.yml    |   4 +-
 CLAUDE.md                   |  13 ++
 docs/rfc-owning-registry.md |  69 ++++++
 src/context.rs              | 533 ++++++++++++++++++++++++--------------------
 src/layout/bridge.rs        |  14 ++
 src/layout/form.rs          |  18 ++
 src/main.rs                 | 279 +++++++++++------------
 src/scene/tree.rs           | 132 ++++++++++-
 src/widget/handle.rs        | 174 +++++++++++++++
 src/widget/mod.rs           |   2 +
 10 files changed, 845 insertions(+), 393 deletions(-)

diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 820b0b1..887a574 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -116,9 +116,9 @@ jobs:
       - uses: Swatinem/rust-cache@v2
       - run: cargo miri setup
       - name: Stacked Borrows
-        run: cargo miri test --lib widget::owned
+        run: cargo miri test --lib -- widget::owned widget::handle
       - name: Tree Borrows
-        run: cargo miri test --lib widget::owned
+        run: cargo miri test --lib -- widget::owned widget::handle
         env:
           MIRIFLAGS: -Zmiri-disable-isolation -Zmiri-tree-borrows
 
diff --git a/CLAUDE.md b/CLAUDE.md
index 7433a0f..453047a 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -1811,6 +1811,19 @@ reaches `x`, and a `UiContext` call handed the widget uses what it was handed. T
 that makes even that the compiler's job is a registry that owns its widgets and lends them out
 by handle; it would touch every widget access in every app.
 
+**The registry can own its widgets** (since 2026-10-08, `docs/rfc-owning-registry.md`, the
+end state the rule above points at). `ctx.insert(w)` moves a widget into the context and
+returns a `Handle<W>` (`Copy`, typed); the app reaches it through the context —
+`ctx[h]`, `ctx.get(h)` / `get_mut(h)`, `ctx.lend_h(h, |w, ctx| ..)` when it needs the
+widget and the context together (`Dialog::open`, `fit`) — so the borrow checker refuses an
+app access that overlaps a context call. `ctx.remove(h)` gives it back by value; dropping
+the context drops the rest; `clear_hierarchy` keeps them. And every call the context makes
+into a widget that hands it the context goes through `lend`, which takes the widget out of
+reach for the call: a widget reaching itself through the context mid-event gets `None`,
+for owned and pointer entries alike. The demo app is on handles; the other apps move one at
+a time, then `Owned` and the pointer API go (the RFC's phases). New code uses handles:
+`render_widget_h`, `Form::widget_h`, `register_popover_id`, `paint_root_into(ctx, &ctx[h], pc)`.
+
 `a_dropped_widget_is_never_handed_out`, `a_clone_has_a_liveness_of_its_own`,
 `an_owned_widget_survives_its_vec_reallocating`,
 `swapping_the_widget_out_of_its_box_never_leaves_a_dangling_entry`,
diff --git a/docs/rfc-owning-registry.md b/docs/rfc-owning-registry.md
new file mode 100644
index 0000000..51d2354
--- /dev/null
+++ b/docs/rfc-owning-registry.md
@@ -0,0 +1,69 @@
+# RFC: a registry that owns its widgets
+
+Status: in progress (opened 2026-10-08). Phases below carry DONE notes as they land.
+
+## Why
+
+`UiContext`'s tree does not own its widgets. The app owns them (struct fields, `Vec`s of
+`Owned<Adapted<X>>`) and registers raw pointers; the context dispatches through those pointers,
+and every accessor checks a liveness token before it resolves one. Since 2026-10-08 that is
+sound and checked by Miri (`widget::owned`), but one rule is left to discipline rather than the
+compiler: **a `&mut` reached through the registry must not overlap one taken through the
+`Owned`.** An app that holds `&mut self.x` across a `UiContext` call that reaches `x`, or a
+widget that reaches itself through the context while the context is dispatching into it, aliases
+two `&mut`s to one widget.
+
+The end state that makes the compiler enforce it: the context OWNS every registered widget and
+lends it out. The app keeps a typed handle; to touch a widget it borrows the context
+(`ui.get_mut(h)`), so the borrow checker refuses any overlap with another context call; and the
+context, while it is inside a widget, has that widget out on loan, so nothing — not the widget
+itself, reaching back through the context — can reach it a second time.
+
+## Design
+
+- **`Handle<W>`** — `Copy`, typed, a `WidgetId` and a marker. Stable for the widget's life;
+  a removed widget's handle resolves to `None`.
+- **Owned slots.** `UiContext::insert(w) -> Handle<W>` moves the widget into a heap allocation
+  the tree keeps (the same raw-root shape `Owned` uses, never a `Box` held across accesses), with
+  the allocation's `TypeId` for typed access and its liveness token. `get(h)` / `get_mut(h)` hand
+  out `&W` / `&mut W` borrowed from the context; `remove(h)` gives the widget back by value;
+  dropping the context drops every widget it owns.
+- **Lending.** Every call the context makes INTO a widget — events, ticks, focus changes,
+  `mark_dirty`, popover and context-menu actions — goes through `UiContext::lend(id, |w, ctx|
+  ..)`, which marks the slot lent for the call. While lent, nothing in the context resolves it
+  (`get_ptr`, `children_ptrs`, `iter_registered`, `get_widget[_mut]`, `get(h)`, `lend` itself
+  all answer `None`/skip it), so a re-entrant reach is a visible miss, never an alias. Lending
+  applies to the legacy pointer entries too, which closes the self-reach hole for apps that have
+  not moved yet.
+- **Hosts that place and paint a widget** take a handle: `render_widget_h(pc, h, rect, ctx)`,
+  `Form::widget_h`, `set_focused_h`, `link_handles`… — each lends the widget for its call.
+- **Embedded children** (a tree list's search box, a paginator's menu): the parent holds handles
+  and reaches its children through the context it is handed (`EventCtx`, `paint_ui`'s `&UiContext`).
+  The parent is out on loan while it runs, its children are not.
+
+## Phases
+
+1. **Owned slots, handles and lending in the toolkit.** `Handle`, `insert` / `get` / `get_mut` /
+   `remove` / `lend`; the lent flag in the tree and every resolver honouring it; the context's
+   own calls into widgets routed through `lend`. Both kinds of entry coexist: an app migrates
+   field by field. Tests, Miri included.
+   **DONE (2026-10-08).** `widget::Handle`; `UiContext::{insert, get, get_mut, remove, lend,
+   lend_h, register_popover_id}` and `ctx[h]`; `WidgetTree::{insert_owned, owned_root,
+   take_owned, set_lent}` (owned slots keep the raw-root shape `Owned` uses; `clear_all`
+   keeps them). Every call the context makes into a widget that hands it the context —
+   routed events, drag start / update / end, grabs, keys to the focused widget, ticks,
+   FocusIn / FocusOut, the focused widget's context action, the popover-close sweep — goes
+   through `lend`; dispatch works by id (`propagate_event_impl`, `find_hovered_scrollable`).
+   Lending covers the pointer entries too, so a widget that reached itself through the
+   context mid-event now misses instead of aliasing. Host helpers: `render_widget_h`,
+   `Form::widget_h`. Tests in `widget::handle` (CI's Miri job runs them beside
+   `widget::owned`); the 604 existing tests pass unchanged.
+2. **The demo app** (`src/main.rs`) on handles — the reference every client copies.
+   **DONE (2026-10-08).** Twelve widgets inserted in `create`; `register_roots` and the
+   first-frame registration went. A shadow session driven through a click, the toggle,
+   typing, the theme dropdown and the Options dialog (open, pick, OK; and Escape) is
+   identical to the pixel to the pointer-registry build at every step.
+3. **Every app**, one crate at a time, by widget count: the smallest first.
+4. **The toolkit's embedded children** on handles.
+5. **Delete the pointer path**: `Owned`, `register_host` / `register_widget` / `set_focused_ptr`
+   and the other `unsafe fn`s, `Liveness`, `stable_target`. The tree holds owned slots only.
diff --git a/src/context.rs b/src/context.rs
index 4e40643..9e7d05e 100644
--- a/src/context.rs
+++ b/src/context.rs
@@ -1,5 +1,5 @@
 use std::collections::HashMap;
-use crate::widget::{WidgetHost, WidgetId, Key, NamedKey, MouseButton, ElementState, Event, WidgetHostExt};
+use crate::widget::{Handle, WidgetHost, WidgetId, Key, NamedKey, MouseButton, ElementState, Event, WidgetHostExt};
 
 pub struct SpatialGrid {
     pub cell_size: f32,
@@ -152,6 +152,104 @@ impl UiContext {
         self.tree.get_ptr(id).map(|ptr| unsafe { &mut *ptr })
     }
 
+    // ── Widgets the context owns, by handle (docs/rfc-owning-registry.md) ──────────────
+
+    /// Take ownership of `widget`, register it under its own id, and hand back its handle.
+    /// The widget lives in the context from here on; reach it with [`get`](Self::get) /
+    /// [`get_mut`](Self::get_mut) (or `ctx[h]`), give it back with [`remove`](Self::remove).
+    pub fn insert<W: WidgetHost + 'static>(&mut self, widget: W) -> Handle<W> {
+        let id = self.tree.insert_owned(widget);
+        self.invalidate_coverage_cache();
+        Handle::from_id(id)
+    }
+
+    /// The widget `h` names: `None` once it is removed, or while the context has it out on
+    /// loan (a widget reaching for itself while it handles an event).
+    pub fn get<W: WidgetHost + 'static>(&self, h: Handle<W>) -> Option<&W> {
+        // SAFETY: the tree's own allocation, typed by `owned_root`, not lent; borrowed from
+        // `&self`, so no `&mut` to it can be live.
+        self.tree.owned_root::<W>(h.id()).map(|p| unsafe { &*p.as_ptr() })
+    }
+
+    /// [`get`](Self::get), mutably. The borrow is of the whole context, so an app cannot
+    /// hold the widget across another context call — the one rule the pointer registry left
+    /// to discipline, now the compiler's.
+    pub fn get_mut<W: WidgetHost + 'static>(&mut self, h: Handle<W>) -> Option<&mut W> {
+        // SAFETY: as in `get`, and `&mut self` is exclusive: nothing else in the context is
+        // touching the widget while this borrow lives.
+        self.tree.owned_root::<W>(h.id()).map(|p| unsafe { &mut *p.as_ptr() })
+    }
+
+    /// Give the widget `h` names back by value, unregistered: its links, its focus and its
+    /// place in the tick list go with it. `None` if it is gone or out on loan.
+    pub fn remove<W: WidgetHost + 'static>(&mut self, h: Handle<W>) -> Option<W> {
+        let id = h.id();
+        let widget = self.tree.take_owned::<W>(id)?;
+        if self.focused_widget == Some(id) {
+            self.focused_widget = None;
+        }
+        self.tick_receivers.retain(|&r| r != id);
+        self.invalidate_coverage_cache();
+        Some(widget)
+    }
+
+    /// Lend the widget `id` out for one call: `f` gets it and the context, and it is back in
+    /// the registry when `f` returns. While it is out nothing in the context resolves it, so
+    /// whatever `f` does with the context — dispatch, focus, a handle lookup — cannot reach
+    /// the widget a second time. `None` if `id` is unknown, stale, or already out.
+    ///
+    /// Every call the context makes into a widget that hands it the context comes through
+    /// here; so does a host that places or drives a widget it holds by handle.
+    pub fn lend<R>(&mut self, id: WidgetId, f: impl FnOnce(&mut (dyn WidgetHost + 'static), &mut UiContext) -> R) -> Option<R> {
+        let ptr = self.tree.get_ptr(id)?;
+        if !self.tree.set_lent(id, true) {
+            return None;
+        }
+        // SAFETY: a live widget the registry resolved, now out on loan: until it is put back
+        // no resolver in the context hands it out, so this `&mut` is the only one.
+        let out = f(unsafe { &mut *ptr }, self);
+        self.tree.set_lent(id, false);
+        Some(out)
+    }
+
+    /// [`lend`](Self::lend) by handle, typed.
+    pub fn lend_h<W: WidgetHost + 'static, R>(&mut self, h: Handle<W>, f: impl FnOnce(&mut W, &mut UiContext) -> R) -> Option<R> {
+        let root = self.tree.owned_root::<W>(h.id())?;
+        if !self.tree.set_lent(h.id(), true) {
+            return None;
+        }
+        // SAFETY: as in `lend`; the root is the tree's own `W`.
+        let out = f(unsafe { &mut *root.as_ptr() }, self);
+        self.tree.set_lent(h.id(), false);
+        Some(out)
+    }
+
+    /// Hand `event` to widget `id`, lent for the call; a widget that takes it is marked dirty.
+    fn deliver(&mut self, id: WidgetId, event: &Event) -> bool {
+        self.lend(id, |w, ctx| {
+            let handled = w.handle_event(event, ctx);
+            if handled {
+                w.mark_dirty(ctx);
+            }
+            handled
+        })
+        .unwrap_or(false)
+    }
+
+    /// [`deliver`](Self::deliver), marking the widget dirty whatever it answers (the drag
+    /// lifecycle's start and end).
+    fn deliver_dirty(&mut self, id: WidgetId, event: &Event) {
+        self.lend(id, |w, ctx| {
+            w.handle_event(event, ctx);
+            w.mark_dirty(ctx);
+        });
+    }
+
+    /// A widget's rect, read without lending it.
+    fn rect_of(&self, id: WidgetId) -> Option<(f32, f32, f32, f32)> {
+        self.get_widget(id).map(|w| w.rect())
+    }
+
     /// Dispatch an event into the tree rooted at `root` — a `WidgetId` resolved through the
     /// registry (the plumbing retype: the router's last raw-pointer API boundary is gone; apps
     /// name roots by id and the registry is the one place a pointer lives). The root must be
@@ -193,10 +291,10 @@ impl UiContext {
     }
 
     pub fn propagate_event(&mut self, event: &Event, root: WidgetId) -> bool {
-        let Some(root_ptr) = self.tree.get_ptr(root) else {
+        if self.tree.get_ptr(root).is_none() {
             eprintln!("propagate_event: unregistered/stale root {root:?} — event dropped");
             return false;
-        };
+        }
         if let Event::MouseWheel { .. } = event {
             self.note_scroll_event();
         }
@@ -211,210 +309,169 @@ impl UiContext {
                     | Key::Named(NamedKey::ArrowDown)
             );
             if is_scroll_key {
-                let mut handled = false;
-                if let Some(focused) = self.focused_widget.and_then(|id| self.tree.get_ptr(id)) {
-                    unsafe {
-                        if (*focused).handle_event(event, self) {
-                            (*focused).mark_dirty(self);
-                            handled = true;
-                        }
+                if let Some(focused) = self.focused_widget {
+                    if self.deliver(focused, event) {
+                        return true;
                     }
                 }
-                if handled {
-                    return true;
-                }
                 let (cx, cy) = self.cursor_pos;
-                if let Some(scrollable) = self.find_hovered_scrollable(root_ptr, cx, cy) {
-                    unsafe {
-                        if (*scrollable).handle_event(event, self) {
-                            (*scrollable).mark_dirty(self);
-                            return true;
-                        }
+                if let Some(scrollable) = self.find_hovered_scrollable(root, cx, cy) {
+                    if self.deliver(scrollable, event) {
+                        return true;
                     }
                 }
             }
         }
-        self.propagate_event_impl(event, root_ptr)
+        self.propagate_event_impl(event, root)
     }
 
-    /// The dispatch body. Private — `root` is the registry-resolved pointer from
-    /// `propagate_event`, live for the duration of this call.
-    fn propagate_event_impl(&mut self, event: &Event, root: *mut (dyn WidgetHost + 'static)) -> bool {
+    /// The dispatch body, by id: each widget it calls is lent for the call.
+    fn propagate_event_impl(&mut self, event: &Event, root: WidgetId) -> bool {
         if let Event::Tick(_) = event {
             return false;
         }
-        unsafe {
-            // Track drag gestures based on mouse events
-            match event {
-                Event::MouseButton { button, state, x, y, .. } if *button == MouseButton::Left => {
-                    if *state == ElementState::Pressed {
-                        // Apps re-dispatch the SAME press to several roots (a plain loop
-                        // over their top-level widgets); only the first call for a given
-                        // press may reset the drag bookkeeping — a later call would wipe
-                        // the target an earlier root just armed, killing the drag before
-                        // its first move. drag_start_pos is cleared on release, so an
-                        // equal position here means "same press, next root".
-                        if self.drag_start_pos != Some((*x, *y)) {
-                            // A fresh press while a grab is still armed means the
-                            // release never arrived (lost to a focus change or eaten
-                            // compositor-side). End the stale drag and drop the grab —
-                            // otherwise active_grab redirects every event to the old
-                            // target forever and the whole UI stops responding.
-                            if self.active_grab.is_some() {
-                                if self.is_dragging {
-                                    if let Some(target_ptr) = self.drag_target.and_then(|id| self.tree.get_ptr(id)) {
-                                        (*target_ptr).handle_event(&Event::DragEnd, self);
-                                        (*target_ptr).mark_dirty(self);
-                                    }
-                                }
-                                self.active_grab = None;
-                            }
-                            self.drag_start_pos = Some((*x, *y));
-                            self.is_dragging = false;
-                            self.drag_target = None;
-                        }
-                    } else if *state == ElementState::Released {
-                        if self.is_dragging {
-                            if let Some(target_id) = self.drag_target {
-                                if let Some(target_ptr) = self.tree.get_ptr(target_id) {
-                                    (*target_ptr).handle_event(&Event::DragEnd, self);
-                                    (*target_ptr).mark_dirty(self);
+        // Track drag gestures based on mouse events
+        match event {
+            Event::MouseButton { button, state, x, y, .. } if *button == MouseButton::Left => {
+                if *state == ElementState::Pressed {
+                    // Apps re-dispatch the SAME press to several roots (a plain loop
+                    // over their top-level widgets); only the first call for a given
+                    // press may reset the drag bookkeeping — a later call would wipe
+                    // the target an earlier root just armed, killing the drag before
+                    // its first move. drag_start_pos is cleared on release, so an
+                    // equal position here means "same press, next root".
+                    if self.drag_start_pos != Some((*x, *y)) {
+                        // A fresh press while a grab is still armed means the
+                        // release never arrived (lost to a focus change or eaten
+                        // compositor-side). End the stale drag and drop the grab —
+                        // otherwise active_grab redirects every event to the old
+                        // target forever and the whole UI stops responding.
+                        if self.active_grab.is_some() {
+                            if self.is_dragging {
+                                if let Some(target) = self.drag_target {
+                                    self.deliver_dirty(target, &Event::DragEnd);
                                 }
                             }
                             self.active_grab = None;
                         }
-                        self.drag_start_pos = None;
-                        self.drag_target = None;
+                        self.drag_start_pos = Some((*x, *y));
                         self.is_dragging = false;
+                        self.drag_target = None;
+                    }
+                } else if *state == ElementState::Released {
+                    if self.is_dragging {
+                        if let Some(target) = self.drag_target {
+                            self.deliver_dirty(target, &Event::DragEnd);
+                        }
+                        self.active_grab = None;
                     }
+                    self.drag_start_pos = None;
+                    self.drag_target = None;
+                    self.is_dragging = false;
                 }
-                Event::PointerMove { x, y, .. } => {
-                    if let Some((sx, sy)) = self.drag_start_pos {
-                        if let Some(target_id) = self.drag_target {
-                            if self.is_dragging {
-                                let dx = *x - sx;
-                                let dy = *y - sy;
-                                if let Some(target_ptr) = self.tree.get_ptr(target_id) {
-                                    let (cx, cy, _, _) = (*target_ptr).rect();
-                                    let drag_evt = Event::DragUpdate { dx, dy, x: *x, y: *y, local_x: *x - cx, local_y: *y - cy };
-                                    (*target_ptr).handle_event(&drag_evt, self);
-                                    (*target_ptr).mark_dirty(self);
-                                }
-                            } else {
-                                let dx = *x - sx;
-                                let dy = *y - sy;
-                                if (dx * dx + dy * dy).sqrt() > 3.0 {
-                                    self.is_dragging = true;
-                                    self.active_grab = Some(target_id);
-                                    if let Some(target_ptr) = self.tree.get_ptr(target_id) {
-                                        (*target_ptr).handle_event(&Event::DragStart { start_x: sx, start_y: sy }, self);
-                                        (*target_ptr).mark_dirty(self);
-                                    }
-                                }
+            }
+            Event::PointerMove { x, y, .. } => {
+                if let Some((sx, sy)) = self.drag_start_pos {
+                    if let Some(target_id) = self.drag_target {
+                        let (dx, dy) = (*x - sx, *y - sy);
+                        if self.is_dragging {
+                            if let Some((cx, cy, _, _)) = self.rect_of(target_id) {
+                                let drag_evt = Event::DragUpdate { dx, dy, x: *x, y: *y, local_x: *x - cx, local_y: *y - cy };
+                                self.deliver_dirty(target_id, &drag_evt);
                             }
+                        } else if (dx * dx + dy * dy).sqrt() > 3.0 {
+                            self.is_dragging = true;
+                            self.active_grab = Some(target_id);
+                            self.deliver_dirty(target_id, &Event::DragStart { start_x: sx, start_y: sy });
                         }
                     }
                 }
-                _ => {}
             }
+            _ => {}
+        }
 
-            // Normal grab redirection for mouse events if active
-            if let Some(grabbed_id) = self.active_grab {
-                if let Event::PointerMove { .. }
-                | Event::MouseButton { .. }
-                | Event::MouseWheel { .. }
-                | Event::DragStart { .. }
-                | Event::DragUpdate { .. }
-                | Event::DragEnd = event
-                {
-                    if let Some(grabbed_ptr) = self.tree.get_ptr(grabbed_id) {
-                        let handled = (*grabbed_ptr).handle_event(event, self);
-                        if handled {
-                            (*grabbed_ptr).mark_dirty(self);
-                        }
-                        return handled;
-                    }
+        // Normal grab redirection for mouse events if active
+        if let Some(grabbed_id) = self.active_grab {
+            if let Event::PointerMove { .. }
+            | Event::MouseButton { .. }
+            | Event::MouseWheel { .. }
+            | Event::DragStart { .. }
+            | Event::DragUpdate { .. }
+            | Event::DragEnd = event
+            {
+                if self.tree.get_ptr(grabbed_id).is_some() {
+                    return self.deliver(grabbed_id, event);
                 }
             }
+        }
 
-            // For KeyInput, send directly to focused widget if it exists
-            if let Event::KeyInput(_) = event {
-                if let Some(focused) = self.focused_widget.and_then(|id| self.tree.get_ptr(id)) {
-                    if (*focused).handle_event(event, self) {
-                        (*focused).mark_dirty(self);
-                        return true;
-                    }
+        // For KeyInput, send directly to focused widget if it exists
+        if let Event::KeyInput(_) = event {
+            if let Some(focused) = self.focused_widget {
+                if self.deliver(focused, event) {
+                    return true;
                 }
             }
+        }
+
+        let mut handled = false;
+        let mut children: Vec<WidgetId> =
+            self.tree.child_ids(root).into_iter().filter(|&c| self.tree.get_ptr(c).is_some()).collect();
+        children.sort_by_key(|&c| self.get_widget(c).map_or(0, |w| w.z_index()));
 
-            let mut handled = false;
-            let mut children = self.tree.children_ptrs((*root).base().id());
-            children.sort_by_key(|&child_ptr| (*child_ptr).z_index());
+        // Determine if we should record a drag target candidate
+        let check_drag_target = matches!(
+            event,
+            Event::MouseButton { button: MouseButton::Left, state: ElementState::Pressed, .. }
+        );
 
-            // Determine if we should record a drag target candidate
-            let mut check_drag_target = false;
-            if let Event::MouseButton { button, state, .. } = event {
-                if *button == MouseButton::Left && *state == ElementState::Pressed {
-                    check_drag_target = true;
+        let localized = |event: &Event, rect: Option<(f32, f32, f32, f32)>| {
+            let (cx, cy, _, _) = rect.unwrap_or_default();
+            let mut local_adjusted = event.clone();
+            match &mut local_adjusted {
+                Event::PointerMove { local_x, local_y, .. }
+                | Event::MouseButton { local_x, local_y, .. }
+                | Event::MouseWheel { local_x, local_y, .. }
+                | Event::DragUpdate { local_x, local_y, .. } => {
+                    *local_x -= cx;
+                    *local_y -= cy;
                 }
+                _ => {}
             }
+            local_adjusted
+        };
 
-            match event {
-                Event::PointerMove { .. } | Event::Tick(_) => {
-                    for child in children.into_iter().rev() {
-                        let (cx, cy, _, _) = (*child).rect();
-                        let mut local_adjusted = event.clone();
-                        match &mut local_adjusted {
-                            Event::PointerMove { local_x, local_y, .. }
-                            | Event::MouseButton { local_x, local_y, .. }
-                            | Event::MouseWheel { local_x, local_y, .. }
-                            | Event::DragUpdate { local_x, local_y, .. } => {
-                                *local_x -= cx;
-                                *local_y -= cy;
-                            }
-                            _ => {}
-                        }
-                        if self.propagate_event_impl(&local_adjusted, child) {
-                            handled = true;
-                        }
-                    }
-                    if (*root).handle_event(event, self) {
-                        (*root).mark_dirty(self);
+        match event {
+            Event::PointerMove { .. } | Event::Tick(_) => {
+                for child in children.into_iter().rev() {
+                    let local_adjusted = localized(event, self.rect_of(child));
+                    if self.propagate_event_impl(&local_adjusted, child) {
                         handled = true;
                     }
                 }
-                _ => {
-                    for child in children.into_iter().rev() {
-                        let (cx, cy, _, _) = (*child).rect();
-                        let mut local_adjusted = event.clone();
-                        match &mut local_adjusted {
-                            Event::PointerMove { local_x, local_y, .. }
-                            | Event::MouseButton { local_x, local_y, .. }
-                            | Event::MouseWheel { local_x, local_y, .. }
-                            | Event::DragUpdate { local_x, local_y, .. } => {
-                                *local_x -= cx;
-                                *local_y -= cy;
-                            }
-                            _ => {}
-                        }
-                        if self.propagate_event_impl(&local_adjusted, child) {
-                            if check_drag_target {
-                                self.drag_target = Some((*child).base().id());
-                            }
-                            return true;
-                        }
-                    }
-                    if (*root).handle_event(event, self) {
-                        (*root).mark_dirty(self);
+                if self.deliver(root, event) {
+                    handled = true;
+                }
+            }
+            _ => {
+                for child in children.into_iter().rev() {
+                    let local_adjusted = localized(event, self.rect_of(child));
+                    if self.propagate_event_impl(&local_adjusted, child) {
                         if check_drag_target {
-                            self.drag_target = Some((*root).base().id());
+                            self.drag_target = Some(child);
                         }
                         return true;
                     }
                 }
+                if self.deliver(root, event) {
+                    if check_drag_target {
+                        self.drag_target = Some(root);
+                    }
+                    return true;
+                }
             }
-            handled
         }
+        handled
     }
 
     pub fn is_dirty(&self) -> bool {
@@ -494,14 +551,14 @@ impl UiContext {
         let ids = self.tick_receivers.clone();
         for id in ids {
             if self.is_widget_visible(id) {
-                if let Some(ptr) = self.tree.get_ptr(id) {
-                    unsafe {
-                        if (*ptr).tick(dt, self) {
-                            (*ptr).mark_dirty(self);
-                            changed = true;
-                        }
+                let ticked = self.lend(id, |w, ctx| {
+                    let moved = w.tick(dt, ctx);
+                    if moved {
+                        w.mark_dirty(ctx);
                     }
-                }
+                    moved
+                });
+                changed |= ticked.unwrap_or(false);
             }
         }
         changed
@@ -538,29 +595,20 @@ impl UiContext {
     }
 
     pub fn set_focused_id(&mut self, id: WidgetId) {
+        if self.focused_widget == Some(id) {
+            return;
+        }
         if let Some(old_id) = self.focused_widget {
-            if old_id != id {
-                if let Some(old_ptr) = self.tree.get_ptr(old_id) {
-                    unsafe {
-                        (*old_ptr).unfocus();
-                        (*old_ptr).handle_event(&Event::FocusOut, self);
-                    }
-                }
-                self.focused_widget = Some(id);
-                if let Some(new_ptr) = self.tree.get_ptr(id) {
-                    unsafe {
-                        (*new_ptr).handle_event(&Event::FocusIn, self);
-                    }
-                }
-            }
-        } else {
-            self.focused_widget = Some(id);
-            if let Some(new_ptr) = self.tree.get_ptr(id) {
-                unsafe {
-                    (*new_ptr).handle_event(&Event::FocusIn, self);
-                }
-            }
+            self.lend(old_id, |w, ctx| {
+                w.unfocus();
+                w.handle_event(&Event::FocusOut, ctx);
+            });
         }
+        self.focused_widget = Some(id);
+        // A widget focusing itself mid-event is out on loan: it is not told (`claim_focus`).
+        self.lend(id, |w, ctx| {
+            w.handle_event(&Event::FocusIn, ctx);
+        });
     }
 
     pub fn is_focused(&self, w: &dyn WidgetHost) -> bool {
@@ -575,26 +623,23 @@ impl UiContext {
     /// for the `undo` / `redo` chords. Returns whether the widget applied it;
     /// a widget that did is marked dirty.
     pub fn focused_context_action(&mut self, action: crate::widget::ContextAction) -> bool {
-        let Some(ptr) = self.focused_widget.and_then(|id| self.tree.get_ptr(id)) else {
-            return false;
-        };
-        unsafe {
-            if (*ptr).context_action(action) {
-                (*ptr).mark_dirty(self);
-                return true;
+        let Some(id) = self.focused_widget else { return false };
+        self.lend(id, |w, ctx| {
+            let applied = w.context_action(action);
+            if applied {
+                w.mark_dirty(ctx);
             }
-        }
-        false
+            applied
+        })
+        .unwrap_or(false)
     }
 
     pub fn clear_focus(&mut self) {
         if let Some(id) = self.focused_widget.take() {
-            if let Some(ptr) = self.tree.get_ptr(id) {
-                unsafe {
-                    (*ptr).unfocus();
-                    (*ptr).handle_event(&Event::FocusOut, self);
-                }
-            }
+            self.lend(id, |w, ctx| {
+                w.unfocus();
+                w.handle_event(&Event::FocusOut, ctx);
+            });
         }
     }
 
@@ -1004,21 +1049,21 @@ impl UiContext {
     /// and closed the menu under the click.
     pub fn close_popovers_missed_by_press_with(&mut self, x: f32, y: f32, offset: impl Fn(WidgetId) -> (f32, f32)) {
         for id in self.popover_owners() {
-            let Some(ptr) = self.tree.get_ptr(id) else { continue };
+            let Some(w) = self.get_widget(id) else { continue };
             let (dx, dy) = offset(id);
             let (x, y) = (x - dx, y - dy);
-            unsafe {
-                if !(*ptr).hit_test(x, y, self) {
-                    let ev = Event::MouseButton {
-                        button: crate::widget::MouseButton::Left,
-                        state: crate::widget::ElementState::Pressed,
-                        x,
-                        y,
-                        local_x: x,
-                        local_y: y,
-                    };
-                    (*ptr).handle_event(&ev, self);
-                }
+            if !w.hit_test(x, y, self) {
+                let ev = Event::MouseButton {
+                    button: crate::widget::MouseButton::Left,
+                    state: crate::widget::ElementState::Pressed,
+                    x,
+                    y,
+                    local_x: x,
+                    local_y: y,
+                };
+                self.lend(id, |w, ctx| {
+                    w.handle_event(&ev, ctx);
+                });
             }
         }
     }
@@ -1048,6 +1093,15 @@ impl UiContext {
 
     /// Register an open popover. Takes `&mut` so the registry can be refreshed with the
     /// pointer we are handed (the occlusion walks resolve the stored id through the tree).
+    /// [`register_popover`](Self::register_popover) for a widget already registered — one
+    /// the context owns, named by its handle's id.
+    pub fn register_popover_id(&mut self, id: WidgetId) {
+        if !self.active_popovers.contains(&id) {
+            self.active_popovers.push(id);
+        }
+        self.invalidate_coverage_cache();
+    }
+
     pub fn register_popover(&mut self, w: &mut (dyn WidgetHost + 'static)) {
         let id = w.base().id();
         // SAFETY: derived from the live borrow we were handed.
@@ -1362,27 +1416,32 @@ impl UiContext {
         false
     }
 
-    fn find_hovered_scrollable(&self, root: *mut (dyn WidgetHost + 'static), cx: f32, cy: f32) -> Option<*mut (dyn WidgetHost + 'static)> {
-        unsafe {
-            if root.is_null() {
-                return None;
-            }
-            if !(*root).visible() {
-                return None;
-            }
-            if !(*root).hit_test(cx, cy, self) {
-                return None;
-            }
-            for child in self.tree.children_ptrs((*root).base().id()).into_iter().rev() {
-                if let Some(scrollable) = self.find_hovered_scrollable(child, cx, cy) {
-                    return Some(scrollable);
-                }
-            }
-            if (*root).is_scrollable() {
-                return Some(root);
+    fn find_hovered_scrollable(&self, root: WidgetId, cx: f32, cy: f32) -> Option<WidgetId> {
+        let w = self.get_widget(root)?;
+        if !w.visible() || !w.hit_test(cx, cy, self) {
+            return None;
+        }
+        for child in self.tree.child_ids(root).into_iter().rev() {
+            if let Some(scrollable) = self.find_hovered_scrollable(child, cx, cy) {
+                return Some(scrollable);
             }
         }
-        None
+        w.is_scrollable().then_some(root)
+    }
+}
+
+/// `ctx[h]`: the widget `h` names. Panics if it was removed or is out on loan — use
+/// [`UiContext::get`] where either can happen.
+impl<W: WidgetHost + 'static> std::ops::Index<Handle<W>> for UiContext {
+    type Output = W;
+    fn index(&self, h: Handle<W>) -> &W {
+        self.get(h).unwrap_or_else(|| panic!("{h:?} is not in the context (removed, or out on loan)"))
+    }
+}
+
+impl<W: WidgetHost + 'static> std::ops::IndexMut<Handle<W>> for UiContext {
+    fn index_mut(&mut self, h: Handle<W>) -> &mut W {
+        self.get_mut(h).unwrap_or_else(|| panic!("{h:?} is not in the context (removed, or out on loan)"))
     }
 }
 
diff --git a/src/layout/bridge.rs b/src/layout/bridge.rs
index 48c926c..176fef0 100644
--- a/src/layout/bridge.rs
+++ b/src/layout/bridge.rs
@@ -172,6 +172,20 @@ impl RenderTarget for PopoverCollector {
 }
 
 
+/// [`render_widget`] for a widget the context owns, named by its handle: lent for the call.
+/// Draws nothing if the widget is gone or already out on loan.
+pub fn render_widget_h<T: WidgetHost + 'static>(
+    pc: &mut dyn RenderTarget,
+    h: crate::widget::Handle<T>,
+    x: f32,
+    y: f32,
+    ww: f32,
+    wh: f32,
+    ctx: &mut UiContext,
+) {
+    ctx.lend_h(h, |w, ctx| render_widget(pc, w, x, y, ww, wh, ctx));
+}
+
 pub fn render_widget<T: WidgetHost + 'static>(pc: &mut dyn RenderTarget, w: &mut T, x: f32, y: f32, ww: f32, wh: f32, ctx: &mut UiContext) {
     ctx.register_host(w);
     // The flat-host contract, the same block `set_rect` takes: `(x, y)` is the top of
diff --git a/src/layout/form.rs b/src/layout/form.rs
index 31ba968..e59187c 100644
--- a/src/layout/form.rs
+++ b/src/layout/form.rs
@@ -128,6 +128,24 @@ impl<'f, 'w, P: RenderTarget + 'w> Group<'f, 'w, P> {
         self
     }
 
+    /// [`widget`](Self::widget) for a widget the context owns, named by its handle: its height
+    /// read from `ctx` now, and the widget lent for its placement and paint. A handle that no
+    /// longer names a widget takes `fallback` and draws nothing.
+    pub fn widget_h<T: WidgetHost + 'static>(&mut self, ctx: &UiContext, h: crate::widget::Handle<T>, fallback: f32) -> &mut Self {
+        let h_px = ctx.get(h).map_or(fallback, |w| w.preferred_height().unwrap_or(fallback) + w.label_strip());
+        let style = self.leaf_style(self.axis_row);
+        let span = self.row_span();
+        let draw: Draw<'w, P> = Box::new(move |pc, r, ctx| {
+            ctx.lend_h(h, |w, ctx| {
+                let (x, width) = span.unwrap_or((r.x, r.width));
+                w.set_row_rect(x, width);
+                render_widget(pc, w, r.x, r.y, r.width, r.height, ctx);
+            });
+        });
+        self.form.add(self.node, style, Size::new(0.0, h_px), Some(draw));
+        self
+    }
+
     /// A retained widget at a width of its own (a toggle that is not as wide as its row), not
     /// growing. Its height is as for [`widget`](Self::widget).
     pub fn widget_w<T: WidgetHost + 'static>(&mut self, w: &'w mut T, width: f32, fallback: f32) -> &mut Self {
diff --git a/src/main.rs b/src/main.rs
index e838868..4fc9b0b 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -19,11 +19,14 @@
 //!    owns and lays out; [`Dialog`] is the plate under them, and opening it traps the Tab
 //!    walk among them and covers the window behind (`UiContext::open_modal`). Closed, its
 //!    members are hidden, so they are neither Tab stops nor in the accessibility tree.
-//! 5. **No embedded bases.** Widgets are app-owned values (all [`Adapted`]); the window
-//!    plate is prims, not a root plate container; popovers draw INTO the frame (there is no popup
-//!    surface); app state — not any widget tree — is the source of truth.
-
-use cce_ui::widget::Owned;
+//! 5. **The context owns the widgets.** The app holds a [`Handle`] to each (all [`Adapted`])
+//!    and reaches it through the context — `ui[h]`, `ui.get_mut(h)`, `ui.lend_h(h, ..)` when the
+//!    widget and the context are both needed — so the compiler keeps the app's access and the
+//!    context's own from overlapping (`docs/rfc-owning-registry.md`). The window plate is prims,
+//!    not a root plate container; popovers draw INTO the frame (there is no popup surface); app
+//!    state — not any widget tree — is the source of truth.
+
+use cce_ui::context::UiContext;
 use cce_ui::engine::{Application, AppSender, LogicalPosition, LogicalSize, WindowSettings};
 use cce_ui::scene::arena::Arena;
 use cce_ui::scene::layout::{
@@ -31,8 +34,8 @@ use cce_ui::scene::layout::{
 };
 use cce_ui::scene::paint::{DisplayList, PaintCtx};
 use cce_ui::widget::{
-    Adapted, Button, Dialog, Dropdown, ImageView, RadioGroup, WidgetHost, WidgetId, ElementState, Event,
-    KeyEvent, MouseButton, MouseScrollDelta, NamedKey, Slider, TextBox, Toggle,
+    Adapted, Button, Dialog, Dropdown, ElementState, Event, Handle, ImageView, KeyEvent, MouseButton,
+    MouseScrollDelta, NamedKey, RadioGroup, Slider, TextBox, Toggle, WidgetHost, WidgetId,
 };
 
 #[derive(Debug, Clone)]
@@ -57,24 +60,22 @@ fn text_leaf_height(font_size: f32) -> f32 {
 }
 
 pub(crate) struct DemoApp {
-    // ── Widgets: app-owned values on the narrow-trait adapter. Their addresses must be
-    // stable across frames (plain struct fields, not Vec elements): the UiContext
-    // registry and the router's drag-target bookkeeping hold pointers to them.
-    button: Owned<Adapted<Button>>,
-    toggle: Owned<Adapted<Toggle>>,
-    slider: Owned<Adapted<Slider>>,
-    name_box: Owned<Adapted<TextBox>>,
-    theme_dropdown: Owned<Adapted<Dropdown>>,
+    // ── Widgets: owned by `ui_context`, named here by handle (narrow-trait adapters).
+    button: Handle<Adapted<Button>>,
+    toggle: Handle<Adapted<Toggle>>,
+    slider: Handle<Adapted<Slider>>,
+    name_box: Handle<Adapted<TextBox>>,
+    theme_dropdown: Handle<Adapted<Dropdown>>,
     // ImageView pair sharing ONE uploaded texture (the widget borrows ids —
     // upload/free stay app-side): Contain letterboxes, Stretch fills.
-    image_contain: Owned<Adapted<ImageView>>,
-    image_stretch: Owned<Adapted<ImageView>>,
+    image_contain: Handle<Adapted<ImageView>>,
+    image_stretch: Handle<Adapted<ImageView>>,
     // The Options dialog: a button that opens it, the plate, and what stands on it.
-    options_button: Owned<Adapted<Button>>,
-    dialog: Owned<Adapted<Dialog>>,
-    text_size: Owned<Adapted<RadioGroup>>,
-    dialog_cancel: Owned<Adapted<Button>>,
-    dialog_ok: Owned<Adapted<Button>>,
+    options_button: Handle<Adapted<Button>>,
+    dialog: Handle<Adapted<Dialog>>,
+    text_size: Handle<Adapted<RadioGroup>>,
+    dialog_cancel: Handle<Adapted<Button>>,
+    dialog_ok: Handle<Adapted<Button>>,
 
     // ── App state: the source of truth. Widgets are re-asserted from it every rebuild
     // (`set_toggled` below); `take_*` changes flow back into it, never the reverse.
@@ -84,12 +85,13 @@ pub(crate) struct DemoApp {
     /// The text size chosen in the Options dialog (an index into `TEXT_SIZES`).
     text_size_choice: usize,
 
-    ui_context: cce_ui::context::UiContext,
+    ui_context: UiContext,
     width: u32,
     height: u32,
     scale_factor: f64,
     needs_rebuild: bool,
-    widgets_registered: bool,
+    /// The dialog's members are laid out once, on the first frame.
+    laid_out: bool,
     /// Set while `open_dialog` lays the members out before the dialog is open.
     dialog_pending: bool,
     title_rect: Rect,
@@ -98,8 +100,6 @@ pub(crate) struct DemoApp {
 
 impl DemoApp {
     /// The widget root ids, in paint order — what the router dispatches over.
-    /// `propagate_event` takes a `WidgetId` and resolves it through the registry, so the
-    /// event paths need no raw pointers and no unsafe self-alias.
     fn root_ids(&self) -> [WidgetId; 12] {
         // While the dialog is open the rest are still dispatched to: the context answers
         // every one of them "covered", so none takes a press or a hover.
@@ -126,115 +126,104 @@ impl DemoApp {
 
     /// Lay the dialog's members out in the middle of the window, shown while it is open.
     fn layout_dialog(&mut self) {
-        let open = self.dialog.inner().is_open() || self.dialog_pending;
-        for w in [&mut *self.text_size as &mut dyn WidgetHost, &mut *self.dialog_cancel, &mut *self.dialog_ok] {
-            w.set_visible(open);
+        let ui = &mut self.ui_context;
+        let open = ui[self.dialog].inner().is_open() || self.dialog_pending;
+        for id in [self.text_size.id(), self.dialog_cancel.id(), self.dialog_ok.id()] {
+            if let Some(w) = ui.get_widget_mut(id) {
+                w.set_visible(open);
+            }
         }
         if !open {
             return;
         }
         let gap = cce_ui::layout::control_gap();
-        let group_h = self.text_size.intrinsic_size().map_or(0.0, |s| s.height);
+        let group_h = ui[self.text_size].intrinsic_size().map_or(0.0, |s| s.height);
         let (button_w, button_h) = (DIALOG_BUTTON_W, 28.0);
         let content_w = 2.0 * button_w + gap;
         let content_h = group_h + gap * 2.0 + button_h;
-        let (pad, top) = (self.dialog.inner().padding(), self.dialog.inner().headroom());
+        let (pad, top) = (ui[self.dialog].inner().padding(), ui[self.dialog].inner().headroom());
         let x = (self.width as f32 - content_w) * 0.5;
         let y = (self.height as f32 - (top + content_h + pad)) * 0.5 + top;
-        self.text_size.set_rect(x, y, content_w, group_h);
+        ui[self.text_size].set_rect(x, y, content_w, group_h);
         let by = y + group_h + gap * 2.0;
-        self.dialog_cancel.set_rect(x, by, button_w, button_h);
-        self.dialog_ok.set_rect(x + button_w + gap, by, button_w, button_h);
+        ui[self.dialog_cancel].set_rect(x, by, button_w, button_h);
+        ui[self.dialog_ok].set_rect(x + button_w + gap, by, button_w, button_h);
         let window = Rect { x: 0.0, y: 0.0, width: self.width as f32, height: self.height as f32 };
-        self.dialog.set_backdrop(Some(window));
-        self.dialog.fit(&self.ui_context);
+        ui[self.dialog].set_backdrop(Some(window));
+        // The dialog fits itself around its members, which it reads through the context:
+        // lent for the call, so it and the context are both in hand.
+        ui.lend_h(self.dialog, |d, ui| d.fit(ui));
     }
 
     /// Open the Options dialog on the size the app holds.
     fn open_dialog(&mut self) {
-        self.text_size.inner_mut().set_selected(self.text_size_choice);
+        self.ui_context[self.text_size].inner_mut().set_selected(self.text_size_choice);
         self.dialog_pending = true;
         self.layout_dialog();
         self.dialog_pending = false;
         let members = self.dialog_members();
-        self.dialog.open(&mut self.ui_context, members);
+        self.ui_context.lend_h(self.dialog, |d, ui| d.open(ui, members));
         self.needs_rebuild = true;
     }
 
     /// Close it: `keep` takes the choice into the app, else it is dropped.
     fn close_dialog(&mut self, keep: bool) {
         if keep {
-            self.text_size_choice = self.text_size.inner().selected();
+            self.text_size_choice = self.ui_context[self.text_size].inner().selected();
             self.status = format!("Text size: {}", TEXT_SIZES[self.text_size_choice]);
         }
-        self.dialog.close(&mut self.ui_context);
+        self.ui_context.lend_h(self.dialog, |d, ui| d.close(ui));
         self.layout_dialog();
         self.needs_rebuild = true;
     }
 
-    /// The widget roots as pointers, for the one genuinely pointer-consuming path left:
-    /// registration (the registry stores them). The paint walk takes shared borrows.
-    /// Register every dispatch root by reference: the registry keeps a pointer to each
-    /// and resolves it only while the widget lives, so register once `self` is at its
-    /// final address (see `display_list`).
-    fn register_roots(&mut self) {
-        let ctx = &mut self.ui_context;
-        ctx.register_host(&mut self.button);
-        ctx.register_host(&mut self.toggle);
-        ctx.register_host(&mut self.slider);
-        ctx.register_host(&mut self.name_box);
-        ctx.register_host(&mut self.theme_dropdown);
-        ctx.register_host(&mut self.image_contain);
-        ctx.register_host(&mut self.image_stretch);
-        ctx.register_host(&mut self.options_button);
-        ctx.register_host(&mut self.dialog);
-        ctx.register_host(&mut self.text_size);
-        ctx.register_host(&mut self.dialog_cancel);
-        ctx.register_host(&mut self.dialog_ok);
-        self.layout_dialog();
-    }
-
     /// `take_*` plumbing: translate widget changes into app state. Runs after any routed
     /// dispatch; every check is STATE-gated, so it does not matter which propagate call
     /// consumed the event (see the KeyInput note in `handle_key_input`).
     fn drain_widget_changes(&mut self) {
-        if self.options_button.take_click() {
-            self.open_dialog();
-        }
-        if self.dialog_ok.take_click() {
-            self.close_dialog(true);
-        }
-        if self.dialog_cancel.take_click() {
-            self.close_dialog(false);
-        }
-        if self.text_size.take_change() {
+        let ui = &mut self.ui_context;
+        let (options, ok, cancel) = (
+            ui[self.options_button].take_click(),
+            ui[self.dialog_ok].take_click(),
+            ui[self.dialog_cancel].take_click(),
+        );
+        if ui[self.text_size].take_change() {
             self.needs_rebuild = true;
         }
-        if self.button.take_click() {
+        if ui[self.button].take_click() {
             self.clicks += 1;
             self.status = format!("Button clicked {} time(s)", self.clicks);
             self.needs_rebuild = true;
         }
-        if self.toggle.take_change() {
+        if ui[self.toggle].take_change() {
             self.toggle_on = !self.toggle_on;
             self.status = format!("Toggle: {}", if self.toggle_on { "on" } else { "off" });
             self.needs_rebuild = true;
         }
-        if self.slider.take_change() {
-            self.status = format!("Slider: {:.0}", self.slider.get_scaled_value());
+        if ui[self.slider].take_change() {
+            self.status = format!("Slider: {:.0}", ui[self.slider].get_scaled_value());
             self.needs_rebuild = true;
         }
-        if self.theme_dropdown.take_change() {
-            let idx = self.theme_dropdown.selected;
-            if let Some(opt) = self.theme_dropdown.options.get(idx) {
+        if ui[self.theme_dropdown].take_change() {
+            let dropdown = &ui[self.theme_dropdown];
+            if let Some(opt) = dropdown.options.get(dropdown.selected) {
                 self.status = format!("Theme: {opt}");
             }
             self.needs_rebuild = true;
         }
-        if self.name_box.take_change() {
-            self.status = format!("Name: {}", self.name_box.text);
+        if ui[self.name_box].take_change() {
+            self.status = format!("Name: {}", ui[self.name_box].text);
             self.needs_rebuild = true;
         }
+        if options {
+            self.open_dialog();
+        }
+        if ok {
+            self.close_dialog(true);
+        }
+        if cancel {
+            self.close_dialog(false);
+        }
     }
 }
 
@@ -259,46 +248,41 @@ impl Application for DemoApp {
             }
         }
         let gradient_id = cce_ui::draw::upload_rgba(gradient, GRADIENT_W, GRADIENT_H);
+        // The context owns every widget; the app keeps the handles.
+        let mut ui = UiContext::new();
         Self {
             // Relief styling (raised buttons/toggles/dropdowns, recessed
             // wells) is the `control_relief` config default — no opt-in.
-            button: Owned::new(Button::new(0.0, 0.0, 0.0, 0.0).with_label("Click me")),
-            toggle: Owned::new(Toggle::new()),
+            button: ui.insert(Button::new(0.0, 0.0, 0.0, 0.0).with_label("Click me")),
+            toggle: ui.insert(Toggle::new()),
             // Slider `value` is NORMALIZED 0..1; `with_range` only scales the readout
             // (`get_scaled_value`). Wheel nudging is an explicit opt-in.
-            slider: Owned::new(Slider::new()
-                .with_range(0.0, 100.0)
-                .with_value(0.4)
-                .with_scroll(true)),
-            name_box: Owned::new(TextBox::new(String::new())
-                .with_placeholder("Type a name...")),
-            theme_dropdown: Owned::new(Dropdown::new(
-                vec!["Forest".into(), "Ocean".into(), "Ember".into()],
-                0,
-            )),
-            image_contain: Owned::new(ImageView::new()
-                .with_image(gradient_id, GRADIENT_W, GRADIENT_H)
-                .with_fit(FitMode::Contain { max_upscale: 4.0 })
-                .with_bg([0.10, 0.10, 0.16, 1.0])),
-            image_stretch: Owned::new(ImageView::new()
-                .with_image(gradient_id, GRADIENT_W, GRADIENT_H)
-                .with_fit(FitMode::Stretch)),
-            options_button: Owned::new(Button::new(0.0, 0.0, 0.0, 0.0).with_label("Options…")),
-            dialog: Owned::new(Dialog::new().with_label("Text size")),
-            text_size: Owned::new(RadioGroup::new(TEXT_SIZES).with_selected(1)),
-            dialog_cancel: Owned::new(Button::new(0.0, 0.0, 0.0, 0.0).with_label("Cancel")),
-            dialog_ok: Owned::new(Button::new(0.0, 0.0, 0.0, 0.0).with_label("OK")),
+            slider: ui.insert(Slider::new().with_range(0.0, 100.0).with_value(0.4).with_scroll(true)),
+            name_box: ui.insert(TextBox::new(String::new()).with_placeholder("Type a name...")),
+            theme_dropdown: ui.insert(Dropdown::new(vec!["Forest".into(), "Ocean".into(), "Ember".into()], 0)),
+            image_contain: ui.insert(
+                ImageView::new()
+                    .with_image(gradient_id, GRADIENT_W, GRADIENT_H)
+                    .with_fit(FitMode::Contain { max_upscale: 4.0 })
+                    .with_bg([0.10, 0.10, 0.16, 1.0]),
+            ),
+            image_stretch: ui.insert(ImageView::new().with_image(gradient_id, GRADIENT_W, GRADIENT_H).with_fit(FitMode::Stretch)),
+            options_button: ui.insert(Button::new(0.0, 0.0, 0.0, 0.0).with_label("Options…")),
+            dialog: ui.insert(Dialog::new().with_label("Text size")),
+            text_size: ui.insert(RadioGroup::new(TEXT_SIZES).with_selected(1)),
+            dialog_cancel: ui.insert(Button::new(0.0, 0.0, 0.0, 0.0).with_label("Cancel")),
+            dialog_ok: ui.insert(Button::new(0.0, 0.0, 0.0, 0.0).with_label("OK")),
             text_size_choice: 1,
             dialog_pending: false,
             toggle_on: false,
             clicks: 0,
             status: "Ready.".to_string(),
-            ui_context: cce_ui::context::UiContext::new(),
+            ui_context: ui,
             width: 560,
             height: 420,
             scale_factor: 1.0,
             needs_rebuild: true,
-            widgets_registered: false,
+            laid_out: false,
             title_rect: Rect::ZERO,
             status_rect: Rect::ZERO,
         }
@@ -332,12 +316,9 @@ impl Application for DemoApp {
     }
 
     fn display_list(&mut self, size: LogicalSize, scale: f64) -> Option<DisplayList> {
-        // Register once: the registry backs the router (drag targets are looked up by
-        // widget id) and drag_allowed_at. Pointers into `self` are stable only once
-        // `self` sits at its final address — hence here, not in `new()`.
-        if !self.widgets_registered {
-            self.widgets_registered = true;
-            self.register_roots();
+        if !self.laid_out {
+            self.laid_out = true;
+            self.layout_dialog();
         }
 
         let size_changed = self.width != size.width as u32
@@ -350,8 +331,9 @@ impl Application for DemoApp {
             cce_ui::scale::set_scale_factor(scale as f32);
 
             // Re-assert widget visuals from app state (the app is the source of truth).
-            self.toggle.set_toggled(self.toggle_on);
-            self.toggle.set_label(if self.toggle_on { "ON" } else { "OFF" });
+            let ui = &mut self.ui_context;
+            ui[self.toggle].set_toggled(self.toggle_on);
+            ui[self.toggle].set_label(if self.toggle_on { "ON" } else { "OFF" });
 
             // ── Layout: a plain LayoutBox tree, solved in one call. Leaves carry their
             // intrinsic sizes; `grow` distributes leftover space; the solved rects are
@@ -420,22 +402,21 @@ impl Application for DemoApp {
 
             // Stretched children fill the column width; fixed leaves keep their size.
             let r = |id| arena.value(id).unwrap().rect;
-            let b = r(button);
-            self.button.set_rect(b.x, b.y, b.width, b.height);
-            let t = r(toggle);
-            self.toggle.set_rect(t.x, t.y, t.width, t.height);
-            let d = r(dropdown);
-            self.theme_dropdown.set_rect(d.x, d.y, d.width, d.height);
-            let o = r(options);
-            self.options_button.set_rect(o.x, o.y, o.width, o.height);
-            let s = r(slider);
-            self.slider.set_rect(s.x, s.y, s.width, s.height);
-            let n = r(name_box);
-            self.name_box.set_rect(n.x, n.y, n.width, n.height);
-            let ic = r(image_contain);
-            self.image_contain.set_rect(ic.x, ic.y, ic.width, ic.height);
-            let is = r(image_stretch);
-            self.image_stretch.set_rect(is.x, is.y, is.width, is.height);
+            let placed: [(WidgetId, Rect); 8] = [
+                (self.button.id(), r(button)),
+                (self.toggle.id(), r(toggle)),
+                (self.theme_dropdown.id(), r(dropdown)),
+                (self.options_button.id(), r(options)),
+                (self.slider.id(), r(slider)),
+                (self.name_box.id(), r(name_box)),
+                (self.image_contain.id(), r(image_contain)),
+                (self.image_stretch.id(), r(image_stretch)),
+            ];
+            for (id, b) in placed {
+                if let Some(w) = self.ui_context.get_widget_mut(id) {
+                    w.set_rect(b.x, b.y, b.width, b.height);
+                }
+            }
             self.title_rect = r(title);
             self.status_rect = r(status);
             self.layout_dialog();
@@ -448,8 +429,8 @@ impl Application for DemoApp {
         // text occlusion clamp (labels under the open popover get clipped); the popover
         // itself is drawn into this frame below — there is no popup surface.
         self.ui_context.clear_popovers();
-        if self.theme_dropdown.popover_rect().is_some() {
-            self.ui_context.register_popover(&mut self.theme_dropdown);
+        if self.ui_context[self.theme_dropdown].popover_rect().is_some() {
+            self.ui_context.register_popover_id(self.theme_dropdown.id());
         }
 
         let mut pc = PaintCtx::new();
@@ -518,28 +499,36 @@ impl Application for DemoApp {
         // Widgets: each root walked through the single paint pass. The walk recurses,
         // clips, and emits each widget's own geometry AND text (`Adapted::paint_self`
         // serves per-widget fonts and bounds).
-        cce_ui::scene::painter::paint_root_into(&self.ui_context, &self.button, &mut pc);
-        cce_ui::scene::painter::paint_root_into(&self.ui_context, &self.toggle, &mut pc);
-        cce_ui::scene::painter::paint_root_into(&self.ui_context, &self.slider, &mut pc);
-        cce_ui::scene::painter::paint_root_into(&self.ui_context, &self.name_box, &mut pc);
-        cce_ui::scene::painter::paint_root_into(&self.ui_context, &self.image_contain, &mut pc);
-        cce_ui::scene::painter::paint_root_into(&self.ui_context, &self.image_stretch, &mut pc);
-        cce_ui::scene::painter::paint_root_into(&self.ui_context, &self.theme_dropdown, &mut pc);
-        cce_ui::scene::painter::paint_root_into(&self.ui_context, &self.options_button, &mut pc);
+        let ui = &self.ui_context;
+        let roots = [
+            self.button.id(),
+            self.toggle.id(),
+            self.slider.id(),
+            self.name_box.id(),
+            self.image_contain.id(),
+            self.image_stretch.id(),
+            self.theme_dropdown.id(),
+            self.options_button.id(),
+        ];
+        for id in roots {
+            if let Some(w) = ui.get_widget(id) {
+                cce_ui::scene::painter::paint_root_into(ui, w, &mut pc);
+            }
+        }
 
         // The dropdown popover — geometry and labels last, on top of everything, exactly
         // where it hit-tests. Labels carry bounds equal to the popover rect: that clips
         // them to the plate AND exempts them from the occlusion clamp (text whose bounds
         // equal an overlay rect is treated as the overlay's own).
-        if self.theme_dropdown.popover_rect().is_some() {
+        if ui[self.theme_dropdown].popover_rect().is_some() {
             // PaintCtx is a RenderTarget: the popover draws its real prims (the
             // dropdown's expanded inset-plate surface) with its own bounds.
-            self.theme_dropdown.render_popover(&mut pc);
+            ui[self.theme_dropdown].render_popover(&mut pc);
         }
 
         // The dialog over everything: its backdrop, its plate, and its members on the plate
         // (it paints them; they are never painted on their own).
-        cce_ui::scene::painter::paint_root_into(&self.ui_context, &self.dialog, &mut pc);
+        cce_ui::scene::painter::paint_root_into(ui, &ui[self.dialog], &mut pc);
 
         Some(pc.finish())
     }
@@ -651,7 +640,7 @@ impl Application for DemoApp {
         }
 
         // Escape cancels the open dialog, as its Cancel does.
-        if self.dialog.inner().is_open()
+        if self.ui_context[self.dialog].inner().is_open()
             && event.state == ElementState::Pressed
             && event.logical_key == cce_ui::widget::Key::Named(NamedKey::Escape)
         {
diff --git a/src/scene/tree.rs b/src/scene/tree.rs
index 1265b9b..056533b 100644
--- a/src/scene/tree.rs
+++ b/src/scene/tree.rs
@@ -46,21 +46,46 @@ use crate::widget::{WidgetHost, WidgetId};
 /// `layout_tree` link can precede the `widget_registry` entry. (`*mut dyn WidgetHost` is a fat
 /// pointer, so `Option` is the natural "absent" representation — there is no thin null to use as
 /// a sentinel.) `alive` is `None` exactly when there is no non-null pointer to watch.
-#[derive(Clone)]
 struct Entry {
     id: WidgetId,
     ptr: Option<*mut (dyn WidgetHost + 'static)>,
     alive: Option<Weak<()>>,
-    /// The pointer is an `Owned` box's raw root (`WidgetHost::stable_target`), which every
-    /// later access to the widget — the app's and the registry's — derives from.
+    /// The pointer is an `Owned` box's raw root (`WidgetHost::stable_target`) or the tree's
+    /// own slot's, which every later access to the widget — the app's and the registry's —
+    /// derives from.
     stable: bool,
+    /// The widget, when the TREE owns it (`UiContext::insert`): `ptr` is this slot's root.
+    owned: Option<OwnedSlot>,
+    /// Out on loan (`UiContext::lend`): while set, nothing in the tree resolves the widget,
+    /// so a re-entrant reach for it is a miss rather than a second `&mut`.
+    lent: bool,
+}
+
+/// A widget the tree owns: its allocation (held as the raw root `Entry::ptr` names, never a
+/// `Box` across accesses, for the reason `widget::Owned` gives), its type for typed access,
+/// and the liveness token `Entry::alive` watches. Dropping the slot drops the widget.
+struct OwnedSlot {
+    root: std::ptr::NonNull<dyn WidgetHost>,
+    type_id: std::any::TypeId,
+    _live: crate::widget::core::Liveness,
+}
+
+impl Drop for OwnedSlot {
+    fn drop(&mut self) {
+        // SAFETY: `root` was made by `Box::into_raw` in `insert_owned` and is freed only here,
+        // once; the entry naming it is going away with the slot.
+        unsafe { drop(Box::from_raw(self.root.as_ptr())) };
+    }
 }
 
 /// Resolve an entry's pointer to a usable one: non-null (skipping link-only and null-data
-/// pointers exactly as the legacy `filter_map` over the registry did) and naming a widget that
-/// has not been dropped since it was registered.
+/// pointers exactly as the legacy `filter_map` over the registry did), naming a widget that
+/// has not been dropped since it was registered, and not out on loan.
 #[inline]
 fn live_ptr(entry: &Entry) -> Option<*mut (dyn WidgetHost + 'static)> {
+    if entry.lent {
+        return None;
+    }
     match (entry.ptr, &entry.alive) {
         (Some(p), Some(alive)) if !p.is_null() && alive.strong_count() > 0 => Some(p),
         _ => None,
@@ -102,7 +127,7 @@ impl WidgetTree {
                 return node;
             }
         }
-        let node = self.arena.insert(Entry { id, ptr: None, alive: None, stable: false });
+        let node = self.arena.insert(Entry { id, ptr: None, alive: None, stable: false, owned: None, lent: false });
         self.by_id.insert(id, node);
         node
     }
@@ -126,6 +151,10 @@ impl WidgetTree {
         // out of its box is at another address and registers as usual.
         if let Some(node) = self.by_id.get(&id).copied() {
             if let Some(e) = self.arena.value(node) {
+                // The tree's own widget is registered once, by `insert_owned`, and stays its.
+                if e.owned.is_some() {
+                    return;
+                }
                 let same = e.ptr.is_some_and(|p| std::ptr::addr_eq(p, ptr));
                 if e.stable && same && e.alive.as_ref().is_some_and(|w| w.strong_count() > 0) {
                     return;
@@ -197,10 +226,95 @@ impl WidgetTree {
         }
     }
 
-    /// Drop the entire tree. Mirrors `clear_hierarchy`'s reset of both maps.
+    /// Drop the entire tree. Mirrors `clear_hierarchy`'s reset of both maps — except the
+    /// widgets the tree OWNS, which stay registered (unlinked, as roots): an app that rebuilds
+    /// its links every frame does not hand its widgets back by doing so.
     pub fn clear_all(&mut self) {
-        self.arena.clear();
-        self.by_id.clear();
+        let nodes: Vec<NodeId> = self.by_id.values().copied().collect();
+        for &node in &nodes {
+            if self.arena.contains(node) {
+                self.arena.detach(node);
+            }
+        }
+        for node in nodes {
+            let keep = self.arena.value(node).is_some_and(|e| e.owned.is_some());
+            if !keep && self.arena.contains(node) {
+                let id = self.arena.value(node).map(|e| e.id);
+                self.arena.remove_subtree(node);
+                if let Some(id) = id {
+                    self.by_id.remove(&id);
+                }
+            }
+        }
+        self.by_id.retain(|_, n| self.arena.contains(*n));
+    }
+
+    /// Take ownership of `widget`: it moves into an allocation the tree keeps, registered under
+    /// its own id, and is dropped when its node is removed or the tree is. Returns the id.
+    pub fn insert_owned<W: WidgetHost + 'static>(&mut self, widget: W) -> WidgetId {
+        let id = widget.base().id();
+        let live = crate::widget::core::Liveness::new();
+        let alive = live.watch();
+        let raw: *mut (dyn WidgetHost + 'static) = Box::into_raw(Box::new(widget));
+        // SAFETY: `Box::into_raw` never returns null.
+        let root = unsafe { std::ptr::NonNull::new_unchecked(raw) };
+        let node = self.ensure_node(id);
+        let entry = self.arena.value_mut(node).unwrap();
+        entry.ptr = Some(raw);
+        entry.alive = Some(alive);
+        entry.stable = true;
+        entry.lent = false;
+        entry.owned = Some(OwnedSlot { root, type_id: std::any::TypeId::of::<W>(), _live: live });
+        id
+    }
+
+    /// The tree's own widget `id` as a `W`: its root, when the tree owns it, it is a `W`, and
+    /// it is not out on loan.
+    pub fn owned_root<W: WidgetHost + 'static>(&self, id: WidgetId) -> Option<std::ptr::NonNull<W>> {
+        let entry = self.arena.value(*self.by_id.get(&id)?)?;
+        let slot = entry.owned.as_ref()?;
+        if entry.lent || slot.type_id != std::any::TypeId::of::<W>() {
+            return None;
+        }
+        Some(slot.root.cast::<W>())
+    }
+
+    /// Give the tree's own widget `id` back by value, unregistering it (its links go too).
+    pub fn take_owned<W: WidgetHost + 'static>(&mut self, id: WidgetId) -> Option<W> {
+        let node = *self.by_id.get(&id)?;
+        let entry = self.arena.value_mut(node)?;
+        if entry.lent || entry.owned.as_ref()?.type_id != std::any::TypeId::of::<W>() {
+            return None;
+        }
+        let slot = std::mem::ManuallyDrop::new(entry.owned.take()?);
+        entry.ptr = None;
+        entry.alive = None;
+        entry.stable = false;
+        // SAFETY: the slot's root was made by `Box::into_raw` of a `W` (its type id says so);
+        // the slot is forgotten (ManuallyDrop) so it is freed only here, by the `Box` the
+        // widget is moved out of.
+        let widget = unsafe { *Box::from_raw(slot.root.cast::<W>().as_ptr()) };
+        // The token goes now: nothing resolves the old allocation again.
+        drop(unsafe { std::ptr::read(&slot._live) });
+        self.remove(id);
+        Some(widget)
+    }
+
+    /// Whether `id` is the tree's own widget.
+    pub fn is_owned(&self, id: WidgetId) -> bool {
+        self.by_id.get(&id).and_then(|&n| self.arena.value(n)).is_some_and(|e| e.owned.is_some())
+    }
+
+    /// Mark `id` lent (or back). Returns whether it was free to lend: false for an unknown id,
+    /// an unresolvable one, or one already out.
+    pub fn set_lent(&mut self, id: WidgetId, lent: bool) -> bool {
+        let Some(&node) = self.by_id.get(&id) else { return false };
+        let Some(entry) = self.arena.value_mut(node) else { return false };
+        if lent && (entry.lent || entry.ptr.is_none()) {
+            return false;
+        }
+        entry.lent = lent;
+        true
     }
 
     /// Remove `id` and its whole subtree, freeing arena slots and dropping their `by_id` entries.
diff --git a/src/widget/handle.rs b/src/widget/handle.rs
new file mode 100644
index 0000000..47b9343
--- /dev/null
+++ b/src/widget/handle.rs
@@ -0,0 +1,174 @@
+//! `Handle<W>` — how an app names a widget the [`UiContext`] owns.
+//!
+//! A handle is a `WidgetId` and the widget's type: `Copy`, cheap, and good for the widget's
+//! life. It is not a reference; the widget is reached through the context
+//! (`ctx.get_mut(h)`, `ctx[h]`), which is what lets the compiler keep an app's access and the
+//! context's own from overlapping. A removed widget's handle resolves to `None`. See
+//! `docs/rfc-owning-registry.md`.
+//!
+//! [`UiContext`]: crate::context::UiContext
+
+use std::marker::PhantomData;
+
+use super::WidgetId;
+
+pub struct Handle<W: ?Sized> {
+    id: WidgetId,
+    _w: PhantomData<fn() -> W>,
+}
+
+impl<W: ?Sized> Handle<W> {
+    /// The handle for the widget registered under `id` — for the context, which made it.
+    pub(crate) fn from_id(id: WidgetId) -> Self {
+        Handle { id, _w: PhantomData }
+    }
+
+    /// The widget's id: what focus, links, popovers and dispatch roots are keyed by.
+    pub fn id(&self) -> WidgetId {
+        self.id
+    }
+}
+
+impl<W: ?Sized> Clone for Handle<W> {
+    fn clone(&self) -> Self {
+        *self
+    }
+}
+impl<W: ?Sized> Copy for Handle<W> {}
+
+impl<W: ?Sized> PartialEq for Handle<W> {
+    fn eq(&self, other: &Self) -> bool {
+        self.id == other.id
+    }
+}
+impl<W: ?Sized> Eq for Handle<W> {}
+
+impl<W: ?Sized> std::hash::Hash for Handle<W> {
+    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
+        self.id.hash(state);
+    }
+}
+
+impl<W: ?Sized> std::fmt::Debug for Handle<W> {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        write!(f, "Handle<{}>({:?})", std::any::type_name::<W>(), self.id)
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use std::cell::Cell;
+    use std::rc::Rc;
+
+    use crate::context::UiContext;
+    use crate::widget::{ElementState, Event, MouseButton, Slider, Widget, WidgetHost};
+
+    /// A widget that records, each time it handles an event, whether the context would hand
+    /// it out again from inside that call; and counts its drops.
+    struct Reacher {
+        base: Widget,
+        saw_itself: Option<bool>,
+        drops: Rc<Cell<u32>>,
+    }
+    impl Reacher {
+        fn new(drops: &Rc<Cell<u32>>) -> Self {
+            Reacher { base: Widget::new(), saw_itself: None, drops: drops.clone() }
+        }
+    }
+    impl Drop for Reacher {
+        fn drop(&mut self) {
+            self.drops.set(self.drops.get() + 1);
+        }
+    }
+    impl WidgetHost for Reacher {
+        crate::impl_widget_base!(Reacher);
+        fn handle_event(&mut self, _event: &Event, ctx: &mut UiContext) -> bool {
+            let id = self.base.id();
+            self.saw_itself = Some(ctx.get_widget_mut(id).is_some() || ctx.lend(id, |_, _| ()).is_some());
+            true
+        }
+    }
+
+    /// A widget the context owns is reached by its handle, changed through it, given back by
+    /// value, and its handle then resolves to nothing; dropping the context drops the rest.
+    #[test]
+    fn an_owned_widget_is_reached_by_handle_and_given_back() {
+        let drops = Rc::new(Cell::new(0));
+        let mut ctx = UiContext::new();
+        let a = ctx.insert(Reacher::new(&drops));
+        let b = ctx.insert(Reacher::new(&drops));
+        assert!(ctx.tree.is_registered(a.id()), "registered under its own id");
+        ctx[a].saw_itself = Some(false);
+        assert_eq!(ctx.get(a).and_then(|w| w.saw_itself), Some(false));
+        let back = ctx.remove(a).expect("given back");
+        assert_eq!(back.saw_itself, Some(false));
+        assert!(ctx.get(a).is_none() && !ctx.tree.is_registered(a.id()), "a removed handle names nothing");
+        drop(back);
+        assert_eq!(drops.get(), 1);
+        drop(ctx);
+        assert_eq!(drops.get(), 2, "the context drops what it owns");
+        let _ = b;
+    }
+
+    /// Routed dispatch reaches an owned widget exactly as a registered one: a press arms the
+    /// drag, a move past the slop drags the slider.
+    #[test]
+    fn routed_dispatch_reaches_an_owned_widget() {
+        let mut ctx = UiContext::new();
+        let h = ctx.insert(Slider::new());
+        WidgetHost::set_rect(&mut ctx[h], 0.0, 0.0, 200.0, 30.0);
+        let press = Event::MouseButton { button: MouseButton::Left, state: ElementState::Pressed, x: 100.0, y: 15.0, local_x: 100.0, local_y: 15.0 };
+        assert!(ctx.propagate_event(&press, h.id()));
+        assert!(ctx[h].is_dragging());
+        let release = Event::MouseButton { button: MouseButton::Left, state: ElementState::Released, x: 150.0, y: 15.0, local_x: 150.0, local_y: 15.0 };
+        ctx.propagate_event(&release, h.id());
+        assert!(!ctx[h].is_dragging());
+    }
+
+    /// While the context is inside a widget it has that widget out on loan: the widget
+    /// reaching for itself through the context finds nothing — a miss, not a second `&mut`.
+    /// Afterwards it is back.
+    #[test]
+    fn a_lent_widget_is_not_reachable_until_it_is_back() {
+        let drops = Rc::new(Cell::new(0));
+        let mut ctx = UiContext::new();
+        let h = ctx.insert(Reacher::new(&drops));
+        let ev = Event::FocusIn;
+        assert!(ctx.propagate_event(&ev, h.id()));
+        assert_eq!(ctx[h].saw_itself, Some(false), "out on loan while handling");
+        assert!(ctx.get(h).is_some() && ctx.lend(h.id(), |_, _| ()).is_some(), "back afterwards");
+    }
+
+    /// An app that rebuilds its links every frame (`clear_hierarchy`) does not hand back the
+    /// widgets the context owns by doing so.
+    #[test]
+    fn clearing_the_hierarchy_keeps_owned_widgets() {
+        let drops = Rc::new(Cell::new(0));
+        let mut ctx = UiContext::new();
+        let parent = ctx.insert(Reacher::new(&drops));
+        let child = ctx.insert(Reacher::new(&drops));
+        ctx.link_ids(parent.id(), child.id());
+        ctx.clear_hierarchy();
+        assert!(ctx.get(parent).is_some() && ctx.get(child).is_some());
+        assert!(ctx.tree.child_ids(parent.id()).is_empty(), "the links go");
+        assert_eq!(drops.get(), 0);
+    }
+
+    /// The app's access through its handle and the context's dispatch take turns on one
+    /// widget, under Miri's aliasing models too (CI runs this module under Miri).
+    #[test]
+    fn an_app_and_the_context_take_turns_through_a_handle() {
+        let drops = Rc::new(Cell::new(0));
+        let mut ctx = UiContext::new();
+        let h = ctx.insert(Reacher::new(&drops));
+        for _ in 0..3 {
+            ctx[h].saw_itself = None;
+            ctx.propagate_event(&Event::FocusIn, h.id());
+            let w = ctx.get_mut(h).unwrap();
+            assert_eq!(w.saw_itself, Some(false));
+            w.saw_itself = None;
+            ctx.set_focused_id(h.id());
+            ctx.clear_focus();
+        }
+    }
+}
diff --git a/src/widget/mod.rs b/src/widget/mod.rs
index 828f145..c009840 100644
--- a/src/widget/mod.rs
+++ b/src/widget/mod.rs
@@ -746,6 +746,7 @@ pub mod doc_editor;
 pub mod line_edit;
 pub mod model;
 pub mod owned;
+pub mod handle;
 pub mod scroll_region;
 pub mod scroll_motion;
 pub mod side_swipe;
@@ -758,6 +759,7 @@ pub use self::side_swipe::{SideSwipe, SwipeDir};
 pub use self::scroll_motion::{Bounds, ScrollAxis, ScrollMotion, ScrollPhase, ScrollSettings, LINE_PX};
 pub use self::model::{Adapted, EventCtx, Input, Layout, Paint};
 pub use self::owned::Owned;
+pub use self::handle::Handle;
 pub use self::core::{Widget, focus, hover_animation, clipboard, context_menu, clear_widget_references};
 pub use self::core::focus::link_parent_child;
 pub use self::input::{