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

commitb4c3f829687185969a62c457ee68ba9324d5c75b
parent3a57bab91b
authorLucas Galante <[email protected]>
date2026-07-12 15:06
refactor(widget)!: focus stores keyed by WidgetId (Phase 6bc slice 1)

Both focus stores — UiContext.focused_widget and the core.rs
thread-local FOCUSED_WIDGET — now hold Option<WidgetId> instead of
*mut dyn Element. Dispatch to the previous holder (unfocus/FocusOut)
and KeyInput delivery resolve through the generational tree, so a
stale id is skipped instead of dereferencing freed memory (the 6ao/6w
UAF class). set_focused/set_focused_ptr refresh the registry with the
pointer they are handed, so focusing a not-yet-registered widget
keeps working. is_focused_addr is replaced by is_focused_id (base-id
comparison); thread-local fns that dispatch gained an Option<&mut
UiContext>/ctx param (set_focused, clear_focus, navigate_focus).

Verified: 168 tests; workspace builds; A/B identical click sequences
on text-editor (static/editor-focus/menu AE<=6, empty 8% masks) and
data-editor (search-box focus click AE=0); settings notifications
spinbox click-to-focus live.

 src/context.rs                     |  92 ++++++++++++++++++-------------
 src/widget/container/scroll_box.rs |  22 ++++----
 src/widget/core.rs                 | 107 +++++++++++++++++++++----------------
 src/widget/mod.rs                  |   4 +-
 src/widget/model.rs                |   4 +-
 5 files changed, 132 insertions(+), 97 deletions(-)

