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

commit4dcb970d84f0a6aeeef5311ac1d4cacb0c63a803
parentdf44b179a6
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-07 22:06
feat(widget): Owned<W>, a widget at an address the registry can trust

The registry holds raw pointers to widgets the app owns. Since 1d538c6 a
DROPPED widget reads back as absent; a MOVED one (its Vec reallocating,
its struct returned by value) still left a pointer to the old address,
because the widget's own liveness token moves with it.

Owned<W> keeps the widget in a heap allocation of its own and carries a
liveness token for that ALLOCATION: moving the Owned never moves the
widget, and the token dies exactly when the box is freed. Owned is a
WidgetHost that forwards every method and reports the boxed widget
through the new WidgetHost::stable_target, so register_host, set_focused,
render_widget and link_parent_child record the box's widget and token
with no change at their call sites; Deref/DerefMut keep field access
reading as before.

register_host prints once per widget type to stderr when handed a widget
outside an Owned (most apps install no logger); the toolkit's embedded
children register through the crate-private register_embedded, which
does not. The demo, cce-relief and cce-ramp hold their widgets in Owned.

Tests: an Owned row survives its Vec reallocating 64 times; swapping the
widget out of its box and freeing the box leaves no dangling entry; an
Owned behaves as its widget. 17 apps moved to Owned in the same sweep;
all 18 binaries run in a shadow with no "outside an Owned box" line,
cce-system-interface on each of its 14 pages.

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

 CLAUDE.md                         |  49 +++++---
 src/bin/cce-ramp.rs               |  13 ++-
 src/bin/cce-relief.rs             |  75 ++++++------
 src/context.rs                    |  36 +++++-
 src/main.rs                       |  39 ++++---
 src/scene/tree.rs                 |  21 +++-
 src/widget/container/paginator.rs |   2 +-
 src/widget/container/treelist.rs  |  10 +-
 src/widget/core.rs                |   5 +-
 src/widget/mod.rs                 |  10 ++
 src/widget/model.rs               |   4 +-
 src/widget/owned.rs               | 236 ++++++++++++++++++++++++++++++++++++++
 12 files changed, 403 insertions(+), 97 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index 9127973..4ace4ad 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -1570,27 +1570,50 @@ adding anything to this trait.
 ### The registry holds pointers, and knows when they die
 
 `UiContext`'s tree (`scene::tree::WidgetTree`) does not own its widgets: the app does, and
-registers raw pointers to them. Each widget's `Widget` base carries a `Liveness` token, and every
-entry keeps a watch on it; every accessor (`get_ptr`, `children_ptrs`, `iter_registered`, …)
-resolves a pointer only while its widget exists. A widget dropped without `unregister_widget`
-reads back as absent instead of as freed memory. A clone gets a token of its own.
-
-What it does NOT catch is a widget MOVED while registered (a `Vec` that reallocated, a struct
-returned by value): the token moves with it. Register from a live borrow before the pass that
-reads it, as the apps' rebuilds do. The sound end state is a registry that owns its widgets.
+registers raw pointers to them. Every entry keeps a watch on a liveness token beside its pointer,
+and every accessor (`get_ptr`, `children_ptrs`, `iter_registered`, …) resolves a pointer only
+while that token exists.
+
+**App widgets live in `Owned` boxes** (`widget::Owned<W>`, since 2026-10-07). An `Owned` keeps
+the widget in a heap allocation of its own and carries a token for that ALLOCATION. Moving the
+`Owned` (a `Vec` reallocating, a struct returned by value) does not move the widget, and the token
+dies only when the box is freed. `Owned` is itself a `WidgetHost`, forwarding every method, and
+reports the boxed widget through `WidgetHost::stable_target`. So `register_host(&mut self.x)`,
+`set_focused`, `render_widget` and `link_parent_child` all record the boxed widget and the box's
+token without the caller doing anything. `Deref`/`DerefMut` reach the widget, so
+`self.x.set_text(..)` reads as before. Fields are `Owned<Adapted<X>>`, and are built with
+`Owned::new(..)` or `.into()`.
+
+A widget registered OUTSIDE an `Owned` falls back to its own address and its base's `Liveness`
+token. That catches a drop but not a move, so `register_host` prints once per widget type to
+stderr: `cce-ui: register_host: a <Type> is registered outside an Owned box`. A clean run of every
+app prints none. The toolkit's own embedded children (a tree list's fields, a paginator's menu)
+sit inside their parent's allocation and register through the crate-private
+`register_embedded`, which does not warn.
+
+Two shapes the sweep met:
+- A widget used only as a paint STAMP and never registered (the designer dialog's
+  toggle/slider/dropdown stamps) stays a bare `Adapted`.
+- A roster that compares widget ADDRESSES compares the boxed widget, which is what the registry
+  holds. The designer's `get_dyn` hands out the inner widget for that reason; its `get_dyn_mut`
+  hands out the `Owned`, so registering through it records the box.
 
 The pointer-taking entry points say so:
 - `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 a widget with `register_host(&mut w)`.** Every app uses it.
 - `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.
+- The demo's `register_roots` is the pattern to copy.
+
+What is still open is the ALIASING model. The registry derefs its pointers while the app holds
+the `Owned`, so a `&mut` reached through the registry and one reached through the field can
+coexist. The end state for that is a registry that owns its widgets and lends them out.
 
