1use 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#[derive(Component, Clone, Debug)]
34#[require(StyleOverrides)]
35pub struct PopupPanel {
36 pub open_signal: String,
38}
39
40impl StyleOwner for PopupPanel {
41 const RANK: u8 = 20;
42}
43
44#[derive(Clone, Copy, Debug, PartialEq, Eq)]
46pub enum PopupSide {
47 Below,
50 Right,
53}
54
55pub 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
105const POPUP_GAP: f32 = 4.0;
108
109#[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
120pub 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 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
162fn 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
189pub 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 if placed {
222 overrides.release::<PopupPanel>().write_style(&mut style);
223 }
224 continue;
225 }
226 let Some((_, header_t, gap)) = headers
228 .iter()
229 .find(|(h, _, _)| h.open_signal == panel.open_signal)
230 else {
231 continue;
232 };
233 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 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
294fn 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 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 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 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 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 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 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 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 fn run_flip(header_y: f32) -> f32 {
442 run_flip_with_gap(header_y, None)
443 }
444
445 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 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 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 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 #[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 #[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 let top = run_flip(460.0);
523 assert!(top < 0.0, "panel flips above the trigger, got top={top}");
524 }
525}