diff --git a/src/context.rs b/src/context.rs
index 615cec1..415ea84 100644
--- a/src/context.rs
+++ b/src/context.rs
@@ -54,7 +54,9 @@ pub struct UiContext {
     /// core rebuild). Replaces the former `layout_tree` + `widget_registry` maps; see
     /// `scene/tree.rs`.
     pub tree: crate::scene::WidgetTree,
-    pub focused_widget: Option<*mut (dyn Element + 'static)>,
+    /// The focused widget's id (Phase 6bc: stored ids, not pointers — a stale id resolves to
+    /// `None` through the generational tree instead of dereferencing freed memory).
+    pub focused_widget: Option<WidgetId>,
     pub active_popovers: Vec<*const (dyn Element + 'static)>,
     pub hover_state: HoverState,
     pub cursor_pos: (f32, f32),
@@ -139,7 +141,7 @@ impl UiContext {
             };
             if is_scroll_key {
                 let mut handled = false;
-                if let Some(focused) = self.focused_widget {
+                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);
@@ -245,7 +247,7 @@ impl UiContext {
 
             // For KeyInput, send directly to focused widget if it exists
             if let Event::KeyInput(_) = event {
-                if let Some(focused) = self.focused_widget {
+                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;
@@ -413,64 +415,82 @@ impl UiContext {
         changed
     }
 
-    // --- Focus management ---
+    // --- Focus management (id-keyed; Phase 6bc) ---
     pub fn set_focused(&mut self, w: &mut dyn Element) {
+        let Some(id) = w.base().map(|b| b.id()) else { return };
+        // Refresh the registry with the pointer we were just handed, so focus on a
+        // not-yet-registered widget keeps working (the legacy code stored this pointer
+        // directly; the id must resolve for FocusOut/KeyInput dispatch to reach it).
         let new_ptr = unsafe {
             std::mem::transmute::<*mut dyn Element, *mut (dyn Element + 'static)>(w as *mut dyn Element)
         };
-        self.set_focused_ptr(new_ptr);
+        self.tree.register(id, new_ptr);
+        self.set_focused_id(id);
     }
 
+    /// Transitional pointer form (TreeList focuses its adapter via `EventCtx::host_ptr`). The
+    /// pointer must be live at the call — it is only used to derive the id and refresh the
+    /// registry, never stored.
     pub fn set_focused_ptr(&mut self, new_ptr: *mut (dyn Element + 'static)) {
-        if let Some(old_ptr) = self.focused_widget {
-            let old_data = old_ptr as *mut () as usize;
-            let new_data = new_ptr as *mut () as usize;
-            if old_data != new_data {
-                unsafe {
-                    (*old_ptr).unfocus();
-                    (*old_ptr).handle_event(&Event::FocusOut, self);
+        if new_ptr.is_null() {
+            return;
+        }
+        let Some(id) = (unsafe { (*new_ptr).base().map(|b| b.id()) }) else { return };
+        self.tree.register(id, new_ptr);
+        self.set_focused_id(id);
+    }
+
+    pub fn set_focused_id(&mut self, id: WidgetId) {
+        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(new_ptr);
-                unsafe {
-                    (*new_ptr).handle_event(&Event::FocusIn, 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(new_ptr);
-            unsafe {
-                (*new_ptr).handle_event(&Event::FocusIn, self);
+            self.focused_widget = Some(id);
+            if let Some(new_ptr) = self.tree.get_ptr(id) {
+                unsafe {
+                    (*new_ptr).handle_event(&Event::FocusIn, self);
+                }
             }
         }
     }
 
     pub fn is_focused(&self, w: &dyn Element) -> bool {
-        let addr = w as *const dyn Element as *const () as usize;
-        self.is_focused_addr(addr)
+        match w.base() {
+            Some(b) => self.is_focused_id(b.id()),
+            None => false,
+        }
     }
 
-    pub fn is_focused_addr(&self, addr: usize) -> bool {
-        if let Some(ptr) = self.focused_widget {
-            let current_data = ptr as *const () as usize;
-            current_data == addr
-        } else {
-            false
-        }
+    pub fn is_focused_id(&self, id: WidgetId) -> bool {
+        self.focused_widget == Some(id)
     }
 
     pub fn clear_focus(&mut self) {
-        if let Some(ptr) = self.focused_widget.take() {
-            unsafe {
-                (*ptr).unfocus();
-                (*ptr).handle_event(&Event::FocusOut, 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);
+                }
             }
         }
     }
 
     pub fn clear_if_matches(&mut self, w: &dyn Element) {
-        let query_data = w as *const dyn Element as *const () as usize;
-        if let Some(ptr) = self.focused_widget {
-            let current_data = ptr as *const () as usize;
-            if current_data == query_data {
+        if let Some(b) = w.base() {
+            if self.focused_widget == Some(b.id()) {
                 self.focused_widget = None;
             }
         }
@@ -481,7 +501,7 @@ impl UiContext {
     }
 
     pub fn navigate_focus(&mut self, key: &Key, ctrl: bool) -> bool {
-        let ptr = match self.focused_widget {
+        let ptr = match self.focused_widget.and_then(|id| self.tree.get_ptr(id)) {
             Some(p) => p,
             None => return false,
         };
diff --git a/src/widget/container/scroll_box.rs b/src/widget/container/scroll_box.rs
index 9379d95..6784e3e 100644
--- a/src/widget/container/scroll_box.rs
+++ b/src/widget/container/scroll_box.rs
@@ -101,15 +101,15 @@ impl ScrollBox {
     /// unfocusing the previously focused widget (nothing ever queried focus ON the scroll
     /// box through the thread-local, and its own `unfocus` was a no-op) — so just release
     /// the current holder instead of storing a pointer to a non-Element.
-    fn claim_focus(&self) {
-        focus::clear_focus();
+    fn claim_focus(&self, ctx: &mut UiContext) {
+        focus::clear_focus(Some(ctx));
     }
 
     pub fn mouse_input(&mut self, button: MouseButton, state: ElementState, px: f32, py: f32, ctx: &mut UiContext) -> bool {
         if button == MouseButton::Left {
             if state == ElementState::Pressed {
                 if self.hit_test_scrollbar(px, py) {
-                    self.claim_focus();
+                    self.claim_focus(ctx);
                     self.scrollbar_dragging = true;
                     
                     let sb_track_h = self.viewport_h - 8.0;
@@ -143,7 +143,7 @@ impl ScrollBox {
                     self.scrollbar_dragging = false;
                 }
                 if self.hit_test(px, py, ctx) {
-                    self.claim_focus();
+                    self.claim_focus(ctx);
                 }
             } else if state == ElementState::Released {
                 self.scrollbar_dragging = false;
@@ -303,17 +303,19 @@ impl ScrollBox {
     }
 
     pub fn keyboard_input(&mut self, event: &KeyEvent, ctx: &mut UiContext) -> bool {
-        let self_addr = self as *const Self as *const () as usize;
-        let has_focus = ctx.is_focused_addr(self_addr) || {
+        // Focus never lands on the box itself (post-6av it is not an `Element`), and its id is
+        // never a tree ancestor of the focused widget — like the legacy address walk, this
+        // gate only ever passes via the hover check below.
+        let self_id = self.base.id();
+        let has_focus = ctx.is_focused_id(self_id) || {
             let mut current = ctx.focused_widget;
             let mut found = false;
-            while let Some(ptr) = current {
-                let ptr_addr = ptr as *const () as usize;
-                if ptr_addr == self_addr {
+            while let Some(id) = current {
+                if id == self_id {
                     found = true;
                     break;
                 }
-                current = unsafe { (*ptr).parent(ctx) };
+                current = ctx.tree.parent_id(id);
             }
             found
         };
diff --git a/src/widget/core.rs b/src/widget/core.rs
index 9444f0a..fd33eb8 100644
--- a/src/widget/core.rs
+++ b/src/widget/core.rs
@@ -1,63 +1,72 @@
 use crate::widget::{Element, Key};
 
 pub mod focus {
-    use super:: Element;
+    use super::Element;
+    use crate::widget::WidgetId;
     use std::cell::Cell;
 
+    // Phase 6bc: the thread-local focus store keys by id, not pointer. Dispatching to the
+    // previous holder (`unfocus`) resolves through the caller's generational tree, so a
+    // stale id is skipped instead of dereferencing freed memory (the 6w settings UAF class).
     thread_local! {
-        static FOCUSED_WIDGET: Cell<Option<*mut (dyn Element + 'static)>> = Cell::new(None);
+        static FOCUSED_WIDGET: Cell<Option<WidgetId>> = Cell::new(None);
     }
 
-    pub fn set_focused(w: &mut dyn Element) {
-        FOCUSED_WIDGET.with(|cell| {
-            let new_ptr = unsafe {
-                std::mem::transmute::<*mut dyn Element, *mut (dyn Element + 'static)>(w as *mut dyn Element)
-            };
-            if let Some(old_ptr) = cell.get() {
-                let old_data = old_ptr as *mut () as usize;
-                let new_data = new_ptr as *mut () as usize;
-                if old_data != new_data {
-                    unsafe {
-                        (*old_ptr).unfocus();
-                    }
-                    cell.set(Some(new_ptr));
+    /// Resolve `id` in `ctx`'s tree (when a ctx is in reach) and call `unfocus()` on it.
+    fn unfocus_via(ctx: Option<&mut crate::context::UiContext>, id: WidgetId) {
+        if let Some(ctx) = ctx {
+            if let Some(ptr) = ctx.tree.get_ptr(id) {
+                unsafe {
+                    (*ptr).unfocus();
                 }
-            } else {
-                cell.set(Some(new_ptr));
             }
-        });
+        }
     }
 
-    pub fn is_focused(w: &dyn Element) -> bool {
-        FOCUSED_WIDGET.with(|cell| {
-            if let Some(ptr) = cell.get() {
-                let current_data = ptr as *const () as usize;
-                let query_data = w as *const dyn Element as *const () as usize;
-                current_data == query_data
-            } else {
-                false
-            }
-        })
+    pub fn set_focused(w: &mut dyn Element, ctx: Option<&mut crate::context::UiContext>) {
+        let Some(id) = w.base().map(|b| b.id()) else { return };
+        set_focused_id(id, ctx);
     }
 
-    pub fn clear_focus() {
-        FOCUSED_WIDGET.with(|cell| {
-            if let Some(ptr) = cell.take() {
-                unsafe {
-                    (*ptr).unfocus();
-                }
+    pub fn set_focused_id(id: WidgetId, ctx: Option<&mut crate::context::UiContext>) {
+        let old = FOCUSED_WIDGET.with(|cell| cell.get());
+        if let Some(old_id) = old {
+            if old_id != id {
+                unfocus_via(ctx, old_id);
+                FOCUSED_WIDGET.with(|cell| cell.set(Some(id)));
             }
-        });
+        } else {
+            FOCUSED_WIDGET.with(|cell| cell.set(Some(id)));
+        }
+    }
+
+    pub fn is_focused(w: &dyn Element) -> bool {
+        match w.base() {
+            Some(b) => is_focused_id(b.id()),
+            None => false,
+        }
+    }
+
+    pub fn is_focused_id(id: WidgetId) -> bool {
+        FOCUSED_WIDGET.with(|cell| cell.get() == Some(id))
+    }
+
+    pub fn clear_focus(ctx: Option<&mut crate::context::UiContext>) {
+        if let Some(id) = FOCUSED_WIDGET.with(|cell| cell.take()) {
+            unfocus_via(ctx, id);
+        }
     }
 
     pub fn clear_if_matches(w: &dyn Element) {
+        if let Some(b) = w.base() {
+            clear_if_matches_id(b.id());
+        }
+    }
+
+    pub fn clear_if_matches_id(id: WidgetId) {
         FOCUSED_WIDGET.with(|cell| {
-            if let Some(ptr) = cell.get() {
-                let current_data = ptr as *const () as usize;
-                let query_data = w as *const dyn Element as *const () as usize;
-                if current_data == query_data {
-                    cell.set(None);
-                }
+            if cell.get() == Some(id) {
+                cell.set(None);
             }
         });
     }
@@ -81,9 +90,13 @@ pub mod focus {
         child.set_parent(Some(parent_ptr), ctx);
     }
 
-    pub fn navigate_focus(key: &super::Key, ctrl: bool) -> bool {
+    /// Keyboard tree navigation from the focused widget. `ctx` resolves the focused id to a
+    /// live widget; the parent/children walk itself deliberately keeps the legacy dummy-ctx
+    /// semantics (only `container_children`-style overrides that ignore the ctx ever yielded
+    /// anything here).
+    pub fn navigate_focus(key: &super::Key, ctrl: bool, ctx: &mut crate::context::UiContext) -> bool {
         FOCUSED_WIDGET.with(|cell| {
-            let ptr = match cell.get() {
+            let ptr = match cell.get().and_then(|id| ctx.tree.get_ptr(id)) {
                 Some(p) => p,
                 None => return false,
             };
@@ -94,7 +107,7 @@ pub mod focus {
                         let dummy = crate::context::UiContext::new();
                         if let Some(parent_ptr) = (*ptr).parent(&dummy) {
                             let parent_ref = &mut *parent_ptr;
-                            set_focused(parent_ref);
+                            set_focused(parent_ref, Some(&mut *ctx));
                             parent_ref.focus();
                             return true;
                         }
@@ -104,7 +117,7 @@ pub mod focus {
                         let mut children = (*ptr).children(&dummy);
                         if !children.is_empty() {
                             let child_ref = &mut *children[0];
-                            set_focused(child_ref);
+                            set_focused(child_ref, Some(&mut *ctx));
                             child_ref.focus();
                             return true;
                         }
@@ -121,7 +134,7 @@ pub mod focus {
                             if let Some(idx) = current_idx {
                                 let next_idx = (idx + 1) % siblings.len();
                                 let sibling_ref = &mut *siblings[next_idx];
-                                set_focused(sibling_ref);
+                                set_focused(sibling_ref, Some(&mut *ctx));
                                 sibling_ref.focus();
                                 return true;
                             }
@@ -139,7 +152,7 @@ pub mod focus {
                             if let Some(idx) = current_idx {
                                 let prev_idx = if idx == 0 { siblings.len() - 1 } else { idx - 1 };
                                 let sibling_ref = &mut *siblings[prev_idx];
-                                set_focused(sibling_ref);
+                                set_focused(sibling_ref, Some(&mut *ctx));
                                 sibling_ref.focus();
                                 return true;
                             }
diff --git a/src/widget/mod.rs b/src/widget/mod.rs
index 37ace6d..8ffde22 100644
--- a/src/widget/mod.rs
+++ b/src/widget/mod.rs
@@ -406,7 +406,7 @@ pub trait Element {
     }
 
     fn highlight_color(&self, ctx: &UiContext) -> Option<[f32; 4]> {
-        let is_focused = ctx.is_focused_addr(self as *const Self as *const () as usize);
+        let is_focused = self.base().map(|b| ctx.is_focused_id(b.id())).unwrap_or(false);
         if is_focused {
             Some(colors::highlight_primary_color())
         } else if self.hovered() {
@@ -587,7 +587,7 @@ pub trait Element {
         }
     }
     fn focused(&self, ctx: &UiContext) -> bool {
-        ctx.is_focused_addr(self as *const Self as *const () as usize)
+        self.base().map(|b| ctx.is_focused_id(b.id())).unwrap_or(false)
     }
     fn prepare_text(&mut self, _fs: &mut glyphon::FontSystem) {}
     fn set_selected(&mut self, _selected: bool) {}
diff --git a/src/widget/model.rs b/src/widget/model.rs
index e3971a8..474fe76 100644
--- a/src/widget/model.rs
+++ b/src/widget/model.rs
@@ -383,7 +383,7 @@ impl EventCtx<'_> {
     /// Make this widget the global focus target (legacy `focus::set_focused(self)`).
     pub fn request_focus(&mut self) {
         if let Some(ptr) = self.self_ptr {
-            unsafe { crate::widget::focus::set_focused(&mut *ptr) };
+            unsafe { crate::widget::focus::set_focused(&mut *ptr, self.ui.as_deref_mut()) };
         }
     }
 
@@ -1117,7 +1117,7 @@ impl<W: Layout + Paint + Input + 'static> Element for Adapted<W> {
         if !Paint::legacy_focus_highlight(&self.inner) {
             return None;
         }
-        let is_focused = ctx.is_focused_addr(self as *const Self as *const () as usize);
+        let is_focused = ctx.is_focused_id(self.base.id());
         let hc = if is_focused {
             crate::colors::highlight_primary_color()
         } else if self.base.hovered {