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