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

commit69d85d30fa8d8aa2e1a3a161152ea2362e03f64a
parent4c40c43418
authorLucas Galante <[email protected]>
date2026-08-06 15:14
feat: outside presses always close open popovers (engine-level)

Apps commonly region-gate their event routing (a canvas click never
reaches a sidebar dropdown root), so an open menu could stay open on
outside clicks. The engine now calls
UiContext::close_popovers_missed_by_press on every Left press before
the app dispatch: it sweeps the registry for open popovers (like the
coverage fallback — popover registration is optional and spotty) and
delivers the real press to owners the press missed, driving their
ordinary outside-press close. Presses ON an owner are left entirely to
the app dispatch, preserving take_change contracts; double delivery is
idempotent.

render_popovers gains the same registry sweep, so apps that never call
register_popover still get their open popovers DRAWN.

Co-Authored-By: Claude Fable 5 <[email protected]>

 src/backend/window_runner.rs |  9 +++++++++
 src/context.rs               | 39 +++++++++++++++++++++++++++++++++++++++
 src/layout.rs                | 15 +++++++++++++++
 3 files changed, 63 insertions(+)

diff --git a/src/backend/window_runner.rs b/src/backend/window_runner.rs
index a40fd62..eca93f8 100644
--- a/src/backend/window_runner.rs
+++ b/src/backend/window_runner.rs
@@ -3632,6 +3632,15 @@ impl<A: Application> PointerHandler for EngineState<A> {
                         }
                     }
 
+                    // Outside-press close for open popovers, BEFORE the app's
+                    // dispatch: apps commonly region-gate their routing, so an
+                    // open menu's owner may never hear about a press elsewhere.
+                    if btn == MouseButton::Left {
+                        if let Some(ctx) = self.inner.as_mut().unwrap().ui_context_mut() {
+                            ctx.close_popovers_missed_by_press(lx, ly);
+                        }
+                    }
+
                     let mut rebuild = false;
                     if let Some(msg) = self.inner.as_mut().unwrap().handle_mouse_input(btn, ElementState::Pressed, LogicalPosition::new(lx, ly), &mut rebuild) {
                         let mut update_rebuild = false;
diff --git a/src/context.rs b/src/context.rs
index 1a6863e..3d76b95 100644
--- a/src/context.rs
+++ b/src/context.rs
@@ -561,6 +561,45 @@ impl UiContext {
         self.active_popovers.clear();
     }
 
+    /// Close any open popover whose owner the press MISSED — the engine calls
+    /// this on every Left press before the app's dispatch, so an outside click
+    /// always reaches an open menu even in apps that region-gate their event
+    /// routing (a canvas click never reaching a sidebar dropdown's root).
+    /// Scans the whole registry (like `is_coordinate_covered`'s fallback) —
+    /// popover registration is optional and spotty across apps. A press ON the
+    /// owner (trigger or popover) is left entirely to the app's own dispatch:
+    /// its `take_change` plumbing is gated on that delivery. Owners receive the
+    /// real press event, so their ordinary outside-press handling runs; a
+    /// second delivery through the app's own dispatch is idempotent (a closing
+    /// dropdown ignores further presses).
+    pub fn close_popovers_missed_by_press(&mut self, x: f32, y: f32) {
+        let owners: Vec<WidgetId> = self
+            .tree
+            .iter_registered()
+            .filter_map(|(id, ptr)| unsafe {
+                ptr.as_ref().and_then(|w| {
+                    (w.visible() && w.popover_rect().is_some()).then_some(id)
+                })
+            })
+            .collect();
+        for id in owners {
+            let Some(ptr) = self.tree.get_ptr(id) else { continue };
+            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);
+                }
+            }
+        }
+    }
+
     /// 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).
     pub fn register_popover(&mut self, w: &mut (dyn WidgetHost + 'static)) {
diff --git a/src/layout.rs b/src/layout.rs
index 3a50775..c6a43e0 100644
--- a/src/layout.rs
+++ b/src/layout.rs
@@ -3633,6 +3633,21 @@ pub fn render_popovers(pc: &mut dyn RenderTarget, ctx: &UiContext) {
             }
         }
     }
+    // Registry sweep for open popovers the app never registered (popover
+    // registration is optional and spotty) — the same fallback the coverage
+    // check and the engine's outside-press close use.
+    for (id, ptr) in ctx.tree.iter_registered() {
+        if ctx.active_popovers.contains(&id) {
+            continue;
+        }
+        unsafe {
+            if let Some(w) = ptr.as_ref() {
+                if w.visible() && w.popover_rect().is_some() {
+                    w.render_popover(pc);
+                }
+            }
+        }
+    }
 }
 
 pub fn partition_concentric_corners(