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

commit1043c3934aa312968a509092cb161b2a224c8b01
parent75b813d73b
authorLucas Galante <[email protected]>
date2026-07-13 09:29
refactor(context)!: propagate_event roots are WidgetIds — the plumbing retype

The router's last raw-pointer API boundary is gone: dispatch roots are
WidgetIds resolved through the generational registry at the top of
propagate_event (an unresolvable root is a loud eprintln no-op, never a
deref). The handle rule this establishes: raw ptrs live only in the
WidgetTree registry payload, as live-&mut registration arguments, and as
machinery-internal transients. Dead WidgetPtr wrapper and caller-less
register_popover_ptr deleted; the demo's four event loops shed their
unsafe self-alias.

Verified: 165 tests (id-form router/drag regression tests); canary-silent
probes over 13 apps + all settings pages; TI dropdown->Grid relayout
end-to-end.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01N4ajhvVZtyEEEus9bodsj3

 docs/rfc-core-rebuild.md     | 49 +++++++++++++++++++++++++++---
 src/context.rs               | 42 ++++++++++++--------------
 src/main.rs                  | 72 +++++++++++++++++++++++---------------------
 src/widget/input/button.rs   | 10 +++---
 src/widget/input/checkbox.rs |  6 ++--
 src/widget/input/slider.rs   |  2 +-
 src/widget/mod.rs            | 27 -----------------
 src/widget/model.rs          |  8 ++---
 8 files changed, 114 insertions(+), 102 deletions(-)

diff --git a/docs/rfc-core-rebuild.md b/docs/rfc-core-rebuild.md
index e4c9222..b9d1ebf 100644
--- a/docs/rfc-core-rebuild.md
+++ b/docs/rfc-core-rebuild.md
@@ -1807,11 +1807,50 @@ Constraint respected: **each crate still builds standalone** — the new core is
        value is transient and tree-resolved at call time — they die
        with the `Element` endgame rather than warranting a standalone
        signature sweep.
-    4. **`propagate_event(event, root: WidgetId)`** + the app dispatch
-       loops off `as_ptr_mut` (the big app sweep). NOTE from the
-       slice-3 census: propagate roots are live borrows at call time —
-       this slice is API honesty, not a UAF fix; weigh folding it into
-       the endgame instead of touching ~200 app sites twice.
+    4. **`propagate_event(event, root: WidgetId)` — DONE (2026-07-13,
+       the plumbing retype).** The handle rule it establishes: raw
+       `*mut dyn WidgetHost` may appear ONLY as (a) the `WidgetTree`
+       registry payload — the one ownership bridge, written at
+       registration; (b) a registration argument derived from a live
+       `&mut` (`register_widget`, `set_focused_ptr`-class
+       self-registration — never stored); (c) machinery-internal
+       transients resolved from the registry inside one call.
+       Everything else crossing an API boundary carries `WidgetId` and
+       resolves through the generational tree at use — a stale id is a
+       loud no-op (`eprintln` canary), never a deref. Executed: the
+       router resolves the root at the top of `propagate_event`
+       (`propagate_event_impl` keeps its private resolved-ptr param);
+       ~470 app dispatch sites across 14 apps went `.as_ptr_mut()` →
+       `.id()` (field paths regex-converted; `let ptr = …` pairs,
+       ptr-Vec collections, and dyn-roster receivers hand-converted);
+       settings' `section_widgets`/`extra_dispatch_roots`/
+       `page_dispatch_roots` retyped to `Vec<WidgetId>` with the
+       keyboard section-focus block on `focus::is_focused_id`/
+       `set_focused_id`; dead `WidgetPtr` + caller-less
+       `register_popover_ptr` deleted. THE CONTRACT THE RETYPE
+       SURFACES: a dispatch root must be REGISTERED. Most apps get
+       registration as a `render_widget`/`paint_root_into` side
+       effect; the canary caught every gap live: TI (roster never
+       registered — per-frame `register_roster()`), settings chrome
+       (wiped by `rebuild_layout`'s `clear_hierarchy` — re-registered
+       after the view pass) + per-page rows rebuilt on data refresh
+       (`AppPage::register_extra_dispatch_roots(ctx)` runs before
+       each dispatch — the same liveness cadence the ptr router had)
+       + custom-drawn menus (fonts/system pages), email (hand
+       aggregate, per-frame block), authenticator (same), dm's bg
+       root, LI's word-processor box, cloud (registers at its five
+       dispatch sites), designer (frame re-registration skipped
+       INVISIBLE slots while the wheel loop dispatches the whole
+       roster). BUG FOUND: cce-graph registered its graph under a
+       hand-minted `NEXT_WIDGET_ID` instead of the widget's own base
+       id — `graph.id()` was unresolvable all along (focus/drag
+       lookups on it silently failed); registration now uses the
+       real id and the synthetic field is gone. Verified: 165 cce-ui
+       tests + full workspace suite; canary-silent pointer/click/
+       wheel probes over 13 apps and all 10 settings pages; TI
+       dropdown→Grid relayout lands end-to-end through the id
+       router; designer /state serves; demo's four event loops shed
+       their unsafe self-alias entirely.
     5. window_runner render plumbing + remaining `as_ptr` sites; then
        the `Element` + `Adapted` endgame (own design pass).
     Stored-pointer state remaining after slices 1–3, all deliberate:
