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/// Clears the current tooltip state.
195pub fn clear_tooltip(id: &SharedString, cx: &mut App) {
196    cx.global_mut::<ActiveTooltip>()
197        .0
198        .retain(|tooltip| &tooltip.id != id);
199}
200
201/// Clears the current active tooltip state.
202pub fn clear_active_tooltip(cx: &mut App) {
203    cx.global_mut::<ActiveTooltip>().0.clear();
204}
205
206#[derive(Clone)]
207/// Runtime state used by Liora active overlay entry behavior.
208pub struct ActiveOverlayEntry {
209    /// Stable identifier used for GPUI state, callbacks, and automation.
210    pub id: SharedString,
211    /// Entity view rendered inside a portal.
212    pub view: gpui::AnyView,
213}
214
215/// Runtime state used by Liora active popover behavior.
216pub struct ActivePopover(pub Vec<ActiveOverlayEntry>);
217impl Global for ActivePopover {}
218
219/// Returns whether popover active is currently true for this value.
220pub fn is_popover_active(id: &SharedString, cx: &App) -> bool {
221    cx.global::<ActivePopover>()
222        .0
223        .iter()
224        .any(|entry| &entry.id == id)
225}
226
227/// Updates the stored active popover value and keeps the existing component identity.
228pub fn set_active_popover(id: SharedString, view: gpui::AnyView, cx: &mut App) {
229    let popovers = &mut cx.global_mut::<ActivePopover>().0;
230    if let Some(existing) = popovers.iter_mut().find(|entry| entry.id == id) {
231        existing.view = view;
232    } else {
233        popovers.push(ActiveOverlayEntry { id, view });
234    }
235}
236
237/// Clears the current popover state.
238pub fn clear_popover(id: &SharedString, cx: &mut App) {
239    cx.global_mut::<ActivePopover>()
240        .0
241        .retain(|entry| &entry.id != id);
242}
243
244/// Clears the current active popover state.
245pub fn clear_active_popover(cx: &mut App) {
246    cx.global_mut::<ActivePopover>().0.clear();
247}
248
249/// Runtime state used by Liora active modal behavior.
250pub struct ActiveModal(pub Vec<ActiveOverlayEntry>);
251impl Global for ActiveModal {}
252
253/// Updates the stored active modal value and keeps the existing component identity.
254pub fn set_active_modal(id: SharedString, view: gpui::AnyView, cx: &mut App) {
255    let modals = &mut cx.global_mut::<ActiveModal>().0;
256    if let Some(existing) = modals.iter_mut().find(|entry| entry.id == id) {
257        existing.view = view;
258    } else {
259        modals.push(ActiveOverlayEntry { id, view });
260    }
261}
262
263/// Clears the current modal state.
264pub fn clear_modal(id: &SharedString, cx: &mut App) {
265    cx.global_mut::<ActiveModal>()
266        .0
267        .retain(|entry| &entry.id != id);
268}
269
270/// Clears the current active modal state.
271pub fn clear_active_modal(cx: &mut App) {
272    cx.global_mut::<ActiveModal>().0.clear();
273}
274
275/// Runtime state used by Liora active drawer behavior.
276pub struct ActiveDrawer(pub Vec<ActiveOverlayEntry>);
277impl Global for ActiveDrawer {}
278
279/// Updates the stored active drawer value and keeps the existing component identity.
280pub fn set_active_drawer(id: SharedString, view: gpui::AnyView, cx: &mut App) {
281    let drawers = &mut cx.global_mut::<ActiveDrawer>().0;
282    if let Some(existing) = drawers.iter_mut().find(|entry| entry.id == id) {
283        existing.view = view;
284    } else {
285        drawers.push(ActiveOverlayEntry { id, view });
286    }
287}
288
289/// Clears the current drawer state.
290pub fn clear_drawer(id: &SharedString, cx: &mut App) {
291    cx.global_mut::<ActiveDrawer>()
292        .0
293        .retain(|entry| &entry.id != id);
294}
295
296/// Clears the current active drawer state.
297pub fn clear_active_drawer(cx: &mut App) {
298    cx.global_mut::<ActiveDrawer>().0.clear();
299}
300
301/// Runtime state used by Liora popper behavior.
302pub struct Popper {
303    /// Bounds of the trigger element used for popper positioning.
304    pub anchor_bounds: Bounds<Pixels>,
305    /// Preferred placement relative to the trigger or anchor.
306    pub placement: Placement,
307    /// Pixel offset applied after popper placement is resolved.
308    pub offset: Pixels,
309}
310
311impl Popper {
312    /// Calculates the floating element origin for the configured anchor and placement.
313    pub fn calculate_position(&self, content_size: gpui::Size<Pixels>) -> Point<Pixels> {
314        self.calculate_position_with_placement(self.placement, content_size)
315    }
316
317    fn calculate_position_with_placement(
318        &self,
319        placement: Placement,
320        content_size: gpui::Size<Pixels>,
321    ) -> Point<Pixels> {
322        let anchor = self.anchor_bounds;
323        let (x, y) = match placement {
324            Placement::Top => (
325                anchor.left() + (anchor.size.width - content_size.width) / 2.0,
326                anchor.top() - content_size.height - self.offset,
327            ),
328            Placement::TopStart => (
329                anchor.left(),
330                anchor.top() - content_size.height - self.offset,
331            ),
332            Placement::TopEnd => (
333                anchor.right() - content_size.width,
334                anchor.top() - content_size.height - self.offset,
335            ),
336            Placement::Bottom => (
337                anchor.left() + (anchor.size.width - content_size.width) / 2.0,
338                anchor.bottom() + self.offset,
339            ),
340            Placement::BottomStart => (anchor.left(), anchor.bottom() + self.offset),
341            Placement::BottomEnd => (
342                anchor.right() - content_size.width,
343                anchor.bottom() + self.offset,
344            ),
345            Placement::Left => (
346                anchor.left() - content_size.width - self.offset,
347                anchor.top() + (anchor.size.height - content_size.height) / 2.0,
348            ),
349            Placement::LeftStart => (
350                anchor.left() - content_size.width - self.offset,
351                anchor.top(),
352            ),
353            Placement::LeftEnd => (
354                anchor.left() - content_size.width - self.offset,
355                anchor.bottom() - content_size.height,
356            ),
357            Placement::Right => (
358                anchor.right() + self.offset,
359                anchor.top() + (anchor.size.height - content_size.height) / 2.0,
360            ),
361            Placement::RightStart => (anchor.right() + self.offset, anchor.top()),
362            Placement::RightEnd => (
363                anchor.right() + self.offset,
364                anchor.bottom() - content_size.height,
365            ),
366        };
367
368        Point { x, y }
369    }
370
371    /// Calculates the floating element origin and flips placement when needed to fit the viewport.
372    pub fn calculate_position_with_flip(
373        &self,
374        content_size: gpui::Size<Pixels>,
375        viewport: Bounds<Pixels>,
376    ) -> (Point<Pixels>, Placement) {
377        let pos = self.calculate_position_with_placement(self.placement, content_size);
378        let mut final_pos = pos;
379        let mut final_placement = self.placement;
380
381        let out_of_bounds = pos.x < viewport.left()
382            || pos.x + content_size.width > viewport.right()
383            || pos.y < viewport.top()
384            || pos.y + content_size.height > viewport.bottom();
385
386        if out_of_bounds {
387            let flipped_placement = self.placement.flip();
388            let flipped_pos =
389                self.calculate_position_with_placement(flipped_placement, content_size);
390
391            let flipped_out_of_bounds = flipped_pos.x < viewport.left()
392                || flipped_pos.x + content_size.width > viewport.right()
393                || flipped_pos.y < viewport.top()
394                || flipped_pos.y + content_size.height > viewport.bottom();
395
396            if !flipped_out_of_bounds {
397                final_pos = flipped_pos;
398                final_placement = flipped_placement;
399            }
400        }
401
402        final_pos.x = final_pos
403            .x
404            .clamp(viewport.left(), viewport.right() - content_size.width);
405        final_pos.y = final_pos
406            .y
407            .clamp(viewport.top(), viewport.bottom() - content_size.height);
408
409        (final_pos, final_placement)
410    }
411}
412
413#[cfg(test)]
414mod tests {
415    use super::*;
416    use gpui::{point, px, size};
417
418    fn viewport() -> Bounds<Pixels> {
419        Bounds {
420            origin: point(px(0.0), px(0.0)),
421            size: size(px(800.0), px(600.0)),
422        }
423    }
424
425    fn anchor(x: f32, width: f32) -> Bounds<Pixels> {
426        Bounds {
427            origin: point(px(x), px(200.0)),
428            size: size(px(width), px(40.0)),
429        }
430    }
431
432    #[test]
433    fn centered_vertical_placements_align_content_center_with_anchor_center() {
434        let content_size = size(px(180.0), px(80.0));
435        let anchor_bounds = anchor(300.0, 80.0);
436        let popper = Popper {
437            anchor_bounds,
438            placement: Placement::Bottom,
439            offset: px(8.0),
440        };
441
442        let (pos, placement) = popper.calculate_position_with_flip(content_size, viewport());
443
444        assert_eq!(placement, Placement::Bottom);
445        assert_eq!(
446            pos.x + content_size.width / 2.0,
447            anchor_bounds.left() + anchor_bounds.size.width / 2.0
448        );
449        assert_eq!(pos.y, anchor_bounds.bottom() + px(8.0));
450    }
451
452    #[test]
453    fn centered_vertical_placements_clamp_horizontally_to_viewport() {
454        let content_size = size(px(220.0), px(80.0));
455        let near_left = Popper {
456            anchor_bounds: anchor(8.0, 40.0),
457            placement: Placement::Bottom,
458            offset: px(8.0),
459        };
460        let near_right = Popper {
461            anchor_bounds: anchor(760.0, 32.0),
462            placement: Placement::Bottom,
463            offset: px(8.0),
464        };
465
466        let (left_pos, _) = near_left.calculate_position_with_flip(content_size, viewport());
467        let (right_pos, _) = near_right.calculate_position_with_flip(content_size, viewport());
468
469        assert_eq!(left_pos.x, px(0.0));
470        assert_eq!(right_pos.x + content_size.width, viewport().right());
471    }
472}