#![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)
}
}
#[derive(Default)]
pub(crate) struct HostedPopupRegistries {
registries: RefCell<Vec<std::rc::Weak<PopupRegistryState>>>,
}
pub fn dismiss_top_popup() -> bool {
let on_dismiss = crate::render_state::with_hosted_popup_registries(|hosted| {
let mut hosted = hosted.registries.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 {
crate::render_state::with_hosted_popup_registries(|hosted| {
hosted.registries.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)),
});
crate::render_state::with_hosted_popup_registries(|hosted| {
hosted.registries.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();
let dismiss = data.on_dismiss.clone();
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)
.semantics(move |config| {
config.is_modal = dismiss.is_some();
config.dismiss = dismiss.clone().map(|dismiss| {
cranpose_foundation::SemanticsDismiss::new(move || {
dismiss();
true
})
});
}),
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)]
#[path = "tests/popup_tests.rs"]
mod tests;