Skip to main content

liora_core/
popper.rs

1use gpui::{AnyElement, App, Bounds, Global, Pixels, Point, SharedString, Window};
2
3#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4/// Options that control placement behavior.
5pub enum Placement {
6    /// Places the overlay above the anchor.
7    Top,
8    /// Places the overlay above the anchor aligned to the start edge.
9    TopStart,
10    /// Places the overlay above the anchor aligned to the end edge.
11    TopEnd,
12    /// Places the overlay below the anchor.
13    Bottom,
14    /// Places the overlay below the anchor aligned to the start edge.
15    BottomStart,
16    /// Places the overlay below the anchor aligned to the end edge.
17    BottomEnd,
18    /// Places the overlay to the left of the anchor.
19    Left,
20    /// Places the overlay to the left aligned to the start edge.
21    LeftStart,
22    /// Places the overlay to the left aligned to the end edge.
23    LeftEnd,
24    /// Places the overlay to the right of the anchor.
25    Right,
26    /// Places the overlay to the right aligned to the start edge.
27    RightStart,
28    /// Places the overlay to the right aligned to the end edge.
29    RightEnd,
30}
31
32impl Placement {
33    /// Returns the opposite placement used when a popper would overflow its viewport.
34    pub fn flip(&self) -> Self {
35        match self {
36            Placement::Top => Placement::Bottom,
37            Placement::TopStart => Placement::BottomStart,
38            Placement::TopEnd => Placement::BottomEnd,
39            Placement::Bottom => Placement::Top,
40            Placement::BottomStart => Placement::TopStart,
41            Placement::BottomEnd => Placement::TopEnd,
42            Placement::Left => Placement::Right,
43            Placement::LeftStart => Placement::RightStart,
44            Placement::LeftEnd => Placement::RightEnd,
45            Placement::Right => Placement::Left,
46            Placement::RightStart => Placement::LeftStart,
47            Placement::RightEnd => Placement::LeftEnd,
48        }
49    }
50}
51
52/// Type alias for portal render values used by the popper API.
53pub type PortalRender = Box<dyn FnOnce(&mut Window, &mut App) -> AnyElement>;
54
55/// Runtime state used by Liora portal entry behavior.
56pub struct PortalEntry {
57    /// Stable identifier used for GPUI state, callbacks, and automation.
58    pub id: u64,
59    /// One-shot closure that produces the portal element.
60    pub render: PortalRender,
61}
62
63/// Runtime state used by Liora portal behavior.
64pub struct Portal {
65    /// Queued portal entries waiting to be rendered.
66    pub entries: Vec<PortalEntry>,
67    next_id: u64,
68}
69
70impl Global for Portal {}
71
72/// Runtime state used by Liora passive portal behavior.
73pub struct PassivePortal {
74    /// Queued portal entries waiting to be rendered.
75    pub entries: Vec<PortalEntry>,
76    next_id: u64,
77}
78
79impl Global for PassivePortal {}
80
81/// Adds a one-shot portal render entry and returns its id.
82pub fn push_portal(
83    render: impl FnOnce(&mut Window, &mut App) -> AnyElement + 'static,
84    cx: &mut App,
85) -> u64 {
86    if !cx.has_global::<Portal>() {
87        cx.set_global(Portal {
88            entries: vec![],
89            next_id: 1,
90        });
91    }
92    let portal = cx.global_mut::<Portal>();
93    let id = portal.next_id;
94    portal.next_id += 1;
95    portal.entries.push(PortalEntry {
96        id,
97        render: Box::new(render),
98    });
99    id
100}
101
102/// Adds a passive portal render entry and returns its id.
103pub fn push_passive_portal(
104    render: impl FnOnce(&mut Window, &mut App) -> AnyElement + 'static,
105    cx: &mut App,
106) -> u64 {
107    if !cx.has_global::<PassivePortal>() {
108        cx.set_global(PassivePortal {
109            entries: vec![],
110            next_id: 1,
111        });
112    }
113    let portal = cx.global_mut::<PassivePortal>();
114    let id = portal.next_id;
115    portal.next_id += 1;
116    portal.entries.push(PortalEntry {
117        id,
118        render: Box::new(render),
119    });
120    id
121}
122
123/// Removes the matching portal from the component state.
124pub fn remove_portal(id: u64, cx: &mut App) {
125    if cx.has_global::<Portal>() {
126        cx.global_mut::<Portal>().entries.retain(|e| e.id != id);
127    }
128}
129
130/// Clears the current portals state.
131pub fn clear_portals(cx: &mut App) {
132    if cx.has_global::<Portal>() {
133        cx.global_mut::<Portal>().entries.clear();
134    }
135}
136
137/// Runtime state used by Liora zindex stack behavior.
138pub struct ZIndexStack {
139    /// Base value for the z-index stack.
140    pub base: u32,
141    /// Z-index assigned to popup-style overlays.
142    pub popup: u32,
143    /// Modal panel background color.
144    pub modal: u32,
145    /// Z-index assigned to notification overlays.
146    pub notification: u32,
147    /// Tooltip text shown by the operating-system tray area.
148    pub tooltip: u32,
149}
150
151impl Default for ZIndexStack {
152    fn default() -> Self {
153        Self {
154            base: 1000,
155            popup: 1100,
156            modal: 1200,
157            notification: 1300,
158            tooltip: 1400,
159        }
160    }
161}
162
163impl Global for ZIndexStack {}
164
165#[derive(Clone)]
166/// Runtime state used by Liora tooltip data behavior.
167pub struct TooltipData {
168    /// Stable identifier used for GPUI state, callbacks, and automation.
169    pub id: SharedString,
170    /// Content rendered inside the component body.
171    pub content: SharedString,
172    /// Bounds of the trigger element used for popper positioning.
173    pub anchor_bounds: Bounds<Pixels>,
174    /// Preferred placement relative to the trigger or anchor.
175    pub placement: Placement,
176    /// Pixel offset applied after popper placement is resolved.
177    pub offset: Pixels,
178}
179
180/// Runtime state used by Liora active tooltip behavior.
181pub struct ActiveTooltip(pub Vec<TooltipData>);
182impl Global for ActiveTooltip {}
183
184/// Updates the stored active tooltip value and keeps the existing component identity.
185pub fn set_active_tooltip(data: TooltipData, cx: &mut App) {
186    let tooltips = &mut cx.global_mut::<ActiveTooltip>().0;
187    if let Some(existing) = tooltips.iter_mut().find(|tooltip| tooltip.id == data.id) {
188        *existing = data;
189    } else {
190        tooltips.push(data);
191    }
192}
193
194/// Replaces every active tooltip with one tooltip owned by a single trigger.
195///
196/// Returns `true` when the stored tooltip identity, content, or anchor changed.
197/// General-purpose hover tooltips should be exclusive: when a virtualized item
198/// is recycled under an already-stationary mouse cursor, the new hovered item
199/// must refresh the global tooltip immediately instead of waiting for a
200/// `mouse_move` event. Chart/data tooltips can still call [`set_active_tooltip`]
201/// directly when they intentionally manage multiple data-driven entries.
202pub fn set_exclusive_active_tooltip(data: TooltipData, cx: &mut App) -> bool {
203    let active = &mut cx.global_mut::<ActiveTooltip>().0;
204    let changed = active.len() != 1
205        || active.first().is_none_or(|current| {
206            current.id != data.id
207                || current.content != data.content
208                || current.anchor_bounds != data.anchor_bounds
209                || current.placement != data.placement
210                || current.offset != data.offset
211        });
212    if changed {
213        *active = vec![data];
214    }
215    changed
216}
217
218/// Clears the current tooltip state.
219pub fn clear_tooltip(id: &SharedString, cx: &mut App) {
220    cx.global_mut::<ActiveTooltip>()
221        .0
222        .retain(|tooltip| &tooltip.id != id);
223}
224
225/// Clears the current active tooltip state.
226pub fn clear_active_tooltip(cx: &mut App) {
227    cx.global_mut::<ActiveTooltip>().0.clear();
228}
229
230#[derive(Clone)]
231/// Runtime state used by Liora active overlay entry behavior.
232pub struct ActiveOverlayEntry {
233    /// Stable identifier used for GPUI state, callbacks, and automation.
234    pub id: SharedString,
235    /// Entity view rendered inside a portal.
236    pub view: gpui::AnyView,
237}
238
239/// Runtime state used by Liora active popover behavior.
240pub struct ActivePopover(pub Vec<ActiveOverlayEntry>);
241impl Global for ActivePopover {}
242
243/// Returns whether popover active is currently true for this value.
244pub fn is_popover_active(id: &SharedString, cx: &App) -> bool {
245    cx.global::<ActivePopover>()
246        .0
247        .iter()
248        .any(|entry| &entry.id == id)
249}
250
251/// Updates the stored active popover value and keeps the existing component identity.
252pub fn set_active_popover(id: SharedString, view: gpui::AnyView, cx: &mut App) {
253    let popovers = &mut cx.global_mut::<ActivePopover>().0;
254    if let Some(existing) = popovers.iter_mut().find(|entry| entry.id == id) {
255        existing.view = view;
256    } else {
257        popovers.push(ActiveOverlayEntry { id, view });
258    }
259}
260
261/// Clears the current popover state.
262pub fn clear_popover(id: &SharedString, cx: &mut App) {
263    cx.global_mut::<ActivePopover>()
264        .0
265        .retain(|entry| &entry.id != id);
266}
267
268/// Clears the current active popover state.
269pub fn clear_active_popover(cx: &mut App) {
270    cx.global_mut::<ActivePopover>().0.clear();
271}
272
273/// Runtime state used by Liora active modal behavior.
274pub struct ActiveModal(pub Vec<ActiveOverlayEntry>);
275impl Global for ActiveModal {}
276
277/// Updates the stored active modal value and keeps the existing component identity.
278pub fn set_active_modal(id: SharedString, view: gpui::AnyView, cx: &mut App) {
279    let modals = &mut cx.global_mut::<ActiveModal>().0;
280    if let Some(existing) = modals.iter_mut().find(|entry| entry.id == id) {
281        existing.view = view;
282    } else {
283        modals.push(ActiveOverlayEntry { id, view });
284    }
285}
286
287/// Clears the current modal state.
288pub fn clear_modal(id: &SharedString, cx: &mut App) {
289    cx.global_mut::<ActiveModal>()
290        .0
291        .retain(|entry| &entry.id != id);
292}
293
294/// Clears the current active modal state.
295pub fn clear_active_modal(cx: &mut App) {
296    cx.global_mut::<ActiveModal>().0.clear();
297}
298
299/// Runtime state used by Liora active drawer behavior.
300pub struct ActiveDrawer(pub Vec<ActiveOverlayEntry>);
301impl Global for ActiveDrawer {}
302
303/// Updates the stored active drawer value and keeps the existing component identity.
304pub fn set_active_drawer(id: SharedString, view: gpui::AnyView, cx: &mut App) {
305    let drawers = &mut cx.global_mut::<ActiveDrawer>().0;
306    if let Some(existing) = drawers.iter_mut().find(|entry| entry.id == id) {
307        existing.view = view;
308    } else {
309        drawers.push(ActiveOverlayEntry { id, view });
310    }
311}
312
313/// Clears the current drawer state.
314pub fn clear_drawer(id: &SharedString, cx: &mut App) {
315    cx.global_mut::<ActiveDrawer>()
316        .0
317        .retain(|entry| &entry.id != id);
318}
319
320/// Clears the current active drawer state.
321pub fn clear_active_drawer(cx: &mut App) {
322    cx.global_mut::<ActiveDrawer>().0.clear();
323}
324
325/// Runtime state used by Liora popper behavior.
326pub struct Popper {
327    /// Bounds of the trigger element used for popper positioning.
328    pub anchor_bounds: Bounds<Pixels>,
329    /// Preferred placement relative to the trigger or anchor.
330    pub placement: Placement,
331    /// Pixel offset applied after popper placement is resolved.
332    pub offset: Pixels,
333}
334
335impl Popper {
336    /// Calculates the floating element origin for the configured anchor and placement.
337    pub fn calculate_position(&self, content_size: gpui::Size<Pixels>) -> Point<Pixels> {
338        self.calculate_position_with_placement(self.placement, content_size)
339    }
340
341    fn calculate_position_with_placement(
342        &self,
343        placement: Placement,
344        content_size: gpui::Size<Pixels>,
345    ) -> Point<Pixels> {
346        let anchor = self.anchor_bounds;
347        let (x, y) = match placement {
348            Placement::Top => (
349                anchor.left() + (anchor.size.width - content_size.width) / 2.0,
350                anchor.top() - content_size.height - self.offset,
351            ),
352            Placement::TopStart => (
353                anchor.left(),
354                anchor.top() - content_size.height - self.offset,
355            ),
356            Placement::TopEnd => (
357                anchor.right() - content_size.width,
358                anchor.top() - content_size.height - self.offset,
359            ),
360            Placement::Bottom => (
361                anchor.left() + (anchor.size.width - content_size.width) / 2.0,
362                anchor.bottom() + self.offset,
363            ),
364            Placement::BottomStart => (anchor.left(), anchor.bottom() + self.offset),
365            Placement::BottomEnd => (
366                anchor.right() - content_size.width,
367                anchor.bottom() + self.offset,
368            ),
369            Placement::Left => (
370                anchor.left() - content_size.width - self.offset,
371                anchor.top() + (anchor.size.height - content_size.height) / 2.0,
372            ),
373            Placement::LeftStart => (
374                anchor.left() - content_size.width - self.offset,
375                anchor.top(),
376            ),
377            Placement::LeftEnd => (
378                anchor.left() - content_size.width - self.offset,
379                anchor.bottom() - content_size.height,
380            ),
381            Placement::Right => (
382                anchor.right() + self.offset,
383                anchor.top() + (anchor.size.height - content_size.height) / 2.0,
384            ),
385            Placement::RightStart => (anchor.right() + self.offset, anchor.top()),
386            Placement::RightEnd => (
387                anchor.right() + self.offset,
388                anchor.bottom() - content_size.height,
389            ),
390        };
391
392        Point { x, y }
393    }
394
395    /// Calculates the floating element origin and flips placement when needed to fit the viewport.
396    pub fn calculate_position_with_flip(
397        &self,
398        content_size: gpui::Size<Pixels>,
399        viewport: Bounds<Pixels>,
400    ) -> (Point<Pixels>, Placement) {
401        let pos = self.calculate_position_with_placement(self.placement, content_size);
402        let mut final_pos = pos;
403        let mut final_placement = self.placement;
404
405        let out_of_bounds = pos.x < viewport.left()
406            || pos.x + content_size.width > viewport.right()
407            || pos.y < viewport.top()
408            || pos.y + content_size.height > viewport.bottom();
409
410        if out_of_bounds {
411            let flipped_placement = self.placement.flip();
412            let flipped_pos =
413                self.calculate_position_with_placement(flipped_placement, content_size);
414
415            let flipped_out_of_bounds = flipped_pos.x < viewport.left()
416                || flipped_pos.x + content_size.width > viewport.right()
417                || flipped_pos.y < viewport.top()
418                || flipped_pos.y + content_size.height > viewport.bottom();
419
420            if !flipped_out_of_bounds {
421                final_pos = flipped_pos;
422                final_placement = flipped_placement;
423            }
424        }
425
426        final_pos.x = final_pos
427            .x
428            .clamp(viewport.left(), viewport.right() - content_size.width);
429        final_pos.y = final_pos
430            .y
431            .clamp(viewport.top(), viewport.bottom() - content_size.height);
432
433        (final_pos, final_placement)
434    }
435}
436
437#[cfg(test)]
438mod tests {
439    use super::*;
440    use gpui::{point, px, size};
441
442    fn viewport() -> Bounds<Pixels> {
443        Bounds {
444            origin: point(px(0.0), px(0.0)),
445            size: size(px(800.0), px(600.0)),
446        }
447    }
448
449    fn anchor(x: f32, width: f32) -> Bounds<Pixels> {
450        Bounds {
451            origin: point(px(x), px(200.0)),
452            size: size(px(width), px(40.0)),
453        }
454    }
455
456    #[test]
457    fn centered_vertical_placements_align_content_center_with_anchor_center() {
458        let content_size = size(px(180.0), px(80.0));
459        let anchor_bounds = anchor(300.0, 80.0);
460        let popper = Popper {
461            anchor_bounds,
462            placement: Placement::Bottom,
463            offset: px(8.0),
464        };
465
466        let (pos, placement) = popper.calculate_position_with_flip(content_size, viewport());
467
468        assert_eq!(placement, Placement::Bottom);
469        assert_eq!(
470            pos.x + content_size.width / 2.0,
471            anchor_bounds.left() + anchor_bounds.size.width / 2.0
472        );
473        assert_eq!(pos.y, anchor_bounds.bottom() + px(8.0));
474    }
475
476    #[test]
477    fn centered_vertical_placements_clamp_horizontally_to_viewport() {
478        let content_size = size(px(220.0), px(80.0));
479        let near_left = Popper {
480            anchor_bounds: anchor(8.0, 40.0),
481            placement: Placement::Bottom,
482            offset: px(8.0),
483        };
484        let near_right = Popper {
485            anchor_bounds: anchor(760.0, 32.0),
486            placement: Placement::Bottom,
487            offset: px(8.0),
488        };
489
490        let (left_pos, _) = near_left.calculate_position_with_flip(content_size, viewport());
491        let (right_pos, _) = near_right.calculate_position_with_flip(content_size, viewport());
492
493        assert_eq!(left_pos.x, px(0.0));
494        assert_eq!(right_pos.x + content_size.width, viewport().right());
495    }
496}