use std::cell::OnceCell;
use std::sync::Arc;
use blinc_core::context_state::BlincContextState;
use blinc_core::{Signal, State};
use blinc_layout::InstanceKey;
use blinc_layout::click_outside;
use blinc_layout::div::ElementTypeId;
use blinc_layout::element::{ElementBounds, RenderProps};
use blinc_layout::overlay_state::overlay_stack;
use blinc_layout::prelude::*;
use blinc_layout::tree::{LayoutNodeId, LayoutTree};
use blinc_layout::widgets::overlay::AnchorDirection;
use blinc_layout::widgets::overlay_stack::{OverlayBuilder, OverlayHandle};
use blinc_theme::{ColorToken, RadiusToken, SpacingToken, ThemeState};
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum PopoverSide {
Top,
#[default]
Bottom,
Right,
Left,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum PopoverAlign {
#[default]
Start,
Center,
End,
}
type ContentBuilderFn = Arc<dyn Fn() -> Div + Send + Sync>;
type TriggerBuilderFn = Arc<dyn Fn(bool) -> Div + Send + Sync>;
pub struct PopoverBuilder {
trigger: TriggerBuilderFn,
content: Option<ContentBuilderFn>,
side: PopoverSide,
align: PopoverAlign,
offset: f32,
key: InstanceKey,
classes: Vec<std::sync::Arc<str>>,
user_id: Option<String>,
built: OnceCell<Popover>,
}
impl std::fmt::Debug for PopoverBuilder {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("PopoverBuilder")
.field("side", &self.side)
.field("align", &self.align)
.field("offset", &self.offset)
.finish()
}
}
impl PopoverBuilder {
#[track_caller]
pub fn new<F>(trigger_fn: F) -> Self
where
F: Fn(bool) -> Div + Send + Sync + 'static,
{
Self {
trigger: Arc::new(trigger_fn),
content: None,
side: PopoverSide::Bottom,
align: PopoverAlign::Start,
offset: 4.0,
key: InstanceKey::new("popover"),
classes: Vec::new(),
user_id: None,
built: OnceCell::new(),
}
}
pub fn with_key<F>(trigger_fn: F, key: InstanceKey) -> Self
where
F: Fn(bool) -> Div + Send + Sync + 'static,
{
Self {
trigger: Arc::new(trigger_fn),
content: None,
side: PopoverSide::Bottom,
align: PopoverAlign::Start,
offset: 4.0,
key,
classes: Vec::new(),
user_id: None,
built: OnceCell::new(),
}
}
pub fn content<F>(mut self, content_fn: F) -> Self
where
F: Fn() -> Div + Send + Sync + 'static,
{
self.content = Some(Arc::new(content_fn));
self
}
pub fn side(mut self, side: PopoverSide) -> Self {
self.side = side;
self
}
pub fn align(mut self, align: PopoverAlign) -> Self {
self.align = align;
self
}
pub fn offset(mut self, offset: f32) -> Self {
self.offset = offset;
self
}
pub fn class(mut self, name: impl AsRef<str>) -> Self {
self.classes.push(blinc_core::intern::intern(name.as_ref()));
self
}
pub fn id(mut self, id: &str) -> Self {
self.user_id = Some(id.to_string());
self
}
fn get_or_build(&self) -> &Popover {
::blinc_layout::build_once::build_once(&self.built, || self.build_component())
}
fn build_component(&self) -> Popover {
let open_state: Signal<bool> =
BlincContextState::get().use_signal_keyed(self.key.get(), || false);
let overlay_handle_state: Signal<Option<u64>> =
BlincContextState::get().use_signal_keyed(&self.key.derive("handle"), || None);
let side = self.side;
let align = self.align;
let offset = self.offset;
let content_builder = self.content.clone();
let trigger_builder = self.trigger.clone();
let open_state_for_click = open_state;
let overlay_handle_for_click = overlay_handle_state;
let overlay_handle_for_show = overlay_handle_state;
let open_state_for_close = open_state;
let overlay_handle_for_close = overlay_handle_state;
let closed_trigger = trigger_builder.clone();
let trigger_id = self
.user_id
.clone()
.unwrap_or_else(|| format!("cn-popover-trigger-{}", self.key.get()));
let trigger_id_for_click = trigger_id.clone();
let trigger = div()
.id(&trigger_id)
.w_fit()
.cursor_pointer()
.show_or(
open_state,
move || (trigger_builder)(true),
move || (closed_trigger)(false),
)
.on_click(move |ctx| {
let bounds = ElementBounds {
x: ctx.bounds_x,
y: ctx.bounds_y,
width: ctx.bounds_width,
height: ctx.bounds_height,
};
let is_open = open_state_for_click.get();
if is_open {
if let Some(raw) = overlay_handle_for_click.get() {
let handle = OverlayHandle::from_raw(raw);
if handle.is_live() {
handle.close();
}
}
return;
}
let Some(ref content_fn) = content_builder else {
return;
};
let (x, y) = calculate_popover_position(&bounds, side, align, offset);
let stored_close_handle = overlay_handle_for_close;
let stored_close_state = open_state_for_close;
let content_fn = Arc::clone(content_fn);
let handle = build_popover_overlay(
x,
y,
side,
content_fn,
stored_close_state,
stored_close_handle,
trigger_id_for_click.clone(),
);
overlay_handle_for_show.set(Some(handle.raw()));
open_state_for_click.set(true);
});
let mut inner = trigger;
for c in &self.classes {
inner = inner.class(c);
}
Popover { inner }
}
}
fn calculate_popover_position(
bounds: &ElementBounds,
side: PopoverSide,
align: PopoverAlign,
offset: f32,
) -> (f32, f32) {
match side {
PopoverSide::Top => {
let y = bounds.y - bounds.height - offset * 4.0;
let x = match align {
PopoverAlign::Start => bounds.x,
PopoverAlign::Center => bounds.x + bounds.width / 2.0,
PopoverAlign::End => bounds.x + bounds.width,
};
(x.max(0.0), y.max(0.0))
}
PopoverSide::Bottom => {
let y = bounds.y + bounds.height + offset;
let x = match align {
PopoverAlign::Start => bounds.x,
PopoverAlign::Center => bounds.x + bounds.width / 2.0,
PopoverAlign::End => bounds.x + bounds.width,
};
(x.max(0.0), y)
}
PopoverSide::Right => {
let x = bounds.x + bounds.width + offset;
let y = match align {
PopoverAlign::Start => bounds.y,
PopoverAlign::Center => bounds.y + bounds.height / 2.0,
PopoverAlign::End => bounds.y + bounds.height,
};
(x, y.max(0.0))
}
PopoverSide::Left => {
let x = bounds.x - offset;
let y = match align {
PopoverAlign::Start => bounds.y,
PopoverAlign::Center => bounds.y + bounds.height / 2.0,
PopoverAlign::End => bounds.y + bounds.height,
};
(x.max(0.0), y.max(0.0))
}
}
}
fn build_popover_overlay(
x: f32,
y: f32,
side: PopoverSide,
content_fn: ContentBuilderFn,
open_state: Signal<bool>,
overlay_handle_state: Signal<Option<u64>>,
trigger_id: String,
) -> OverlayHandle {
let padding = ThemeState::get().spacing_value(SpacingToken::Space4);
let anchor_dir = match side {
PopoverSide::Top => AnchorDirection::Top,
PopoverSide::Bottom => AnchorDirection::Bottom,
PopoverSide::Left => AnchorDirection::Left,
PopoverSide::Right => AnchorDirection::Right,
};
let next_handle_id = overlay_stack()
.lock()
.ok()
.map(|s| s.peek_next_handle_id())
.unwrap_or(0);
let popover_id = format!("cn-popover-{}", next_handle_id);
let click_outside_key = format!("popover:{}", next_handle_id);
let popover_id_for_content = popover_id.clone();
let click_outside_key_for_close = click_outside_key.clone();
let handle = OverlayBuilder::popover()
.at(x, y)
.size(320.0, 240.0)
.anchor_direction(anchor_dir)
.on_close(move |_reason| {
open_state.set(false);
overlay_handle_state.set(None);
click_outside::unregister_click_outside(&click_outside_key_for_close);
})
.content(move || {
let user_content = (content_fn)();
div()
.class("cn-popover-content")
.id(&popover_id_for_content)
.flex_col()
.p_px(padding)
.min_w(150.0)
.overflow_clip()
.child(user_content)
})
.show();
debug_assert_eq!(
handle.raw(),
next_handle_id,
"peek_next_handle_id was stale — concurrent push?"
);
click_outside::register_click_outside_multi(
&click_outside_key,
&[popover_id, trigger_id],
move || {
handle.close();
},
);
handle
}
pub struct Popover {
inner: Div,
}
impl std::fmt::Debug for Popover {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Popover").finish()
}
}
impl ElementBuilder for PopoverBuilder {
fn build(&self, tree: &mut LayoutTree) -> LayoutNodeId {
self.get_or_build().inner.build(tree)
}
fn render_props(&self) -> RenderProps {
self.get_or_build().inner.render_props()
}
fn children_builders(&self) -> &[Box<dyn ElementBuilder>] {
self.get_or_build().inner.children_builders()
}
fn element_type_id(&self) -> ElementTypeId {
self.get_or_build().inner.element_type_id()
}
fn layout_style(&self) -> Option<&taffy::Style> {
self.get_or_build().inner.layout_style()
}
fn event_handlers(&self) -> Option<&blinc_layout::event_handler::EventHandlers> {
ElementBuilder::event_handlers(&self.get_or_build().inner)
}
fn element_classes(&self) -> &[std::sync::Arc<str>] {
self.get_or_build().inner.element_classes()
}
fn element_id(&self) -> Option<&str> {
self.get_or_build().inner.element_id()
}
}
impl ElementBuilder for Popover {
fn build(&self, tree: &mut LayoutTree) -> LayoutNodeId {
self.inner.build(tree)
}
fn render_props(&self) -> RenderProps {
self.inner.render_props()
}
fn children_builders(&self) -> &[Box<dyn ElementBuilder>] {
self.inner.children_builders()
}
fn element_type_id(&self) -> ElementTypeId {
self.inner.element_type_id()
}
fn layout_style(&self) -> Option<&taffy::Style> {
self.inner.layout_style()
}
fn event_handlers(&self) -> Option<&blinc_layout::event_handler::EventHandlers> {
ElementBuilder::event_handlers(&self.inner)
}
}
#[track_caller]
pub fn popover<F>(trigger_fn: F) -> PopoverBuilder
where
F: Fn(bool) -> Div + Send + Sync + 'static,
{
PopoverBuilder::new(trigger_fn)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_popover_position_bottom() {
let bounds = ElementBounds {
x: 100.0,
y: 50.0,
width: 80.0,
height: 32.0,
};
let (x, y) =
calculate_popover_position(&bounds, PopoverSide::Bottom, PopoverAlign::Start, 4.0);
assert_eq!(x, 100.0);
assert_eq!(y, 86.0); }
#[test]
fn test_popover_position_right() {
let bounds = ElementBounds {
x: 100.0,
y: 50.0,
width: 80.0,
height: 32.0,
};
let (x, y) =
calculate_popover_position(&bounds, PopoverSide::Right, PopoverAlign::Start, 8.0);
assert_eq!(x, 188.0); assert_eq!(y, 50.0);
}
#[test]
fn test_popover_position_top() {
let bounds = ElementBounds {
x: 100.0,
y: 100.0,
width: 80.0,
height: 32.0,
};
let (x, y) =
calculate_popover_position(&bounds, PopoverSide::Top, PopoverAlign::Start, 4.0);
assert_eq!(x, 100.0);
assert_eq!(y, 52.0);
}
#[test]
fn test_popover_position_center_align() {
let bounds = ElementBounds {
x: 100.0,
y: 50.0,
width: 80.0,
height: 32.0,
};
let (x, _y) =
calculate_popover_position(&bounds, PopoverSide::Bottom, PopoverAlign::Center, 4.0);
assert_eq!(x, 140.0);
}
}