use crate::harness;
use crate::source_scan;
use std::cell::RefCell;
use std::rc::Rc;
use gpui::{
point, prelude::*, px, Font, FontFeatures, FontStyle, FontWeight, KeyDownEvent, KeyUpEvent,
Keystroke, Modifiers, MouseButton, TestAppContext, VisualTestContext,
};
use herogpui_components::{
Alert, Button, ButtonGroup, Chip, ChipLabel, CloseButton, InteractiveState, Link, Orientation,
SelectionMode, ToggleButton, ToggleButtonGroup,
};
use harness::{click, events, open_host, press};
#[gpui::test]
fn app_root_tab_moves_once_and_consumes_the_browser_default(cx: &mut TestAppContext) {
let changes = events();
let recorded = changes.clone();
let cx = open_host(cx, move || {
let first = changes.clone();
let second = changes.clone();
gpui::div()
.flex()
.child(
Button::new("first")
.label("First")
.on_press(move |_, _, _| first.borrow_mut().push("first".into())),
)
.child(
Button::new("second")
.label("Second")
.on_press(move |_, _, _| second.borrow_mut().push("second".into())),
)
.into_any_element()
});
for key in ["tab", "tab", "shift-tab"] {
let result = cx.update(|window, cx| {
window.dispatch_event(
gpui::PlatformInput::KeyDown(KeyDownEvent {
keystroke: Keystroke::parse(key).unwrap(),
is_held: false,
prefer_character_input: false,
}),
cx,
)
});
assert!(
!result.propagate,
"{key} must not move the browser's DOM focus"
);
cx.simulate_event(KeyUpEvent {
keystroke: Keystroke::parse(key).unwrap(),
});
cx.update(|window, _| window.refresh());
press(cx, "enter");
}
assert_eq!(recorded.borrow().as_slice(), ["first", "second", "first"]);
}
fn text_width(system: &gpui::WindowTextSystem, text: &str, size: f32, weight: FontWeight) -> f32 {
let run = gpui::TextRun {
len: text.len(),
font: Font {
family: ".SystemUIFont".into(),
features: FontFeatures::default(),
weight,
style: FontStyle::default(),
fallbacks: None,
},
color: gpui::black(),
background_color: None,
underline: None,
strikethrough: None,
};
let line = system.shape_line(text.to_owned().into(), px(size), &[run], None);
f32::from(line.width)
}
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!("button animations never settled");
}
#[gpui::test]
fn button_press_reports_once(cx: &mut TestAppContext) {
let presses = events();
let recorded = presses.clone();
let cx = open_host(cx, move || {
let presses = presses.clone();
Button::new("btn-press")
.label("Go")
.full_width(true)
.on_press(move |_, _, _| presses.borrow_mut().push("press".into()))
.into_any_element()
});
click(cx, 960., 18.);
assert_eq!(
recorded.borrow().as_slice(),
["press"],
"a click must report exactly one press"
);
flush_frame(cx);
press(cx, "enter");
assert_eq!(
recorded.borrow().as_slice(),
["press", "press"],
"Enter must report exactly one more press"
);
press(cx, "space");
assert_eq!(
recorded.borrow().as_slice(),
["press", "press", "press"],
"Space must report exactly one more press"
);
}
#[gpui::test]
fn button_disabled_skips_focus_while_pending_retains_it_without_pressing(cx: &mut TestAppContext) {
let presses = events();
let recorded = presses.clone();
let cx = open_host(cx, move || {
let disabled = presses.clone();
let pending = presses.clone();
let probe = presses.clone();
gpui::div()
.flex()
.flex_col()
.gap(px(4.))
.child(
Button::new("btn-disabled")
.label("Disabled")
.full_width(true)
.is_disabled(true)
.on_press(move |_, _, _| disabled.borrow_mut().push("disabled".into())),
)
.child(
Button::new("btn-pending")
.label("Pending")
.full_width(true)
.is_pending(true)
.on_press(move |_, _, _| pending.borrow_mut().push("pending".into())),
)
.child(
Button::new("btn-probe")
.label("Probe")
.full_width(true)
.on_press(move |_, _, _| probe.borrow_mut().push("probe".into())),
)
.into_any_element()
});
press(cx, "tab");
flush_frame(cx);
press(cx, "space");
assert!(
recorded.borrow().is_empty(),
"a focused pending button must not activate"
);
press(cx, "tab");
flush_frame(cx);
press(cx, "space");
assert_eq!(
recorded.borrow().as_slice(),
["probe"],
"Tab after the focusable pending button must reach the probe"
);
click(cx, 960., 18.);
click(cx, 960., 58.);
assert_eq!(
recorded.borrow().as_slice(),
["probe"],
"disabled and pending buttons must not record pointer presses"
);
}
#[gpui::test]
fn button_group_reports_each_child(cx: &mut TestAppContext) {
let presses = events();
let recorded = presses.clone();
let cx = open_host(cx, move || {
let one = presses.clone();
let two = presses.clone();
let three = presses.clone();
ButtonGroup::new()
.full_width(true)
.button(
Button::new("bg-one")
.label("One")
.on_press(move |_, _, _| one.borrow_mut().push("one".into())),
)
.button(
Button::new("bg-two")
.label("Two")
.on_press(move |_, _, _| two.borrow_mut().push("two".into())),
)
.button(
Button::new("bg-three")
.label("Three")
.on_press(move |_, _, _| three.borrow_mut().push("three".into())),
)
.into_any_element()
});
click(cx, 320., 18.);
click(cx, 960., 18.);
click(cx, 1600., 18.);
assert_eq!(
recorded.borrow().as_slice(),
["one", "two", "three"],
"each member of the group must report its own press, exactly once"
);
}
#[gpui::test]
fn button_group_child_can_override_inherited_disabled(cx: &mut TestAppContext) {
let presses = events();
let recorded = presses.clone();
let cx = open_host(cx, move || {
let inherited = presses.clone();
let override_enabled = presses.clone();
ButtonGroup::new()
.full_width(true)
.is_disabled(true)
.button(
Button::new("bg-inherited-disabled")
.label("Inherited")
.on_press(move |_, _, _| inherited.borrow_mut().push("inherited".into())),
)
.button(
Button::new("bg-override-enabled")
.label("Override")
.is_disabled(false)
.on_press(move |_, _, _| {
override_enabled.borrow_mut().push("override".into());
}),
)
.into_any_element()
});
click(cx, 480., 18.);
click(cx, 1440., 18.);
assert_eq!(
recorded.borrow().as_slice(),
["override"],
"an explicit child value must override the ButtonGroup default"
);
}
#[gpui::test]
fn button_group_full_width_context_preserves_explicit_child_false(cx: &mut TestAppContext) {
let presses = events();
let recorded = presses.clone();
let cx = open_host(cx, move || {
let one = presses.clone();
let two = presses.clone();
let three = presses.clone();
ButtonGroup::new()
.full_width(true)
.button(
Button::new("bgf-inherit-one")
.label("One")
.on_press(move |_, _, _| one.borrow_mut().push("one".into())),
)
.button(
Button::new("bgf-explicit-false")
.label("Two")
.full_width(false)
.on_press(move |_, _, _| two.borrow_mut().push("two".into())),
)
.button(
Button::new("bgf-inherit-three")
.label("Three")
.on_press(move |_, _, _| three.borrow_mut().push("three".into())),
)
.into_any_element()
});
let w_two = cx
.update(|window, _| text_width(window.text_system(), "Two", 14.0, FontWeight::MEDIUM))
+ 32.;
let side = (1920. - w_two) / 2.;
click(cx, side / 2., 18.);
click(cx, side - 50., 18.);
click(cx, side + w_two / 2., 18.);
click(cx, side + w_two + 50., 18.);
assert_eq!(
recorded.borrow().as_slice(),
["one", "one", "two", "three"],
"an explicit fullWidth=false member must hug content while the \
inheriting members share the leftover width"
);
}
#[gpui::test]
fn button_group_full_width_child_true_stretches_without_group_full_width(cx: &mut TestAppContext) {
let presses = events();
let recorded = presses.clone();
let cx = open_host(cx, move || {
let one = presses.clone();
let two = presses.clone();
let three = presses.clone();
ButtonGroup::new()
.button(
Button::new("bgf-hug-one")
.label("One")
.on_press(move |_, _, _| one.borrow_mut().push("one".into())),
)
.button(
Button::new("bgf-explicit-true")
.label("Two")
.full_width(true)
.on_press(move |_, _, _| two.borrow_mut().push("two".into())),
)
.button(
Button::new("bgf-hug-three")
.label("Three")
.on_press(move |_, _, _| three.borrow_mut().push("three".into())),
)
.into_any_element()
});
let w_one = cx
.update(|window, _| text_width(window.text_system(), "One", 14.0, FontWeight::MEDIUM))
+ 32.;
let w_three = cx
.update(|window, _| text_width(window.text_system(), "Three", 14.0, FontWeight::MEDIUM))
+ 32.;
click(cx, w_one + 60., 18.);
click(cx, (w_one + 1920. - w_three) / 2., 18.);
click(cx, 1920. - w_three - 60., 18.);
click(cx, 1920. - w_three / 2., 18.);
assert_eq!(
recorded.borrow().as_slice(),
["two", "two", "two", "three"],
"an explicit fullWidth=true member must stretch while its hugging \
neighbours keep their content width"
);
}
#[gpui::test]
fn button_group_vertical_members_share_one_column(cx: &mut TestAppContext) {
let cx = open_host(cx, || {
ButtonGroup::new()
.orientation(Orientation::Vertical)
.separators(true)
.button(Button::new("bgvc-top").label("Top"))
.button(Button::new("bgvc-wide").label("A much wider middle"))
.button(Button::new("bgvc-bot").label("Bot"))
.into_any_element()
});
flush_frame(cx);
let slot = |cx: &mut VisualTestContext, i: usize| {
cx.debug_bounds(Box::leak(format!("button-group-slot-{i}").into_boxed_str()))
.expect("every member paints")
};
let slots: Vec<_> = (0..3).map(|i| slot(cx, i)).collect();
let column_width = slots[0].size.width;
for (i, bounds) in slots.iter().enumerate() {
assert_eq!(
bounds.size.width, column_width,
"member {i} must share the column width, bounds {bounds:?}"
);
assert_eq!(
bounds.origin.x, slots[0].origin.x,
"member {i} must share the column's left edge, bounds {bounds:?}"
);
assert_eq!(
bounds.size.height,
px(36.),
"member {i} keeps the Md control height, bounds {bounds:?}"
);
}
let widest = slot(cx, 1).size.width;
let narrow = slot(cx, 0).size.width;
let w_top = px(cx
.update(|window, _| text_width(window.text_system(), "Top", 14.0, FontWeight::MEDIUM))
+ 32.);
assert!(
narrow > w_top,
"the narrow member stretches past its own content width {w_top}, got {narrow}"
);
assert_eq!(
narrow, widest,
"the column width is the widest member's, not the window's"
);
}
#[gpui::test]
fn close_button_reports_and_disabled_is_inert(cx: &mut TestAppContext) {
let presses = events();
let recorded = presses.clone();
let dead_presses = events();
let dead_recorded = dead_presses.clone();
let probe_presses = events();
let probe_recorded = probe_presses.clone();
let cx = open_host(cx, move || {
let close = presses.clone();
let dead = dead_presses.clone();
let probe = probe_presses.clone();
gpui::div()
.flex()
.flex_col()
.gap(px(4.))
.child(
CloseButton::new("cb-disabled")
.is_disabled(true)
.on_press(move |_, _, _| dead.borrow_mut().push("dead".into())),
)
.child(
CloseButton::new("cb-enabled")
.on_press(move |_, _, _| close.borrow_mut().push("close".into())),
)
.child(
Button::new("cb-probe")
.label("Probe")
.full_width(true)
.on_press(move |_, _, _| probe.borrow_mut().push("probe".into())),
)
.into_any_element()
});
click(cx, 12., 40.);
assert_eq!(
recorded.borrow().as_slice(),
["close"],
"the close button must report exactly one press"
);
click(cx, 12., 12.);
assert_eq!(
recorded.borrow().as_slice(),
["close"],
"a disabled close button must not report a press"
);
assert!(
dead_recorded.borrow().is_empty(),
"the disabled close button's own handler must never run"
);
press(cx, "tab");
press(cx, "tab");
flush_frame(cx);
press(cx, "space");
assert_eq!(
probe_recorded.borrow().as_slice(),
["probe"],
"Tab must skip the disabled close button and reach the probe"
);
assert_eq!(
recorded.borrow().as_slice(),
["close"],
"the enabled close button must not fire again"
);
}
#[gpui::test]
fn toggle_button_uncontrolled_toggles(cx: &mut TestAppContext) {
let changes = events();
let recorded = changes.clone();
let cx = open_host(cx, move || {
let changes = changes.clone();
let press_changes = changes.clone();
ToggleButton::new("tb-standalone")
.label("Bold")
.default_selected(true)
.on_change(move |selected, _, _| changes.borrow_mut().push(format!("{selected}")))
.on_press(move |_, _, _| press_changes.borrow_mut().push("press".into()))
.into_any_element()
});
let w =
cx.update(|window, _| text_width(window.text_system(), "Bold", 14.0, FontWeight::MEDIUM));
let centre_x = 16. + w / 2.;
click(cx, centre_x, 18.);
assert_eq!(
recorded.borrow().as_slice(),
["false", "press"],
"the first click must report the changed selection before onPress"
);
click(cx, centre_x, 18.);
assert_eq!(
recorded.borrow().as_slice(),
["false", "press", "true", "press"],
"the second click must select it and keep the callback order"
);
}
#[gpui::test]
fn toggle_button_group_single_and_multiple(cx: &mut TestAppContext) {
let single_changes = events();
let single_seen = single_changes.clone();
let single_state = Rc::new(RefCell::new(Vec::new()));
let multi_changes = events();
let multi_seen = multi_changes.clone();
let multi_state = Rc::new(RefCell::new(Vec::new()));
let third_state = single_state;
let fourth_state = multi_state;
let cx = open_host(cx, move || {
let single_changes = single_changes.clone();
let multi_changes = multi_changes.clone();
let single_state = Rc::clone(&third_state);
let multi_state = Rc::clone(&fourth_state);
let single_selected = single_state.borrow().iter().cloned().collect::<Vec<_>>();
let multi_selected = multi_state.borrow().iter().cloned().collect::<Vec<_>>();
gpui::div()
.flex()
.flex_col()
.gap(px(320.))
.child(
ToggleButtonGroup::new("buttons-single-group")
.full_width(true)
.selection_mode(SelectionMode::Single)
.selected_keys(single_selected)
.on_selection_change(move |next, _, _| {
*single_state.borrow_mut() = next.to_vec();
let joined = next
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join(",");
single_changes.borrow_mut().push(joined);
})
.child_toggle(ToggleButton::new("grp-s-b").key("bold").label("Bold"))
.child_toggle(ToggleButton::new("grp-s-i").key("italic").label("Italic"))
.child_toggle(
ToggleButton::new("grp-s-u")
.key("underline")
.label("Underline"),
),
)
.child(
ToggleButtonGroup::new("buttons-multiple-group")
.full_width(true)
.selection_mode(SelectionMode::Multiple)
.selected_keys(multi_selected)
.on_selection_change(move |next, _, _| {
*multi_state.borrow_mut() = next.to_vec();
let joined = next
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join(",");
multi_changes.borrow_mut().push(joined);
})
.child_toggle(ToggleButton::new("grp-m-b").key("bold").label("Bold"))
.child_toggle(ToggleButton::new("grp-m-i").key("italic").label("Italic"))
.child_toggle(
ToggleButton::new("grp-m-u")
.key("underline")
.label("Underline"),
),
)
.into_any_element()
});
click(cx, 320., 18.);
assert_eq!(single_seen.borrow().as_slice(), ["bold"]);
click(cx, 960., 18.);
assert_eq!(single_seen.borrow().as_slice(), ["bold", "italic"]);
click(cx, 960., 18.);
assert_eq!(single_seen.borrow().as_slice(), ["bold", "italic", ""]);
click(cx, 320., 18.);
assert_eq!(
single_seen.borrow().as_slice(),
["bold", "italic", "", "bold"],
"single mode must replace the pick and clear on re-click"
);
click(cx, 320., 374.);
assert_eq!(multi_seen.borrow().as_slice(), ["bold"]);
click(cx, 960., 374.);
assert_eq!(multi_seen.borrow().as_slice(), ["bold", "bold,italic"]);
click(cx, 320., 374.);
assert_eq!(
multi_seen.borrow().as_slice(),
["bold", "bold,italic", "italic"],
"multiple mode must accumulate picks and toggle one off"
);
}
#[gpui::test]
fn link_press_reports_and_disabled_is_inert(cx: &mut TestAppContext) {
let presses = events();
let recorded = presses.clone();
let cx = open_host(cx, move || {
let go = presses.clone();
let dead = presses.clone();
gpui::div()
.flex()
.flex_row()
.gap(px(8.))
.child(
Link::new("lnk-go")
.label("Home")
.on_press(move |_, _, _| go.borrow_mut().push("go".into())),
)
.child(
Link::new("lnk-dead")
.label("Disabled")
.is_disabled(true)
.on_press(move |_, _, _| dead.borrow_mut().push("dead".into())),
)
.into_any_element()
});
let w1 =
cx.update(|window, _| text_width(window.text_system(), "Home", 14.0, FontWeight::MEDIUM));
let w2 = cx
.update(|window, _| text_width(window.text_system(), "Disabled", 14.0, FontWeight::MEDIUM));
click(cx, w1 / 2., 10.);
assert_eq!(
recorded.borrow().as_slice(),
["go"],
"an enabled link must report exactly one press"
);
click(cx, w1 + 8. + w2 / 2., 10.);
assert_eq!(
recorded.borrow().as_slice(),
["go"],
"a disabled link must not report a press"
);
}
#[gpui::test]
fn disabled_link_leaves_tab_order(cx: &mut TestAppContext) {
let presses = events();
let recorded = presses.clone();
let cx = open_host(cx, move || {
let probe = presses.clone();
gpui::div()
.flex()
.flex_col()
.gap(px(4.))
.child(
Link::new("lnk-skip")
.label("Disabled")
.is_disabled(true)
.on_press(|_, _, _| {}),
)
.child(
Button::new("lnk-probe")
.label("Probe")
.full_width(true)
.on_press(move |_, _, _| probe.borrow_mut().push("probe".into())),
)
.into_any_element()
});
press(cx, "tab");
flush_frame(cx);
press(cx, "space");
assert_eq!(
recorded.borrow().as_slice(),
["probe"],
"a disabled link must not be a tab stop"
);
}
#[gpui::test]
fn chip_close_reports_and_plain_chip_has_nothing_to_press(cx: &mut TestAppContext) {
let presses = events();
let recorded = presses.clone();
let cx = open_host(cx, move || {
let close = presses.clone();
gpui::div()
.flex()
.flex_col()
.gap(px(4.))
.child(
Chip::new()
.child(
CloseButton::new("chip-x")
.on_press(move |_, _, _| close.borrow_mut().push("close".into())),
)
.child(ChipLabel::new().child("Tag")),
)
.child(Chip::new().child(ChipLabel::new().child("Tag")))
.into_any_element()
});
click(cx, 20., 14.);
assert_eq!(
recorded.borrow().as_slice(),
["close"],
"a close affordance composed into a chip must report its press"
);
click(cx, 24., 40.);
click(cx, 40., 40.);
assert_eq!(
recorded.borrow().as_slice(),
["close"],
"a plain chip must have nothing to press"
);
}
#[gpui::test]
fn alert_composed_close_button_reports_and_alert_has_no_built_in_close(cx: &mut TestAppContext) {
let presses = events();
let recorded = presses.clone();
let cx = open_host(cx, move || {
let presses = presses.clone();
gpui::div()
.flex()
.flex_col()
.gap(px(8.))
.child(
Alert::new("Saved")
.description("Your changes are live.")
.child(
CloseButton::new("alert-composed-x")
.on_press(move |_, _, _| presses.borrow_mut().push("close".into())),
),
)
.child(Alert::new("No close here"))
.into_any_element()
});
press(cx, "tab");
flush_frame(cx);
press(cx, "space");
assert_eq!(
recorded.borrow().as_slice(),
["close"],
"a CloseButton composed into an Alert must be reachable and activatable by keyboard"
);
click(cx, 1892., 24.);
assert_eq!(
recorded.borrow().as_slice(),
["close", "close"],
"a CloseButton composed into an Alert must report its press"
);
click(cx, 500., 24.);
assert_eq!(
recorded.borrow().as_slice(),
["close", "close"],
"the alert body must not report a close"
);
click(cx, 1897., 93.);
assert_eq!(
recorded.borrow().as_slice(),
["close", "close"],
"an alert with no composed close affordance must have nothing at the removed built-in close position"
);
}
#[gpui::test]
fn button_content_render_prop_reports_pending_and_retains_focus(cx: &mut TestAppContext) {
let seen = Rc::new(RefCell::new((false, false, false, false, false)));
let record = seen.clone();
let cx = open_host(cx, move || {
let record = record.clone();
Button::new("btn-pending-state")
.full_width(true)
.is_pending(true)
.content(move |state: InteractiveState| {
*record.borrow_mut() = (
state.is_pending,
state.is_disabled,
state.is_focused,
state.is_hovered,
state.is_pressed,
);
gpui::div().child("pending".to_owned()).into_any_element()
})
.into_any_element()
});
assert_eq!(
*seen.borrow(),
(true, false, false, false, false),
"pending must be reported separately from disabled"
);
press(cx, "tab");
flush_frame(cx);
assert_eq!(
*seen.borrow(),
(true, false, true, false, false),
"a pending button must retain its focus stop without becoming interactive"
);
let centre = point(px(960.), px(18.));
cx.simulate_mouse_move(centre, None::<MouseButton>, Modifiers::none());
cx.simulate_mouse_down(centre, MouseButton::Left, Modifiers::none());
flush_frame(cx);
assert_eq!(
*seen.borrow(),
(true, false, true, false, false),
"pending must suppress hover and press render state"
);
}
#[gpui::test]
fn button_pending_transition_clears_stale_interaction_state(cx: &mut TestAppContext) {
let seen = Rc::new(RefCell::new((false, false)));
let record = seen.clone();
let pending = Rc::new(RefCell::new(false));
let for_view = pending.clone();
let cx = open_host(cx, move || {
let record = record.clone();
Button::new("btn-pending-transition")
.full_width(true)
.is_pending(*for_view.borrow())
.content(move |state: InteractiveState| {
*record.borrow_mut() = (state.is_hovered, state.is_pending);
gpui::div().child("state".to_owned()).into_any_element()
})
.into_any_element()
});
cx.simulate_mouse_move(
point(px(960.), px(18.)),
None::<MouseButton>,
Modifiers::none(),
);
flush_frame(cx);
assert_eq!(*seen.borrow(), (true, false));
*pending.borrow_mut() = true;
flush_frame(cx);
assert_eq!(*seen.borrow(), (false, true));
cx.simulate_mouse_move(
point(px(4.), px(500.)),
None::<MouseButton>,
Modifiers::none(),
);
flush_frame(cx);
*pending.borrow_mut() = false;
flush_frame(cx);
assert_eq!(
*seen.borrow(),
(false, false),
"ending pending must not revive hover recorded before the inert period"
);
}
#[gpui::test]
fn disabling_button_clears_render_prop_focus(cx: &mut TestAppContext) {
let seen = Rc::new(RefCell::new((false, false, false)));
let record = seen.clone();
let disabled = Rc::new(RefCell::new(false));
let for_view = disabled.clone();
let cx = open_host(cx, move || {
let record = record.clone();
Button::new("btn-disable-focus")
.full_width(true)
.is_disabled(*for_view.borrow())
.content(move |state: InteractiveState| {
*record.borrow_mut() =
(state.is_focused, state.is_focus_visible, state.is_disabled);
gpui::div().child("state".to_owned()).into_any_element()
})
.into_any_element()
});
press(cx, "tab");
flush_frame(cx);
assert_eq!(
*seen.borrow(),
(true, true, false),
"the enabled button must report keyboard focus"
);
*disabled.borrow_mut() = true;
flush_frame(cx);
assert_eq!(
*seen.borrow(),
(false, false, true),
"disabling Button.Content must stop reporting focus"
);
*disabled.borrow_mut() = false;
flush_frame(cx);
assert_eq!(
*seen.borrow(),
(false, false, false),
"re-enabling must not restore focus without a new user action"
);
}
#[gpui::test]
fn button_content_render_prop_sees_press(cx: &mut TestAppContext) {
let seen = Rc::new(RefCell::new((false, false)));
let record = seen.clone();
let cx = open_host(cx, move || {
let record = record.clone();
Button::new("btn-state")
.full_width(true)
.content(move |state: InteractiveState| {
*record.borrow_mut() = (state.is_hovered, state.is_pressed);
gpui::div().child("state".to_owned()).into_any_element()
})
.into_any_element()
});
let centre = point(px(960.), px(18.));
assert_eq!(*seen.borrow(), (false, false), "initial state must be idle");
cx.simulate_mouse_move(centre, None::<MouseButton>, Modifiers::none());
flush_frame(cx);
assert_eq!(
*seen.borrow(),
(true, false),
"the frame after the move must see the hover"
);
cx.simulate_mouse_down(centre, MouseButton::Left, Modifiers::none());
flush_frame(cx);
assert_eq!(
*seen.borrow(),
(true, true),
"the frame after the down must see the press"
);
cx.simulate_mouse_up(centre, MouseButton::Left, Modifiers::none());
flush_frame(cx);
assert_eq!(
*seen.borrow(),
(true, false),
"the frame after the up must see the press lifted"
);
cx.simulate_mouse_move(
point(px(4.), px(500.)),
None::<MouseButton>,
Modifiers::none(),
);
flush_frame(cx);
assert_eq!(
*seen.borrow(),
(false, false),
"the frame after leaving must see the hover lifted"
);
}
#[gpui::test]
fn button_hover_bg_keeps_the_interaction_slot_reporting(cx: &mut TestAppContext) {
let seen = Rc::new(RefCell::new((false, false)));
let record = seen.clone();
let cx = open_host(cx, move || {
let record = record.clone();
Button::new("btn-hover-bg")
.full_width(true)
.hover_bg(gpui::rgba(0x804000ff))
.content(move |state: InteractiveState| {
*record.borrow_mut() = (state.is_hovered, state.is_pressed);
gpui::div().child("state".to_owned()).into_any_element()
})
.into_any_element()
});
let centre = point(px(960.), px(18.));
assert_eq!(*seen.borrow(), (false, false), "initial state must be idle");
cx.simulate_mouse_move(centre, None::<MouseButton>, Modifiers::none());
flush_frame(cx);
assert_eq!(
*seen.borrow(),
(true, false),
"the frame after the move must see the hover"
);
cx.simulate_mouse_down(centre, MouseButton::Left, Modifiers::none());
flush_frame(cx);
assert_eq!(
*seen.borrow(),
(true, true),
"the frame after the down must see the press"
);
cx.simulate_mouse_up(centre, MouseButton::Left, Modifiers::none());
flush_frame(cx);
assert_eq!(
*seen.borrow(),
(true, false),
"the frame after the up must see the press lifted"
);
cx.simulate_mouse_move(
point(px(4.), px(500.)),
None::<MouseButton>,
Modifiers::none(),
);
flush_frame(cx);
assert_eq!(
*seen.borrow(),
(false, false),
"the frame after leaving must see the hover lifted"
);
}
#[gpui::test]
fn button_content_render_prop_sees_keyboard_press(cx: &mut TestAppContext) {
let seen = Rc::new(RefCell::new(false));
let record = seen.clone();
let cx = open_host(cx, move || {
let record = record.clone();
gpui::div()
.child(Button::new("btn-key-state").full_width(true).content(
move |state: InteractiveState| {
*record.borrow_mut() = state.is_pressed;
gpui::div().child("state".to_owned()).into_any_element()
},
))
.child(Button::new("btn-key-next").label("Next"))
.into_any_element()
});
press(cx, "tab");
cx.simulate_keystrokes("enter");
flush_frame(cx);
assert!(
*seen.borrow(),
"the frame after Enter down must report the held keyboard press"
);
cx.simulate_keystrokes("tab");
flush_frame(cx);
cx.simulate_event(KeyUpEvent {
keystroke: Keystroke::parse("enter").unwrap(),
});
flush_frame(cx);
assert!(
!*seen.borrow(),
"the frame after Enter up must report the keyboard press lifted"
);
press(cx, "shift-tab");
cx.simulate_event(KeyDownEvent {
keystroke: Keystroke::parse("space").unwrap(),
is_held: true,
prefer_character_input: false,
});
flush_frame(cx);
assert!(
!*seen.borrow(),
"a repeated Space without its first keydown must not begin a press"
);
cx.simulate_keystrokes("space");
flush_frame(cx);
assert!(
*seen.borrow(),
"the frame after Space down must report the held keyboard press"
);
cx.simulate_mouse_up(
point(px(4.), px(500.)),
MouseButton::Left,
Modifiers::none(),
);
flush_frame(cx);
assert!(
*seen.borrow(),
"an unrelated mouse-up must not release a held keyboard press"
);
cx.simulate_event(KeyUpEvent {
keystroke: Keystroke::parse("space").unwrap(),
});
flush_frame(cx);
assert!(
!*seen.borrow(),
"the frame after Space up must report the keyboard press lifted"
);
}
#[gpui::test]
fn button_content_render_prop_tracks_interleaved_keyboard_presses(cx: &mut TestAppContext) {
let seen = Rc::new(RefCell::new((false, false)));
let record = seen.clone();
let cx = open_host(cx, move || {
let first = record.clone();
let second = record.clone();
gpui::div()
.child(Button::new("btn-key-first").content(move |state| {
first.borrow_mut().0 = state.is_pressed;
gpui::div().child("first".to_owned()).into_any_element()
}))
.child(Button::new("btn-key-second").content(move |state| {
second.borrow_mut().1 = state.is_pressed;
gpui::div().child("second".to_owned()).into_any_element()
}))
.into_any_element()
});
press(cx, "tab");
cx.simulate_keystrokes("enter");
flush_frame(cx);
cx.simulate_keystrokes("tab");
flush_frame(cx);
cx.simulate_keystrokes("space");
flush_frame(cx);
assert_eq!(
*seen.borrow(),
(true, true),
"moving focus may begin a second press without losing the first key's release"
);
cx.simulate_event(KeyUpEvent {
keystroke: Keystroke::parse("space").unwrap(),
});
flush_frame(cx);
assert_eq!(
*seen.borrow(),
(true, false),
"Space up must release only the matching second press"
);
cx.simulate_event(KeyUpEvent {
keystroke: Keystroke::parse("enter").unwrap(),
});
flush_frame(cx);
assert_eq!(
*seen.borrow(),
(false, false),
"Enter up must still release the first press after focus moved"
);
}
#[gpui::test]
fn toggle_button_content_render_prop_sees_state(cx: &mut TestAppContext) {
let seen = Rc::new(RefCell::new((false, false, false)));
let record = seen.clone();
let cx = open_host(cx, move || {
let record = record.clone();
ToggleButton::new("tb-state")
.default_selected(true)
.content(move |state: InteractiveState| {
*record.borrow_mut() = (state.is_hovered, state.is_pressed, state.is_selected);
gpui::div()
.w(px(64.))
.h(px(16.))
.child("bold".to_owned())
.into_any_element()
})
.into_any_element()
});
let centre = point(px(48.), px(18.));
assert_eq!(
*seen.borrow(),
(false, false, true),
"initial state must be idle and selected"
);
cx.simulate_mouse_move(centre, None::<MouseButton>, Modifiers::none());
flush_frame(cx);
assert_eq!(
*seen.borrow(),
(true, false, true),
"the frame after the move must see the hover"
);
cx.simulate_mouse_down(centre, MouseButton::Left, Modifiers::none());
flush_frame(cx);
assert_eq!(
*seen.borrow(),
(true, true, true),
"the frame after the down must see the press"
);
cx.simulate_mouse_up(centre, MouseButton::Left, Modifiers::none());
flush_frame(cx);
assert_eq!(
*seen.borrow(),
(true, false, false),
"the up must lift the press and toggle the selection off"
);
cx.simulate_mouse_move(
point(px(4.), px(500.)),
None::<MouseButton>,
Modifiers::none(),
);
flush_frame(cx);
assert_eq!(
*seen.borrow(),
(false, false, false),
"the frame after leaving must see the hover lifted"
);
}
#[gpui::test]
fn toggle_button_content_render_prop_sees_focus_state(cx: &mut TestAppContext) {
let seen = Rc::new(RefCell::new((false, false, false, false)));
let record = seen.clone();
let cx = open_host(cx, move || {
let record = record.clone();
ToggleButton::new("tb-focus-state")
.default_selected(true)
.content(move |state: InteractiveState| {
*record.borrow_mut() = (
state.is_focused,
state.is_focus_visible,
state.is_disabled,
state.is_selected,
);
gpui::div()
.w(px(64.))
.h(px(16.))
.child("bold".to_owned())
.into_any_element()
})
.into_any_element()
});
assert_eq!(
*seen.borrow(),
(false, false, false, true),
"the initial render must report an enabled, unfocused selected toggle"
);
press(cx, "tab");
flush_frame(cx);
assert_eq!(
*seen.borrow(),
(true, true, false, true),
"keyboard focus and its visible flag must reach the closure without changing selection"
);
}
#[gpui::test]
fn disabled_toggle_button_content_reports_disabled_and_stays_inert(cx: &mut TestAppContext) {
let seen = Rc::new(RefCell::new((false, false, false)));
let record = seen.clone();
let changes = events();
let recorded = changes.clone();
let cx = open_host(cx, move || {
let record = record.clone();
let changes = changes.clone();
ToggleButton::new("tb-disabled-state")
.default_selected(true)
.is_disabled(true)
.content(move |state: InteractiveState| {
*record.borrow_mut() = (state.is_focused, state.is_disabled, state.is_selected);
gpui::div()
.w(px(64.))
.h(px(16.))
.child("bold".to_owned())
.into_any_element()
})
.on_change(move |selected, _, _| {
changes.borrow_mut().push(format!("{selected}"));
})
.into_any_element()
});
assert_eq!(
*seen.borrow(),
(false, true, true),
"the disabled state must reach the content closure"
);
click(cx, 48., 18.);
press(cx, "tab enter");
flush_frame(cx);
assert!(
recorded.borrow().is_empty(),
"a disabled toggle must answer neither pointer nor keyboard activation"
);
assert_eq!(
*seen.borrow(),
(false, true, true),
"a disabled toggle must remain unfocused and selected"
);
}
#[gpui::test]
fn button_press_collapses_toward_its_centre(cx: &mut TestAppContext) {
let seen: Rc<RefCell<Option<gpui::Bounds<gpui::Pixels>>>> = Rc::new(RefCell::new(None));
let sink = seen.clone();
let cx = open_host(cx, move || {
let sink = sink.clone();
Button::new("btn-centre")
.full_width(true)
.child(
gpui::canvas(
|_, _, _| {},
move |bounds, _, _, _| {
*sink.borrow_mut() = Some(bounds);
},
)
.size_full(),
)
.into_any_element()
});
flush_frame(cx);
let rest = seen.borrow().expect("the content probe painted at rest");
let rest_centre = point(
px(f32::from(rest.origin.x) + f32::from(rest.size.width) / 2.),
px(f32::from(rest.origin.y) + f32::from(rest.size.height) / 2.),
);
let rest_bottom = f32::from(rest.origin.y) + f32::from(rest.size.height);
cx.simulate_mouse_move(rest_centre, None, Modifiers::none());
flush_frame(cx);
cx.simulate_mouse_down(rest_centre, MouseButton::Left, Modifiers::none());
flush_frame(cx);
assert!(
pending_frames(cx) > 0,
"a press must start the transform ramp, not snap"
);
settle(cx);
let pressed = seen
.borrow()
.expect("the content probe painted while pressed");
let pressed_centre = point(
px(f32::from(pressed.origin.x) + f32::from(pressed.size.width) / 2.),
px(f32::from(pressed.origin.y) + f32::from(pressed.size.height) / 2.),
);
let pressed_bottom = f32::from(pressed.origin.y) + f32::from(pressed.size.height);
let width_ratio = f32::from(pressed.size.width) / f32::from(rest.size.width);
let height_ratio = f32::from(pressed.size.height) / f32::from(rest.size.height);
assert!(
(width_ratio - 0.97).abs() < 0.01 && (height_ratio - 0.97).abs() < 0.01,
"both axes scale by 0.97, got width {width_ratio} height {height_ratio} (rest {rest:?}, pressed {pressed:?})"
);
assert!(
(f32::from(pressed_centre.x) - f32::from(rest_centre.x)).abs() < 0.25
&& (f32::from(pressed_centre.y) - f32::from(rest_centre.y)).abs() < 0.25,
"the press keeps its centre, rest {rest_centre:?} pressed {pressed_centre:?}"
);
assert!(
rest_bottom - pressed_bottom > 0.4,
"the bottom edge lifts with the press, rest {rest_bottom} pressed {pressed_bottom}"
);
cx.simulate_mouse_up(rest_centre, MouseButton::Left, Modifiers::none());
flush_frame(cx);
assert!(
pending_frames(cx) > 0,
"the release must ramp back rather than snap"
);
settle(cx);
let released = seen
.borrow()
.expect("the content probe painted after release");
assert!(
(f32::from(released.size.height) - f32::from(rest.size.height)).abs() < 0.01,
"the button springs back after release, got {released:?}"
);
}
#[gpui::test]
fn button_release_mid_press_resumes_from_the_painted_frame(cx: &mut TestAppContext) {
let seen: Rc<RefCell<Option<gpui::Bounds<gpui::Pixels>>>> = Rc::new(RefCell::new(None));
let sink = seen.clone();
let cx = open_host(cx, move || {
let sink = sink.clone();
Button::new("btn-reverse")
.child(
gpui::canvas(
|_, _, _| {},
move |bounds, _, _, _| {
*sink.borrow_mut() = Some(bounds);
},
)
.w(px(120.))
.h(px(20.)),
)
.into_any_element()
});
flush_frame(cx);
let rest = seen.borrow().expect("the content probe painted at rest");
let centre = point(
px(f32::from(rest.origin.x) + f32::from(rest.size.width) / 2.),
px(f32::from(rest.origin.y) + f32::from(rest.size.height) / 2.),
);
cx.simulate_mouse_down(centre, MouseButton::Left, Modifiers::none());
flush_frame(cx);
std::thread::sleep(std::time::Duration::from_millis(60));
assert!(
pending_frames(cx) > 0,
"the press ramp is still running 60ms in"
);
cx.simulate_mouse_up(centre, MouseButton::Left, Modifiers::none());
flush_frame(cx);
assert!(
pending_frames(cx) > 0,
"a mid-press release must schedule the return ramp"
);
settle(cx);
let released = seen
.borrow()
.expect("the content probe painted after release");
assert!(
(f32::from(released.size.height) - f32::from(rest.size.height)).abs() < 0.01
&& (f32::from(released.size.width) - f32::from(rest.size.width)).abs() < 0.01,
"the interrupted press must settle back at rest, got {released:?}"
);
cx.simulate_mouse_down(centre, MouseButton::Left, Modifiers::none());
flush_frame(cx);
assert!(
pending_frames(cx) > 0,
"a press after a mid-flight reversal must animate again"
);
settle(cx);
}
#[gpui::test]
fn reduced_motion_press_snaps_without_animating(cx: &mut TestAppContext) {
harness::still();
let presses = events();
let recorded = presses.clone();
let cx = open_host(cx, move || {
let recorded = recorded.clone();
Button::new("btn-still")
.label("Still")
.on_press(move |_, _, _| recorded.borrow_mut().push("press".into()))
.into_any_element()
});
flush_frame(cx);
let at = point(px(24.), px(18.));
cx.simulate_mouse_down(at, MouseButton::Left, Modifiers::none());
flush_frame(cx);
assert_eq!(
pending_frames(cx),
0,
"reduced motion must snap the press instead of animating it"
);
cx.simulate_mouse_up(at, MouseButton::Left, Modifiers::none());
flush_frame(cx);
assert_eq!(pending_frames(cx), 0, "the release must snap too");
assert_eq!(
presses.borrow().as_slice(),
["press"],
"reduced motion must not swallow the press"
);
}
fn resting_and_hovered_fills(
cx: &mut TestAppContext,
build: impl Fn() -> gpui::AnyElement + 'static,
) -> (Vec<gpui::Hsla>, Vec<gpui::Hsla>, Vec<gpui::Hsla>) {
let vcx = open_host(cx, move || {
gpui::div()
.flex()
.items_start()
.p(px(24.))
.child(build())
.into_any_element()
});
let resting = harness::painted(vcx).solids();
vcx.simulate_mouse_move(point(px(30.), px(30.)), None, Modifiers::none());
harness::wait_real(vcx, 300);
let hovered = harness::painted(vcx).solids();
vcx.simulate_mouse_move(point(px(600.), px(600.)), None, Modifiers::none());
harness::wait_real(vcx, 300);
let left = harness::painted(vcx).solids();
(resting, hovered, left)
}
#[gpui::test]
fn toggle_button_reads_the_hover_override_and_the_sx_background(cx: &mut TestAppContext) {
let (rest, over): (gpui::Hsla, gpui::Hsla) =
(gpui::rgb(0x224466).into(), gpui::rgb(0x88aa22).into());
let (resting, hovered, left) = resting_and_hovered_fills(cx, move || {
ToggleButton::new("tb-hover")
.label("Bold")
.sx(move |s| s.bg(rest))
.hover_bg(over)
.into_any_element()
});
assert!(
harness::has_color(&resting, rest) && !harness::has_color(&resting, over),
"the resting endpoint must be the sx background, painted {resting:?}"
);
assert!(
harness::has_color(&hovered, over),
"the hover endpoint must be the named override, painted {hovered:?}"
);
let stock_rest = cx.update(|cx| {
use herogpui_theme::ActiveTheme;
cx.colors().default.color
});
assert!(
harness::has_color(&left, rest) && !harness::has_color(&left, stock_rest),
"leaving must ease back to the sx background, not the variant's \
stock fill, painted {left:?}"
);
let (_, stock_hover, _) = resting_and_hovered_fills(cx, move || {
ToggleButton::new("tb-hover-stock")
.label("Bold")
.sx(move |s| s.bg(rest))
.into_any_element()
});
assert!(
!harness::has_color(&stock_hover, over),
"without the override the hover must not paint it"
);
}
fn press_boxes(
cx: &mut TestAppContext,
build: impl Fn() -> gpui::AnyElement + 'static,
pick: impl Fn(&harness::Painted) -> gpui::Bounds<gpui::Pixels>,
) -> (
gpui::Bounds<gpui::Pixels>,
gpui::Bounds<gpui::Pixels>,
gpui::Bounds<gpui::Pixels>,
Vec<gpui::Hsla>,
) {
let vcx = open_host(cx, move || {
gpui::div()
.flex()
.items_start()
.p(px(24.))
.child(build())
.into_any_element()
});
harness::wait_real(vcx, 50);
let resting = pick(&harness::painted(vcx));
let at = resting.center();
vcx.simulate_mouse_move(at, None, Modifiers::none());
vcx.simulate_mouse_down(at, MouseButton::Left, Modifiers::none());
vcx.update(|window, _| window.refresh());
vcx.run_until_parked();
let early_scene = harness::painted(vcx);
let early = pick(&early_scene);
harness::wait_real(vcx, 400);
let settled = pick(&harness::painted(vcx));
vcx.simulate_mouse_up(at, MouseButton::Left, Modifiers::none());
(resting, early, settled, early_scene.solids())
}
fn skin(scene: &harness::Painted) -> gpui::Bounds<gpui::Pixels> {
scene
.quads
.iter()
.filter(|q| q.background.as_solid().is_some_and(|c| c.a > 0.))
.max_by(|a, b| a.bounds.size.width.0.total_cmp(&b.bounds.size.width.0))
.map(|q| scene.bounds(q))
.expect("the control must paint its skin")
}
#[gpui::test]
fn toggle_button_press_rides_the_pinned_ramp(cx: &mut TestAppContext) {
let (resting, early, settled, _) = press_boxes(
cx,
|| {
ToggleButton::new("tb-ramp")
.label("Bold")
.into_any_element()
},
skin,
);
let (w0, w1, w2) = (
f32::from(resting.size.width),
f32::from(early.size.width),
f32::from(settled.size.width),
);
assert!(
w2 < w0 - 0.5,
"a held standalone toggle must scale its skin down ({w0} -> {w2})"
);
assert!(
w1 > w2 + 0.2,
"the scale must ramp, not snap: {w1} right after the press, {w2} settled"
);
let (resting, _, settled, _) = press_boxes(
cx,
move || {
ToggleButtonGroup::new("tg-ramp")
.child_toggle(ToggleButton::new("tg-a").label("A"))
.child_toggle(ToggleButton::new("tg-b").label("B"))
.into_any_element()
},
|scene| {
scene
.quads
.iter()
.find(|q| {
let b = scene.bounds(q);
f32::from(b.origin.x) < 40. && q.background.as_solid().is_some_and(|c| c.a > 0.)
})
.map(|q| scene.bounds(q))
.expect("the first member must paint")
},
);
assert_eq!(
resting.size, settled.size,
"a grouped member must not scale on press"
);
assert!(
source_scan::component_src("toggle_button.rs")
.contains("el.active(move |style| style.bg(hover_bg))"),
"a grouped member suppresses the scale and keeps the instant endpoint"
);
}
#[gpui::test]
fn button_press_uses_the_pinned_background_endpoint(cx: &mut TestAppContext) {
use herogpui_components::Variant;
use herogpui_theme::ActiveTheme;
harness::still();
let vcx = open_host(cx, || {
gpui::div()
.flex()
.items_start()
.p(px(24.))
.child(
Button::new("b-press")
.label("Save")
.variant(Variant::Outline),
)
.into_any_element()
});
let pressed_token = vcx.update(|_, cx| cx.colors().default.color);
let resting = harness::painted(vcx);
assert!(
!resting.solids().contains(&pressed_token),
"the resting outline button must not paint the pressed token"
);
let at = point(px(40.), px(42.));
vcx.simulate_mouse_move(at, None, Modifiers::none());
vcx.simulate_mouse_down(at, MouseButton::Left, Modifiers::none());
let pressed = harness::painted(vcx);
let skins = pressed.filled(pressed_token);
assert!(
!skins.is_empty(),
"the held button must paint the variant's pressed token\n{}",
pressed.describe()
);
assert!(
pressed.quads.iter().all(|q| q
.background
.as_solid()
.is_none_or(|c| { (c.a - 1.).abs() < 1e-3 || c.a < 1e-3 })),
"a press recolours; it never dims the button\n{}",
pressed.describe()
);
vcx.simulate_mouse_up(at, MouseButton::Left, Modifiers::none());
}