mod harness;
use gpui::{
point, prelude::*, Bounds, ElementId, Modifiers, MouseButton, Pixels, TestAppContext,
VisualTestContext,
};
use harness::{events, open_host, press};
use herogpui_components::CloseButton;
const PRESS_SCALE: f32 = 0.93;
const BOX: f32 = 24.;
const ICON: f32 = 16.;
const SVG_MARGIN: f32 = 2.;
fn flush_frame(cx: &mut VisualTestContext) {
cx.update(|window, _| window.refresh());
}
fn pending_frames(cx: &mut VisualTestContext) -> usize {
cx.update(|window, cx| window.simulate_next_frame(cx))
}
fn settle(cx: &mut VisualTestContext) {
for _ in 0..100 {
if pending_frames(cx) == 0 {
return;
}
std::thread::sleep(std::time::Duration::from_millis(10));
}
panic!("close button animations never settled");
}
fn probe(name: String) -> &'static str {
Box::leak(name.into_boxed_str())
}
fn root_probe(id: &'static str) -> &'static str {
probe(format!("{:?}", ElementId::from(id)))
}
fn icon_probe(id: &'static str) -> &'static str {
probe(format!("{:?}-icon", ElementId::from(id)))
}
fn near(a: impl Into<Pixels>, b: f32) -> bool {
(f32::from(a.into()) - b).abs() < 0.5
}
fn bounds(cx: &mut VisualTestContext, name: &'static str) -> Bounds<Pixels> {
cx.debug_bounds(name)
.unwrap_or_else(|| panic!("{name} must paint"))
}
fn centre(b: Bounds<Pixels>) -> gpui::Point<Pixels> {
point(
b.origin.x + b.size.width / 2.,
b.origin.y + b.size.height / 2.,
)
}
#[cfg(test)]
mod pinned_source {
fn source() -> &'static str {
include_str!("../src/close_button.rs")
.split("#[cfg(test)]")
.next()
.expect("the implementation section is always present")
}
fn code() -> String {
source()
.lines()
.filter(|line| !line.trim_start().starts_with("//"))
.collect::<Vec<_>>()
.join("\n")
}
#[test]
fn press_uses_the_pinned_0_93_scale_not_opacity() {
let code = code();
assert!(
code.contains("0.93"),
"CloseButton must apply the pinned press scale of 0.93"
);
assert!(
!code.contains("opacity(0.7)"),
"CloseButton must not substitute a dim for the pinned press scale"
);
}
#[test]
fn default_svg_uses_the_pinned_margins() {
let code = code();
assert!(
code.contains("mx(px(-2.))") && code.contains("my(px(2.))"),
"the default CloseButton glyph must take the pinned -mx-0.5 my-0.5 \
margins, representable as px(-2)/px(2)"
);
}
#[test]
fn press_scale_is_instant_and_enabled_only() {
let src = source();
assert!(
src.contains("const PRESS_SCALE: f32 = 0.93;"),
"CloseButton must keep the pinned 0.93 press scale at the call site"
);
assert!(
src.contains("crate::anim::pressed_with_background_ramp(")
&& src.contains("crate::anim::CLOSE_BUTTON_PRESS"),
"the press must ride the shared ramp with the pinned close-button \
timing, not an instant style swap"
);
assert!(
!src.contains(".active("),
"the press is keyed state and a timeline now, not GPUI's active style"
);
assert!(
!src.contains("opacity(0.7)"),
"CloseButton must not substitute a dim for the pinned press scale"
);
}
}
#[gpui::test]
fn press_scales_the_box_to_0_93(cx: &mut TestAppContext) {
let presses = events();
let recorded = presses.clone();
let cx = open_host(cx, move || {
let recorded = recorded.clone();
CloseButton::new("cb")
.on_press(move |_, _, _| recorded.borrow_mut().push("press".into()))
.into_any_element()
});
let root = root_probe("cb");
flush_frame(cx);
let at_rest = bounds(cx, root);
assert!(
near(at_rest.size.width, BOX) && near(at_rest.size.height, BOX),
"a resting CloseButton is a 24px square, got {at_rest:?}"
);
let at = centre(at_rest);
cx.simulate_mouse_move(at, None, Modifiers::none());
flush_frame(cx);
let hovered = bounds(cx, root);
assert!(
near(hovered.size.width, BOX) && near(hovered.size.height, BOX),
"hover must not change the box, got {hovered:?}"
);
cx.simulate_mouse_down(at, MouseButton::Left, Modifiers::none());
flush_frame(cx);
let starting = bounds(cx, root);
assert!(
near(starting.size.width, BOX),
"the ramp starts from the painted frame, not the endpoint, got {starting:?}"
);
assert!(
pending_frames(cx) > 0,
"a press must start the transform ramp, not snap"
);
settle(cx);
let pressed = bounds(cx, root);
let scaled = BOX * PRESS_SCALE;
assert!(
near(pressed.size.width, scaled) && near(pressed.size.height, scaled),
"a pressed CloseButton must scale to 0.93 ({scaled}px), got {pressed:?}"
);
let inset = (BOX - scaled) / 2.;
assert!(
near(pressed.origin.x, inset) && near(pressed.origin.y, inset),
"the scale is about the centre, so the leftover is margin, got {pressed:?}"
);
cx.simulate_mouse_up(at, MouseButton::Left, Modifiers::none());
flush_frame(cx);
assert!(
pending_frames(cx) > 0,
"the release must ramp back rather than snap"
);
settle(cx);
let released = bounds(cx, root);
assert!(
near(released.size.width, BOX) && near(released.size.height, BOX),
"the box must spring back after the release, got {released:?}"
);
assert_eq!(
presses.borrow().as_slice(),
["press"],
"the completed click must still report on_press"
);
}
#[gpui::test]
fn reduced_motion_keeps_the_press_scale_instantly(cx: &mut TestAppContext) {
harness::still();
let presses = events();
let recorded = presses.clone();
let cx = open_host(cx, move || {
let recorded = recorded.clone();
CloseButton::new("cb-still")
.on_press(move |_, _, _| recorded.borrow_mut().push("press".into()))
.into_any_element()
});
let root = root_probe("cb-still");
flush_frame(cx);
let at = centre(bounds(cx, root));
cx.simulate_mouse_down(at, MouseButton::Left, Modifiers::none());
flush_frame(cx);
let pressed = bounds(cx, root);
let scaled = BOX * PRESS_SCALE;
assert!(
near(pressed.size.width, scaled) && near(pressed.size.height, scaled),
"reduced motion must apply the press scale immediately ({scaled}px), got {pressed:?}"
);
let inset = (BOX - scaled) / 2.;
assert!(
near(pressed.origin.x, inset) && near(pressed.origin.y, inset),
"reduced-motion press must stay centered, got {pressed:?}"
);
cx.simulate_mouse_up(at, MouseButton::Left, Modifiers::none());
flush_frame(cx);
assert_eq!(
presses.borrow().as_slice(),
["press"],
"reduced motion must not swallow the press"
);
}
#[gpui::test]
fn disabled_press_does_not_scale_or_fire(cx: &mut TestAppContext) {
let presses = events();
let recorded = presses.clone();
let cx = open_host(cx, move || {
let recorded = recorded.clone();
CloseButton::new("cb-off")
.is_disabled(true)
.on_press(move |_, _, _| recorded.borrow_mut().push("press".into()))
.into_any_element()
});
let root = root_probe("cb-off");
flush_frame(cx);
let at = centre(bounds(cx, root));
cx.simulate_mouse_move(at, None, Modifiers::none());
cx.simulate_mouse_down(at, MouseButton::Left, Modifiers::none());
flush_frame(cx);
let pressed = bounds(cx, root);
assert!(
near(pressed.size.width, BOX) && near(pressed.size.height, BOX),
"a disabled CloseButton must not scale, got {pressed:?}"
);
cx.simulate_mouse_up(at, MouseButton::Left, Modifiers::none());
flush_frame(cx);
assert!(
presses.borrow().is_empty(),
"a disabled CloseButton must not report on_press"
);
}
#[gpui::test]
fn focus_visible_keeps_the_24px_box(cx: &mut TestAppContext) {
let cx = open_host(cx, || CloseButton::new("cb-focus").into_any_element());
let root = root_probe("cb-focus");
flush_frame(cx);
press(cx, "tab");
flush_frame(cx);
let focused = bounds(cx, root);
assert!(
near(focused.size.width, BOX) && near(focused.size.height, BOX),
"focus-visible must not change the box, got {focused:?}"
);
}
#[gpui::test]
fn default_glyph_fills_the_padded_content_box(cx: &mut TestAppContext) {
let cx = open_host(cx, || CloseButton::new("cb-icon").into_any_element());
let icon = icon_probe("cb-icon");
flush_frame(cx);
let glyph = bounds(cx, icon);
assert!(
near(glyph.size.width, ICON) && near(glyph.size.height, ICON),
"the default glyph is size-4 (16px), got {glyph:?}"
);
assert!(
near(glyph.origin.x, 4.) && near(glyph.origin.y, 4.),
"items-center + justify-center keep a 16px glyph at the p-1 inset, \
got {glyph:?}"
);
let _ = SVG_MARGIN;
}
#[test]
fn close_button_reads_the_hover_override_and_the_sx_background() {
let source = include_str!("../src/close_button.rs");
assert!(
source.contains("crate::util::sx_background(&self.sx)"),
"the resting endpoint must come from the sx background"
);
assert!(
source.contains("crate::util::fade_endpoints("),
"the close button must resolve its fade through the shared resolver"
);
assert!(
source.contains("self.hover_bg = Some(color.into());"),
"the hover_bg builder must store the override"
);
}