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