cranpose_ui/widgets/popup.rs
1//! Top-level overlay / `Popup` primitive.
2//!
3//! Compose parity with `androidx.compose.ui.window.Popup`: content composed
4//! inside a [`Popup`] renders in a top-level overlay that draws above all
5//! normal content and is **not** clipped by the bounds of the ancestor the
6//! call site sits under. This is what lets a text field's selection handles
7//! hang below the last line, and a contextual menu float above a selection,
8//! without being cut off by a scrolling parent's clip rectangle.
9//!
10//! # How it works
11//!
12//! Composition is single-rooted and both paint order and hit-test order are
13//! derived purely from tree position (later sibling = on top, and a node is
14//! only clipped by an ancestor that opted into `clip_to_bounds`). Therefore the
15//! only way for content to draw above *everything* and escape *any* ancestor
16//! clip is to be composed as a last-order sibling directly under an unclipped
17//! root. [`PopupHost`] provides exactly that root: it wraps the whole app in an
18//! unclipped, viewport-filling [`Box`] and renders every registered popup as a
19//! trailing child, positioned absolutely at its anchor.
20//!
21//! [`Popup`] itself emits **no node at its call site**. Instead it registers
22//! its `(position, content)` into a [`PopupRegistry`] carried down the tree by
23//! a [`CompositionLocal`]; the enclosing [`PopupHost`] reads that registry and
24//! composes the content at the root. Registration/teardown is reactive:
25//! adding, removing, or moving a popup invalidates the host so it recomposes.
26
27#![allow(non_snake_case)]
28
29use std::cell::{Cell, RefCell};
30use std::rc::Rc;
31
32use crate::modifier::Modifier;
33use crate::{composable, PointerInputScope};
34use cranpose_core::{
35 mutableStateOf, remember, staticCompositionLocalOf, CompositionLocalProvider, MutableState,
36 SideEffect, StaticCompositionLocal,
37};
38use cranpose_foundation::PointerEventKind;
39use cranpose_ui_graphics::{Point, Rect};
40
41use super::box_widget::{Box, BoxSpec};
42
43/// One registered popup: a stable id, its absolute top-left position (logical
44/// px, in [`PopupHost`] space, i.e. window coordinates) and its content.
45#[derive(Clone)]
46struct PopupEntry {
47 id: u64,
48 position: Point,
49 content: Rc<dyn Fn()>,
50 /// When set, the host renders a viewport-filling scrim beneath this popup
51 /// that invokes the callback on an outside tap (Compose's
52 /// `onDismissRequest`).
53 on_dismiss: Option<Rc<dyn Fn()>>,
54}
55
56struct PopupRegistryState {
57 entries: RefCell<Vec<PopupEntry>>,
58 next_id: Cell<u64>,
59 /// Reactive dirtiness signal. `Some` for a hosted registry (created by a
60 /// [`PopupHost`]); `None` for the detached default registry used when no
61 /// host is present, so `Popup` calls without a host are inert instead of
62 /// panicking.
63 revision: Option<MutableState<u64>>,
64}
65
66/// Shared, cheaply-cloneable handle to the popup registry provided by the
67/// nearest [`PopupHost`].
68#[derive(Clone)]
69pub struct PopupRegistry {
70 inner: Rc<PopupRegistryState>,
71}
72
73impl PartialEq for PopupRegistry {
74 fn eq(&self, other: &Self) -> bool {
75 Rc::ptr_eq(&self.inner, &other.inner)
76 }
77}
78
79impl PopupRegistry {
80 fn hosted() -> Self {
81 Self {
82 inner: Rc::new(PopupRegistryState {
83 entries: RefCell::new(Vec::new()),
84 next_id: Cell::new(0),
85 revision: Some(mutableStateOf(0u64)),
86 }),
87 }
88 }
89
90 /// The default registry when no [`PopupHost`] is installed: it accepts
91 /// registrations but is never rendered, so stray `Popup` calls are no-ops.
92 fn detached() -> Self {
93 Self {
94 inner: Rc::new(PopupRegistryState {
95 entries: RefCell::new(Vec::new()),
96 next_id: Cell::new(0),
97 revision: None,
98 }),
99 }
100 }
101
102 fn allocate_id(&self) -> u64 {
103 let id = self.inner.next_id.get();
104 self.inner.next_id.set(id.wrapping_add(1));
105 id
106 }
107
108 /// Marks the registry dirty so the host recomposes. Safe to call from a
109 /// [`SideEffect`]/dispose callback (uses a non-subscribing update).
110 fn bump(&self) {
111 if let Some(revision) = self.inner.revision.as_ref() {
112 revision.update(|value| *value = value.wrapping_add(1));
113 }
114 }
115
116 /// Inserts a new popup or updates an existing one. Structural changes (a
117 /// new id), a moved position, or re-registered content (a new closure —
118 /// the caller recomposed) dirty the host so the overlay re-renders with
119 /// the fresh content. A popup whose caller did not recompose never calls
120 /// this, so resting frames do not spin recomposition.
121 fn upsert(
122 &self,
123 id: u64,
124 position: Point,
125 content: Rc<dyn Fn()>,
126 on_dismiss: Option<Rc<dyn Fn()>>,
127 ) {
128 let mut entries = self.inner.entries.borrow_mut();
129 if let Some(existing) = entries.iter_mut().find(|entry| entry.id == id) {
130 let moved = existing.position != position;
131 let content_changed =
132 !std::ptr::addr_eq(Rc::as_ptr(&existing.content), Rc::as_ptr(&content));
133 existing.position = position;
134 existing.content = content;
135 existing.on_dismiss = on_dismiss;
136 drop(entries);
137 if moved || content_changed {
138 self.bump();
139 }
140 } else {
141 entries.push(PopupEntry {
142 id,
143 position,
144 content,
145 on_dismiss,
146 });
147 drop(entries);
148 self.bump();
149 }
150 }
151
152 fn remove(&self, id: u64) {
153 let mut entries = self.inner.entries.borrow_mut();
154 let before = entries.len();
155 entries.retain(|entry| entry.id != id);
156 let changed = entries.len() != before;
157 drop(entries);
158 if changed {
159 self.bump();
160 }
161 }
162
163 /// Subscribes the current recompose scope to add/remove/move events.
164 fn subscribe(&self) {
165 if let Some(revision) = self.inner.revision.as_ref() {
166 let _ = revision.value();
167 }
168 }
169
170 fn snapshot(&self) -> Vec<PopupEntry> {
171 self.inner.entries.borrow().clone()
172 }
173}
174
175/// The [`CompositionLocal`](cranpose_core::CompositionLocal) carrying the active
176/// [`PopupRegistry`] down the tree. One shared static local per thread.
177fn local_popup_registry() -> StaticCompositionLocal<PopupRegistry> {
178 thread_local! {
179 static LOCAL: RefCell<Option<StaticCompositionLocal<PopupRegistry>>> =
180 const { RefCell::new(None) };
181 }
182 LOCAL.with(|cell| {
183 cell.borrow_mut()
184 .get_or_insert_with(|| staticCompositionLocalOf(PopupRegistry::detached))
185 .clone()
186 })
187}
188
189/// The [`PopupHost`]'s live measured viewport size (logical px), published on
190/// every measure pass through a shared cell. Overlay content (selection
191/// menus, the loupe) reads it to clamp itself to the window edges;
192/// `Size::ZERO` means "not measured yet" (or no host) — treat as unclamped.
193pub fn local_popup_viewport() -> StaticCompositionLocal<Rc<Cell<cranpose_ui_graphics::Size>>> {
194 type ViewportCell = Rc<Cell<cranpose_ui_graphics::Size>>;
195 thread_local! {
196 static LOCAL: RefCell<Option<StaticCompositionLocal<ViewportCell>>> =
197 const { RefCell::new(None) };
198 }
199 LOCAL.with(|cell| {
200 cell.borrow_mut()
201 .get_or_insert_with(|| {
202 staticCompositionLocalOf(|| {
203 Rc::new(Cell::new(cranpose_ui_graphics::Size {
204 width: 0.0,
205 height: 0.0,
206 }))
207 })
208 })
209 .clone()
210 })
211}
212
213/// Installs the top-level overlay layer and composes `content` beneath it.
214///
215/// Wrap an application's root content in a single `PopupHost` so that any
216/// [`Popup`] composed anywhere inside `content` renders in the overlay, above
217/// everything and clipped only by the viewport. The host itself is an
218/// unclipped, viewport-filling [`Box`]; the app content is its first child and
219/// each registered popup is a trailing child (drawn last, hit-tested first).
220#[composable]
221pub fn PopupHost<F>(content: F)
222where
223 F: FnMut() + 'static,
224{
225 let registry = remember(PopupRegistry::hosted).with(PopupRegistry::clone);
226 let viewport = remember(|| {
227 Rc::new(Cell::new(cranpose_ui_graphics::Size {
228 width: 0.0,
229 height: 0.0,
230 }))
231 })
232 .with(Rc::clone);
233 let report_sink = Rc::clone(&viewport);
234 Box(
235 Modifier::empty().fill_max_size().report_size(report_sink),
236 BoxSpec::default(),
237 move || {
238 let registry = registry.clone();
239 let viewport = Rc::clone(&viewport);
240 CompositionLocalProvider(
241 [
242 local_popup_registry().provides(registry.clone()),
243 local_popup_viewport().provides(viewport),
244 ],
245 || {
246 // App content: `Popup` calls inside here register into `registry`.
247 content();
248 // The overlay is its own recompose scope: registry bumps
249 // re-render the popups without re-running the app content
250 // (which would re-register fresh popup content and spin).
251 PopupOverlay(registry.clone());
252 },
253 );
254 },
255 );
256}
257
258/// Renders the registered popups. Isolated in its own composable so registry
259/// changes (add/remove/move/content refresh) recompose only the overlay.
260#[composable]
261fn PopupOverlay(registry: PopupRegistry) {
262 registry.subscribe();
263 for entry in registry.snapshot() {
264 if let Some(on_dismiss) = entry.on_dismiss {
265 // Outside-tap scrim: fills the host (the viewport), beneath the
266 // popup content, so any tap that misses the popup dismisses it.
267 // Consume the press as well as the release: a regular `clickable`
268 // leaves Down unconsumed so scroll ancestors can participate, but
269 // a modal scrim has no such ancestor and must not capture covered
270 // sibling controls (for example a tab bar) into the same gesture.
271 Box(
272 Modifier::empty()
273 .fill_max_size()
274 .then(popup_scrim_pointer_input(entry.id, on_dismiss)),
275 BoxSpec::default(),
276 || {},
277 );
278 }
279 let content = entry.content;
280 Box(
281 Modifier::empty().absolute_offset(entry.position.x, entry.position.y),
282 BoxSpec::default(),
283 move || content(),
284 );
285 }
286}
287
288/// Modal outside-tap handling for dismissable popups. Consuming Down prevents
289/// lower z-order siblings from joining the shell's captured hit path; consuming
290/// every follow-up keeps the entire gesture inside the overlay even when the
291/// dismiss callback removes the popup on release.
292fn popup_scrim_pointer_input(id: u64, on_dismiss: Rc<dyn Fn()>) -> Modifier {
293 Modifier::empty().pointer_input(id, move |scope: PointerInputScope| {
294 let on_dismiss = Rc::clone(&on_dismiss);
295 async move {
296 scope
297 .await_pointer_event_scope(|await_scope| async move {
298 let mut pressed = false;
299 loop {
300 let event = await_scope.await_pointer_event().await;
301 match event.kind {
302 PointerEventKind::Down => {
303 pressed = true;
304 event.consume();
305 }
306 PointerEventKind::Move => event.consume(),
307 PointerEventKind::Up => {
308 let should_dismiss = pressed;
309 pressed = false;
310 event.consume();
311 if should_dismiss {
312 on_dismiss();
313 }
314 }
315 PointerEventKind::Cancel => {
316 pressed = false;
317 event.consume();
318 }
319 _ => {}
320 }
321 }
322 })
323 .await;
324 }
325 })
326}
327
328/// Composes `content` in the top-level overlay layer, positioned at
329/// `anchor` shifted by `offset` (logical px, window coordinates).
330///
331/// The content is not clipped by the ancestor bounds of the `Popup` call site
332/// and draws above all normal content. Requires an enclosing [`PopupHost`]
333/// (installed at the app root); without one the call is inert.
334///
335/// `anchor` is supplied by the caller (there is no automatic
336/// `onGloballyPositioned` yet) — derive it from a pointer position, a tracked
337/// layout rect, or a text-field caret/selection geometry.
338#[composable]
339pub fn Popup<F>(anchor: Rect, offset: Point, content: F)
340where
341 F: Fn() + 'static,
342{
343 popup_impl(anchor, offset, None, Rc::new(content));
344}
345
346/// A [`Popup`] with an outside-tap dismissal: the host renders a
347/// viewport-filling scrim beneath the content that calls `on_dismiss` — the
348/// analogue of Compose's `Popup(onDismissRequest = …)`. Menus and pickers use
349/// this; anchored chrome like selection handles uses plain [`Popup`].
350#[composable]
351pub fn PopupDismissable<F>(anchor: Rect, offset: Point, on_dismiss: impl Fn() + 'static, content: F)
352where
353 F: Fn() + 'static,
354{
355 PopupDismissableWhen(true, anchor, offset, on_dismiss, content);
356}
357
358/// A dismissable popup whose modal scrim can be disabled without unmounting
359/// its visual content. Controls with an exit animation use this to stop
360/// intercepting the rest of the UI as soon as dismissal begins while their
361/// popup surface finishes animating out.
362#[composable]
363pub fn PopupDismissableWhen<F>(
364 dismissable: bool,
365 anchor: Rect,
366 offset: Point,
367 on_dismiss: impl Fn() + 'static,
368 content: F,
369) where
370 F: Fn() + 'static,
371{
372 let on_dismiss = popup_dismiss_callback(dismissable, Rc::new(on_dismiss));
373 popup_impl(anchor, offset, on_dismiss, Rc::new(content));
374}
375
376fn popup_dismiss_callback(dismissable: bool, on_dismiss: Rc<dyn Fn()>) -> Option<Rc<dyn Fn()>> {
377 dismissable.then_some(on_dismiss)
378}
379
380fn popup_impl(
381 anchor: Rect,
382 offset: Point,
383 on_dismiss: Option<Rc<dyn Fn()>>,
384 content: Rc<dyn Fn()>,
385) {
386 let registry = local_popup_registry().current();
387 let id = remember(|| registry.allocate_id()).with(|id| *id);
388 // The freshly captured closure is registered on every recomposition so
389 // popup content follows the caller's state (an animating menu morph, a
390 // changing label). The host is only dirtied when the popup moved or its
391 // content was re-registered — a popup whose caller did not recompose
392 // costs nothing.
393 let position = Point {
394 x: anchor.x + offset.x,
395 y: anchor.y + offset.y,
396 };
397
398 let sync_registry = registry.clone();
399 let sync_content = content.clone();
400 SideEffect(move || {
401 sync_registry.upsert(id, position, sync_content.clone(), on_dismiss.clone())
402 });
403
404 let dispose_registry = registry;
405 cranpose_core::DisposableEffect!((), move |scope| {
406 let dispose_registry = dispose_registry.clone();
407 scope.on_dispose(move || dispose_registry.remove(id))
408 });
409}
410
411#[cfg(test)]
412mod tests {
413 use super::*;
414 use crate::modifier::collect_slices_from_modifier;
415 use cranpose_foundation::PointerEvent;
416
417 #[test]
418 fn non_dismissable_exit_frame_has_no_modal_scrim_callback() {
419 let callback: Rc<dyn Fn()> = Rc::new(|| {});
420 assert!(popup_dismiss_callback(false, Rc::clone(&callback)).is_none());
421 assert!(popup_dismiss_callback(true, callback).is_some());
422 }
423
424 #[test]
425 fn dismiss_scrim_consumes_the_whole_tap_before_dismissing() {
426 let _app_context = crate::render_state::app_context_test_scope();
427 let dismissed = Rc::new(Cell::new(false));
428 let action: Rc<dyn Fn()> = {
429 let dismissed = Rc::clone(&dismissed);
430 Rc::new(move || dismissed.set(true))
431 };
432 let modifier = popup_scrim_pointer_input(7, action);
433 let slices = collect_slices_from_modifier(&modifier);
434 assert_eq!(slices.pointer_inputs().len(), 1);
435 let handler = slices.pointer_inputs()[0].clone();
436
437 let down = PointerEvent::new(
438 PointerEventKind::Down,
439 Point { x: 12.0, y: 18.0 },
440 Point { x: 12.0, y: 18.0 },
441 );
442 handler(down.clone());
443 assert!(
444 down.is_consumed(),
445 "covered controls must never receive Down"
446 );
447 assert!(!dismissed.get(), "dismissal fires on release");
448
449 let up = PointerEvent::new(
450 PointerEventKind::Up,
451 Point { x: 12.0, y: 18.0 },
452 Point { x: 12.0, y: 18.0 },
453 );
454 handler(up.clone());
455 assert!(up.is_consumed(), "the release stays inside the scrim");
456 assert!(
457 dismissed.get(),
458 "a completed outside tap dismisses the popup"
459 );
460 }
461}