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

commitdf44b179a60961ead00152deb883d34fbe30b411
parent1d538c6e08
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-07 15:30
fix(context): register_widget is an unsafe fn; register by reference

register_widget(id, ptr) read a raw pointer inside a safe function — the
last two of clippy's not_unsafe_ptr_arg_deref errors. Every app now
registers through register_host(&mut w) (16 crates moved in the same
sweep), so register_widget becomes an unsafe fn whose contract is the one
WidgetTree::register already states.

The toolkit's own paths that hold a borrow moved too: render_widget, the
paginator's sidebar, the tree list's fields, and Adapted::set_parent,
which now takes its parent by reference (every caller passes None). What
still registers a pointer does so in an unsafe block with its SAFETY:
link_parent_child, whose trait objects are not 'static, and the tests
that drive raw pointers on purpose. The demo and cce-relief swap their
`roots() -> [*mut dyn WidgetHost; N]` helpers for a register_roots() that
registers each field by reference — the pattern new apps copy.

All 30 dependents check; the suites of the 16 changed apps pass;
cce-fonts (a filter rebuilding its rows), cce-designer, the demo,
cce-weather and cce-data-editor driven in a shadow behave as before.

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

 CLAUDE.md                           | 13 ++++++---
 src/bin/cce-ramp.rs                 |  8 +++---
 src/bin/cce-relief.rs               | 54 +++++++++++++++++--------------------
 src/context.rs                      | 41 ++++++++++++++++------------
 src/layout.rs                       |  5 +---
 src/main.rs                         | 30 +++++++++------------
 src/scene/painter.rs                |  6 +++--
 src/widget/container/group.rs       |  3 ++-
 src/widget/container/menu.rs        |  6 ++---
 src/widget/container/paginator.rs   |  9 +++----
 src/widget/container/spreadsheet.rs | 30 ++++++++-------------
 src/widget/container/treelist.rs    | 23 +++++-----------
 src/widget/core.rs                  |  7 +++--
 src/widget/display/graph.rs         | 21 +++++----------
 src/widget/display/node.rs          |  3 +--
 src/widget/input/bevel_preview.rs   |  4 +--
 src/widget/input/button.rs          |  7 +++--
 src/widget/input/checkbox.rs        |  8 +++---
 src/widget/input/slider.rs          | 10 +++----
 src/widget/input/spinbox.rs         |  9 +++----
 src/widget/model.rs                 | 33 ++++++++++++-----------
 21 files changed, 151 insertions(+), 179 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index 6f859a7..9127973 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -1580,10 +1580,15 @@ returned by value): the token moves with it. Register from a live borrow before
 reads it, as the apps' rebuilds do. The sound end state is a registry that owns its widgets.
 
 The pointer-taking entry points say so:
-- `WidgetTree::register`, `UiContext::set_focused_ptr`, `show_context_menu` and
-  `handle_right_click` are `unsafe fn`.
-- `register_widget` stays safe only because 105 app call sites use it. New code calls
-  `register_host(&mut w)`.
+- `WidgetTree::register`, `UiContext::register_widget`, `set_focused_ptr`,
+  `show_context_menu` and `handle_right_click` are `unsafe fn`.
+- `Adapted::set_parent` takes its parent by reference.
+- **Register a widget with `register_host(&mut w)`.** Every app moved to it, and so did
+  the toolkit's own paths that hold a borrow (`render_widget`, the paginator, the tree list).
+- `register_widget` remains for the paths that only have a pointer: `link_parent_child`,
+  whose trait objects are not `'static`, and tests that exercise raw pointers.
+- The demo's `register_roots` is the pattern to copy. The `roots()` helpers that returned
+  `[*mut dyn WidgetHost; N]` for a registration loop are gone.
 
 `a_dropped_widget_is_never_handed_out` and `a_clone_has_a_liveness_of_its_own` are the tests.
 
