use gpui::{ AnyElement, App, Bounds, Context, Corner, DismissEvent, ElementId, EventEmitter, FocusHandle, Focusable, InteractiveElement as _, IntoElement, KeyBinding, MouseButton, ParentElement, Pixels, Point, Render, RenderOnce, StyleRefinement, Styled, Subscription, Window, anchored, canvas, deferred, div, prelude::FluentBuilder as _, px, }; use std::rc::Rc; use crate::{Selectable, StyledExt as _, actions::Cancel, v_flex}; const CONTEXT: &str = "Popover"; pub(crate) fn init(cx: &mut App) { cx.bind_keys([KeyBinding::new("escape", Cancel, Some(CONTEXT))]) } /// A popover element that can be triggered by a button or any other element. #[derive(IntoElement)] pub struct Popover { id: ElementId, style: StyleRefinement, anchor: Corner, default_open: bool, open: Option, tracked_focus_handle: Option, trigger: Option AnyElement + 'static>>, content: Option< Rc< dyn Fn(&mut PopoverState, &mut Window, &mut Context) -> AnyElement + 'static, >, >, children: Vec, /// Style for trigger element. /// This is used for hotfix the trigger element style to support w_full. trigger_style: Option, mouse_button: MouseButton, appearance: bool, overlay_closable: bool, on_open_change: Option>, } impl Popover { /// Create a new Popover with `view` mode. pub fn new(id: impl Into) -> Self { Self { id: id.into(), style: StyleRefinement::default(), anchor: Corner::TopLeft, trigger: None, trigger_style: None, content: None, tracked_focus_handle: None, children: vec![], mouse_button: MouseButton::Left, appearance: true, overlay_closable: true, default_open: false, open: None, on_open_change: None, } } /// Set the anchor corner of the popover, default is `Corner::TopLeft`. pub fn anchor(mut self, anchor: Corner) -> Self { self.anchor = anchor; self } /// Set the mouse button to trigger the popover, default is `MouseButton::Left`. pub fn mouse_button(mut self, mouse_button: MouseButton) -> Self { self.mouse_button = mouse_button; self } /// Set the trigger element of the popover. pub fn trigger(mut self, trigger: T) -> Self where T: Selectable + IntoElement + 'static, { self.trigger = Some(Box::new(|is_open, _, _| { let selected = trigger.is_selected(); trigger.selected(selected || is_open).into_any_element() })); self } /// Set the default open state of the popover, default is `false`. /// /// This is only used to initialize the open state of the popover. /// /// And please note that if you use the `open` method, this value will be ignored. pub fn default_open(mut self, open: bool) -> Self { self.default_open = open; self } /// Force set the open state of the popover. /// /// If this is set, the popover will be controlled by this value. /// /// NOTE: You must be used in conjunction with `on_open_change` to handle state changes. pub fn open(mut self, open: bool) -> Self { self.open = Some(open); self } /// Add a callback to be called when the open state changes. /// /// The first `&bool` parameter is the **new open state**. /// /// This is useful when using the `open` method to control the popover state. pub fn on_open_change(mut self, callback: F) -> Self where F: Fn(&bool, &mut Window, &mut App) + 'static, { self.on_open_change = Some(Rc::new(callback)); self } /// Set the style for the trigger element. pub fn trigger_style(mut self, style: StyleRefinement) -> Self { self.trigger_style = Some(style); self } /// Set whether clicking outside the popover will dismiss it, default is `true`. pub fn overlay_closable(mut self, closable: bool) -> Self { self.overlay_closable = closable; self } /// Set the content builder for content of the Popover. /// /// This callback will called every time on render the popover. /// So, you should avoid creating new elements or entities in the content closure. pub fn content(mut self, content: F) -> Self where E: IntoElement, F: Fn(&mut PopoverState, &mut Window, &mut Context) -> E + 'static, { self.content = Some(Rc::new(move |state, window, cx| { content(state, window, cx).into_any_element() })); self } /// Set whether the popover no style, default is `false`. /// /// If no style: /// /// - The popover will not have a bg, border, shadow, or padding. /// - The click out of the popover will not dismiss it. pub fn appearance(mut self, appearance: bool) -> Self { self.appearance = appearance; self } /// Bind the focus handle to receive focus when the popover is opened. /// If you not set this, a new focus handle will be created for the popover to /// /// If popover is opened, the focus will be moved to the focus handle. pub fn track_focus(mut self, handle: &FocusHandle) -> Self { self.tracked_focus_handle = Some(handle.clone()); self } fn resolved_corner(anchor: Corner, bounds: Bounds) -> Point { bounds.corner(match anchor { Corner::TopLeft => Corner::BottomLeft, Corner::TopRight => Corner::BottomRight, Corner::BottomLeft => Corner::TopLeft, Corner::BottomRight => Corner::TopRight, }) + Point { x: px(0.), y: -bounds.size.height, } } } impl ParentElement for Popover { fn extend(&mut self, elements: impl IntoIterator) { self.children.extend(elements); } } impl Styled for Popover { fn style(&mut self) -> &mut StyleRefinement { &mut self.style } } pub struct PopoverState { focus_handle: FocusHandle, pub(crate) tracked_focus_handle: Option, trigger_bounds: Option>, open: bool, on_open_change: Option>, _dismiss_subscription: Option, } impl PopoverState { pub fn new(default_open: bool, cx: &mut App) -> Self { Self { focus_handle: cx.focus_handle(), tracked_focus_handle: None, trigger_bounds: None, open: default_open, on_open_change: None, _dismiss_subscription: None, } } /// Check if the popover is open. pub fn is_open(&self) -> bool { self.open } /// Dismiss the popover if it is open. pub fn dismiss(&mut self, window: &mut Window, cx: &mut Context) { if self.open { self.toggle_open(window, cx); } } /// Open the popover if it is closed. pub fn show(&mut self, window: &mut Window, cx: &mut Context) { if !self.open { self.toggle_open(window, cx); } } fn toggle_open(&mut self, window: &mut Window, cx: &mut Context) { self.open = !self.open; if self.open { let state = cx.entity(); let focus_handle = if let Some(tracked_focus_handle) = self.tracked_focus_handle.clone() { tracked_focus_handle } else { self.focus_handle.clone() }; focus_handle.focus(window); self._dismiss_subscription = Some( window.subscribe(&cx.entity(), cx, move |_, _: &DismissEvent, window, cx| { state.update(cx, |state, cx| { state.dismiss(window, cx); }); window.refresh(); }), ); } else { self._dismiss_subscription = None; } if let Some(callback) = self.on_open_change.as_ref() { callback(&self.open, window, cx); } cx.notify(); } fn on_action_cancel(&mut self, _: &Cancel, window: &mut Window, cx: &mut Context) { self.dismiss(window, cx); } } impl Focusable for PopoverState { fn focus_handle(&self, _: &App) -> FocusHandle { self.focus_handle.clone() } } impl Render for PopoverState { fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { div() } } impl EventEmitter for PopoverState {} impl RenderOnce for Popover { fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { let force_open = self.open; let default_open = self.default_open; let tracked_focus_handle = self.tracked_focus_handle.clone(); let state = window.use_keyed_state(self.id.clone(), cx, |_, cx| { PopoverState::new(default_open, cx) }); state.update(cx, |state, _| { if let Some(tracked_focus_handle) = tracked_focus_handle { state.tracked_focus_handle = Some(tracked_focus_handle); } state.on_open_change = self.on_open_change.clone(); if let Some(force_open) = force_open { state.open = force_open; } }); let open = state.read(cx).open; let focus_handle = state.read(cx).focus_handle.clone(); let trigger_bounds = state.read(cx).trigger_bounds; let Some(trigger) = self.trigger else { return div().id("empty"); }; let parent_view_id = window.current_view(); let el = div() .id(self.id) .child((trigger)(open, window, cx)) .on_mouse_down(self.mouse_button, { let state = state.clone(); move |_, window, cx| { state.update(cx, |state, cx| { // We force set open to false to toggle it correctly. // Because if the mouse down out will toggle open first. state.open = open; state.toggle_open(window, cx); }); cx.notify(parent_view_id); } }) .child( canvas( { let state = state.clone(); move |bounds, _, cx| { state.update(cx, |state, _| { state.trigger_bounds = Some(bounds); }) } }, |_, _, _, _| {}, ) .absolute() .size_full(), ); if !open { return el; } el.child( deferred( anchored() .snap_to_window_with_margin(px(8.)) .anchor(self.anchor) .when_some(trigger_bounds, |this, trigger_bounds| { this.position(Self::resolved_corner(self.anchor, trigger_bounds)) }) .child( v_flex() .id("content") .track_focus(&focus_handle) .key_context(CONTEXT) .on_action(window.listener_for(&state, PopoverState::on_action_cancel)) .size_full() .occlude() .tab_group() .when(self.appearance, |this| this.popover_style(cx).p_3()) .map(|this| match self.anchor { Corner::TopLeft | Corner::TopRight => this.top_1(), Corner::BottomLeft | Corner::BottomRight => this.bottom_1(), }) .when_some(self.content, |this, content| { this.child( state.update(cx, |state, cx| (content)(state, window, cx)), ) }) .children(self.children) .when(self.overlay_closable, |this| { this.on_mouse_down_out({ let state = state.clone(); move |_, window, cx| { state.update(cx, |state, cx| { state.dismiss(window, cx); }); cx.notify(parent_view_id); } }) }) .refine_style(&self.style), ), ) .with_priority(1), ) } }