-`a_dropped_widget_is_never_handed_out` and `a_clone_has_a_liveness_of_its_own` are the tests.
+`a_dropped_widget_is_never_handed_out`, `a_clone_has_a_liveness_of_its_own`,
+`an_owned_widget_survives_its_vec_reallocating` and
+`swapping_the_widget_out_of_its_box_never_leaves_a_dangling_entry` are the tests.
 
 **Runtime verification matters here.** Several scene changes are "compiles + tests pass; runtime
 verification pending" per the RFC — the headless tests can't catch paint/event regressions. When
diff --git a/src/bin/cce-ramp.rs b/src/bin/cce-ramp.rs
index fb8d27b..1017f0e 100644
--- a/src/bin/cce-ramp.rs
+++ b/src/bin/cce-ramp.rs
@@ -13,6 +13,7 @@
 //! Architecture mirrors the reference `DemoApp` (`src/main.rs`): display-list
 //! frame, routed events, in-frame popovers.
 
+use cce_ui::widget::Owned;
 use cce_ui::engine::{Application, AppSender, LogicalPosition, LogicalSize, WindowSettings};
 use cce_ui::scene::layout::Rect;
 use cce_ui::scene::paint::{DisplayList, PaintCtx};
@@ -32,12 +33,12 @@ enum RampMsg {
 }
 
 struct RampPopup {
-    ramp: Adapted<Ramp>,
+    ramp: Owned<Adapted<Ramp>>,
     /// Last spec printed to stdout — edits log their curve for copy/paste.
     last_spec: String,
     /// `--key` mode only; parked off-screen in the scratchpad.
-    save_button: Adapted<Button>,
-    cancel_button: Adapted<Button>,
+    save_button: Owned<Adapted<Button>>,
+    cancel_button: Owned<Adapted<Button>>,
     /// `--key <dotted.key>`: Save writes the spec as a `(ramp)` value at
     /// this key; the curve seeds from it. None = the stdout scratchpad.
     target_key: Option<String>,
@@ -124,10 +125,10 @@ impl Application for RampPopup {
 
         let last_spec = ramp.inner().spec_string();
         Self {
-            ramp,
+            ramp: Owned::new(ramp),
             last_spec,
-            save_button: Button::new(0.0, 0.0, 0.0, 0.0).with_label("Save"),
-            cancel_button: Button::new(0.0, 0.0, 0.0, 0.0).with_label("Cancel"),
+            save_button: Owned::new(Button::new(0.0, 0.0, 0.0, 0.0).with_label("Save")),
+            cancel_button: Owned::new(Button::new(0.0, 0.0, 0.0, 0.0).with_label("Cancel")),
             status: match &target_key {
                 Some(k) => format!("Edits are live in the curve; Save writes the {k} key."),
                 None => String::new(),
diff --git a/src/bin/cce-relief.rs b/src/bin/cce-relief.rs
index 3ea70cc..1ea8580 100644
--- a/src/bin/cce-relief.rs
+++ b/src/bin/cce-relief.rs
@@ -64,6 +64,7 @@
 //! DE-wide loader are unchanged — free-form specs from cce-designer or a
 //! hand-edited config still load everywhere.
 
+use cce_ui::widget::Owned;
 use cce_ui::engine::{Application, AppSender, LogicalPosition, LogicalSize, WindowSettings};
 use cce_ui::layout::RELIEF_PROFILE_IDENTITY_SPEC as IDENTITY_SPEC;
 use cce_ui::scene::layout::Rect;
@@ -390,9 +391,9 @@ const GROOVE_FLOOR: f32 = 0.45;
 /// One profile section's shape state: the three knob sliders plus whether
 /// the profile has diverged from the analytic default.
 struct ProfileKnobs {
-    shoulder: Adapted<Slider>,
-    base: Adapted<Slider>,
-    bias: Adapted<Slider>,
+    shoulder: Owned<Adapted<Slider>>,
+    base: Owned<Adapted<Slider>>,
+    bias: Owned<Adapted<Slider>>,
     /// False until a knob moves or config installed a real (non-identity)
     /// profile for this section: the DE renders its analytic profile and
     /// Save writes the identity sentinel.
@@ -422,9 +423,9 @@ impl ProfileKnobs {
                 .with_scroll(true)
         };
         let mut this = Self {
-            shoulder: knob(s, "Shoulder"),
-            base: knob(b, "Base"),
-            bias: knob(c, "Bias"),
+            shoulder: Owned::new(knob(s, "Shoulder")),
+            base: Owned::new(knob(b, "Base")),
+            bias: Owned::new(knob(c, "Bias")),
             custom: installed,
             last_spec: String::new(),
         };
@@ -466,17 +467,17 @@ impl ProfileKnobs {
 
 struct BevelPopup {
     /// Which SHAPE the section shows; the curve it edits follows from it.
-    profile_dropdown: Adapted<Dropdown>,
+    profile_dropdown: Owned<Adapted<Dropdown>>,
     /// Which wall of the rect the section is through — see [`Edge`].
-    edge_dropdown: Adapted<Dropdown>,
+    edge_dropdown: Owned<Adapted<Dropdown>>,
     /// The carve wall — what `carve_slope` renders on every
     /// recess/boss/ridge in the DE.
     wall: ProfileKnobs,
     /// The plate perimeter roll — `roll_slope`'s descent profile.
     edge: ProfileKnobs,
-    depth_slider: Adapted<Slider>,
-    width_slider: Adapted<Slider>,
-    height_slider: Adapted<Slider>,
+    depth_slider: Owned<Adapted<Slider>>,
+    width_slider: Owned<Adapted<Slider>>,
+    height_slider: Owned<Adapted<Slider>>,
     /// The wall height as the Save target spelled it when this window
     /// opened — value AND unit — or `None` for a config with no height. The
     /// unit Save writes back in (`height_len`); the value is what an
@@ -487,15 +488,15 @@ struct BevelPopup {
     /// strength (`Light` above) — `scene::material::Finish`'s spec /
     /// shininess / curvature. Applied live to the DE finish, or to the pane
     /// rung's bound material when config binds one.
-    spec_slider: Adapted<Slider>,
-    shine_slider: Adapted<Slider>,
-    curv_slider: Adapted<Slider>,
+    spec_slider: Owned<Adapted<Slider>>,
+    shine_slider: Owned<Adapted<Slider>>,
+    curv_slider: Owned<Adapted<Slider>>,
     /// The Frost column: the pane material's recipe — compression,
     /// refraction, blur radius (`scene::material::Frost`). Same live target.
-    comp_slider: Adapted<Slider>,
-    refr_slider: Adapted<Slider>,
-    radius_slider: Adapted<Slider>,
-    save_button: Adapted<Button>,
+    comp_slider: Owned<Adapted<Slider>>,
+    refr_slider: Owned<Adapted<Slider>>,
+    radius_slider: Owned<Adapted<Slider>>,
+    save_button: Owned<Adapted<Button>>,
     /// The material the Save target binds its pane rung to, if any — read
     /// from the `--config` file (this process's own config is not the
     /// target's), else this process's binding. `material_frosted` says
@@ -505,7 +506,7 @@ struct BevelPopup {
     material_frosted: bool,
     /// Cancel = discard-and-close: edits are live only in THIS process, so
     /// with nothing persisted, closing IS the discard (same as Escape).
-    cancel_button: Adapted<Button>,
+    cancel_button: Owned<Adapted<Button>>,
     /// Set by the cancel click in `drain_widget_changes` (no exit access
     /// there); `handle_mouse_input` turns it into `BevelMsg::Exit`.
     exit_requested: bool,
@@ -1638,47 +1639,47 @@ impl Application for BevelPopup {
             })
             .unwrap_or_else(|| cce_ui::color::root_plate_opacity());
         Self {
-            profile_dropdown: Dropdown::new(
+            profile_dropdown: Owned::new(Dropdown::new(
                 Shape::ALL.iter().map(|s| s.label().to_string()).collect(),
                 0,
             )
-            .with_label("Shape"),
-            edge_dropdown: Dropdown::new(
+            .with_label("Shape")),
+            edge_dropdown: Owned::new(Dropdown::new(
                 Edge::ALL.iter().map(|e| e.label().to_string()).collect(),
                 0,
             )
-            .with_label("Edge"),
+            .with_label("Edge")),
             wall: ProfileKnobs::new(wall_seed, cce_ui::layout::bevel_profile_slopes().is_some()),
             edge: ProfileKnobs::new(edge_seed, cce_ui::layout::roll_profile_slopes().is_some()),
-            depth_slider: Slider::new()
+            depth_slider: Owned::new(Slider::new()
                 .with_label("Light")
                 .with_range(dmin, dmax)
                 .with_value(((depth - dmin) / (dmax - dmin)).clamp(0.0, 1.0))
                 .with_readout(true)
                 .with_decimals(2)
-                .with_scroll(true),
-            width_slider: Slider::new()
+                .with_scroll(true)),
+            width_slider: Owned::new(Slider::new()
                 .with_label("Width")
                 .with_range(wmin, wmax)
                 .with_value(((width - wmin) / (wmax - wmin)).clamp(0.0, 1.0))
                 .with_readout(true)
                 .with_decimals(1)
-                .with_scroll(true),
-            height_slider: Slider::new()
+                .with_scroll(true)),
+            height_slider: Owned::new(Slider::new()
                 .with_label("Height")
                 .with_range(hmin, hmax)
                 .with_value(((height - hmin) / (hmax - hmin)).clamp(0.0, 1.0))
                 .with_readout(true)
                 .with_decimals(1)
-                .with_scroll(true),
-            spec_slider: material_slider("Specular", spec0, SPEC_RANGE, 2),
-            shine_slider: material_slider("Shininess", shine0, SHINE_RANGE, 0),
-            curv_slider: material_slider("Curvature", curv0, CURV_RANGE, 2),
-            comp_slider: material_slider("Compression", comp0, COMP_RANGE, 2),
-            refr_slider: material_slider("Refraction", refr0, REFR_RANGE, 2),
-            radius_slider: material_slider("Blur radius", radius0, RADIUS_RANGE, 1),
-            save_button: Button::new(0.0, 0.0, 0.0, 0.0).with_label("Save"),
-            cancel_button: Button::new(0.0, 0.0, 0.0, 0.0).with_label("Cancel"),
+                .with_scroll(true)),
+            spec_slider: Owned::new(material_slider("Specular", spec0, SPEC_RANGE, 2)),
+            shine_slider: Owned::new(material_slider("Shininess", shine0, SHINE_RANGE, 0)),
+            curv_slider: Owned::new(material_slider("Curvature", curv0, CURV_RANGE, 2)),
+            comp_slider: Owned::new(material_slider("Compression", comp0, COMP_RANGE, 2)),
+            refr_slider: Owned::new(material_slider("Refraction", refr0, REFR_RANGE, 2)),
+            radius_slider: Owned::new(material_slider("Blur radius", radius0, RADIUS_RANGE, 1)),
+            save_button: Owned::new(Button::new(0.0, 0.0, 0.0, 0.0).with_label("Save")),
+            cancel_button: Owned::new(Button::new(0.0, 0.0, 0.0, 0.0).with_label("Cancel")),
             material_target,
             material_frosted,
             exit_requested: false,
diff --git a/src/context.rs b/src/context.rs
index 7fcc1cb..a40bfde 100644
--- a/src/context.rs
+++ b/src/context.rs
@@ -49,6 +49,20 @@ impl SpatialGrid {
     }
 }
 
+/// `register_host` was handed a widget outside an `Owned` box: say so once per widget type, on
+/// stderr (most apps install no logger), so a missed field shows up without failing anything.
+fn warn_unowned(type_name: &'static str) {
+    thread_local! {
+        static WARNED: std::cell::RefCell<std::collections::HashSet<&'static str>> = Default::default();
+    }
+    if WARNED.with(|w| w.borrow_mut().insert(type_name)) {
+        eprintln!(
+            "cce-ui: register_host: a {type_name} is registered outside an Owned box; it must \
+             not move while registered (hold it as cce_ui::widget::Owned<..>)"
+        );
+    }
+}
+
 pub struct UiContext {
     /// The widget tree + registry, consolidated into one generational store (Phase 1b of the
     /// core rebuild). Replaces the former `layout_tree` + `widget_registry` maps; see
@@ -766,13 +780,25 @@ impl UiContext {
     // walked an empty dummy context (provably inert). Section-level keyboard nav lives
     // app-side (settings' focused_section machinery).
 
-    /// Register a widget the app owns, by reference: the safe form of
-    /// [`register_widget`](Self::register_widget). `ctx.register_host(&mut self.button)`.
+    /// Register a widget the app owns, by reference: `ctx.register_host(&mut self.button)`.
     ///
-    /// The registry keeps a pointer to the widget and resolves it only while the widget has not
-    /// been dropped (see `widget::core::Liveness`); a widget MOVED after registering must be
-    /// registered again before the next pass reads it.
+    /// Hold the widget in an [`Owned`](crate::widget::Owned) box: the registry then points at
+    /// the boxed widget, which stays put however the field or `Vec` holding the `Owned` moves,
+    /// and stops resolving it when the `Owned` drops. A bare widget is registered at its own
+    /// address, which is only good until it moves — so that is logged, once per widget type.
     pub fn register_host(&mut self, w: &mut (dyn WidgetHost + 'static)) {
+        if w.stable_target().is_none() {
+            warn_unowned(w.type_name());
+        }
+        let id = w.base().id();
+        // SAFETY: derived from the live borrow we were handed.
+        unsafe { self.register_widget(id, w as *mut (dyn WidgetHost + 'static)) };
+    }
+
+    /// Register a widget that lives INSIDE another registered widget (a tree list's search box,
+    /// a paginator's menu): it is as stable as its parent's allocation, so no `Owned` of its own
+    /// is wanted and none is warned about.
+    pub(crate) fn register_embedded(&mut self, w: &mut (dyn WidgetHost + 'static)) {
         let id = w.base().id();
         // SAFETY: derived from the live borrow we were handed.
         unsafe { self.register_widget(id, w as *mut (dyn WidgetHost + 'static)) };
diff --git a/src/main.rs b/src/main.rs
index c5dfb34..41d22f4 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -19,6 +19,7 @@
 //!    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;
 use cce_ui::engine::{Application, AppSender, LogicalPosition, LogicalSize, WindowSettings};
 use cce_ui::scene::arena::Arena;
 use cce_ui::scene::layout::{
@@ -51,15 +52,15 @@ 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: Adapted<Button>,
-    toggle: Adapted<Toggle>,
-    slider: Adapted<Slider>,
-    name_box: Adapted<TextBox>,
-    theme_dropdown: Adapted<Dropdown>,
+    button: Owned<Adapted<Button>>,
+    toggle: Owned<Adapted<Toggle>>,
+    slider: Owned<Adapted<Slider>>,
+    name_box: Owned<Adapted<TextBox>>,
+    theme_dropdown: Owned<Adapted<Dropdown>>,
     // ImageView pair sharing ONE uploaded texture (the widget borrows ids —
     // upload/free stay app-side): Contain letterboxes, Stretch fills.
-    image_contain: Adapted<ImageView>,
-    image_stretch: Adapted<ImageView>,
+    image_contain: Owned<Adapted<ImageView>>,
+    image_stretch: Owned<Adapted<ImageView>>,
 
     // ── 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.
@@ -165,27 +166,27 @@ impl Application for DemoApp {
         Self {
             // Relief styling (raised buttons/toggles/dropdowns, recessed
             // wells) is the `control_relief` config default — no opt-in.
-            button: Button::new(0.0, 0.0, 0.0, 0.0).with_label("Click me"),
-            toggle: Toggle::new(),
+            button: Owned::new(Button::new(0.0, 0.0, 0.0, 0.0).with_label("Click me")),
+            toggle: Owned::new(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: Slider::new()
+            slider: Owned::new(Slider::new()
                 .with_range(0.0, 100.0)
                 .with_value(0.4)
-                .with_scroll(true),
-            name_box: TextBox::new(String::new())
-                .with_placeholder("Type a name..."),
-            theme_dropdown: Dropdown::new(
+                .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: ImageView::new()
+            )),
+            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: ImageView::new()
+                .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),
+                .with_fit(FitMode::Stretch)),
             toggle_on: false,
             clicks: 0,
             status: "Ready.".to_string(),
diff --git a/src/scene/tree.rs b/src/scene/tree.rs
index bf05ffb..15702fc 100644
--- a/src/scene/tree.rs
+++ b/src/scene/tree.rs
@@ -27,10 +27,12 @@
 //! ## What a pointer here is worth
 //!
 //! The tree does not own its widgets; the app does, and registers raw pointers to them. Each
-//! entry also keeps a watch on the widget's liveness token (`widget::core::Liveness`), and
-//! every accessor resolves a pointer only while that token exists — so a widget dropped
-//! without being unregistered reads back as absent rather than as a pointer to freed memory.
-//! A widget MOVED while registered is not caught (its token moves with it); see `Liveness`.
+//! entry keeps a watch on a liveness token beside the pointer, and every accessor resolves a
+//! pointer only while that token exists. For a widget in an [`Owned`](crate::widget::Owned)
+//! box (every app widget since cce-ui's phase 2) the pointer is the boxed widget and the token
+//! the box's own: the address cannot move, and the token dies when the box is freed. For any
+//! other widget it is the widget's address and its base's token (`widget::core::Liveness`),
+//! which catches a drop but not a move.
 
 use std::collections::HashMap;
 use std::sync::Weak;
@@ -112,8 +114,15 @@ impl WidgetTree {
     /// a watch on the widget's liveness token. After the call the tree resolves the pointer only
     /// while that widget has not been dropped.
     pub unsafe fn register(&mut self, id: WidgetId, ptr: *mut (dyn WidgetHost + 'static)) {
-        // SAFETY: the caller's contract — null, or a live widget.
-        let alive = unsafe { ptr.as_ref() }.map(|w| w.base().live.watch());
+        // SAFETY: the caller's contract — null, or a live widget. A widget in an `Owned` box
+        // names the boxed widget and the allocation's token instead of itself.
+        let (ptr, alive) = match unsafe { ptr.as_mut() } {
+            None => (ptr, None),
+            Some(w) => match w.stable_target() {
+                Some((inner, alive)) => (inner, Some(alive)),
+                None => (ptr, Some(w.base().live.watch())),
+            },
+        };
         let node = self.ensure_node(id);
         // `ensure_node` guarantees the node exists.
         let entry = self.arena.value_mut(node).unwrap();
diff --git a/src/widget/container/paginator.rs b/src/widget/container/paginator.rs
index 2114ff2..de6de82 100644
--- a/src/widget/container/paginator.rs
+++ b/src/widget/container/paginator.rs
@@ -161,7 +161,7 @@ impl Layout for Paginator {
     }
 
     fn register_embedded_children(&mut self, host_id: WidgetId, ctx: &mut UiContext) {
-        ctx.register_host(&mut self.sidebar_menu);
+        ctx.register_embedded(&mut self.sidebar_menu);
         ctx.link_ids(host_id, self.sidebar_menu.id());
     }
 }
diff --git a/src/widget/container/treelist.rs b/src/widget/container/treelist.rs
index 38ffeb2..ecd3253 100644
--- a/src/widget/container/treelist.rs
+++ b/src/widget/container/treelist.rs
@@ -489,7 +489,7 @@ impl TreeList {
                         self.edit_box.select_anchor = Some(0);
                         
                         let eb_id = self.edit_box.base().id();
-                        ui.register_host(&mut self.edit_box);
+                        ui.register_embedded(&mut self.edit_box);
                         ui.link_ids(host_id, eb_id);
                         
                         ui.set_focused(&mut self.edit_box);
@@ -696,14 +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;
-        ctx.register_host(&mut self.search_box);
+        ctx.register_embedded(&mut self.search_box);
 
-        ctx.register_host(&mut self.add_key_btn);
+        ctx.register_embedded(&mut self.add_key_btn);
 
-        ctx.register_host(&mut self.add_key_popover_box);
+        ctx.register_embedded(&mut self.add_key_popover_box);
 
         if self.editing_key_idx.is_some() {
-            ctx.register_host(&mut self.edit_box);
+            ctx.register_embedded(&mut self.edit_box);
         }
     }
 }
diff --git a/src/widget/core.rs b/src/widget/core.rs
index f6a5b0e..ce0bda8 100644
--- a/src/widget/core.rs
+++ b/src/widget/core.rs
@@ -1974,9 +1974,8 @@ pub struct Widget {
 ///
 /// It does NOT catch a widget that was MOVED while registered (a `Vec` that
 /// reallocated, a struct returned by value): the token moves with it, and the stored
-/// pointer still names the old address. Registering from a live borrow just before the
-/// pass that uses it, as every app's rebuild does, is what keeps that case sound until
-/// the registry owns its widgets.
+/// pointer still names the old address. That is what [`Owned`](crate::widget::Owned) is
+/// for: a box whose ALLOCATION carries the token the registry watches instead.
 ///
 /// A clone is a different widget at a different address, so it gets a fresh token —
 /// not a share of the original's, which would keep a dropped original "alive".
diff --git a/src/widget/mod.rs b/src/widget/mod.rs
index 5e1370c..bc8fbe2 100644
--- a/src/widget/mod.rs
+++ b/src/widget/mod.rs
@@ -240,6 +240,14 @@ pub trait WidgetHost {
     /// implementor) always owns a base; test shims carry one via `impl_widget_base!`.
     fn base(&self) -> &Widget;
     fn base_mut(&mut self) -> &mut Widget;
+
+    /// Where the registry should point for this widget, and the token that says the address
+    /// still holds it — `Some` only for a widget whose address cannot move under the registry
+    /// ([`Owned`]). `None` (every other widget) registers the widget's own address, watched by
+    /// its base's liveness token, which catches a drop but not a move.
+    fn stable_target(&mut self) -> Option<(*mut (dyn WidgetHost + 'static), std::sync::Weak<()>)> {
+        None
+    }
     /// The widget's natural CONTENT height — the control below its detached label, if
     /// any. What a layout strategy allots; [`WidgetHost::layout`] places that content
     /// box at the origin it is given and hangs the label ([`WidgetHost::label_strip`])
@@ -625,6 +633,7 @@ impl EmbedImage {
 pub mod doc_editor;
 pub mod line_edit;
 pub mod model;
+pub mod owned;
 pub mod scroll_region;
 pub mod scroll_motion;
 pub mod side_swipe;
@@ -637,6 +646,7 @@ pub use self::scroll_region::{ScrollRegion, ScrollbarActivity};
 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::core::{Widget, focus, hover_animation, clipboard, context_menu, clear_widget_references};
 pub use self::core::focus::link_parent_child;
 pub use self::input::{
diff --git a/src/widget/model.rs b/src/widget/model.rs
index 1ef36db..efd252d 100644
--- a/src/widget/model.rs
+++ b/src/widget/model.rs
@@ -909,8 +909,8 @@ impl<W: Layout + Paint + Input + 'static> Adapted<W> {
         let id = self.base.id();
         if let Some(p) = parent {
             let p_id = p.base().id();
-            ctx.register_host(p);
-            ctx.register_host(self);
+            ctx.register_embedded(p);
+            ctx.register_embedded(self);
             ctx.tree.set_parent(id, Some(p_id));
         } else {
             ctx.tree.set_parent(id, None);
diff --git a/src/widget/owned.rs b/src/widget/owned.rs
new file mode 100644
index 0000000..877e2d5
--- /dev/null
+++ b/src/widget/owned.rs
@@ -0,0 +1,236 @@
+//! `Owned<W>` — a widget at an address that does not move, for the registry to point at.
+//!
+//! `UiContext` keeps raw pointers to the widgets an app registers, and the app owns those
+//! widgets as struct fields and `Vec` elements. Two things can leave such a pointer naming
+//! something that is no longer the widget:
+//!
+//! - the widget is DROPPED (a rebuilt list whose old rows were not unregistered) — caught by
+//!   the widget's own liveness token (`widget::core::Liveness`) since cce-ui@1d538c6;
+//! - the widget is MOVED (the `Vec` it sits in reallocates, the struct holding it is returned
+//!   by value) — its token moves with it, so the registry cannot tell, and the next sweep reads
+//!   freed or reused memory.
+//!
+//! `Owned` closes the second. It keeps the widget in a heap allocation of its own and carries a
+//! liveness token for that ALLOCATION. Moving an `Owned` moves the box pointer, not the widget;
+//! the allocation is freed only when the `Owned` drops, which is exactly when its token dies. The
+//! widget can be reached and changed through `Deref`/`DerefMut` — even swapped for another `W`
+//! with `mem::swap` — but the allocation always holds a valid `W` while the token lives. So a
+//! pointer the registry resolves through an `Owned`'s token always names a live `W`.
+//!
+//! `Owned<W>` is itself a [`WidgetHost`] (every method forwards to the boxed widget), so it goes
+//! wherever a widget went: `register_host(&mut self.button)`, `set_focused`, `render_widget`,
+//! `link_parent_child`. Each of those registers through `WidgetTree::register`, which asks
+//! [`WidgetHost::stable_target`] and, for an `Owned`, stores the BOXED widget and the
+//! allocation's token rather than the `Owned` itself.
+//!
+//! ```ignore
+//! struct App { search: Owned<Adapted<TextBox>>, rows: Vec<Owned<Adapted<Button>>>, ui: UiContext }
+//! let search = Owned::new(TextBox::new(""));          // or `TextBox::new("").into()`
+//! app.ui.register_host(&mut app.search);              // the Vec may reallocate freely now
+//! app.search.set_text("x");                           // Deref: the widget's own methods
+//! ```
+
+use std::ops::{Deref, DerefMut};
+
+use super::core::Liveness;
+use super::{ContextAction, CornerRadii, Event, FocusRole, LayoutConstraints, Point, Size, Widget, WidgetHost, WidgetId};
+use crate::context::UiContext;
+
+/// A widget in a heap allocation of its own, which the registry can point at however the
+/// `Owned` is moved. See the module docs.
+pub struct Owned<W: WidgetHost + 'static> {
+    // Declared first so it is dropped first: the registry stops resolving the widget before
+    // the widget's own `Drop` runs and the allocation is freed.
+    live: Liveness,
+    widget: Box<W>,
+}
+
+impl<W: WidgetHost + 'static> Owned<W> {
+    pub fn new(widget: W) -> Self {
+        Owned { live: Liveness::new(), widget: Box::new(widget) }
+    }
+
+    /// The widget, by value; the allocation and its token go with the `Owned`.
+    pub fn into_inner(self) -> W {
+        let Owned { live, widget } = self;
+        drop(live);
+        *widget
+    }
+}
+
+impl<W: WidgetHost + 'static> Deref for Owned<W> {
+    type Target = W;
+    fn deref(&self) -> &W {
+        &self.widget
+    }
+}
+
+impl<W: WidgetHost + 'static> DerefMut for Owned<W> {
+    fn deref_mut(&mut self) -> &mut W {
+        &mut self.widget
+    }
+}
+
+impl<W: WidgetHost + 'static> From<W> for Owned<W> {
+    fn from(widget: W) -> Self {
+        Owned::new(widget)
+    }
+}
+
+/// A clone is a different widget in a different allocation, with a token of its own.
+impl<W: WidgetHost + Clone + 'static> Clone for Owned<W> {
+    fn clone(&self) -> Self {
+        Owned::new((*self.widget).clone())
+    }
+}
+
+impl<W: WidgetHost + Default + 'static> Default for Owned<W> {
+    fn default() -> Self {
+        Owned::new(W::default())
+    }
+}
+
+impl<W: WidgetHost + std::fmt::Debug + 'static> std::fmt::Debug for Owned<W> {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        f.debug_tuple("Owned").field(&*self.widget).finish()
+    }
+}
+
+/// Every method forwards to the boxed widget, so an `Owned` behaves exactly as the widget does;
+/// the one addition is [`stable_target`](WidgetHost::stable_target).
+impl<W: WidgetHost + 'static> WidgetHost for Owned<W> {
+    fn stable_target(&mut self) -> Option<(*mut (dyn WidgetHost + 'static), std::sync::Weak<()>)> {
+        let ptr: *mut (dyn WidgetHost + 'static) = &mut *self.widget as *mut W;
+        Some((ptr, self.live.watch()))
+    }
+
+    fn base(&self) -> &Widget { self.widget.base() }
+    fn base_mut(&mut self) -> &mut Widget { self.widget.base_mut() }
+    fn preferred_height(&self) -> Option<f32> { self.widget.preferred_height() }
+    fn label_strip(&self) -> f32 { self.widget.label_strip() }
+    fn detached_label_rect(&self) -> Option<crate::scene::layout::Rect> { self.widget.detached_label_rect() }
+    fn mark_dirty(&mut self, ctx: &mut UiContext) { self.widget.mark_dirty(ctx) }
+    fn as_any(&self) -> &dyn std::any::Any { self.widget.as_any() }
+    fn as_any_mut(&mut self) -> &mut dyn std::any::Any { self.widget.as_any_mut() }
+    fn handle_event(&mut self, event: &Event, ctx: &mut UiContext) -> bool { self.widget.handle_event(event, ctx) }
+    fn measure(&self, constraints: LayoutConstraints, ctx: &UiContext) -> Size { self.widget.measure(constraints, ctx) }
+    fn layout(&mut self, origin: Point, constraints: LayoutConstraints, ctx: &mut UiContext) { self.widget.layout(origin, constraints, ctx) }
+    fn rect(&self) -> (f32, f32, f32, f32) { self.widget.rect() }
+    fn label(&self) -> Option<String> { self.widget.label() }
+    fn context_action(&mut self, action: ContextAction) -> bool { self.widget.context_action(action) }
+    fn set_rect(&mut self, x: f32, y: f32, w: f32, h: f32) { self.widget.set_rect(x, y, w, h) }
+    fn set_row_rect(&mut self, x: f32, w: f32) { self.widget.set_row_rect(x, w) }
+    fn hit_test(&self, px: f32, py: f32, ctx: &UiContext) -> bool { self.widget.hit_test(px, py, ctx) }
+    fn highlight_quad(&self, ctx: &UiContext) -> Option<(f32, f32, f32, f32, [f32; 4])> { self.widget.highlight_quad(ctx) }
+    fn color(&self) -> [f32; 4] { self.widget.color() }
+    fn solid_border(&self) -> Option<([f32; 4], f32)> { self.widget.solid_border() }
+    fn plate_bevel(&self) -> Option<f32> { self.widget.plate_bevel() }
+    fn extra_quads(&self) -> Vec<(f32, f32, f32, f32, [f32; 4])> { self.widget.extra_quads() }
+    fn extra_arcs(&self) -> Vec<(f32, f32, f32, f32, f32, f32, [f32; 4])> { self.widget.extra_arcs() }
+    fn extra_circles(&self) -> Vec<(f32, f32, f32, [f32; 4])> { self.widget.extra_circles() }
+    fn all_quads(&self, ctx: &UiContext) -> Vec<(f32, f32, f32, f32, [f32; 4])> { self.widget.all_quads(ctx) }
+    fn paint_self(&self, ui: &UiContext, ctx: &mut crate::scene::paint::PaintCtx) { self.widget.paint_self(ui, ctx) }
+    fn clips_children(&self) -> bool { self.widget.clips_children() }
+    fn renders_own_subtree(&self) -> bool { self.widget.renders_own_subtree() }
+    fn all_rounded_quads(&self, ctx: &UiContext) -> Vec<(f32, f32, f32, f32, f32, [f32; 4], (bool, bool, bool, bool))> {
+        self.widget.all_rounded_quads(ctx)
+    }
+    fn widget_font(&self) -> Option<String> { self.widget.widget_font() }
+    fn type_name(&self) -> &'static str { self.widget.type_name() }
+    fn popover_rect(&self) -> Option<(f32, f32, f32, f32)> { self.widget.popover_rect() }
+    fn render_popover(&self, pc: &mut dyn crate::layout::RenderTarget) { self.widget.render_popover(pc) }
+    fn focus(&mut self) { self.widget.focus() }
+    fn unfocus(&mut self) { self.widget.unfocus() }
+    fn focused(&self, ctx: &UiContext) -> bool { self.widget.focused(ctx) }
+    fn prepare_text(&mut self, fs: &mut cosmic_text::FontSystem) { self.widget.prepare_text(fs) }
+    fn set_visible(&mut self, visible: bool) { self.widget.set_visible(visible) }
+    fn visible(&self) -> bool { self.widget.visible() }
+    fn tick(&mut self, dt: f32, ctx: &mut UiContext) -> bool { self.widget.tick(dt, ctx) }
+    fn wants_tick(&self) -> bool { self.widget.wants_tick() }
+    fn is_child_visible(&self, child_id: WidgetId) -> bool { self.widget.is_child_visible(child_id) }
+    fn set_modifiers(&mut self, ctrl: bool, shift: bool, alt: bool) { self.widget.set_modifiers(ctrl, shift, alt) }
+    fn z_index(&self) -> i32 { self.widget.z_index() }
+    fn is_scrollable(&self) -> bool { self.widget.is_scrollable() }
+    fn blocks_root_plate_drag(&self) -> bool { self.widget.blocks_root_plate_drag() }
+    fn corner_style(&self) -> (f32, (bool, bool, bool, bool)) { self.widget.corner_style() }
+    fn focus_role(&self) -> FocusRole { self.widget.focus_role() }
+    fn corner_radii(&self) -> CornerRadii { self.widget.corner_radii() }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::scene::tree::WidgetTree;
+
+    #[derive(Clone)]
+    struct Tag {
+        base: Widget,
+        n: u32,
+    }
+    impl WidgetHost for Tag {
+        crate::impl_widget_base!(Tag);
+        fn color(&self) -> [f32; 4] {
+            [0.0; 4]
+        }
+    }
+    fn tag(n: u32) -> Owned<Tag> {
+        Owned::new(Tag { base: Widget::new(), n })
+    }
+    /// What the registry hands back for `id`, read as a `Tag`.
+    fn read(tree: &WidgetTree, id: WidgetId) -> Option<u32> {
+        let p = tree.get_ptr(id)?;
+        // SAFETY: the registry resolved it, which is what is under test.
+        Some(unsafe { (*p).as_any().downcast_ref::<Tag>().unwrap().n })
+    }
+
+    #[test]
+    fn an_owned_widget_survives_its_vec_reallocating() {
+        // The hole `Owned` closes: rows registered, then the Vec grows and moves them.
+        let mut rows: Vec<Owned<Tag>> = Vec::with_capacity(1);
+        rows.push(tag(7));
+        let id = rows[0].base().id();
+        let mut tree = WidgetTree::new();
+        unsafe { tree.register(id, &mut rows[0] as &mut (dyn WidgetHost + 'static)) };
+        let before = tree.get_ptr(id).unwrap();
+        for n in 0..64 {
+            rows.push(tag(n)); // reallocates, moving every `Owned` — but not what they own
+        }
+        assert_eq!(tree.get_ptr(id).map(|p| p as *const () as usize), Some(before as *const () as usize));
+        assert_eq!(read(&tree, id), Some(7));
+        let moved = rows.remove(0); // moved out of the Vec entirely
+        assert_eq!(read(&tree, id), Some(7));
+        drop(moved);
+        assert_eq!(tree.get_ptr(id), None, "dropping the Owned ends it");
+    }
+
+    #[test]
+    fn swapping_the_widget_out_of_its_box_never_leaves_a_dangling_entry() {
+        // The widget's OWN token would say "alive" after it was swapped out and its box freed;
+        // the allocation's token is what the registry watches.
+        let mut owned = tag(1);
+        let id = owned.base().id();
+        let mut tree = WidgetTree::new();
+        unsafe { tree.register(id, &mut owned as &mut (dyn WidgetHost + 'static)) };
+        let mut outside = Tag { base: Widget::new(), n: 2 };
+        std::mem::swap(&mut *owned, &mut outside);
+        assert_eq!(read(&tree, id), Some(2), "the box holds a valid Tag, the swapped-in one");
+        drop(owned);
+        assert_eq!(outside.n, 1, "the original lives on outside the box");
+        assert_eq!(tree.get_ptr(id), None, "but the box it was registered in is gone");
+    }
+
+    #[test]
+    fn an_owned_widget_is_the_widget() {
+        let mut owned = tag(3);
+        owned.set_rect(1.0, 2.0, 3.0, 4.0);
+        assert_eq!(WidgetHost::rect(&*owned), (1.0, 2.0, 3.0, 4.0));
+        assert_eq!(WidgetHost::rect(&owned), (1.0, 2.0, 3.0, 4.0));
+        assert_eq!(owned.base().id(), owned.widget.base().id());
+        assert!(owned.as_any().downcast_ref::<Tag>().is_some(), "as_any reaches the widget");
+        let copy = owned.clone();
+        let (a, _) = owned.stable_target().unwrap();
+        let mut copy = copy;
+        let (b, _) = copy.stable_target().unwrap();
+        assert_ne!(a as *const () as usize, b as *const () as usize, "a clone has its own box");
+    }
+}