#![allow(non_snake_case)]
use std::{
cell::{Cell, RefCell},
rc::Rc,
};
use cranpose_core::{
CompositionLocalProvider, OwnedMutableState, SideEffect, StaticCompositionLocal,
ownedMutableStateOf, remember, staticCompositionLocalOf,
};
use cranpose_foundation::PointerEventKind;
use cranpose_ui_graphics::{Point, Rect};
use super::box_widget::{Box, BoxSpec};
use crate::{PointerInputScope, composable, modifier::Modifier};
#[derive(Clone)]
struct PopupEntry {
id: u64,
data: OwnedMutableState<PopupContent>,
}
impl PartialEq for PopupEntry {
fn eq(&self, other: &Self) -> bool {
self.id == other.id && self.data.handle() == other.data.handle()
}
}
#[derive(Clone)]
struct PopupContent {
position: Point,
content: Rc<dyn Fn()>,
on_dismiss: Option<Rc<dyn Fn()>>,
}
impl PartialEq for PopupContent {
fn eq(&self, other: &Self) -> bool {
self.position == other.position
&& Rc::ptr_eq(&self.content, &other.content)
&& match (&self.on_dismiss, &other.on_dismiss) {
(Some(left), Some(right)) => Rc::ptr_eq(left, right),
(None, None) => true,
_ => false,
}
}
}
struct PopupRegistryState {
entries: RefCell<Vec<PopupEntry>>,
next_id: Cell<u64>,
revision: Option<OwnedMutableState<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)
}
}
thread_local! {
static HOSTED_REGISTRIES: RefCell<Vec<std::rc::Weak<PopupRegistryState>>> =
const { RefCell::new(Vec::new()) };
}
pub fn dismiss_top_popup() -> bool {
let on_dismiss = HOSTED_REGISTRIES.with(|hosted| {
let mut hosted = hosted.borrow_mut();
hosted.retain(|registry| registry.strong_count() > 0);
hosted.iter().rev().find_map(|registry| {
let registry = registry.upgrade()?;
let entries = registry.entries.borrow();
entries
.iter()
.rev()
.find_map(|entry| entry.data.get_non_reactive().on_dismiss)
})
});
match on_dismiss {
Some(on_dismiss) => {
on_dismiss();
true
}
None => false,
}
}
pub fn dismissable_popup_open() -> bool {
HOSTED_REGISTRIES.with(|hosted| {
hosted.borrow().iter().any(|registry| {
let Some(registry) = registry.upgrade() else {
return false;
};
if let Some(revision) = registry.revision.as_ref() {
let _ = revision.value();
}
registry
.entries
.borrow()
.iter()
.any(|entry| entry.data.get_non_reactive().on_dismiss.is_some())
})
})
}
impl PopupRegistry {
fn hosted() -> Self {
let inner = Rc::new(PopupRegistryState {
entries: RefCell::new(Vec::new()),
next_id: Cell::new(0),
revision: Some(ownedMutableStateOf(0u64)),
});
HOSTED_REGISTRIES.with(|hosted| hosted.borrow_mut().push(Rc::downgrade(&inner)));
Self { inner }
}
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 next = PopupContent {
position,
content,
on_dismiss,
};
let mut entries = self.inner.entries.borrow_mut();
if let Some(existing) = entries.iter_mut().find(|entry| entry.id == id) {
let state = existing.data.clone();
let dismiss_changed =
state.get_non_reactive().on_dismiss.is_some() != next.on_dismiss.is_some();
drop(entries);
state.set(next);
if dismiss_changed {
self.bump();
}
} else {
entries.push(PopupEntry {
id,
data: ownedMutableStateOf(next),
});
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<OwnedMutableState<cranpose_ui_graphics::Size>> {
type ViewportState = OwnedMutableState<cranpose_ui_graphics::Size>;
thread_local! {
static LOCAL: RefCell<Option<StaticCompositionLocal<ViewportState>>> =
const { RefCell::new(None) };
}
LOCAL.with(|cell| {
cell.borrow_mut()
.get_or_insert_with(|| {
staticCompositionLocalOf(|| ownedMutableStateOf(cranpose_ui_graphics::Size::ZERO))
})
.clone()
})
}
#[composable]
pub fn PopupHost<F>(content: F)
where
F: FnMut() + 'static,
{
let registry = remember(PopupRegistry::hosted).with(PopupRegistry::clone);
let viewport =
remember(|| ownedMutableStateOf(cranpose_ui_graphics::Size::ZERO)).with(Clone::clone);
let report_sink = viewport.handle();
Box(
Modifier::empty()
.fill_max_size()
.report_size_state(report_sink),
BoxSpec::default(),
move || {
let registry = registry.clone();
let viewport = viewport.clone();
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() {
cranpose_core::key(entry.id, || PopupLayer(entry));
}
}
#[composable]
fn PopupLayer(entry: PopupEntry) {
let data = entry.data.get();
if let Some(on_dismiss) = data.on_dismiss {
Box(
Modifier::empty()
.fill_max_size()
.then(popup_scrim_pointer_input(entry.id, on_dismiss)),
BoxSpec::default(),
|| {},
);
}
let content = data.content;
Box(
Modifier::empty().absolute_offset(data.position.x, data.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 && !event.is_consumed();
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 PopupAnchored<A, P>(
modifier: Modifier,
expanded: bool,
offset: Point,
anchor_content: A,
popup_content: P,
) -> cranpose_core::NodeId
where
A: Fn() + 'static,
P: Fn() + 'static,
{
let anchor = cranpose_core::rememberMutableStateOf(|| {
Rect::from_origin_size(
Point { x: 0.0, y: 0.0 },
cranpose_ui_graphics::Size {
width: 0.0,
height: 0.0,
},
)
});
let measured = modifier.report_window_rect_state(anchor);
let anchor_content = Rc::new(anchor_content);
let popup_content = Rc::new(popup_content);
Box(measured, BoxSpec::default(), move || {
anchor_content();
if expanded {
let popup_content = Rc::clone(&popup_content);
Popup(anchor.get(), offset, move || popup_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 cranpose_foundation::PointerEvent;
use super::*;
use crate::modifier::collect_slices_from_modifier;
#[test]
fn refreshing_popup_content_does_not_invalidate_the_popup_list() {
let mut composition = cranpose_core::Composition::new(cranpose_core::MemoryApplier::new());
composition
.render(
cranpose_core::location_key(file!(), line!(), column!()),
|| {
let registry = PopupRegistry::hosted();
let id = registry.allocate_id();
registry.upsert(id, Point::default(), Rc::new(|| {}), Some(Rc::new(|| {})));
let revision = registry.inner.revision.as_ref().expect("hosted revision");
let before = revision.get_non_reactive();
let calls = Rc::new(Cell::new(0));
let content_calls = calls.clone();
let dismiss_calls = calls.clone();
registry.upsert(
id,
Point::new(10.0, 20.0),
Rc::new(move || content_calls.set(content_calls.get() + 1)),
Some(Rc::new(move || dismiss_calls.set(dismiss_calls.get() + 10))),
);
assert_eq!(
revision.get_non_reactive(),
before,
"only the changed popup needs recomposition"
);
let entries = registry.snapshot();
assert_eq!(entries.len(), 1);
let updated = entries[0].data.get_non_reactive();
assert_eq!(updated.position, Point::new(10.0, 20.0));
(updated.content)();
assert_eq!(calls.get(), 1);
(updated.on_dismiss.expect("updated dismiss callback"))();
assert_eq!(calls.get(), 11);
registry.upsert(id, Point::default(), Rc::new(|| {}), None);
assert_eq!(revision.get_non_reactive(), before + 1);
registry.remove(id);
assert_eq!(revision.get_non_reactive(), before + 2);
},
)
.expect("composition");
}
#[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 event = |kind| PointerEvent::new(kind, Point::new(12.0, 18.0), Point::new(12.0, 18.0));
let down = event(PointerEventKind::Down);
handler(down.clone());
assert!(
down.is_consumed(),
"covered controls must never receive Down"
);
assert!(!dismissed.get(), "dismissal fires on release");
let up = event(PointerEventKind::Up);
handler(up.clone());
assert!(up.is_consumed(), "the release stays inside the scrim");
assert!(
dismissed.get(),
"a completed outside tap dismisses the popup"
);
for terminal in [PointerEventKind::Up, PointerEventKind::Cancel] {
dismissed.set(false);
handler(event(PointerEventKind::Down));
let handled = event(terminal);
handled.consume();
handler(handled);
assert!(
!dismissed.get(),
"a handled or cancelled press stays inside the popup"
);
}
}
}