diff --git a/src/bin/cce-ramp.rs b/src/bin/cce-ramp.rs
index 4c9a08f..fb8d27b 100644
--- a/src/bin/cce-ramp.rs
+++ b/src/bin/cce-ramp.rs
@@ -189,11 +189,9 @@ impl Application for RampPopup {
     fn display_list(&mut self, size: LogicalSize, scale: f64) -> Option<DisplayList> {
         if !self.registered {
             self.registered = true;
-            let w = self.ramp.as_ptr_mut();
-            let id = self.ramp.id();
-            self.ui_context.register_widget(id, w);
-            self.ui_context.register_widget(self.save_button.id(), self.save_button.as_ptr_mut());
-            self.ui_context.register_widget(self.cancel_button.id(), self.cancel_button.as_ptr_mut());
+            self.ui_context.register_host(&mut self.ramp);
+            self.ui_context.register_host(&mut self.save_button);
+            self.ui_context.register_host(&mut self.cancel_button);
         }
 
         let size_changed = self.width != size.width as u32
diff --git a/src/bin/cce-relief.rs b/src/bin/cce-relief.rs
index 6f7c369..3ea70cc 100644
--- a/src/bin/cce-relief.rs
+++ b/src/bin/cce-relief.rs
@@ -1065,28 +1065,30 @@ impl BevelPopup {
         ]
     }
 
-    fn roots(&mut self) -> [*mut (dyn WidgetHost + 'static); 19] {
-        [
-            self.profile_dropdown.as_ptr_mut(),
-            self.edge_dropdown.as_ptr_mut(),
-            self.wall.shoulder.as_ptr_mut(),
-            self.wall.base.as_ptr_mut(),
-            self.wall.bias.as_ptr_mut(),
-            self.edge.shoulder.as_ptr_mut(),
-            self.edge.base.as_ptr_mut(),
-            self.edge.bias.as_ptr_mut(),
-            self.depth_slider.as_ptr_mut(),
-            self.width_slider.as_ptr_mut(),
-            self.height_slider.as_ptr_mut(),
-            self.spec_slider.as_ptr_mut(),
-            self.shine_slider.as_ptr_mut(),
-            self.curv_slider.as_ptr_mut(),
-            self.comp_slider.as_ptr_mut(),
-            self.refr_slider.as_ptr_mut(),
-            self.radius_slider.as_ptr_mut(),
-            self.save_button.as_ptr_mut(),
-            self.cancel_button.as_ptr_mut(),
-        ]
+    /// 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.profile_dropdown);
+        ctx.register_host(&mut self.edge_dropdown);
+        ctx.register_host(&mut self.wall.shoulder);
+        ctx.register_host(&mut self.wall.base);
+        ctx.register_host(&mut self.wall.bias);
+        ctx.register_host(&mut self.edge.shoulder);
+        ctx.register_host(&mut self.edge.base);
+        ctx.register_host(&mut self.edge.bias);
+        ctx.register_host(&mut self.depth_slider);
+        ctx.register_host(&mut self.width_slider);
+        ctx.register_host(&mut self.height_slider);
+        ctx.register_host(&mut self.spec_slider);
+        ctx.register_host(&mut self.shine_slider);
+        ctx.register_host(&mut self.curv_slider);
+        ctx.register_host(&mut self.comp_slider);
+        ctx.register_host(&mut self.refr_slider);
+        ctx.register_host(&mut self.radius_slider);
+        ctx.register_host(&mut self.save_button);
+        ctx.register_host(&mut self.cancel_button);
     }
 
     /// The pane rung's bound material name, when config binds one — the
@@ -1744,13 +1746,7 @@ impl Application for BevelPopup {
     fn display_list(&mut self, size: LogicalSize, scale: f64) -> Option<DisplayList> {
         if !self.registered {
             self.registered = true;
-            let self_ptr = self as *mut Self;
-            unsafe {
-                for w in (*self_ptr).roots() {
-                    let id = (*w).base().id();
-                    self.ui_context.register_widget(id, w);
-                }
-            }
+            self.register_roots();
         }
 
         let size_changed = self.width != size.width as u32
diff --git a/src/context.rs b/src/context.rs
index 3f141d5..7fcc1cb 100644
--- a/src/context.rs
+++ b/src/context.rs
@@ -774,18 +774,19 @@ impl UiContext {
     /// registered again before the next pass reads it.
     pub fn register_host(&mut self, w: &mut (dyn WidgetHost + 'static)) {
         let id = w.base().id();
-        self.register_widget(id, w as *mut (dyn WidgetHost + 'static));
+        // SAFETY: derived from the live borrow we were handed.
+        unsafe { self.register_widget(id, w as *mut (dyn WidgetHost + 'static)) };
     }
 
-    /// Register a widget by raw pointer — the legacy form 105 app call sites still use
-    /// (`ctx.register_widget(id, w.as_ptr_mut())`). Prefer [`register_host`](Self::register_host).
+    /// Register a widget by raw pointer. Prefer [`register_host`](Self::register_host), which
+    /// takes a reference; this form is for the toolkit's own pointer-routed paths.
+    ///
+    /// # Safety
     ///
-    /// `ptr` must be null or point to a live widget AT THE CALL; it is read here. Taking a raw
-    /// pointer in a safe function is unsound in principle (clippy says so) and is kept only until
-    /// the apps have moved to `register_host`; a pointer derived from a live borrow just before
-    /// the call, which is every call site today, meets the requirement.
-    pub fn register_widget(&mut self, id: WidgetId, ptr: *mut (dyn WidgetHost + 'static)) {
-        // SAFETY: the documented precondition above.
+    /// `ptr` must be null or point to a live widget at the call; it is read here (see
+    /// [`WidgetTree::register`](crate::scene::tree::WidgetTree::register)).
+    pub unsafe fn register_widget(&mut self, id: WidgetId, ptr: *mut (dyn WidgetHost + 'static)) {
+        // SAFETY: the caller's contract.
         unsafe { self.tree.register(id, ptr) };
         // A newcomer may itself have a popover rect, so the coverage memo can no
         // longer be trusted. Pages that re-register a whole list do it before
@@ -1281,7 +1282,8 @@ mod tests {
         WidgetHost::set_rect(&mut slider, 0.0, 0.0, 200.0, 30.0);
         let ptr = slider.as_ptr_mut();
         let id = slider.base().id();
-        ctx.register_widget(id, ptr);
+        // SAFETY: a test widget, live for the whole test.
+        unsafe { ctx.register_widget(id, ptr) };
 
         let press = Event::MouseButton {
             button: MouseButton::Left,
@@ -1329,13 +1331,14 @@ mod tests {
     fn multi_root_press_dispatch_keeps_the_drag_target() {
         let mut ctx = UiContext::new();
         let mut slider = crate::widget::Slider::new().with_value(0.5);
-        let (id, ptr) = (slider.id(), slider.as_ptr_mut());
-        ctx.register_widget(id, ptr);
+        let id = slider.id();
+        ctx.register_host(&mut slider);
         slider.set_rect(0.0, 0.0, 200.0, 30.0);
         let mut other = Block { base: Widget::new_rect(300.0, 300.0, 50.0, 50.0) };
         let other_ptr = &mut other as *mut _ as *mut (dyn crate::widget::WidgetHost + 'static);
         let other_id = other.base.id();
-        ctx.register_widget(other_id, other_ptr);
+        // SAFETY: a test widget, live for the whole test.
+        unsafe { ctx.register_widget(other_id, other_ptr) };
 
         let press = Event::MouseButton {
             button: MouseButton::Left,
@@ -1394,7 +1397,8 @@ mod tests {
         let mut ctx = UiContext::new();
         let mut w = Block { base: Widget::new_rect(10.0, 10.0, 50.0, 50.0) };
         let ptr = &mut w as *mut _ as *mut (dyn crate::widget::WidgetHost + 'static);
-        ctx.register_widget(w.base.id(), ptr);
+        // SAFETY: a test widget, live for the whole test.
+        unsafe { ctx.register_widget(w.base.id(), ptr) };
         ctx.rebuild_spatial_grid();
 
         assert!(ctx.drag_allowed_at(200.0, 200.0), "empty surface is draggable");
@@ -1423,7 +1427,8 @@ mod focus_step_tests {
         for w in [&mut b as &mut dyn WidgetHost, &mut a, &mut t] {
             let (id, ptr) = (w.base().id(), w as *mut dyn WidgetHost);
             let ptr = unsafe { std::mem::transmute::<*mut dyn WidgetHost, *mut (dyn WidgetHost + 'static)>(ptr) };
-            ctx.register_widget(id, ptr);
+            // SAFETY: a test widget, live for the whole test.
+            unsafe { ctx.register_widget(id, ptr) };
         }
         let (ia, ib, it) = (a.id(), b.id(), t.id());
 
@@ -1450,7 +1455,8 @@ mod focus_step_tests {
         let mut g = crate::widget::Group::new(vec![ia, it]);
         let (gid, gptr) = (g.base().id(), &mut g as *mut dyn WidgetHost);
         let gptr = unsafe { std::mem::transmute::<*mut dyn WidgetHost, *mut (dyn WidgetHost + 'static)>(gptr) };
-        ctx.register_widget(gid, gptr);
+        // SAFETY: a test widget, live for the whole test.
+        unsafe { ctx.register_widget(gid, gptr) };
         assert_eq!(ctx.focus_clusters(), vec![vec![ia, it], vec![ib]]);
         ctx.set_focused_id(ia);
         assert!(ctx.focus_step(false));
@@ -1468,7 +1474,8 @@ mod focus_step_tests {
         WidgetHost::set_rect(&mut parked, -1000.0, -1000.0, 1.0, 1.0);
         let (pid, pptr) = (parked.base().id(), &mut parked as *mut dyn WidgetHost);
         let pptr = unsafe { std::mem::transmute::<*mut dyn WidgetHost, *mut (dyn WidgetHost + 'static)>(pptr) };
-        ctx.register_widget(pid, pptr);
+        // SAFETY: a test widget, live for the whole test.
+        unsafe { ctx.register_widget(pid, pptr) };
         for _ in 0..4 {
             ctx.focus_step(false);
             assert!(!ctx.is_focused_id(pid), "the parked plate never takes focus");
diff --git a/src/layout.rs b/src/layout.rs
index cb81482..a404298 100644
--- a/src/layout.rs
+++ b/src/layout.rs
@@ -4220,10 +4220,7 @@ impl RenderTarget for PopoverCollector {
 
 
 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) {
-    let id = Some(w.base().id());
-    if let Some(w_id) = id {
-        ctx.register_widget(w_id, w as *mut T as *mut (dyn WidgetHost + 'static));
-    }
+    ctx.register_host(w);
     // The flat-host contract, the same block `set_rect` takes: `(x, y)` is the top of
     // the detached label and `wh` the block height, label strip included. `layout`
     // takes the CONTENT origin and height, so step down by the strip.
diff --git a/src/main.rs b/src/main.rs
index c6455e9..c5dfb34 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -95,16 +95,18 @@ impl DemoApp {
 
     /// The widget roots as pointers, for the one genuinely pointer-consuming path left:
     /// registration (the registry stores them). The paint walk takes shared borrows.
-    fn roots(&mut self) -> [*mut (dyn WidgetHost + 'static); 7] {
-        [
-            self.button.as_ptr_mut(),
-            self.toggle.as_ptr_mut(),
-            self.slider.as_ptr_mut(),
-            self.name_box.as_ptr_mut(),
-            self.theme_dropdown.as_ptr_mut(),
-            self.image_contain.as_ptr_mut(),
-            self.image_stretch.as_ptr_mut(),
-        ]
+    /// 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);
     }
 
     /// `take_*` plumbing: translate widget changes into app state. Runs after any routed
@@ -231,13 +233,7 @@ impl Application for DemoApp {
         // `self` sits at its final address — hence here, not in `new()`.
         if !self.widgets_registered {
             self.widgets_registered = true;
-            let self_ptr = self as *mut Self;
-            unsafe {
-                for w in (*self_ptr).roots() {
-                    let id = (*w).base().id();
-                    self.ui_context.register_widget(id, w);
-                }
-            }
+            self.register_roots();
         }
 
         let size_changed = self.width != size.width as u32
diff --git a/src/scene/painter.rs b/src/scene/painter.rs
index 433521d..e5b4392 100644
--- a/src/scene/painter.rs
+++ b/src/scene/painter.rs
@@ -239,7 +239,8 @@ mod tests {
     fn reg(ctx: &mut UiContext, w: &mut P) -> (crate::widget::WidgetId, ElemPtr) {
         let ptr = &mut *w as *mut _ as *mut (dyn crate::widget::WidgetHost + 'static);
         let id = w.base.id();
-        ctx.register_widget(id, ptr);
+        // SAFETY: a test widget, live for the whole test.
+        unsafe { ctx.register_widget(id, ptr) };
         (id, ptr)
     }
 
@@ -375,7 +376,8 @@ mod tests {
         w.base.w = 20.0;
         w.base.h = 10.0;
         let ptr = &mut w as *mut _ as *mut (dyn crate::widget::WidgetHost + 'static);
-        ctx.register_widget(w.base.id(), ptr);
+        // SAFETY: a test widget, live for the whole test.
+        unsafe { ctx.register_widget(w.base.id(), ptr) };
 
         let list = paint_tree(&ctx, unsafe { &*ptr });
         assert!(
diff --git a/src/widget/container/group.rs b/src/widget/container/group.rs
index 42c997a..1e349b9 100644
--- a/src/widget/container/group.rs
+++ b/src/widget/container/group.rs
@@ -319,7 +319,8 @@ mod tests {
     fn register(ctx: &mut UiContext, w: &mut dyn WidgetHost) -> WidgetId {
         let id = w.base().id();
         let ptr = unsafe { std::mem::transmute::<*mut dyn WidgetHost, *mut (dyn WidgetHost + 'static)>(w as *mut dyn WidgetHost) };
-        ctx.register_widget(id, ptr);
+        // SAFETY: a test widget, live for the whole test.
+        unsafe { ctx.register_widget(id, ptr) };
         id
     }
 
diff --git a/src/widget/container/menu.rs b/src/widget/container/menu.rs
index a9fa30d..2e812ea 100644
--- a/src/widget/container/menu.rs
+++ b/src/widget/container/menu.rs
@@ -1219,8 +1219,7 @@ mod tests {
     fn menu_open_click_and_controller_roundtrip() {
         let mut ctx = UiContext::new();
         let mut mb = bar();
-        let (id, ptr) = (mb.id(), mb.as_ptr_mut());
-        ctx.register_widget(id, ptr);
+        ctx.register_host(&mut mb);
         WidgetHost::set_rect(&mut mb, 0.0, 0.0, 400.0, 24.0);
 
         // Click the "File" strip button (the strip commits selection on release): the dropdown
@@ -1247,8 +1246,7 @@ mod tests {
     fn hidden_menubar_reports_no_menu_and_rejects_hits() {
         let mut ctx = UiContext::new();
         let mut mb = bar();
-        let (id, ptr) = (mb.id(), mb.as_ptr_mut());
-        ctx.register_widget(id, ptr);
+        ctx.register_host(&mut mb);
         WidgetHost::set_rect(&mut mb, 0.0, 0.0, 400.0, 24.0);
 
         WidgetHost::set_visible(&mut mb, false);
diff --git a/src/widget/container/paginator.rs b/src/widget/container/paginator.rs
index 770cf9f..2114ff2 100644
--- a/src/widget/container/paginator.rs
+++ b/src/widget/container/paginator.rs
@@ -161,8 +161,7 @@ impl Layout for Paginator {
     }
 
     fn register_embedded_children(&mut self, host_id: WidgetId, ctx: &mut UiContext) {
-        let menu_ptr = self.sidebar_menu.as_ptr_mut();
-        ctx.register_widget(self.sidebar_menu.id(), menu_ptr);
+        ctx.register_host(&mut self.sidebar_menu);
         ctx.link_ids(host_id, self.sidebar_menu.id());
     }
 }
@@ -293,8 +292,7 @@ mod tests {
     fn sidebar_click_switches_page_and_drains_menu_click() {
         let mut ctx = UiContext::new();
         let mut p = paginator();
-        let (id, ptr) = (p.id(), p.as_ptr_mut());
-        ctx.register_widget(id, ptr);
+        ctx.register_host(&mut p);
 
         // Click the second tab (the strip commits selection on release): the selection moves
         // and menu_click reports (1, 0) once.
@@ -316,8 +314,7 @@ mod tests {
     fn plain_quads_split_like_legacy_and_registration_heals_on_tick() {
         let mut ctx = UiContext::new();
         let mut p = paginator();
-        let (id, ptr) = (p.id(), p.as_ptr_mut());
-        ctx.register_widget(id, ptr);
+        ctx.register_host(&mut p);
 
         // Legacy split: `extra_quads` is the children's chrome only; the sidebar background
         // (a rounded rect) lives in `all_rounded_quads` alone (layout-interface draws its
diff --git a/src/widget/container/spreadsheet.rs b/src/widget/container/spreadsheet.rs
index f3bf3e8..d92bc6e 100644
--- a/src/widget/container/spreadsheet.rs
+++ b/src/widget/container/spreadsheet.rs
@@ -1225,8 +1225,7 @@ mod tests {
     fn horizontal_wheel_integrates_and_decays_through_tick() {
         let mut ctx = UiContext::new();
         let mut s = wide(6);
-        let (id, ptr) = (s.id(), s.as_ptr_mut());
-        ctx.register_widget(id, ptr);
+        ctx.register_host(&mut s);
 
         let wheel = Event::MouseWheel {
             delta: MouseScrollDelta::LineDelta(-2.0, 0.0),
@@ -1250,7 +1249,8 @@ mod tests {
         // A pane whose columns fit ignores horizontal wheels.
         let mut fits = wide(2);
         let (fid, fptr) = (fits.id(), fits.as_ptr_mut());
-        ctx.register_widget(fid, fptr);
+        // SAFETY: a test widget, live for the whole test.
+        unsafe { ctx.register_widget(fid, fptr) };
         assert!(!fits.handle_event(&wheel, &mut ctx), "no overflow, wheel passes through");
     }
 
@@ -1258,8 +1258,7 @@ mod tests {
     fn wheel_velocity_integrates_and_decays_through_tick() {
         let mut ctx = UiContext::new();
         let mut s = filled(50);
-        let (id, ptr) = (s.id(), s.as_ptr_mut());
-        ctx.register_widget(id, ptr);
+        ctx.register_host(&mut s);
 
         // A wheel over the body feeds velocity (negated delta, the ScrollRegion
         // convention: a negative line delta scrolls the view down)…
@@ -1289,8 +1288,7 @@ mod tests {
     fn scrollbar_drag_and_keys_move_the_scroll() {
         let mut ctx = UiContext::new();
         let mut s = filled(50);
-        let (id, ptr) = (s.id(), s.as_ptr_mut());
-        ctx.register_widget(id, ptr);
+        ctx.register_host(&mut s);
         let rect = Rect { x: 0.0, y: 0.0, width: 200.0, height: 124.0 };
 
         // content 1200, viewport 100 -> overflowing, so the host may drag it.
@@ -1365,8 +1363,7 @@ mod tests {
         let mut s = Spreadsheet::new();
         s.set_visible(true);
         WidgetHost::set_rect(&mut s, 0.0, 0.0, 200.0, 124.0);
-        let (id, ptr) = (s.id(), s.as_ptr_mut());
-        ctx.register_widget(id, ptr);
+        ctx.register_host(&mut s);
         // Numeric strings out of lexicographic order: "10" must sort after "9".
         let rows = vec![
             vec!["10".to_string(), "b".to_string()],
@@ -1425,8 +1422,7 @@ mod tests {
     fn rows_select_alone_toggled_and_in_runs() {
         let mut ctx = UiContext::new();
         let mut s = filled(50);
-        let (id, ptr) = (s.id(), s.as_ptr_mut());
-        ctx.register_widget(id, ptr);
+        ctx.register_host(&mut s);
         // Rows are 24 tall under a 24 header: row k spans 24 + 24k.
         let row_y = |k: usize| 24.0 + 24.0 * k as f32 + 12.0;
 
@@ -1477,8 +1473,7 @@ mod tests {
         let mut t = Spreadsheet::new();
         t.set_visible(true);
         WidgetHost::set_rect(&mut t, 0.0, 0.0, 200.0, 124.0);
-        let (id, ptr) = (t.id(), t.as_ptr_mut());
-        ctx.register_widget(id, ptr);
+        ctx.register_host(&mut t);
         let rows = vec![
             vec!["10".to_string(), "b".to_string()],
             vec!["9".to_string(), "c".to_string()],
@@ -1511,8 +1506,7 @@ mod tests {
         s.set_visible(true);
         let rect = Rect { x: 0.0, y: 0.0, width: 200.0, height: 124.0 };
         WidgetHost::set_rect(&mut s, rect.x, rect.y, rect.width, rect.height);
-        let (id, ptr) = (s.id(), s.as_ptr_mut());
-        ctx.register_widget(id, ptr);
+        ctx.register_host(&mut s);
         let headers: Vec<String> = (0..6).map(|i| format!("c{i}")).collect();
         let rows: Vec<Vec<String>> = (0..50).map(|r| (0..6).map(|c| format!("{r}.{c}")).collect()).collect();
         SpreadsheetController::set_spreadsheet_data(&mut *s, headers, rows);
@@ -1648,8 +1642,7 @@ mod tests {
         let mut s = Spreadsheet::new();
         s.set_visible(true);
         WidgetHost::set_rect(&mut s, 0.0, 0.0, 200.0, 124.0);
-        let (id, ptr) = (s.id(), s.as_ptr_mut());
-        ctx.register_widget(id, ptr);
+        ctx.register_host(&mut s);
         let n = 1000;
         SpreadsheetController::set_spreadsheet_columns(
             &mut *s,
@@ -1696,8 +1689,7 @@ mod tests {
         let mut s = Spreadsheet::new();
         s.set_visible(true);
         WidgetHost::set_rect(&mut s, 0.0, 0.0, 200.0, 124.0);
-        let (id, ptr) = (s.id(), s.as_ptr_mut());
-        ctx.register_widget(id, ptr);
+        ctx.register_host(&mut s);
         SpreadsheetController::set_spreadsheet_data(
             &mut *s,
             vec!["a".into(), "b".into()],
diff --git a/src/widget/container/treelist.rs b/src/widget/container/treelist.rs
index e523fdb..38ffeb2 100644
--- a/src/widget/container/treelist.rs
+++ b/src/widget/container/treelist.rs
@@ -488,9 +488,8 @@ impl TreeList {
                         self.edit_box.cursor_idx = self.edit_box.text.chars().count();
                         self.edit_box.select_anchor = Some(0);
                         
-                        let eb_ptr = self.edit_box.as_ptr_mut();
                         let eb_id = self.edit_box.base().id();
-                        ui.register_widget(eb_id, eb_ptr);
+                        ui.register_host(&mut self.edit_box);
                         ui.link_ids(host_id, eb_id);
                         
                         ui.set_focused(&mut self.edit_box);
@@ -697,22 +696,14 @@ impl Layout for TreeList {
         // opened, and the wheel died the same way). Registration alone keeps the ids
         // resolvable for focus, coverage, and the spatial grid.
         let _ = host_id;
-        let sb_ptr = self.search_box.as_ptr_mut();
-        let sb_id = self.search_box.base().id();
-        ctx.register_widget(sb_id, sb_ptr);
+        ctx.register_host(&mut self.search_box);
 
-        let btn_ptr = self.add_key_btn.as_ptr_mut();
-        let btn_id = self.add_key_btn.base().id();
-        ctx.register_widget(btn_id, btn_ptr);
+        ctx.register_host(&mut self.add_key_btn);
 
-        let pop_ptr = self.add_key_popover_box.as_ptr_mut();
-        let pop_id = self.add_key_popover_box.base().id();
-        ctx.register_widget(pop_id, pop_ptr);
+        ctx.register_host(&mut self.add_key_popover_box);
 
         if self.editing_key_idx.is_some() {
-            let eb_ptr = self.edit_box.as_ptr_mut();
-            let eb_id = self.edit_box.base().id();
-            ctx.register_widget(eb_id, eb_ptr);
+            ctx.register_host(&mut self.edit_box);
         }
     }
 }
@@ -1433,7 +1424,7 @@ mod tests {
         let mut tree_list = TreeList::new();
         tree_list.set_rect(10.0, 52.0, 380.0, 500.0);
 
-        ctx.register_widget(tree_list.base().id(), tree_list.as_ptr_mut());
+        ctx.register_host(&mut tree_list);
         ctx.tick(0.016);
         ctx.clear_dirty();
 
@@ -1447,7 +1438,7 @@ mod tests {
         let mut ctx = UiContext::new();
         let mut tree_list = TreeList::new();
 
-        ctx.register_widget(tree_list.base().id(), tree_list.as_ptr_mut());
+        ctx.register_host(&mut tree_list);
         ctx.rebuild_spatial_grid();
 
         let list_top = 52.0;
diff --git a/src/widget/core.rs b/src/widget/core.rs
index 0b522b1..f6a5b0e 100644
--- a/src/widget/core.rs
+++ b/src/widget/core.rs
@@ -77,8 +77,11 @@ pub mod focus {
             std::mem::transmute::<*mut dyn WidgetHost, *mut (dyn WidgetHost + 'static)>(child as *mut dyn WidgetHost)
         };
         let (p_id, c_id) = (parent.base().id(), child.base().id());
-        ctx.register_widget(p_id, parent_ptr);
-        ctx.register_widget(c_id, child_ptr);
+        // SAFETY: both derived from the live borrows we were handed.
+        unsafe {
+            ctx.register_widget(p_id, parent_ptr);
+            ctx.register_widget(c_id, child_ptr);
+        }
         // The old add_child + set_parent pair, as the tree ops they always were.
         ctx.tree.link(p_id, c_id);
         ctx.tree.set_parent(c_id, Some(p_id));
diff --git a/src/widget/display/graph.rs b/src/widget/display/graph.rs
index 9a2376d..7af03f9 100644
--- a/src/widget/display/graph.rs
+++ b/src/widget/display/graph.rs
@@ -1822,8 +1822,7 @@ mod tests {
     fn double_click_survives_the_between_press_node_resync() {
         let mut ctx = UiContext::new();
         let mut g = two_nodes();
-        let (id, ptr) = (g.id(), g.as_ptr_mut());
-        ctx.register_widget(id, ptr);
+        ctx.register_host(&mut g);
 
         // First press on node a, then the host re-syncs (same content),
         // then the second press: this is the real event sequence.
@@ -1843,8 +1842,7 @@ mod tests {
     fn presses_on_two_nodes_are_not_a_double_click() {
         let mut ctx = UiContext::new();
         let mut g = two_nodes();
-        let (id, ptr) = (g.id(), g.as_ptr_mut());
-        ctx.register_widget(id, ptr);
+        ctx.register_host(&mut g);
 
         assert!(g.mouse_input(MouseButton::Left, ElementState::Pressed, 110.0, 120.0, &mut ctx));
         g.mouse_input(MouseButton::Left, ElementState::Released, 110.0, 120.0, &mut ctx);
@@ -1857,8 +1855,7 @@ mod tests {
     fn node_press_selects_arms_drag_and_commit_snaps_to_grid() {
         let mut ctx = UiContext::new();
         let mut g = two_nodes();
-        let (id, ptr) = (g.id(), g.as_ptr_mut());
-        ctx.register_widget(id, ptr);
+        ctx.register_host(&mut g);
 
         // Node a occupies (100, 100, 80, 40). Press its body (away from ports/toggle).
         assert!(g.mouse_input(MouseButton::Left, ElementState::Pressed, 110.0, 120.0, &mut ctx));
@@ -1916,8 +1913,7 @@ mod tests {
             };
             // alpha above beta, beta reading alpha.
             g.set_nodes(&[node("a", "alpha", 0.0, 0.0, ""), node("b", "beta", 0.0, 2.0, "alpha")]);
-            let (id, ptr) = (g.id(), g.as_ptr_mut());
-            ctx.register_widget(id, ptr);
+            ctx.register_host(&mut g);
             // Drag alpha by its middle onto beta's middle.
             assert!(g.mouse_input(MouseButton::Left, ElementState::Pressed, 140.0, 120.0, &mut ctx));
             g.drag_begin(140.0, 120.0);
@@ -1977,8 +1973,7 @@ mod tests {
             node("b", "beta", 2.0, 0.0, p("alpha")),
             node("c", "gamma", 0.0, 2.0, p("")),
         ]);
-        let (id, ptr) = (g.id(), g.as_ptr_mut());
-        ctx.register_widget(id, ptr);
+        ctx.register_host(&mut g);
 
         // Drag gamma's body onto the wire's horizontal run (cell (1, 0)).
         assert!(g.mouse_input(MouseButton::Left, ElementState::Pressed, 110.0, 240.0, &mut ctx));
@@ -2216,8 +2211,7 @@ mod tests {
     fn port_click_starts_and_completes_a_connection() {
         let mut ctx = UiContext::new();
         let mut g = two_nodes();
-        let (id, ptr) = (g.id(), g.as_ptr_mut());
-        ctx.register_widget(id, ptr);
+        ctx.register_host(&mut g);
 
         // Ports float OUTSIDE the node box (port_center): node a's output
         // hangs below the bottom-center of (100,100,80,40), node b's input
@@ -2240,8 +2234,7 @@ mod tests {
     fn every_node_parameter_is_a_wire_into_its_own_port() {
         let mut ctx = UiContext::new();
         let mut g = two_nodes();
-        let (id, ptr) = (g.id(), g.as_ptr_mut());
-        ctx.register_widget(id, ptr);
+        ctx.register_host(&mut g);
         let wire = |n: &str, v: &str| (n.to_string(), v.to_string(), "node".to_string());
         let node = |id: &str, col: f32, row: f32, parameters: Vec<(String, String, String)>, inputs: usize| GraphNode {
             id: id.into(),
diff --git a/src/widget/display/node.rs b/src/widget/display/node.rs
index cf96c18..9ea1d45 100644
--- a/src/widget/display/node.rs
+++ b/src/widget/display/node.rs
@@ -262,8 +262,7 @@ mod tests {
     fn toggle_click_flips_geom_and_press_starts_drag() {
         let mut ctx = UiContext::new();
         let mut node = Node::new(100.0, 100.0, 120.0, 40.0, "geo1");
-        let (id, ptr) = (node.id(), node.as_ptr_mut());
-        ctx.register_widget(id, ptr);
+        ctx.register_host(&mut node);
 
         // Toggle zone: (100+120-30, 100+11) => 18x18 at (190, 111).
         assert!(node.mouse_input(MouseButton::Left, ElementState::Pressed, 195.0, 115.0, &mut ctx));
diff --git a/src/widget/input/bevel_preview.rs b/src/widget/input/bevel_preview.rs
index 9010690..6271582 100644
--- a/src/widget/input/bevel_preview.rs
+++ b/src/widget/input/bevel_preview.rs
@@ -202,8 +202,8 @@ mod tests {
     fn click_reports_once() {
         let mut ctx = UiContext::new();
         let mut p = BevelPreview::new();
-        let (id, ptr) = (p.id(), p.as_ptr_mut());
-        ctx.register_widget(id, ptr);
+        let id = p.id();
+        ctx.register_host(&mut p);
         WidgetHost::set_rect(&mut p, 0.0, 0.0, 125.0, 26.0);
         let ev = Event::MouseButton {
             button: MouseButton::Left,
diff --git a/src/widget/input/button.rs b/src/widget/input/button.rs
index a831b44..ac1032b 100644
--- a/src/widget/input/button.rs
+++ b/src/widget/input/button.rs
@@ -757,8 +757,7 @@ mod tests {
         use crate::scene::paint::Prim;
         let mut ctx = UiContext::new();
         let mut b = Button::new(0.0, 0.0, 0.0, 0.0).with_label("Load Images");
-        let (id, ptr) = (b.id(), b.as_ptr_mut());
-        ctx.register_widget(id, ptr);
+        ctx.register_host(&mut b);
         let size = b.intrinsic_size().unwrap();
         WidgetHost::set_rect(&mut b, 10.0, 20.0, size.width, size.height);
 
@@ -818,8 +817,8 @@ mod tests {
         let mut b = Button::new(10.0, 10.0, 80.0, 24.0)
             .with_label("Go")
             .on_click(move || { fired2.fetch_add(1, std::sync::atomic::Ordering::SeqCst); });
-        let (id, ptr) = (b.id(), b.as_ptr_mut());
-        ctx.register_widget(id, ptr);
+        let id = b.id();
+        ctx.register_host(&mut b);
 
         // Press in, release in -> click.
         assert!(ctx.propagate_event(&press(20.0, 20.0), id));
diff --git a/src/widget/input/checkbox.rs b/src/widget/input/checkbox.rs
index 28257a5..b209aa3 100644
--- a/src/widget/input/checkbox.rs
+++ b/src/widget/input/checkbox.rs
@@ -648,8 +648,8 @@ mod tests {
     fn checkbox_click_toggles_and_polls_like_legacy() {
         let mut ctx = UiContext::new();
         let mut cb = Checkbox::new();
-        let (id, ptr) = (cb.id(), cb.as_ptr_mut());
-        ctx.register_widget(id, ptr);
+        let id = cb.id();
+        ctx.register_host(&mut cb);
         WidgetHost::set_rect(&mut cb, 0.0, 0.0, 20.0, 20.0);
 
         assert!(ctx.propagate_event(&click_at(10.0, 10.0), id), "in-rect click consumed");
@@ -766,8 +766,8 @@ mod tests {
     fn toggle_click_glides_the_run_across_its_field() {
         let mut ctx = UiContext::new();
         let mut t = Toggle::new();
-        let (id, ptr) = (t.id(), t.as_ptr_mut());
-        ctx.register_widget(id, ptr);
+        let id = t.id();
+        ctx.register_host(&mut t);
         WidgetHost::set_rect(&mut t, 0.0, 0.0, 60.0, 30.0);
 
         let rect = Rect { x: 0.0, y: 0.0, width: 60.0, height: 30.0 };
diff --git a/src/widget/input/slider.rs b/src/widget/input/slider.rs
index 8e1829f..e2f016b 100644
--- a/src/widget/input/slider.rs
+++ b/src/widget/input/slider.rs
@@ -1126,8 +1126,8 @@ fn probe_slider_bridge() {
     fn slider_press_drag_and_wheel() {
         let mut ctx = UiContext::new();
         let mut sl = Slider::new().with_value(0.5);
-        let (id, ptr) = (sl.id(), sl.as_ptr_mut());
-        ctx.register_widget(id, ptr);
+        let id = sl.id();
+        ctx.register_host(&mut sl);
         WidgetHost::set_rect(&mut sl, 0.0, 0.0, 100.0, 20.0);
 
         // Press on the track grabs the thumb.
@@ -1161,8 +1161,7 @@ fn probe_slider_bridge() {
         let notch = |min: f32, max: f32, at: f32| -> f32 {
             let mut ctx = UiContext::new();
             let mut sl = Slider::new().with_range(min, max);
-            let (id, ptr) = (sl.id(), sl.as_ptr_mut());
-            ctx.register_widget(id, ptr);
+            ctx.register_host(&mut sl);
             WidgetHost::set_rect(&mut sl, 0.0, 0.0, 200.0, 20.0);
             sl.inner_mut().set_scaled_value(at);
             let before = sl.inner().get_scaled_value();
@@ -1207,8 +1206,7 @@ fn probe_slider_bridge() {
     fn a_slider_hovers_under_the_pointer() {
         let mut ctx = UiContext::new();
         let mut sl = Slider::new();
-        let (id, ptr) = (sl.id(), sl.as_ptr_mut());
-        ctx.register_widget(id, ptr);
+        ctx.register_host(&mut sl);
         WidgetHost::set_rect(&mut sl, 0.0, 0.0, 100.0, 20.0);
         assert!(!sl.inner().hovered());
         assert!(sl.on_cursor_moved(50.0, 10.0, &mut ctx), "entering is a change");
diff --git a/src/widget/input/spinbox.rs b/src/widget/input/spinbox.rs
index c4048dd..dea9da9 100644
--- a/src/widget/input/spinbox.rs
+++ b/src/widget/input/spinbox.rs
@@ -721,8 +721,7 @@ mod tests {
     fn spinbox_button_zones_step_the_value() {
         let mut ctx = UiContext::new();
         let mut sb = Spinbox::new(0, -100, 100, 1);
-        let (id, ptr) = (sb.id(), sb.as_ptr_mut());
-        ctx.register_widget(id, ptr);
+        ctx.register_host(&mut sb);
         WidgetHost::set_rect(&mut sb, 10.0, 20.0, 100.0, 26.0);
 
         // Legacy test: click at (75, 33) lands in the decrement zone.
@@ -743,8 +742,7 @@ mod tests {
         // FocusOut commit resets it to the stale text.
         let mut ctx = UiContext::new();
         let mut sb = Spinbox::new(6, 0, 100, 1);
-        let (id, ptr) = (sb.id(), sb.as_ptr_mut());
-        ctx.register_widget(id, ptr);
+        ctx.register_host(&mut sb);
         WidgetHost::set_rect(&mut sb, 10.0, 20.0, 100.0, 26.0);
         sb.begin_edit(true);
         assert_eq!(sb.edit_buffer, "6");
@@ -764,8 +762,7 @@ mod tests {
         use crate::widget::MouseScrollDelta;
         let mut ctx = UiContext::new();
         let mut sb = Spinbox::new(10, 0, 100, 5);
-        let (id, ptr) = (sb.id(), sb.as_ptr_mut());
-        ctx.register_widget(id, ptr);
+        ctx.register_host(&mut sb);
         WidgetHost::set_rect(&mut sb, 10.0, 20.0, 100.0, 26.0);
 
         // One notch up steps up, one notch down steps down — and the wheel is consumed.
diff --git a/src/widget/model.rs b/src/widget/model.rs
index 126eac7..1ef36db 100644
--- a/src/widget/model.rs
+++ b/src/widget/model.rs
@@ -904,14 +904,13 @@ impl<W: Layout + Paint + Input + 'static> Adapted<W> {
     }
 
     /// Register + (un)link this widget under a parent (off `WidgetHost` in 6bd batch 4).
-    pub fn set_parent(&mut self, parent: Option<*mut (dyn WidgetHost + 'static)>, ctx: &mut UiContext) {
+    pub fn set_parent(&mut self, parent: Option<&mut (dyn WidgetHost + 'static)>, ctx: &mut UiContext) {
         // Replica of the old WidgetHost default: symmetric tree link.
         let id = self.base.id();
-        if let Some(p_ptr) = parent {
-            let p_id = unsafe { (*p_ptr).base().id() };
-            ctx.register_widget(p_id, p_ptr);
-            let self_ptr = self.as_ptr_mut();
-            ctx.register_widget(id, self_ptr);
+        if let Some(p) = parent {
+            let p_id = p.base().id();
+            ctx.register_host(p);
+            ctx.register_host(self);
             ctx.tree.set_parent(id, Some(p_id));
         } else {
             ctx.tree.set_parent(id, None);
@@ -1818,9 +1817,12 @@ mod tests {
         let (root_id, root_ptr) = (root.id(), root.as_ptr_mut());
         let (a_id, a_ptr) = (a.id(), a.as_ptr_mut());
         let (b_id, b_ptr) = (b.id(), b.as_ptr_mut());
-        ctx.register_widget(root_id, root_ptr);
-        ctx.register_widget(a_id, a_ptr);
-        ctx.register_widget(b_id, b_ptr);
+        // SAFETY: a test widget, live for the whole test.
+        unsafe { ctx.register_widget(root_id, root_ptr) };
+        // SAFETY: a test widget, live for the whole test.
+        unsafe { ctx.register_widget(a_id, a_ptr) };
+        // SAFETY: a test widget, live for the whole test.
+        unsafe { ctx.register_widget(b_id, b_ptr) };
         ctx.link_ids(root_id, a_id);
         ctx.link_ids(root_id, b_id);
 
@@ -1893,8 +1895,9 @@ mod tests {
         use crate::widget::{ElementState, MouseButton};
         let mut ctx = UiContext::new();
         let mut w = Box::new(Adapted::new(Clicker { clicks: 0, entered: 0, left: 0 }));
-        let (id, ptr) = (w.id(), w.as_ptr_mut());
-        ctx.register_widget(id, ptr);
+        let id = w.id();
+        ctx.register_host(&mut *w);
+        let ptr = w.as_ptr_mut();
         unsafe { (*ptr).set_rect(10.0, 10.0, 40.0, 20.0) };
 
         let click_at = |x: f32, y: f32| Event::MouseButton {
@@ -1982,8 +1985,8 @@ mod tests {
 
         let mut ctx = UiContext::new();
         let mut w = Box::new(Adapted::new(Tag));
-        let (id, ptr) = (w.id(), w.as_ptr_mut());
-        ctx.register_widget(id, ptr);
+        ctx.register_host(&mut *w);
+        let ptr = w.as_ptr_mut();
         unsafe { (*ptr).set_rect(10.0, 20.0, 100.0, 30.0) };
 
         let list = paint_tree(&ctx, unsafe { &*ptr });
@@ -2022,8 +2025,8 @@ mod tests {
 
         let mut ctx = UiContext::new();
         let mut w = Box::new(Adapted::new(Tag).with_label("Name"));
-        let (id, ptr) = (w.id(), w.as_ptr_mut());
-        ctx.register_widget(id, ptr);
+        ctx.register_host(&mut *w);
+        let ptr = w.as_ptr_mut();
         unsafe { (*ptr).set_rect(10.0, 20.0, 100.0, 60.0) };
 
         let list = paint_tree(&ctx, unsafe { &*ptr });