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