Skip to main content

lumen_primitives/
popup.rs

1//! Floating-panel behavior shared by `<dropdown>` and `<menu>`:
2//! outside-click dismissal and viewport edge-flip anchoring.
3//!
4//! The markup parser wraps each panel in an `<if eq="true" mode="hide">`
5//! keyed on a synthetic open signal (`__dropdown_open:*` /
6//! `__menu_open:*`) and tags the panel entity with [`PopupPanel`]. Two
7//! tiny systems then act on any open panel:
8//!
9//! - [`dismiss_popups_on_outside_press`] closes a panel when a primary
10//!   press lands outside both the panel's subtree and its trigger.
11//! - [`flip_open_dropdown_panels`] positions a dropdown panel just below
12//!   its trigger, flipping above when the panel would overflow the
13//!   viewport bottom - the same edge-flip rule
14//!   [`crate::tooltip`] applies, factored into [`anchored_popup_origin`].
15
16use bevy_ecs::message::MessageReader;
17use bevy_ecs::prelude::*;
18use glam::Vec2;
19use lumen_core::components::Style;
20use lumen_core::prelude::*;
21use lumen_core::render_world::Viewport;
22use lumen_core::style_overrides::{Live, StyleFields, StyleOverrides, StyleOwner, StyleValues};
23
24use crate::tabs::DropdownButton;
25
26/// Marker on a `<dropdown>` / `<menu>` floating panel. Carries the
27/// open-state signal so the dismissal + edge-flip systems can query the
28/// panel by its bound signal.
29///
30/// While the panel is open, the edge-flip holds its `inset` in
31/// [`StyleOverrides`]; on close it lets go and the panel is back where its
32/// styles place it, as they stand then.
33#[derive(Component, Clone, Debug)]
34#[require(StyleOverrides)]
35pub struct PopupPanel {
36    /// Open-state signal (`__dropdown_open:*` / `__menu_open:*`).
37    pub open_signal: String,
38}
39
40impl StyleOwner for PopupPanel {
41    const RANK: u8 = 20;
42}
43
44/// Which side of the anchor a popup prefers on its primary axis.
45#[derive(Clone, Copy, Debug, PartialEq, Eq)]
46pub enum PopupSide {
47    /// Below the anchor; flips above near the viewport bottom
48    /// (dropdowns, menus).
49    Below,
50    /// Right of the anchor; flips left near the viewport right edge
51    /// (tooltips, submenus).
52    Right,
53}
54
55/// Viewport-space top-left for a `popup`-sized box anchored to a trigger
56/// rect (`anchor_pos` / `anchor_size`), preferring `side` and flipping
57/// to the opposite side of the viewport when the preferred placement
58/// would overflow. `gap` px separate the popup from the anchor on the
59/// primary axis; the cross axis aligns to the anchor's leading edge and
60/// also flips inward when it would overflow.
61///
62/// Generalises the edge-flip `tooltip::spawn_tooltip_popups` performs so
63/// dropdown and menu panels can share one anchoring rule (Qt's
64/// `QToolTip` / Adwaita popover contract).
65pub fn anchored_popup_origin(
66    anchor_pos: Vec2,
67    anchor_size: Vec2,
68    popup: Vec2,
69    viewport: Vec2,
70    gap: f32,
71    side: PopupSide,
72) -> Vec2 {
73    match side {
74        PopupSide::Below => {
75            let below = anchor_pos.y + anchor_size.y + gap;
76            let y = if below + popup.y <= viewport.y {
77                below
78            } else {
79                (anchor_pos.y - gap - popup.y).max(0.0)
80            };
81            let x = if anchor_pos.x + popup.x <= viewport.x {
82                anchor_pos.x
83            } else {
84                (anchor_pos.x + anchor_size.x - popup.x).max(0.0)
85            };
86            Vec2::new(x, y)
87        }
88        PopupSide::Right => {
89            let right = anchor_pos.x + anchor_size.x + gap;
90            let x = if right + popup.x <= viewport.x {
91                right
92            } else {
93                (anchor_pos.x - gap - popup.x).max(0.0)
94            };
95            let y = if anchor_pos.y + popup.y <= viewport.y {
96                anchor_pos.y
97            } else {
98                (anchor_pos.y + anchor_size.y - popup.y).max(0.0)
99            };
100            Vec2::new(x, y)
101        }
102    }
103}
104
105/// Fallback gap (px) between a dropdown panel and its trigger, used when
106/// the trigger carries no [`PopupGap`] (e.g. no CSS `popup-gap` authored).
107const POPUP_GAP: f32 = 4.0;
108
109/// Gap between a popup panel and the control that opens it, in logical
110/// pixels. Sits on the trigger entity.
111#[derive(Component, Clone, Copy, Debug, PartialEq)]
112pub struct PopupGap(pub f32);
113
114impl Default for PopupGap {
115    fn default() -> Self {
116        Self(POPUP_GAP)
117    }
118}
119
120/// Close any open [`PopupPanel`] when a primary press lands outside both
121/// the panel's own subtree and (for dropdowns) its trigger header.
122///
123/// Reads [`PointerPressed`] and the entity currently under the cursor
124/// ([`Hovered`], maintained by `lumen_input::hit_test`). A press over
125/// empty space clears the cursor target, so it dismisses every open
126/// panel. Pressing the dropdown header is treated as *inside* so the
127/// header's own toggle (`tabs::dispatch_dropdown_clicks`, which fires on
128/// release) stays authoritative and the panel doesn't close-then-reopen.
129pub fn dismiss_popups_on_outside_press(
130    mut presses: MessageReader<PointerPressed>,
131    hovered: Query<Entity, With<Hovered>>,
132    panels: Query<(Entity, &PopupPanel)>,
133    headers: Query<&DropdownButton>,
134    parents: Query<&ChildOf>,
135    mut store: ResMut<PropertyStore>,
136) {
137    let mut primary_press = false;
138    for press in presses.read() {
139        if matches!(press.button, PointerButton::Primary) {
140            primary_press = true;
141        }
142    }
143    if !primary_press {
144        return;
145    }
146    // At most one entity is hovered; `None` means the cursor is over
147    // empty space (a genuine outside click).
148    let target = hovered.iter().next();
149    for (panel_e, panel) in &panels {
150        if store.get_global_bool(&panel.open_signal) != Some(true) {
151            continue;
152        }
153        let inside = target
154            .map(|t| press_hits_popup(t, panel_e, &panel.open_signal, &headers, &parents))
155            .unwrap_or(false);
156        if !inside {
157            store.set_global_bool(&panel.open_signal, false);
158        }
159    }
160}
161
162/// Walk `target`'s ancestor chain; the press counts as inside the popup
163/// when it reaches the panel entity (its subtree) or a dropdown header
164/// bound to the same open signal (the trigger).
165fn press_hits_popup(
166    target: Entity,
167    panel_e: Entity,
168    open_signal: &str,
169    headers: &Query<&DropdownButton>,
170    parents: &Query<&ChildOf>,
171) -> bool {
172    let mut cur = Some(target);
173    while let Some(e) = cur {
174        if e == panel_e {
175            return true;
176        }
177        if headers
178            .get(e)
179            .map(|h| h.open_signal == open_signal)
180            .unwrap_or(false)
181        {
182            return true;
183        }
184        cur = parents.get(e).ok().map(|c| c.parent());
185    }
186    false
187}
188
189/// Anchor each open dropdown panel below its trigger header, flipping it
190/// above when the panel's bottom edge would fall past the viewport
191/// bottom. Runs after the first layout pass has measured the panel
192/// (mirrors the tooltip's spawn-then-measure ordering).
193///
194/// The panel is `position: absolute` inside its `<if>` wrapper (its
195/// containing block), so the viewport-space origin from
196/// [`anchored_popup_origin`] is converted to a local `inset.top` against
197/// the containing block; `inset.bottom` is released to `auto` (`NaN`) so
198/// the panel sizes to its option list in both orientations. Menu panels
199/// have no queryable trigger and are left untouched here - their
200/// dismissal still works via [`dismiss_popups_on_outside_press`].
201///
202/// The placement is held in [`StyleOverrides`] while the panel is open, so
203/// a restyle meanwhile keeps it, and let go of on close.
204pub fn flip_open_dropdown_panels(
205    viewport: Option<Res<Viewport>>,
206    store: Res<PropertyStore>,
207    headers: Query<(&DropdownButton, &Transform, Option<&PopupGap>)>,
208    parents: Query<&ChildOf>,
209    transforms: Query<&Transform>,
210    mut panels: Query<(Entity, &PopupPanel, &mut Style, &mut StyleOverrides)>,
211) {
212    let Some(viewport) = viewport else {
213        return;
214    };
215    let vp = viewport.size;
216    for (panel_e, panel, mut style, mut overrides) in &mut panels {
217        let open = store.get_global_bool(&panel.open_signal) == Some(true);
218        let placed = overrides.held_by::<PopupPanel>().is_some();
219        if !open {
220            // Back to where its styles place it, once, on close.
221            if placed {
222                overrides.release::<PopupPanel>().write_style(&mut style);
223            }
224            continue;
225        }
226        // Anchor to the dropdown header bound to the same open signal.
227        let Some((_, header_t, gap)) = headers
228            .iter()
229            .find(|(h, _, _)| h.open_signal == panel.open_signal)
230        else {
231            continue;
232        };
233        // Where the styles place it; only the vertical edges move.
234        let resting = overrides
235            .below::<PopupPanel>(
236                StyleFields::INSET,
237                &Live {
238                    style: Some(&style),
239                    ..Live::default()
240                },
241            )
242            .inset
243            .unwrap_or(style.inset);
244        let place = |top: f32| StyleValues {
245            inset: Some(lumen_core::components::Edges {
246                top,
247                bottom: f32::NAN,
248                ..resting
249            }),
250            ..StyleValues::default()
251        };
252        // Release the panel to content height so layout can measure it,
253        // then position precisely once it has a size.
254        if !placed {
255            let live = Live {
256                style: Some(&style),
257                ..Live::default()
258            };
259            let out = overrides.hold::<PopupPanel>(place(resting.top), &live);
260            out.write_style(&mut style);
261        }
262        let Ok(panel_t) = transforms.get(panel_e) else {
263            continue;
264        };
265        if panel_t.size.y <= 0.0 {
266            continue;
267        }
268        let Ok(cb) = parents.get(panel_e) else {
269            continue;
270        };
271        let Ok(cb_t) = transforms.get(cb.parent()) else {
272            continue;
273        };
274        let origin = anchored_popup_origin(
275            header_t.absolute,
276            header_t.size,
277            panel_t.size,
278            vp,
279            gap.copied().unwrap_or_default().0,
280            PopupSide::Below,
281        );
282        let local_top = origin.y - cb_t.absolute.y;
283        if !edge_eq(style.inset.top, local_top) {
284            let live = Live {
285                style: Some(&style),
286                ..Live::default()
287            };
288            let out = overrides.hold::<PopupPanel>(place(local_top), &live);
289            out.write_style(&mut style);
290        }
291    }
292}
293
294/// Inset comparison that treats `NaN` (`auto`) as equal to itself so the
295/// flip system doesn't rewrite (and needlessly re-lay-out) an unchanged
296/// `auto` edge every tick.
297fn edge_eq(a: f32, b: f32) -> bool {
298    if a.is_nan() && b.is_nan() {
299        return true;
300    }
301    (a - b).abs() < 0.5
302}
303
304#[cfg(test)]
305mod tests {
306    use super::*;
307
308    #[test]
309    fn below_placement_prefers_under_anchor() {
310        // Anchor at (10, 10), 100x30. Roomy viewport -> sits below.
311        let o = anchored_popup_origin(
312            Vec2::new(10.0, 10.0),
313            Vec2::new(100.0, 30.0),
314            Vec2::new(100.0, 80.0),
315            Vec2::new(500.0, 500.0),
316            4.0,
317            PopupSide::Below,
318        );
319        assert_eq!(o.x, 10.0, "aligns to anchor left");
320        assert_eq!(o.y, 44.0, "10 + 30 + 4 gap");
321    }
322
323    #[test]
324    fn below_placement_flips_above_near_viewport_bottom() {
325        // Anchor near the bottom edge; an 80-tall panel below would
326        // overflow, so it flips above the anchor.
327        let anchor_y = 460.0;
328        let o = anchored_popup_origin(
329            Vec2::new(10.0, anchor_y),
330            Vec2::new(100.0, 30.0),
331            Vec2::new(100.0, 80.0),
332            Vec2::new(500.0, 500.0),
333            4.0,
334            PopupSide::Below,
335        );
336        // 460 + 30 + 4 + 80 = 574 > 500 -> flip: anchor_y - gap - height.
337        assert_eq!(o.y, anchor_y - 4.0 - 80.0);
338        assert!(o.y < anchor_y, "panel now sits above the trigger");
339    }
340
341    #[test]
342    fn right_placement_flips_left_near_viewport_right() {
343        let anchor_x = 470.0;
344        let o = anchored_popup_origin(
345            Vec2::new(anchor_x, 10.0),
346            Vec2::new(20.0, 20.0),
347            Vec2::new(120.0, 40.0),
348            Vec2::new(500.0, 500.0),
349            8.0,
350            PopupSide::Right,
351        );
352        // 470 + 20 + 8 + 120 = 618 > 500 -> flip left.
353        assert_eq!(o.x, anchor_x - 8.0 - 120.0);
354    }
355
356    #[test]
357    fn edge_eq_treats_nan_as_equal() {
358        assert!(edge_eq(f32::NAN, f32::NAN));
359        assert!(!edge_eq(f32::NAN, 4.0));
360        assert!(edge_eq(4.0, 4.2));
361        assert!(!edge_eq(4.0, 40.0));
362    }
363
364    use bevy_ecs::message::Messages;
365    use bevy_ecs::system::RunSystemOnce;
366    use bevy_ecs::world::World;
367
368    fn open_panel_world() -> (World, Entity) {
369        let mut world = World::new();
370        world.init_resource::<Messages<PointerPressed>>();
371        world.insert_resource(PropertyStore::default());
372        world
373            .resource_mut::<PropertyStore>()
374            .set_global_bool("__dropdown_open:fruit", true);
375        let panel = world
376            .spawn(PopupPanel {
377                open_signal: "__dropdown_open:fruit".to_string(),
378            })
379            .id();
380        (world, panel)
381    }
382
383    fn press_primary(world: &mut World) {
384        world
385            .resource_mut::<Messages<PointerPressed>>()
386            .write(PointerPressed {
387                position: Vec2::ZERO,
388                button: PointerButton::Primary,
389                local: None,
390            });
391    }
392
393    fn is_open(world: &World) -> bool {
394        world
395            .resource::<PropertyStore>()
396            .get_global_bool("__dropdown_open:fruit")
397            == Some(true)
398    }
399
400    #[test]
401    fn outside_press_closes_panel() {
402        let (mut world, _panel) = open_panel_world();
403        // Cursor over an unrelated entity - outside the panel + trigger.
404        world.spawn(Hovered);
405        press_primary(&mut world);
406        world
407            .run_system_once(dismiss_popups_on_outside_press)
408            .unwrap();
409        assert!(!is_open(&world), "press outside the panel closes it");
410    }
411
412    #[test]
413    fn press_inside_panel_keeps_it_open() {
414        let (mut world, panel) = open_panel_world();
415        // Cursor over the panel entity itself (its subtree).
416        world.entity_mut(panel).insert(Hovered);
417        press_primary(&mut world);
418        world
419            .run_system_once(dismiss_popups_on_outside_press)
420            .unwrap();
421        assert!(is_open(&world), "press inside the panel leaves it open");
422    }
423
424    #[test]
425    fn press_on_trigger_keeps_it_open() {
426        let (mut world, _panel) = open_panel_world();
427        // Cursor over the dropdown header (the trigger) - the header's
428        // own toggle owns close-on-reclick, so dismissal must stand down.
429        world.spawn((DropdownButton::new("__dropdown_open:fruit"), Hovered));
430        press_primary(&mut world);
431        world
432            .run_system_once(dismiss_popups_on_outside_press)
433            .unwrap();
434        assert!(is_open(&world), "press on the trigger leaves it open");
435    }
436
437    /// Build a dropdown (header + containing block + panel) whose header
438    /// sits `header_y` px down a 500 px-tall viewport, then run the flip
439    /// system once and return the panel's resulting `inset.top`. No
440    /// [`PopupGap`] on the header - the no-CSS-authored path.
441    fn run_flip(header_y: f32) -> f32 {
442        run_flip_with_gap(header_y, None)
443    }
444
445    /// The component-based counterpart of [`run_flip`]: same shape, but
446    /// `gap` optionally inserts a [`PopupGap`] on the trigger header, to
447    /// prove a CSS-supplied gap changes the panel's anchored position.
448    fn run_flip_with_gap(header_y: f32, gap: Option<f32>) -> f32 {
449        let mut world = World::new();
450        world.insert_resource(Viewport {
451            size: Vec2::new(500.0, 500.0),
452            ..Viewport::default()
453        });
454        world.insert_resource(PropertyStore::default());
455        world
456            .resource_mut::<PropertyStore>()
457            .set_global_bool("__dropdown_open:fruit", true);
458
459        let mut header = world.spawn((
460            DropdownButton::new("__dropdown_open:fruit"),
461            Transform::new(Vec2::new(10.0, header_y), Vec2::new(100.0, 30.0)),
462        ));
463        if let Some(gap) = gap {
464            header.insert(PopupGap(gap));
465        }
466        // Containing block (`<if>` wrapper) sits just below the header.
467        let cb = world
468            .spawn(Transform::new(
469                Vec2::new(10.0, header_y + 30.0),
470                Vec2::new(100.0, 0.0),
471            ))
472            .id();
473        let panel = world
474            .spawn((
475                PopupPanel {
476                    open_signal: "__dropdown_open:fruit".to_string(),
477                },
478                Style::default(),
479                // Pre-measured 80 px-tall panel (layout would supply this).
480                Transform::new(Vec2::new(10.0, header_y + 34.0), Vec2::new(100.0, 80.0)),
481                ChildOf(cb),
482            ))
483            .id();
484
485        world.run_system_once(flip_open_dropdown_panels).unwrap();
486        world.get::<Style>(panel).unwrap().inset.top
487    }
488
489    #[test]
490    fn dropdown_panel_sits_below_when_room() {
491        // Header near the top: 10 + 30 + 4 gap = 44 px below viewport
492        // top; 44 + 80 = 124 < 500 -> no flip. Local top = 44 - cb.y(40).
493        let top = run_flip(10.0);
494        assert!(top > 0.0, "panel below the trigger, got top={top}");
495        assert!((top - 4.0).abs() < 0.5, "4 px gap below the header");
496    }
497
498    /// No [`PopupGap`] on the trigger must keep today's 4 px gap - the
499    /// explicit no-component counterpart of
500    /// [`custom_popup_gap_moves_the_panel`].
501    #[test]
502    fn no_popup_gap_keeps_the_default_gap() {
503        let top = run_flip_with_gap(10.0, None);
504        assert!((top - 4.0).abs() < 0.5, "no PopupGap -> today's 4 px gap");
505    }
506
507    /// A CSS-supplied [`PopupGap`] on the trigger changes the panel's
508    /// anchored offset.
509    #[test]
510    fn custom_popup_gap_moves_the_panel() {
511        let top = run_flip_with_gap(10.0, Some(20.0));
512        assert!(
513            (top - 20.0).abs() < 0.5,
514            "a CSS-supplied popup-gap moves the panel off the 4 px default (got {top})"
515        );
516    }
517
518    #[test]
519    fn dropdown_panel_flips_above_near_viewport_bottom() {
520        // Header near the bottom: 460 + 30 + 4 + 80 = 574 > 500 -> flip.
521        // Panel is anchored above, so its local top goes negative.
522        let top = run_flip(460.0);
523        assert!(top < 0.0, "panel flips above the trigger, got top={top}");
524    }
525}