diff --git a/src/context.rs b/src/context.rs
index 30cef24..6623141 100644
--- a/src/context.rs
+++ b/src/context.rs
@@ -112,7 +112,16 @@ impl UiContext {
         self.tree.get_ptr(id).map(|ptr| unsafe { &mut *ptr })
     }
 
-    pub fn propagate_event(&mut self, event: &Event, root: *mut (dyn WidgetHost + 'static)) -> bool {
+    /// 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
+    /// registered — apps already register every widget for focus/coverage — and an
+    /// unresolvable root is a loud no-op, never a deref.
+    pub fn propagate_event(&mut self, event: &Event, root: WidgetId) -> bool {
+        let Some(root_ptr) = self.tree.get_ptr(root) else {
+            eprintln!("propagate_event: unregistered/stale root {root:?} — event dropped");
+            return false;
+        };
         if let Event::MouseWheel { .. } = event {
             let now = std::time::Instant::now();
             let elapsed_ms = match self.last_scroll_time {
@@ -154,7 +163,7 @@ impl UiContext {
                     return true;
                 }
                 let (cx, cy) = self.cursor_pos;
-                if let Some(scrollable) = self.find_hovered_scrollable(root, cx, cy) {
+                if let Some(scrollable) = self.find_hovered_scrollable(root_ptr, cx, cy) {
                     unsafe {
                         if (*scrollable).handle_event(event, self) {
                             (*scrollable).mark_dirty(self);
@@ -164,13 +173,12 @@ impl UiContext {
                 }
             }
         }
-        self.propagate_event_impl(event, root)
+        self.propagate_event_impl(event, root_ptr)
     }
 
+    /// 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 {
-        if root.is_null() {
-            return false;
-        }
         if let Event::Tick(_) = event {
             return false;
         }
@@ -596,17 +604,6 @@ impl UiContext {
         }
     }
 
-    pub fn register_popover_ptr(&mut self, ptr: *mut (dyn WidgetHost + 'static)) {
-        if ptr.is_null() {
-            return;
-        }
-        let id = unsafe { (*ptr).base().id() };
-        self.tree.register(id, ptr);
-        if !self.active_popovers.contains(&id) {
-            self.active_popovers.push(id);
-        }
-    }
-
     /// Whether `(px, py)` is covered by an open popover or a popover-carrying widget other
     /// than `query_id` (the querying widget excludes itself). Every widget has a base id
     /// now (the flip) — the old `WidgetId(0)` no-base sentinel is gone.
@@ -905,7 +902,8 @@ mod tests {
         let mut slider = Slider::new();
         WidgetHost::set_rect(&mut slider, 0.0, 0.0, 200.0, 30.0);
         let ptr = slider.as_ptr_mut();
-        ctx.register_widget(slider.base().id(), ptr);
+        let id = slider.base().id();
+        ctx.register_widget(id, ptr);
 
         let press = Event::MouseButton {
             button: MouseButton::Left,
@@ -915,15 +913,15 @@ mod tests {
             local_x: 100.0,
             local_y: 15.0,
         };
-        assert!(ctx.propagate_event(&press, ptr), "press in the track arms the drag");
+        assert!(ctx.propagate_event(&press, id), "press in the track arms the drag");
         assert!(WidgetHost::is_dragging(&slider));
         let v0 = slider.value;
 
         // First move past the 3px threshold starts the drag; the next one updates it.
         let mv = |x: f32| Event::PointerMove { x, y: 15.0, local_x: x, local_y: 15.0 };
-        ctx.propagate_event(&mv(110.0), ptr);
+        ctx.propagate_event(&mv(110.0), id);
         assert!(ctx.is_dragging, "router crossed the drag threshold");
-        ctx.propagate_event(&mv(140.0), ptr);
+        ctx.propagate_event(&mv(140.0), id);
         assert!(
             slider.value > v0 + 0.05,
             "DragUpdate reached Input::drag_update (value {} -> {})",
@@ -939,7 +937,7 @@ mod tests {
             local_x: 140.0,
             local_y: 15.0,
         };
-        ctx.propagate_event(&release, ptr);
+        ctx.propagate_event(&release, id);
         assert!(!WidgetHost::is_dragging(&slider), "DragEnd reached Input::drag_end");
         assert!(!ctx.is_dragging);
     }
diff --git a/src/main.rs b/src/main.rs
index b9a5691..fd695f9 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -26,7 +26,7 @@ use cce_ui::scene::layout::{
 };
 use cce_ui::scene::paint::{DisplayList, PaintCtx};
 use cce_ui::widget::{
-    Adapted, Button, Dropdown, WidgetHost, ElementState, Event, KeyEvent, MouseButton,
+    Adapted, Button, Dropdown, WidgetHost, WidgetId, ElementState, Event, KeyEvent, MouseButton,
     MouseScrollDelta, Slider, TextBox, Toggle,
 };
 use wayland_client::QueueHandle;
@@ -63,7 +63,21 @@ struct DemoApp {
 }
 
 impl DemoApp {
-    /// The widget roots, in paint order (events route over the same list).
+    /// 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; 5] {
+        [
+            self.button.id(),
+            self.toggle.id(),
+            self.slider.id(),
+            self.name_box.id(),
+            self.theme_dropdown.id(),
+        ]
+    }
+
+    /// The widget roots as pointers, for the two genuinely pointer-consuming paths:
+    /// registration (the registry stores them) and the paint walk (it derefs them).
     fn roots(&mut self) -> [*mut (dyn WidgetHost + 'static); 5] {
         [
             self.button.as_ptr_mut(),
@@ -354,15 +368,12 @@ impl Application for DemoApp {
         let (px, py) = (pos.x, pos.y);
         let ev = Event::PointerMove { x: px, y: py, local_x: px, local_y: py };
         let mut changed = false;
-        let self_ptr = self as *mut Self;
-        unsafe {
-            // PointerMove visits every root: hover bookkeeping everywhere, and the
-            // router forwards DragUpdate to the recorded drag target (slider thumb,
-            // text selection) once its 3px threshold trips.
-            for root in (*self_ptr).roots() {
-                if self.ui_context.propagate_event(&ev, root) {
-                    changed = true;
-                }
+        // PointerMove visits every root: hover bookkeeping everywhere, and the
+        // router forwards DragUpdate to the recorded drag target (slider thumb,
+        // text selection) once its 3px threshold trips.
+        for root in self.root_ids() {
+            if self.ui_context.propagate_event(&ev, root) {
+                changed = true;
             }
         }
         self.drain_widget_changes();
@@ -382,15 +393,12 @@ impl Application for DemoApp {
         let (px, py) = (pos.x, pos.y);
         let ev = Event::MouseButton { button, state, x: px, y: py, local_x: px, local_y: py };
         let mut changed = false;
-        let self_ptr = self as *mut Self;
-        unsafe {
-            // Presses are hit-gated per widget by the adapter and releases delivered
-            // everywhere (press-tracking widgets commit or cancel on them) — a straight
-            // loop is correct for pointer-positioned events.
-            for root in (*self_ptr).roots() {
-                if self.ui_context.propagate_event(&ev, root) {
-                    changed = true;
-                }
+        // Presses are hit-gated per widget by the adapter and releases delivered
+        // everywhere (press-tracking widgets commit or cancel on them) — a straight
+        // loop is correct for pointer-positioned events.
+        for root in self.root_ids() {
+            if self.ui_context.propagate_event(&ev, root) {
+                changed = true;
             }
         }
         self.drain_widget_changes();
@@ -410,14 +418,11 @@ impl Application for DemoApp {
         let (px, py) = (pos.x, pos.y);
         let ev = Event::MouseWheel { delta: delta.clone(), x: px, y: py, local_x: px, local_y: py };
         let mut changed = false;
-        let self_ptr = self as *mut Self;
-        unsafe {
-            // Wheel is hit-scoped per widget (the slider nudges its value under the
-            // cursor); roots that miss return false.
-            for root in (*self_ptr).roots() {
-                if self.ui_context.propagate_event(&ev, root) {
-                    changed = true;
-                }
+        // Wheel is hit-scoped per widget (the slider nudges its value under the
+        // cursor); roots that miss return false.
+        for root in self.root_ids() {
+            if self.ui_context.propagate_event(&ev, root) {
+                changed = true;
             }
         }
         self.drain_widget_changes();
@@ -448,13 +453,10 @@ impl Application for DemoApp {
         // the state-gated `drain_widget_changes` instead.
         let ev = Event::KeyInput(event.clone());
         let mut handled = false;
-        let self_ptr = self as *mut Self;
-        unsafe {
-            for root in (*self_ptr).roots() {
-                if self.ui_context.propagate_event(&ev, root) {
-                    handled = true;
-                    break;
-                }
+        for root in self.root_ids() {
+            if self.ui_context.propagate_event(&ev, root) {
+                handled = true;
+                break;
             }
         }
         self.drain_widget_changes();
diff --git a/src/widget/input/button.rs b/src/widget/input/button.rs
index 7acd972..81a8a9e 100644
--- a/src/widget/input/button.rs
+++ b/src/widget/input/button.rs
@@ -397,19 +397,19 @@ mod tests {
         ctx.register_widget(id, ptr);
 
         // Press in, release in -> click.
-        assert!(ctx.propagate_event(&press(20.0, 20.0), ptr));
-        assert!(ctx.propagate_event(&release(25.0, 20.0), ptr), "release consumed (was pressed)");
+        assert!(ctx.propagate_event(&press(20.0, 20.0), id));
+        assert!(ctx.propagate_event(&release(25.0, 20.0), id), "release consumed (was pressed)");
         assert!(b.take_click());
         assert_eq!(fired.load(std::sync::atomic::Ordering::SeqCst), 1, "callback fired");
 
         // Press in, release OUT -> cancelled, no click, but release still consumed.
-        assert!(ctx.propagate_event(&press(20.0, 20.0), ptr));
-        assert!(ctx.propagate_event(&release(500.0, 500.0), ptr), "cancelling release consumed");
+        assert!(ctx.propagate_event(&press(20.0, 20.0), id));
+        assert!(ctx.propagate_event(&release(500.0, 500.0), id), "cancelling release consumed");
         assert!(!b.take_click(), "no click on out-of-rect release");
         assert_eq!(fired.load(std::sync::atomic::Ordering::SeqCst), 1, "callback not re-fired");
 
         // Release without a press is not consumed.
-        assert!(!ctx.propagate_event(&release(20.0, 20.0), ptr));
+        assert!(!ctx.propagate_event(&release(20.0, 20.0), id));
     }
 
     /// Bridge parity for the default config: bg on the rounded or plain path per the configured
diff --git a/src/widget/input/checkbox.rs b/src/widget/input/checkbox.rs
index 2143f04..4addf5b 100644
--- a/src/widget/input/checkbox.rs
+++ b/src/widget/input/checkbox.rs
@@ -463,13 +463,13 @@ mod tests {
         ctx.register_widget(id, ptr);
         WidgetHost::set_rect(&mut cb, 0.0, 0.0, 20.0, 20.0);
 
-        assert!(ctx.propagate_event(&click_at(10.0, 10.0), ptr), "in-rect click consumed");
+        assert!(ctx.propagate_event(&click_at(10.0, 10.0), id), "in-rect click consumed");
         assert!(cb.checked(), "click checked it");
         assert!(cb.take_click(), "take_click reads once");
         assert!(!cb.take_click(), "...then clears");
         assert!(cb.take_change());
 
-        assert!(!ctx.propagate_event(&click_at(100.0, 100.0), ptr), "miss is not consumed");
+        assert!(!ctx.propagate_event(&click_at(100.0, 100.0), id), "miss is not consumed");
         assert!(cb.checked(), "miss does not toggle");
     }
 
@@ -529,7 +529,7 @@ mod tests {
         let before: Vec<_> = WidgetHost::all_rounded_quads(&t, &ctx);
         let before_quads = WidgetHost::extra_quads(&t);
 
-        assert!(ctx.propagate_event(&click_at(30.0, 15.0), ptr), "toggle consumed the click");
+        assert!(ctx.propagate_event(&click_at(30.0, 15.0), id), "toggle consumed the click");
         assert!(t.toggled());
         assert!(t.take_click());
 
diff --git a/src/widget/input/slider.rs b/src/widget/input/slider.rs
index 2ba6eb6..de98dc5 100644
--- a/src/widget/input/slider.rs
+++ b/src/widget/input/slider.rs
@@ -750,7 +750,7 @@ fn probe_slider_bridge() {
         // Press on the track grabs the thumb.
         assert!(ctx.propagate_event(
             &Event::MouseButton { button: MouseButton::Left, state: ElementState::Pressed, x: 50.0, y: 10.0, local_x: 50.0, local_y: 10.0 },
-            ptr,
+            id,
         ));
         assert!(WidgetHost::is_dragging(&sl));
         assert!(sl.drag_update(80.0, 10.0));
diff --git a/src/widget/mod.rs b/src/widget/mod.rs
index 12a819d..7edee1f 100644
--- a/src/widget/mod.rs
+++ b/src/widget/mod.rs
@@ -111,33 +111,6 @@ pub struct LayoutTree {
     pub children: HashMap<WidgetId, Vec<WidgetId>>,
 }
 
-#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
-pub struct WidgetPtr(pub *mut (dyn WidgetHost + 'static));
-
-impl WidgetPtr {
-    pub fn is_null(&self) -> bool {
-        self.0.is_null()
-    }
-    pub fn as_ptr(&self) -> *mut (dyn WidgetHost + 'static) {
-        self.0
-    }
-}
-
-impl std::ops::Deref for WidgetPtr {
-    type Target = dyn WidgetHost + 'static;
-    fn deref(&self) -> &Self::Target {
-        assert!(!self.0.is_null(), "Attempted to dereference a null WidgetPtr!");
-        unsafe { &*self.0 }
-    }
-}
-
-impl std::ops::DerefMut for WidgetPtr {
-    fn deref_mut(&mut self) -> &mut Self::Target {
-        assert!(!self.0.is_null(), "Attempted to dereference a null WidgetPtr!");
-        unsafe { &mut *self.0 }
-    }
-}
-
 pub use crate::context::UiContext;
 
 #[derive(Debug, Clone, PartialEq)]
diff --git a/src/widget/model.rs b/src/widget/model.rs
index a6be981..1ec5384 100644
--- a/src/widget/model.rs
+++ b/src/widget/model.rs
@@ -1740,17 +1740,17 @@ mod tests {
         };
 
         // A click inside the rect is hit-gated in, consumed, and counted.
-        assert!(ctx.propagate_event(&click_at(20.0, 15.0), ptr), "in-rect click is consumed");
+        assert!(ctx.propagate_event(&click_at(20.0, 15.0), id), "in-rect click is consumed");
         // A click outside never reaches on_event (the adapter's hit gate rejects it).
-        assert!(!ctx.propagate_event(&click_at(200.0, 200.0), ptr), "out-of-rect click passes through");
+        assert!(!ctx.propagate_event(&click_at(200.0, 200.0), id), "out-of-rect click passes through");
         assert_eq!(w.inner().clicks, 1, "only the in-rect click was counted");
 
         // Hover: moving inside synthesizes MouseEnter (via the legacy bookkeeping the adapter
         // preserves) and sets the base hover flag; moving away synthesizes MouseLeave.
-        ctx.propagate_event(&Event::PointerMove { x: 20.0, y: 15.0, local_x: 20.0, local_y: 15.0 }, ptr);
+        ctx.propagate_event(&Event::PointerMove { x: 20.0, y: 15.0, local_x: 20.0, local_y: 15.0 }, id);
         assert_eq!(w.inner().entered, 1, "MouseEnter reached on_event");
         assert!(unsafe { (*ptr).base().hovered }, "base hover flag set through the adapter");
-        ctx.propagate_event(&Event::PointerMove { x: 200.0, y: 200.0, local_x: 200.0, local_y: 200.0 }, ptr);
+        ctx.propagate_event(&Event::PointerMove { x: 200.0, y: 200.0, local_x: 200.0, local_y: 200.0 }, id);
         assert_eq!(w.inner().left, 1, "MouseLeave reached on_event");
         assert!(!unsafe { (*ptr).base().hovered }, "base hover flag cleared");
     }