GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git
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");
+ }
+}