1#![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#[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#[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
159fn 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
173pub 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#[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#[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
262fn 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#[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#[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#[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#[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}