use crate::harness;
use std::cell::RefCell;
use std::rc::Rc;
use std::time::Duration;
use gpui::{
point, prelude::*, px, ElementId, Modifiers, MouseButton, ScrollDelta, ScrollWheelEvent,
TestAppContext, VisualTestContext,
};
use harness::{click, events, open_host, press, Events};
use herogpui_components::{
Button, Drawer, DrawerPlacement, Modal, ModalPlacement, ModalScroll, ModalSize, Placement,
Popover, PopoverArrow,
};
fn still() {
harness::still();
}
fn flush_frame(cx: &mut VisualTestContext) {
cx.update(|window, _| window.refresh());
}
fn let_exit_finish(cx: &mut VisualTestContext) {
cx.executor().advance_clock(Duration::from_millis(300));
flush_frame(cx);
}
fn drag(cx: &mut VisualTestContext, from: (f32, f32), to: (f32, f32)) {
let modifiers = Modifiers::none();
cx.simulate_mouse_down(point(px(from.0), px(from.1)), MouseButton::Left, modifiers);
cx.simulate_mouse_move(point(px(to.0), px(to.1)), MouseButton::Left, modifiers);
cx.simulate_mouse_up(point(px(to.0), px(to.1)), MouseButton::Left, modifiers);
}
fn slow_drag(cx: &mut VisualTestContext, from: (f32, f32), to: (f32, f32)) {
let modifiers = Modifiers::none();
cx.simulate_mouse_down(point(px(from.0), px(from.1)), MouseButton::Left, modifiers);
std::thread::sleep(Duration::from_millis(100));
cx.simulate_mouse_move(point(px(to.0), px(to.1)), MouseButton::Left, modifiers);
std::thread::sleep(Duration::from_millis(100));
cx.simulate_mouse_up(point(px(to.0), px(to.1)), MouseButton::Left, modifiers);
}
fn wheel(cx: &mut VisualTestContext, x: f32, y: f32, dy: f32) {
cx.simulate_event(ScrollWheelEvent {
position: point(px(x), px(y)),
delta: ScrollDelta::Pixels(point(px(0.), px(dy))),
..Default::default()
});
flush_frame(cx);
}
fn probe(id: impl Into<ElementId>, label: &'static str, recorded: Events) -> gpui::AnyElement {
gpui::div()
.id(id)
.w(px(40.))
.h(px(36.))
.flex()
.items_center()
.justify_center()
.cursor_pointer()
.on_click(move |_, _, _| recorded.borrow_mut().push(label.to_owned()))
.child(label)
.into_any_element()
}
#[gpui::test]
fn drawer_right_placement_lands_on_edge_and_drags_shut(cx: &mut TestAppContext) {
still();
let rec = events();
let recorded = rec.clone();
let hits = events();
let probed = hits.clone();
let open = Rc::new(RefCell::new(true));
let open_flag = open.clone();
let cx = open_host(cx, move || {
let rec = rec.clone();
let hits = hits.clone();
let is_open = *open_flag.borrow();
Drawer::new()
.id("pl-drawer-right")
.is_open(is_open)
.placement(DrawerPlacement::Right)
.title("Drag me shut")
.child(probe("pl-drawer-right-probe", "hit", hits))
.on_open_change({
let open_flag = open_flag.clone();
move |v, window, _| {
*open_flag.borrow_mut() = *v;
rec.borrow_mut().push(format!("open:{v}"));
window.refresh();
}
})
.into_any_element()
});
click(cx, 1580., 86.);
assert_eq!(
probed.borrow().as_slice(),
["hit"],
"the body probe must be reachable"
);
assert!(
recorded.borrow().is_empty(),
"a press inside the panel is not a backdrop press"
);
press(cx, "escape");
assert_eq!(
recorded.borrow().as_slice(),
["open:false"],
"escape must dismiss the drawer"
);
let_exit_finish(cx);
click(cx, 1580., 86.);
assert_eq!(
probed.borrow().as_slice(),
["hit"],
"the panel must be gone after the exit: the probe spot records nothing"
);
*open.borrow_mut() = true;
flush_frame(cx);
slow_drag(cx, (1728., 48.), (1768., 48.));
assert_eq!(
recorded.borrow().as_slice(),
["open:false"],
"a sub-threshold pull must spring back without dismissing; recorded: {:?}",
recorded.borrow().as_slice()
);
click(cx, 1580., 86.);
assert_eq!(
probed.borrow().as_slice(),
["hit", "hit"],
"the drawer must still be open after the spring-back"
);
*open.borrow_mut() = true;
flush_frame(cx);
drag(cx, (1728., 48.), (1860., 48.));
assert_eq!(
recorded.borrow().as_slice(),
["open:false", "open:false"],
"the far pull must report the close exactly once"
);
let_exit_finish(cx);
click(cx, 1580., 86.);
assert_eq!(
probed.borrow().as_slice(),
["hit", "hit"],
"the panel must be gone after the far pull"
);
}
#[gpui::test]
fn drawer_left_placement_lands_on_edge_and_drags_shut(cx: &mut TestAppContext) {
still();
let rec = events();
let recorded = rec.clone();
let hits = events();
let probed = hits.clone();
let open = Rc::new(RefCell::new(true));
let open_flag = open.clone();
let cx = open_host(cx, move || {
let rec = rec.clone();
let hits = hits.clone();
let is_open = *open_flag.borrow();
Drawer::new()
.id("pl-drawer-left")
.is_open(is_open)
.placement(DrawerPlacement::Left)
.title("Drag me shut")
.child(probe("pl-drawer-left-probe", "hit", hits))
.on_open_change({
let open_flag = open_flag.clone();
move |v, window, _| {
*open_flag.borrow_mut() = *v;
rec.borrow_mut().push(format!("open:{v}"));
window.refresh();
}
})
.into_any_element()
});
click(cx, 44., 86.);
assert_eq!(
probed.borrow().as_slice(),
["hit"],
"the body probe must be reachable"
);
assert!(
recorded.borrow().is_empty(),
"an inside press must not dismiss"
);
press(cx, "escape");
assert_eq!(
recorded.borrow().as_slice(),
["open:false"],
"escape must dismiss the drawer"
);
let_exit_finish(cx);
click(cx, 44., 86.);
assert_eq!(
probed.borrow().as_slice(),
["hit"],
"the panel must be gone"
);
*open.borrow_mut() = true;
flush_frame(cx);
slow_drag(cx, (192., 48.), (152., 48.));
assert_eq!(
recorded.borrow().as_slice(),
["open:false"],
"a sub-threshold pull must spring back without dismissing; recorded: {:?}",
recorded.borrow().as_slice()
);
click(cx, 44., 86.);
assert_eq!(
probed.borrow().as_slice(),
["hit", "hit"],
"the drawer must still be open after the spring-back"
);
*open.borrow_mut() = true;
flush_frame(cx);
drag(cx, (192., 48.), (60., 48.));
assert_eq!(
recorded.borrow().as_slice(),
["open:false", "open:false"],
"the far pull must report the close exactly once"
);
let_exit_finish(cx);
click(cx, 44., 86.);
assert_eq!(
probed.borrow().as_slice(),
["hit", "hit"],
"the panel must be gone"
);
}
#[gpui::test]
fn drawer_top_placement_lands_on_edge_and_maximal_pull_dismisses(cx: &mut TestAppContext) {
still();
let rec = events();
let recorded = rec.clone();
let hits = events();
let probed = hits.clone();
let open = Rc::new(RefCell::new(true));
let open_flag = open.clone();
let cx = open_host(cx, move || {
let rec = rec.clone();
let hits = hits.clone();
let is_open = *open_flag.borrow();
Drawer::new()
.id("pl-drawer-top")
.is_open(is_open)
.placement(DrawerPlacement::Top)
.title("Drag me shut")
.child(probe("pl-drawer-top-probe", "hit", hits))
.on_open_change({
let open_flag = open_flag.clone();
move |v, window, _| {
*open_flag.borrow_mut() = *v;
rec.borrow_mut().push(format!("open:{v}"));
window.refresh();
}
})
.into_any_element()
});
click(cx, 44., 86.);
assert_eq!(
probed.borrow().as_slice(),
["hit"],
"the body probe must be reachable"
);
assert!(
recorded.borrow().is_empty(),
"an inside press must not dismiss"
);
press(cx, "escape");
assert_eq!(
recorded.borrow().as_slice(),
["open:false"],
"escape must dismiss the drawer"
);
let_exit_finish(cx);
click(cx, 44., 86.);
assert_eq!(
probed.borrow().as_slice(),
["hit"],
"the panel must be gone"
);
*open.borrow_mut() = true;
flush_frame(cx);
drag(cx, (960., 48.), (960., 0.));
assert_eq!(
recorded.borrow().as_slice(),
["open:false", "open:false"],
"the top-edge flick must add exactly one dismissal; recorded: {:?}",
recorded.borrow().as_slice()
);
let_exit_finish(cx);
click(cx, 44., 86.);
assert_eq!(
probed.borrow().as_slice(),
["hit"],
"the top drawer must be gone after the captured flick"
);
}
#[gpui::test]
fn drawer_bottom_placement_lands_on_edge_and_drags_shut(cx: &mut TestAppContext) {
still();
let rec = events();
let recorded = rec.clone();
let hits = events();
let probed = hits.clone();
let open = Rc::new(RefCell::new(true));
let open_flag = open.clone();
let cx = open_host(cx, move || {
let rec = rec.clone();
let hits = hits.clone();
let is_open = *open_flag.borrow();
Drawer::new()
.id("pl-drawer-bottom")
.is_open(is_open)
.placement(DrawerPlacement::Bottom)
.title("Drag me shut")
.child(probe("pl-drawer-bottom-probe", "hit", hits))
.on_open_change({
let open_flag = open_flag.clone();
move |v, window, _| {
*open_flag.borrow_mut() = *v;
rec.borrow_mut().push(format!("open:{v}"));
window.refresh();
}
})
.into_any_element()
});
click(cx, 44., 1038.);
assert_eq!(
probed.borrow().as_slice(),
["hit"],
"the body probe must be reachable"
);
assert!(
recorded.borrow().is_empty(),
"an inside press must not dismiss"
);
press(cx, "escape");
assert_eq!(
recorded.borrow().as_slice(),
["open:false"],
"escape must dismiss the drawer"
);
let_exit_finish(cx);
click(cx, 44., 1038.);
assert_eq!(
probed.borrow().as_slice(),
["hit"],
"the panel must be gone"
);
*open.borrow_mut() = true;
flush_frame(cx);
slow_drag(cx, (960., 1000.), (960., 1020.));
assert_eq!(
recorded.borrow().as_slice(),
["open:false"],
"a sub-threshold pull must spring back without dismissing; recorded: {:?}",
recorded.borrow().as_slice()
);
click(cx, 44., 1038.);
assert_eq!(
probed.borrow().as_slice(),
["hit", "hit"],
"the drawer must still be open after the spring-back"
);
*open.borrow_mut() = true;
flush_frame(cx);
drag(cx, (960., 1000.), (960., 1050.));
assert_eq!(
recorded.borrow().as_slice(),
["open:false", "open:false"],
"the far pull must report the close exactly once"
);
let_exit_finish(cx);
click(cx, 44., 1038.);
assert_eq!(
probed.borrow().as_slice(),
["hit", "hit"],
"the panel must be gone"
);
}
const POP_CASES: [Placement; 22] = [
Placement::Bottom,
Placement::BottomStart,
Placement::BottomLeft,
Placement::BottomEnd,
Placement::BottomRight,
Placement::Top,
Placement::TopStart,
Placement::TopLeft,
Placement::TopEnd,
Placement::TopRight,
Placement::Left,
Placement::LeftTop,
Placement::LeftBottom,
Placement::Right,
Placement::RightTop,
Placement::RightBottom,
Placement::Start,
Placement::StartTop,
Placement::StartBottom,
Placement::End,
Placement::EndTop,
Placement::EndBottom,
];
#[gpui::test]
fn popover_every_placement_opens_from_its_trigger_and_dismisses(cx: &mut TestAppContext) {
for placement in &POP_CASES {
still();
let closes = events();
let recorded = closes.clone();
let cx = open_host(cx, move || {
let recorded = recorded.clone();
Popover::new(Button::new("pl-pop-trigger").label("Go"))
.id(gpui::SharedString::from(format!("pl-pop-{placement:?}")))
.placement(*placement)
.child(gpui::div().w(px(200.)).h(px(60.)).child("panel"))
.on_open_change(move |v, window, _| {
recorded.borrow_mut().push(format!("open:{v}"));
window.refresh();
})
.into_any_element()
});
click(cx, 20., 18.);
flush_frame(cx);
assert_eq!(
closes.borrow().as_slice(),
["open:true"],
"{placement:?}: the trigger must open the popover"
);
press(cx, "escape");
flush_frame(cx);
assert_eq!(
closes.borrow().as_slice(),
["open:true", "open:false"],
"{placement:?}: an open popover must answer Escape exactly once"
);
}
}
#[gpui::test]
fn popover_should_flip_moves_an_overflowing_bottom_panel_above(cx: &mut TestAppContext) {
still();
let pressed = events();
let recorded = pressed.clone();
let cx = open_host(cx, move || {
let pressed = pressed.clone();
gpui::div()
.mt(px(1000.))
.child(
Popover::new(gpui::div().w(px(100.)).h(px(36.)).child("Trigger"))
.id("pl-pop-flip")
.default_open(true)
.placement(Placement::Bottom)
.should_flip(true)
.show_close_button(false)
.child(PopoverArrow::new())
.child(
gpui::div()
.id("pl-pop-flip-probe")
.debug_selector(|| "pl-pop-flip-probe".to_owned())
.w(px(40.))
.h(px(36.))
.on_click(move |_, _, _| {
pressed.borrow_mut().push("flipped".to_owned());
}),
),
)
.into_any_element()
});
flush_frame(cx);
let bounds = cx
.debug_bounds("pl-pop-flip-probe")
.expect("open popover probe must render");
let arrow = cx
.debug_bounds("popover-arrow")
.expect("Popover.Arrow must render on the resolved side");
assert_eq!(
(arrow.origin.y, arrow.bottom()),
(px(980.), px(992.)),
"the arrow must follow the resolved top side; got {arrow:?}"
);
click(cx, 36., 958.);
assert_eq!(
recorded.borrow().as_slice(),
["flipped"],
"shouldFlip must mirror an overflowing bottom panel above its trigger; got {bounds:?}"
);
}
#[gpui::test]
fn popover_should_flip_false_keeps_the_requested_overflowing_side(cx: &mut TestAppContext) {
still();
let cx = open_host(cx, move || {
gpui::div()
.mt(px(1000.))
.child(
Popover::new(gpui::div().w(px(100.)).h(px(36.)).child("Trigger"))
.id("pl-pop-no-flip")
.default_open(true)
.placement(Placement::Bottom)
.should_flip(false)
.show_close_button(false)
.child(
gpui::div()
.id("pl-pop-no-flip-probe")
.debug_selector(|| "pl-pop-no-flip-probe".to_owned())
.w(px(40.))
.h(px(36.)),
),
)
.into_any_element()
});
flush_frame(cx);
let bounds = cx
.debug_bounds("pl-pop-no-flip-probe")
.expect("open popover probe must render");
assert!(
f32::from(bounds.origin.y) >= 1060.,
"shouldFlip=false must preserve the requested bottom orientation; got {bounds:?}"
);
}
#[gpui::test]
fn popover_without_the_arrow_part_renders_no_arrow(cx: &mut TestAppContext) {
still();
let cx = open_host(cx, || {
Popover::new(gpui::div().w(px(100.)).h(px(36.)).child("Trigger"))
.id("pl-pop-no-arrow")
.default_open(true)
.placement(Placement::Bottom)
.show_close_button(false)
.child(
gpui::div()
.id("pl-pop-no-arrow-probe")
.debug_selector(|| "pl-pop-no-arrow-probe".to_owned())
.w(px(40.))
.h(px(36.)),
)
.into_any_element()
});
flush_frame(cx);
assert!(
cx.debug_bounds("pl-pop-no-arrow-probe").is_some(),
"the open panel must render"
);
assert!(
cx.debug_bounds("popover-arrow").is_none(),
"omitting the Popover.Arrow part must render no arrow"
);
}
#[gpui::test]
fn popover_arrow_part_renders_custom_arrow_content(cx: &mut TestAppContext) {
still();
let cx = open_host(cx, || {
Popover::new(gpui::div().w(px(100.)).h(px(36.)).child("Trigger"))
.id("pl-pop-custom-arrow")
.default_open(true)
.placement(Placement::Bottom)
.show_close_button(false)
.child(
PopoverArrow::new().child(
gpui::div()
.id("pl-pop-custom-arrow-tip")
.debug_selector(|| "pl-pop-custom-arrow-tip".to_owned())
.w(px(12.))
.h(px(12.)),
),
)
.child(
gpui::div()
.id("pl-pop-custom-arrow-probe")
.w(px(40.))
.h(px(36.)),
)
.into_any_element()
});
flush_frame(cx);
let tip = cx
.debug_bounds("pl-pop-custom-arrow-tip")
.expect("a composed Popover.Arrow with custom content must render");
assert_eq!(
(tip.origin.x, tip.origin.y),
(px(44.), px(44.)),
"the custom arrow element must take the resolved arrow position; got {tip:?}"
);
}
#[gpui::test]
fn popover_arrow_nested_in_another_child_renders_no_arrow(cx: &mut TestAppContext) {
still();
let cx = open_host(cx, || {
Popover::new(gpui::div().w(px(100.)).h(px(36.)).child("Trigger"))
.id("pl-pop-nested-arrow")
.default_open(true)
.placement(Placement::Bottom)
.show_close_button(false)
.child(
gpui::div()
.debug_selector(|| "pl-pop-nested-arrow-wrap".to_owned())
.child(PopoverArrow::new()),
)
.child(
gpui::div()
.id("pl-pop-nested-arrow-probe")
.debug_selector(|| "pl-pop-nested-arrow-probe".to_owned())
.w(px(40.))
.h(px(36.)),
)
.into_any_element()
});
flush_frame(cx);
assert!(
cx.debug_bounds("pl-pop-nested-arrow-wrap").is_some(),
"the wrapper and the open panel must render"
);
assert!(
cx.debug_bounds("pl-pop-nested-arrow-probe").is_some(),
"the open panel must render"
);
assert!(
cx.debug_bounds("popover-arrow").is_none(),
"a Popover.Arrow that is not a direct child of Popover must render no arrow"
);
}
#[gpui::test]
fn popover_arrow_outside_a_popover_renders_nothing(cx: &mut TestAppContext) {
still();
let cx = open_host(cx, || {
gpui::div()
.flex()
.flex_col()
.child(
gpui::div()
.debug_selector(|| "pl-pop-outside-marker".to_owned())
.w(px(40.))
.h(px(36.)),
)
.child(PopoverArrow::new())
.into_any_element()
});
flush_frame(cx);
assert!(
cx.debug_bounds("pl-pop-outside-marker").is_some(),
"the host frame must render"
);
assert!(
cx.debug_bounds("popover-arrow").is_none(),
"a Popover.Arrow outside a popover must paint nothing"
);
}
#[gpui::test]
fn popover_controlled_open_answers_escape(cx: &mut TestAppContext) {
still();
let closes = events();
let recorded = closes.clone();
let cx = open_host(cx, move || {
let recorded = recorded.clone();
Popover::new(gpui::div().w(px(100.)).h(px(36.)).child("Go"))
.id("pl-pop-controlled")
.is_open(true)
.child(gpui::div().w(px(200.)).h(px(60.)).child("panel"))
.on_open_change(move |v, window, _| {
recorded.borrow_mut().push(format!("open:{v}"));
window.refresh();
})
.into_any_element()
});
press(cx, "escape");
flush_frame(cx);
assert_eq!(
closes.borrow().as_slice(),
["open:false"],
"an open popover must answer Escape however it was opened"
);
}
#[gpui::test]
fn modal_every_size_opens_and_dismisses(cx: &mut TestAppContext) {
let sizes = [
ModalSize::Xs,
ModalSize::Sm,
ModalSize::Md,
ModalSize::Lg,
ModalSize::Cover,
ModalSize::Full,
];
for (i, size) in sizes.iter().enumerate() {
still();
let closes = events();
let recorded = closes.clone();
let open = Rc::new(RefCell::new(false));
let open_for_view = open.clone();
let size = *size;
let cx = open_host(cx, move || {
let is_open = *open_for_view.borrow();
let recorded = recorded.clone();
let held = open_for_view.clone();
Modal::new()
.id(gpui::SharedString::from(format!("pl-modal-{i}")))
.is_open(is_open)
.size(size)
.child(gpui::div().w(px(120.)).h(px(40.)).child("body"))
.on_open_change(move |v, window, _| {
*held.borrow_mut() = *v;
recorded.borrow_mut().push(format!("open:{v}"));
window.refresh();
})
.into_any_element()
});
*open.borrow_mut() = true;
flush_frame(cx);
press(cx, "escape");
flush_frame(cx);
assert_eq!(
closes.borrow().as_slice(),
["open:false"],
"{size:?}: an open modal must answer Escape exactly once"
);
assert!(
!*open.borrow(),
"{size:?}: the dismissal must reach the caller's flag"
);
}
}
#[gpui::test]
fn modal_full_covers_every_viewport_edge_without_backdrop_dismissal(cx: &mut TestAppContext) {
still();
let closes = events();
let recorded = closes.clone();
let cx = open_host(cx, move || {
let recorded = recorded.clone();
Modal::new()
.id("pl-modal-full-edges")
.is_open(true)
.is_dismissible(true)
.size(ModalSize::Full)
.child(gpui::div().child("body"))
.on_open_change(move |v, _, _| recorded.borrow_mut().push(format!("open:{v}")))
.into_any_element()
});
for (x, y) in [(8., 8.), (1912., 8.), (8., 1072.), (1912., 1072.)] {
click(cx, x, y);
}
press(cx, "escape");
assert_eq!(
closes.borrow().as_slice(),
["open:false"],
"no Full corner is dismissible backdrop, while Escape still proves the dialog is live"
);
}
#[gpui::test]
fn modal_long_body_scrolls_to_reach_the_deepest_control(cx: &mut TestAppContext) {
for with_chrome in [false, true] {
still();
let hits = events();
let probed = hits.clone();
let closes = events();
let recorded = closes.clone();
let open = Rc::new(RefCell::new(true));
let open_flag = open;
let cx = open_host(cx, move || {
let probed = hits.clone();
let recorded = closes.clone();
let is_open = *open_flag.borrow();
let mut body = gpui::div().flex().flex_col().gap(px(10.));
for i in 0..24 {
let label = format!("p{i}");
let click_label = label.clone();
let recorded = probed.clone();
body = body.child(
gpui::div()
.id(gpui::SharedString::from(format!("pl-scroll-probe-{i}")))
.debug_selector(move || format!("pl-scroll-probe-{i}"))
.w_full()
.h(px(36.))
.flex()
.items_center()
.justify_center()
.cursor_pointer()
.on_click(move |_, _, _| recorded.borrow_mut().push(click_label.clone()))
.child(label),
);
}
Modal::new()
.id("pl-modal-scroll")
.is_open(is_open)
.when(with_chrome, |modal| {
modal.title("Long dialog").footer_child(
gpui::div()
.h(px(36.))
.debug_selector(|| "pl-scroll-footer".to_owned())
.child("Footer"),
)
})
.child(body)
.on_open_change({
let open_flag = open_flag.clone();
move |v, window, _| {
*open_flag.borrow_mut() = *v;
recorded.borrow_mut().push(format!("open:{v}"));
window.refresh();
}
})
.into_any_element()
});
let first = cx
.debug_bounds("pl-scroll-probe-0")
.expect("first row paints");
assert!(
first.origin.y >= px(64.),
"40px scrim plus 24px panel padding: {first:?}"
);
click(cx, f32::from(first.center().x), f32::from(first.center().y));
assert!(probed.borrow().contains(&"p0".to_owned()));
let footer = cx.debug_bounds("pl-scroll-footer");
assert_eq!(footer.is_some(), with_chrome, "requested footer must paint");
for _ in 0..6 {
wheel(cx, 960., 500., -1000.);
}
let last = cx
.debug_bounds("pl-scroll-probe-23")
.expect("last row paints");
let viewport_height = cx.update(|window, _| window.viewport_size().height);
assert!(
last.bottom() <= viewport_height - px(64.),
"last row fits above padding and scrim: {last:?}"
);
assert_eq!(
cx.debug_bounds("pl-scroll-footer"),
footer,
"footer must not scroll with the body"
);
if let Some(footer) = footer {
assert!(
last.bottom() <= footer.origin.y,
"deepest row stays above the footer"
);
assert!(footer.bottom() <= viewport_height - px(64.));
}
click(cx, f32::from(last.center().x), f32::from(last.center().y));
assert!(
recorded.borrow().is_empty(),
"body presses must not dismiss"
);
assert!(
probed.borrow().iter().any(|hit| hit == "p23"),
"deepest row responds after scrolling"
);
}
}
#[gpui::test]
fn modal_cover_inside_scroll_reaches_the_deepest_control(cx: &mut TestAppContext) {
still();
let hits = events();
let probed = hits.clone();
let closes = events();
let recorded = closes.clone();
let cx = open_host(cx, move || {
let recorded = recorded.clone();
let mut body = gpui::div().flex().flex_col().gap(px(10.));
for i in 0..24 {
let label = format!("p{i}");
let click_label = label.clone();
let hit = hits.clone();
body = body.child(
gpui::div()
.id(gpui::SharedString::from(format!("pl-cover-probe-{i}")))
.w_full()
.h(px(36.))
.flex()
.items_center()
.justify_center()
.cursor_pointer()
.on_click(move |_, _, _| hit.borrow_mut().push(click_label.clone()))
.child(label),
);
}
Modal::new()
.id("pl-modal-cover-scroll")
.is_open(true)
.is_dismissible(true)
.size(ModalSize::Cover)
.child(body)
.on_open_change(move |v, _, _| recorded.borrow_mut().push(format!("open:{v}")))
.into_any_element()
});
click(cx, 960., 82.);
assert_eq!(probed.borrow().as_slice(), ["p0"]);
for _ in 0..6 {
wheel(cx, 960., 500., -1000.);
}
let mut y = 500.;
while y < 1030. {
click(cx, 960., y);
y += 55.;
}
assert!(
closes.borrow().is_empty(),
"Cover's retained inset must not clip the Inside body or expose backdrop"
);
assert!(
probed.borrow().iter().any(|hit| hit == "p23"),
"the deepest Cover row must remain reachable; recorded: {:?}",
probed.borrow().as_slice()
);
}
#[gpui::test]
fn modal_outside_scroll_keeps_each_placement_top_reachable(cx: &mut TestAppContext) {
for placement in [
ModalPlacement::Auto,
ModalPlacement::Center,
ModalPlacement::Top,
ModalPlacement::Bottom,
] {
still();
let hits = events();
let probed = hits.clone();
let cx = open_host(cx, move || {
let mut body = gpui::div().flex().flex_col().gap(px(10.));
for i in 0..40 {
let label = format!("p{i}");
let click_label = label.clone();
let recorded = hits.clone();
body = body.child(
gpui::div()
.id(gpui::SharedString::from(format!("pl-outside-probe-{i}")))
.w_full()
.h(px(36.))
.flex()
.items_center()
.justify_center()
.cursor_pointer()
.on_click(move |_, _, _| recorded.borrow_mut().push(click_label.clone()))
.child(label),
);
}
Modal::new()
.id("pl-modal-outside")
.is_open(true)
.placement(placement)
.scroll(ModalScroll::Outside)
.child(body)
.into_any_element()
});
click(cx, 960., 82.);
assert_eq!(
probed.borrow().as_slice(),
["p0"],
"{placement:?}: the first row of a tall Outside dialog must remain reachable"
);
}
}
#[gpui::test]
fn modal_full_outside_scroll_keeps_deep_content_reachable(cx: &mut TestAppContext) {
still();
let hits = events();
let probed = hits.clone();
let closes = events();
let recorded = closes.clone();
let cx = open_host(cx, move || {
let recorded = recorded.clone();
let mut body = gpui::div().flex().flex_col().gap(px(10.));
for i in 0..40 {
let label = format!("p{i}");
let click_label = label.clone();
let recorded = hits.clone();
body = body.child(
gpui::div()
.id(gpui::SharedString::from(format!(
"pl-full-outside-probe-{i}"
)))
.w_full()
.h(px(36.))
.flex()
.items_center()
.justify_center()
.cursor_pointer()
.on_click(move |_, _, _| recorded.borrow_mut().push(click_label.clone()))
.child(label),
);
}
Modal::new()
.id("pl-modal-full-outside")
.is_open(true)
.is_dismissible(true)
.size(ModalSize::Full)
.scroll(ModalScroll::Outside)
.child(body)
.on_open_change(move |v, _, _| recorded.borrow_mut().push(format!("open:{v}")))
.into_any_element()
});
click(cx, 960., 42.);
assert_eq!(probed.borrow().as_slice(), ["p0"]);
for _ in 0..12 {
wheel(cx, 960., 500., -1000.);
}
let mut y = 500.;
while y < 1060. {
click(cx, 960., y);
y += 55.;
}
assert!(
closes.borrow().is_empty(),
"Full Outside content must enlarge the panel and scroll, not expose backdrop"
);
assert!(
probed.borrow().iter().any(|hit| hit == "p39"),
"the deepest Full Outside row must remain reachable; recorded: {:?}",
probed.borrow().as_slice()
);
}
#[gpui::test]
fn modal_outside_scroll_preserves_each_fitting_placement(cx: &mut TestAppContext) {
for (placement, row_y) in [
(ModalPlacement::Auto, 540.),
(ModalPlacement::Center, 540.),
(ModalPlacement::Top, 82.),
(ModalPlacement::Bottom, 998.),
] {
still();
let hits = events();
let probed = hits.clone();
let cx = open_host(cx, move || {
let recorded = hits.clone();
Modal::new()
.id("pl-modal-outside-fitting")
.is_open(true)
.placement(placement)
.scroll(ModalScroll::Outside)
.child(
gpui::div()
.id("pl-outside-fitting-probe")
.w_full()
.h(px(36.))
.flex()
.items_center()
.justify_center()
.cursor_pointer()
.on_click(move |_, _, _| recorded.borrow_mut().push("hit".to_owned()))
.child("probe"),
)
.into_any_element()
});
click(cx, 960., row_y);
assert_eq!(
probed.borrow().as_slice(),
["hit"],
"{placement:?}: a fitting Outside dialog must keep its v3 placement"
);
}
}