Skip to main content

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}