#![allow(non_snake_case)]
use std::cell::{Cell, RefCell};
use std::rc::Rc;
use crate::modifier::Modifier;
use crate::{composable, PointerInputScope};
use cranpose_core::{
mutableStateOf, remember, staticCompositionLocalOf, CompositionLocalProvider, MutableState,
SideEffect, StaticCompositionLocal,
};
use cranpose_foundation::PointerEventKind;
use cranpose_ui_graphics::{Point, Rect};
use super::box_widget::{Box, BoxSpec};
#[derive(Clone)]
struct PopupEntry {
id: u64,
position: Point,
content: Rc<dyn Fn()>,
on_dismiss: Option<Rc<dyn Fn()>>,
}
struct PopupRegistryState {
entries: RefCell<Vec<PopupEntry>>,
next_id: Cell<u64>,
revision: Option<MutableState<u64>>,
}
#[derive(Clone)]
pub struct PopupRegistry {
inner: Rc<PopupRegistryState>,
}
impl PartialEq for PopupRegistry {
fn eq(&self, other: &Self) -> bool {
Rc::ptr_eq(&self.inner, &other.inner)
}
}
impl PopupRegistry {
fn hosted() -> Self {
Self {
inner: Rc::new(PopupRegistryState {
entries: RefCell::new(Vec::new()),
next_id: Cell::new(0),
revision: Some(mutableStateOf(0u64)),
}),
}
}
fn detached() -> Self {
Self {
inner: Rc::new(PopupRegistryState {
entries: RefCell::new(Vec::new()),
next_id: Cell::new(0),
revision: None,
}),
}
}
fn allocate_id(&self) -> u64 {
let id = self.inner.next_id.get();
self.inner.next_id.set(id.wrapping_add(1));
id
}
fn bump(&self) {
if let Some(revision) = self.inner.revision.as_ref() {
revision.update(|value| *value = value.wrapping_add(1));
}
}
fn upsert(
&self,
id: u64,
position: Point,
content: Rc<dyn Fn()>,
on_dismiss: Option<Rc<dyn Fn()>>,
) {
let mut entries = self.inner.entries.borrow_mut();
if let Some(existing) = entries.iter_mut().find(|entry| entry.id == id) {
let moved = existing.position != position;
let content_changed =
!std::ptr::addr_eq(Rc::as_ptr(&existing.content), Rc::as_ptr(&content));
existing.position = position;
existing.content = content;
existing.on_dismiss = on_dismiss;
drop(entries);
if moved || content_changed {
self.bump();
}
} else {
entries.push(PopupEntry {
id,
position,
content,
on_dismiss,
});
drop(entries);
self.bump();
}
}
fn remove(&self, id: u64) {
let mut entries = self.inner.entries.borrow_mut();
let before = entries.len();
entries.retain(|entry| entry.id != id);
let changed = entries.len() != before;
drop(entries);
if changed {
self.bump();
}
}
fn subscribe(&self) {
if let Some(revision) = self.inner.revision.as_ref() {
let _ = revision.value();
}
}
fn snapshot(&self) -> Vec<PopupEntry> {
self.inner.entries.borrow().clone()
}
}
fn local_popup_registry() -> StaticCompositionLocal<PopupRegistry> {
thread_local! {
static LOCAL: RefCell<Option<StaticCompositionLocal<PopupRegistry>>> =
const { RefCell::new(None) };
}
LOCAL.with(|cell| {
cell.borrow_mut()
.get_or_insert_with(|| staticCompositionLocalOf(PopupRegistry::detached))
.clone()
})
}
pub fn local_popup_viewport() -> StaticCompositionLocal<Rc<Cell<cranpose_ui_graphics::Size>>> {
type ViewportCell = Rc<Cell<cranpose_ui_graphics::Size>>;
thread_local! {
static LOCAL: RefCell<Option<StaticCompositionLocal<ViewportCell>>> =
const { RefCell::new(None) };
}
LOCAL.with(|cell| {
cell.borrow_mut()
.get_or_insert_with(|| {
staticCompositionLocalOf(|| {
Rc::new(Cell::new(cranpose_ui_graphics::Size {
width: 0.0,
height: 0.0,
}))
})
})
.clone()
})
}
#[composable]
pub fn PopupHost<F>(content: F)
where
F: FnMut() + 'static,
{
let registry = remember(PopupRegistry::hosted).with(PopupRegistry::clone);
let viewport = remember(|| {
Rc::new(Cell::new(cranpose_ui_graphics::Size {
width: 0.0,
height: 0.0,
}))
})
.with(Rc::clone);
let report_sink = Rc::clone(&viewport);
Box(
Modifier::empty().fill_max_size().report_size(report_sink),
BoxSpec::default(),
move || {
let registry = registry.clone();
let viewport = Rc::clone(&viewport);
CompositionLocalProvider(
[
local_popup_registry().provides(registry.clone()),
local_popup_viewport().provides(viewport),
],
|| {
content();
PopupOverlay(registry.clone());
},
);
},
);
}
#[composable]
fn PopupOverlay(registry: PopupRegistry) {
registry.subscribe();
for entry in registry.snapshot() {
if let Some(on_dismiss) = entry.on_dismiss {
Box(
Modifier::empty()
.fill_max_size()
.then(popup_scrim_pointer_input(entry.id, on_dismiss)),
BoxSpec::default(),
|| {},
);
}
let content = entry.content;
Box(
Modifier::empty().absolute_offset(entry.position.x, entry.position.y),
BoxSpec::default(),
move || content(),
);
}
}
fn popup_scrim_pointer_input(id: u64, on_dismiss: Rc<dyn Fn()>) -> Modifier {
Modifier::empty().pointer_input(id, move |scope: PointerInputScope| {
let on_dismiss = Rc::clone(&on_dismiss);
async move {
scope
.await_pointer_event_scope(|await_scope| async move {
let mut pressed = false;
loop {
let event = await_scope.await_pointer_event().await;
match event.kind {
PointerEventKind::Down => {
pressed = true;
event.consume();
}
PointerEventKind::Move => event.consume(),
PointerEventKind::Up => {
let should_dismiss = pressed;
pressed = false;
event.consume();
if should_dismiss {
on_dismiss();
}
}
PointerEventKind::Cancel => {
pressed = false;
event.consume();
}
_ => {}
}
}
})
.await;
}
})
}
#[composable]
pub fn Popup<F>(anchor: Rect, offset: Point, content: F)
where
F: Fn() + 'static,
{
popup_impl(anchor, offset, None, Rc::new(content));
}
#[composable]
pub fn PopupDismissable<F>(anchor: Rect, offset: Point, on_dismiss: impl Fn() + 'static, content: F)
where
F: Fn() + 'static,
{
PopupDismissableWhen(true, anchor, offset, on_dismiss, content);
}
#[composable]
pub fn PopupDismissableWhen<F>(
dismissable: bool,
anchor: Rect,
offset: Point,
on_dismiss: impl Fn() + 'static,
content: F,
) where
F: Fn() + 'static,
{
let on_dismiss = popup_dismiss_callback(dismissable, Rc::new(on_dismiss));
popup_impl(anchor, offset, on_dismiss, Rc::new(content));
}
fn popup_dismiss_callback(dismissable: bool, on_dismiss: Rc<dyn Fn()>) -> Option<Rc<dyn Fn()>> {
dismissable.then_some(on_dismiss)
}
fn popup_impl(
anchor: Rect,
offset: Point,
on_dismiss: Option<Rc<dyn Fn()>>,
content: Rc<dyn Fn()>,
) {
let registry = local_popup_registry().current();
let id = remember(|| registry.allocate_id()).with(|id| *id);
let position = Point {
x: anchor.x + offset.x,
y: anchor.y + offset.y,
};
let sync_registry = registry.clone();
let sync_content = content.clone();
SideEffect(move || {
sync_registry.upsert(id, position, sync_content.clone(), on_dismiss.clone())
});
let dispose_registry = registry;
cranpose_core::DisposableEffect!((), move |scope| {
let dispose_registry = dispose_registry.clone();
scope.on_dispose(move || dispose_registry.remove(id))
});
}
#[cfg(test)]
mod tests {
use super::*;
use crate::modifier::collect_slices_from_modifier;
use cranpose_foundation::PointerEvent;
#[test]
fn non_dismissable_exit_frame_has_no_modal_scrim_callback() {
let callback: Rc<dyn Fn()> = Rc::new(|| {});
assert!(popup_dismiss_callback(false, Rc::clone(&callback)).is_none());
assert!(popup_dismiss_callback(true, callback).is_some());
}
#[test]
fn dismiss_scrim_consumes_the_whole_tap_before_dismissing() {
let _app_context = crate::render_state::app_context_test_scope();
let dismissed = Rc::new(Cell::new(false));
let action: Rc<dyn Fn()> = {
let dismissed = Rc::clone(&dismissed);
Rc::new(move || dismissed.set(true))
};
let modifier = popup_scrim_pointer_input(7, action);
let slices = collect_slices_from_modifier(&modifier);
assert_eq!(slices.pointer_inputs().len(), 1);
let handler = slices.pointer_inputs()[0].clone();
let down = PointerEvent::new(
PointerEventKind::Down,
Point { x: 12.0, y: 18.0 },
Point { x: 12.0, y: 18.0 },
);
handler(down.clone());
assert!(
down.is_consumed(),
"covered controls must never receive Down"
);
assert!(!dismissed.get(), "dismissal fires on release");
let up = PointerEvent::new(
PointerEventKind::Up,
Point { x: 12.0, y: 18.0 },
Point { x: 12.0, y: 18.0 },
);
handler(up.clone());
assert!(up.is_consumed(), "the release stays inside the scrim");
assert!(
dismissed.get(),
"a completed outside tap dismisses the popup"
);
}
}