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