use crate::motion::pop_in;
use gpui::{
AnyElement, App, Bounds, Component, Context, ElementId, GlobalElementId, InspectorElementId,
IntoElement, KeyBinding, LayoutId, MouseButton, Pixels, Render, RenderOnce, SharedString,
Window, actions, div, point, prelude::*, px,
};
use liora_core::{
Config, Placement, clear_popover, is_popover_active, set_active_popover, stable_unique_id,
};
use std::cell::Cell;
use std::rc::Rc;
use std::sync::Arc;
actions!(popover, [PopoverClose]);
pub struct Popover {
trigger: AnyElement,
content: Arc<dyn Fn(&mut Window, &mut Context<PopoverView>) -> AnyElement + 'static>,
placement: Placement,
offset: Pixels,
close_on_click_outside: bool,
close_on_escape: bool,
trigger_id: Option<ElementId>,
}
pub struct PopoverView {
content: Arc<dyn Fn(&mut Window, &mut Context<Self>) -> AnyElement + 'static>,
anchor_bounds: Bounds<Pixels>,
placement: Placement,
offset: Pixels,
close_on_click_outside: bool,
close_on_escape: bool,
id: SharedString,
on_close: Arc<dyn Fn(&mut Window, &mut App) + 'static>,
}
impl PopoverView {
pub fn new(
content: Arc<dyn Fn(&mut Window, &mut Context<Self>) -> AnyElement + 'static>,
anchor_bounds: Bounds<Pixels>,
placement: Placement,
offset: Pixels,
close_on_click_outside: bool,
close_on_escape: bool,
id: SharedString,
on_close: impl Fn(&mut Window, &mut App) + 'static,
) -> Self {
Self {
content,
anchor_bounds,
placement,
offset,
close_on_click_outside,
close_on_escape,
id,
on_close: Arc::new(on_close),
}
}
}
impl Render for PopoverView {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let theme = cx.global::<Config>().theme.clone();
let anchor_bounds = self.anchor_bounds;
let placement = self.placement;
let offset = self.offset;
let on_close = self.on_close.clone();
let close_on_click_outside = self.close_on_click_outside;
let close_on_escape = self.close_on_escape;
let id = self.id.clone();
let content = (self.content)(_window, cx);
let viewport_size = _window.viewport_size();
let popover_anchor = popover_anchor_point(anchor_bounds, placement, offset);
let popover_anchor_corner = popover_anchor_corner(placement);
let viewport_margin = px(4.0);
let max_w = (viewport_size.width - viewport_margin * 2.0).max(px(0.0));
div()
.id(id.clone())
.absolute()
.top_0()
.left_0()
.size_full()
.cursor_default()
.occlude()
.bg(gpui::transparent_black())
.on_hover(|_, _, cx| {
cx.stop_propagation();
})
.on_mouse_move(|_, _, cx| {
cx.stop_propagation();
})
.when(close_on_escape, |s| {
let on_close = on_close.clone();
s.on_action(move |_: &PopoverClose, window, cx| {
on_close(window, cx);
})
})
.when(close_on_click_outside, |s| {
s.on_mouse_down(
MouseButton::Left,
cx.listener(move |_, _, window, cx| {
on_close(window, cx);
}),
)
})
.child(
gpui::anchored()
.position(popover_anchor)
.anchor(popover_anchor_corner)
.snap_to_window_with_margin(viewport_margin)
.child(pop_in(
format!("{}-motion", id),
div()
.id(format!("{}-content", id))
.flex_shrink_0() .cursor_default()
.occlude()
.max_w(max_w)
.on_hover(|_, _, cx| {
cx.stop_propagation();
})
.on_mouse_move(|_, _, cx| {
cx.stop_propagation();
})
.on_mouse_down(MouseButton::Left, |_, _, cx| {
cx.stop_propagation();
}) .bg(theme.neutral.card)
.border_1()
.border_color(theme.neutral.border)
.rounded(px(theme.radius.md))
.shadow_lg()
.p_4()
.child(content),
)),
)
}
}
fn popover_anchor_point(
anchor_bounds: Bounds<Pixels>,
placement: Placement,
offset: Pixels,
) -> gpui::Point<Pixels> {
match placement {
Placement::Top => point(
anchor_bounds.left() + anchor_bounds.size.width / 2.0,
anchor_bounds.top() - offset,
),
Placement::TopStart => point(anchor_bounds.left(), anchor_bounds.top() - offset),
Placement::TopEnd => point(anchor_bounds.right(), anchor_bounds.top() - offset),
Placement::Bottom => point(
anchor_bounds.left() + anchor_bounds.size.width / 2.0,
anchor_bounds.bottom() + offset,
),
Placement::BottomStart => point(anchor_bounds.left(), anchor_bounds.bottom() + offset),
Placement::BottomEnd => point(anchor_bounds.right(), anchor_bounds.bottom() + offset),
Placement::Left => point(
anchor_bounds.left() - offset,
anchor_bounds.top() + anchor_bounds.size.height / 2.0,
),
Placement::LeftStart => point(anchor_bounds.left() - offset, anchor_bounds.top()),
Placement::LeftEnd => point(anchor_bounds.left() - offset, anchor_bounds.bottom()),
Placement::Right => point(
anchor_bounds.right() + offset,
anchor_bounds.top() + anchor_bounds.size.height / 2.0,
),
Placement::RightStart => point(anchor_bounds.right() + offset, anchor_bounds.top()),
Placement::RightEnd => point(anchor_bounds.right() + offset, anchor_bounds.bottom()),
}
}
fn popover_anchor_corner(placement: Placement) -> gpui::Anchor {
match placement {
Placement::Top => gpui::Anchor::BottomCenter,
Placement::TopStart => gpui::Anchor::BottomLeft,
Placement::TopEnd => gpui::Anchor::BottomRight,
Placement::Bottom => gpui::Anchor::TopCenter,
Placement::BottomStart => gpui::Anchor::TopLeft,
Placement::BottomEnd => gpui::Anchor::TopRight,
Placement::Left => gpui::Anchor::RightCenter,
Placement::LeftStart => gpui::Anchor::TopRight,
Placement::LeftEnd => gpui::Anchor::BottomRight,
Placement::Right => gpui::Anchor::LeftCenter,
Placement::RightStart => gpui::Anchor::TopLeft,
Placement::RightEnd => gpui::Anchor::BottomLeft,
}
}
impl Popover {
pub fn new(trigger: impl IntoElement) -> Self {
Self {
trigger: trigger.into_any_element(),
content: Arc::new(|_, _| div().child("Popover Content").into_any_element()),
placement: Placement::Bottom,
offset: px(8.0),
close_on_click_outside: true,
close_on_escape: true,
trigger_id: None,
}
}
pub fn content<F, E>(mut self, f: F) -> Self
where
F: Fn(&mut Window, &mut Context<PopoverView>) -> E + 'static,
E: IntoElement,
{
self.content = Arc::new(move |window, cx| f(window, cx).into_any_element());
self
}
pub fn placement(mut self, placement: Placement) -> Self {
self.placement = placement;
self
}
pub fn offset(mut self, offset: impl Into<Pixels>) -> Self {
self.offset = offset.into();
self
}
pub fn offset_lg(self) -> Self {
self.offset(px(20.0))
}
pub fn close_on_click_outside(mut self, c: bool) -> Self {
self.close_on_click_outside = c;
self
}
pub fn close_on_escape(mut self, c: bool) -> Self {
self.close_on_escape = c;
self
}
pub fn register_key_bindings(cx: &mut App) {
cx.bind_keys([KeyBinding::new("escape", PopoverClose, None)]);
}
pub fn id(mut self, id: impl Into<SharedString>) -> Self {
self.trigger_id = Some(ElementId::from(id.into()));
self
}
}
impl RenderOnce for Popover {
fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
let placement = self.placement;
let offset = self.offset;
let close_on_click_outside = self.close_on_click_outside;
let close_on_escape = self.close_on_escape;
let content = self.content.clone();
let trigger_id = self.trigger_id.unwrap_or_else(|| {
stable_unique_id("popover-trigger", "popover-trigger", _window, _cx).into()
});
let popover_id = match &trigger_id {
ElementId::Name(name) => name.clone(),
_ => SharedString::from(format!("popover-{:?}", trigger_id)),
};
let bounds_cell = Rc::new(Cell::new(None));
let bounds_cell_clone = bounds_cell.clone();
div()
.id(trigger_id)
.child(BoundsTracker {
trigger: self.trigger,
bounds: bounds_cell,
})
.on_click(move |_event, _window, cx| {
if is_popover_active(&popover_id, cx) {
clear_popover(&popover_id, cx);
return;
}
if let Some(anchor_bounds) = bounds_cell_clone.get() {
let content = content.clone();
let popover_id_for_close = popover_id.clone();
let popover_id_for_view = popover_id.clone();
let view = cx.new(|_cx| {
PopoverView::new(
content,
anchor_bounds,
placement,
offset,
close_on_click_outside,
close_on_escape,
popover_id_for_view,
move |_window, _cx| {
clear_popover(&popover_id_for_close, _cx);
},
)
});
set_active_popover(popover_id.clone(), view.into(), cx);
}
})
}
}
impl IntoElement for Popover {
type Element = Component<Self>;
fn into_element(self) -> Self::Element {
Component::new(self)
}
}
struct BoundsTracker {
trigger: AnyElement,
bounds: Rc<Cell<Option<Bounds<Pixels>>>>,
}
impl IntoElement for BoundsTracker {
type Element = Self;
fn into_element(self) -> Self::Element {
self
}
}
impl gpui::Element for BoundsTracker {
type RequestLayoutState = ();
type PrepaintState = ();
fn id(&self) -> Option<ElementId> {
None
}
fn source_location(&self) -> Option<&'static std::panic::Location<'static>> {
None
}
fn request_layout(
&mut self,
_id: Option<&GlobalElementId>,
_id2: Option<&InspectorElementId>,
window: &mut Window,
cx: &mut App,
) -> (LayoutId, ()) {
(self.trigger.request_layout(window, cx), ())
}
fn prepaint(
&mut self,
_id: Option<&GlobalElementId>,
_id2: Option<&InspectorElementId>,
_bounds: Bounds<Pixels>,
_rl: &mut (),
window: &mut Window,
cx: &mut App,
) -> () {
self.trigger.prepaint(window, cx);
}
fn paint(
&mut self,
_id: Option<&GlobalElementId>,
_id2: Option<&InspectorElementId>,
bounds: Bounds<Pixels>,
_rl: &mut (),
_ps: &mut (),
window: &mut Window,
cx: &mut App,
) {
self.bounds.set(Some(bounds));
self.trigger.paint(window, cx);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn popover_offset_lg_sets_demo_offset() {
assert_eq!(Popover::new("trigger").offset_lg().offset, px(20.0));
}
#[test]
fn popover_content_uses_liora_motion() {
let source = include_str!("popover.rs")
.split("#[cfg(test)]")
.next()
.unwrap();
assert!(source.contains("pop_in("));
assert!(source.contains("-motion"));
}
}
#[cfg(test)]
mod spacing_regression_tests {
#[test]
fn popover_content_has_default_spacing() {
let source = include_str!("popover.rs")
.split("#[cfg(test)]")
.next()
.unwrap();
assert!(
source.contains(".p_4()"),
"popover content wrapper should provide default padding so bubble content is not cramped"
);
}
}