use std::{cell::RefCell, rc::Rc}; use gpui::{ AnyWindowHandle, AppContext as _, Context, IntoElement, KeyDownEvent, KeyUpEvent, Keystroke, Modifiers, MouseButton, ParentElement as _, Render, Styled as _, TestAppContext, VisualTestContext, Window, div, point, px, }; use gpui_native_core::button::ButtonVariant; use gpui_native_core::dropdown_menu::DropdownMenu; use gpui_native_core::menu::MenuItem; use gpui_native_theme::Theme; struct Harness { open: bool, split: bool, disabled: bool, changes: Rc>>, clicks: Rc>, activated: Rc>>, } impl Render for Harness { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { let view = cx.entity(); let changes = self.changes.clone(); let clicks = self.clicks.clone(); let activated = self.activated.clone(); let mut trigger = DropdownMenu::new("test-dd") .label("Save") .variant(ButtonVariant::Secondary) .open(self.open) .on_open_change(move |next, _, cx| { changes.borrow_mut().push(next); view.update(cx, |harness, cx| { harness.open = next; cx.notify(); }); }) .items(move |_, _| { vec![ export_item(activated.clone(), "png"), export_item(activated.clone(), "pdf"), ] }); if self.disabled { trigger = trigger.disabled(true); } if self.split { trigger = trigger.on_click(move |_, _, _| *clicks.borrow_mut() += 1); } div().size_full().child(trigger) } } fn export_item(activated: Rc>>, format: &'static str) -> MenuItem { MenuItem::new(format).handler(move |_, _| activated.borrow_mut().push(format)) } type Changes = Rc>>; type Clicks = Rc>; type Activated = Rc>>; /// A maximized window whose trigger starts in the top-left corner, so the /// main zone is under the cursor at the button's usual tap position and the /// chevron zone is found through its debug bounds. fn dropdown_window( split: bool, disabled: bool, ) -> ( TestAppContext, VisualTestContext, AnyWindowHandle, Changes, Clicks, Activated, ) { let mut cx = TestAppContext::single(); cx.update(Theme::init); let changes = Rc::new(RefCell::new(Vec::new())); let clicks = Rc::new(RefCell::new(0)); let activated = Rc::new(RefCell::new(Vec::new())); let harness_changes = changes.clone(); let harness_clicks = clicks.clone(); let harness_activated = activated.clone(); let window = cx.add_window(move |_, _| Harness { open: false, split, disabled, changes: harness_changes, clicks: harness_clicks, activated: harness_activated, }); let window = window.into(); cx.update_window(window, |_, window, cx| { window.draw(cx).clear(cx); }) .unwrap(); let visual = VisualTestContext::from_window(window, &cx); (cx, visual, window, changes, clicks, activated) } fn draw(visual: &mut VisualTestContext, window: AnyWindowHandle) { visual .update_window(window, |_, window, cx| { window.draw(cx).clear(cx); }) .unwrap(); } fn click(visual: &mut VisualTestContext, position: gpui::Point) { visual.simulate_mouse_down(position, MouseButton::Left, Modifiers::default()); visual.simulate_mouse_up(position, MouseButton::Left, Modifiers::default()); } /// The painted center of an element found through its debug selector. fn center(visual: &mut VisualTestContext, selector: &'static str) -> gpui::Point { let bounds = visual .debug_bounds(selector) .unwrap_or_else(|| panic!("{selector} should have been laid out")); point( bounds.origin.x + bounds.size.width / 2., bounds.origin.y + bounds.size.height / 2., ) } /// Split form: clicking the main zone fires the action and the menu stays /// closed — the button keeps its normal behavior under a child menu. #[test] fn action_button_fires_and_does_not_open() { let (_cx, mut visual, window, changes, clicks, _activated) = dropdown_window(true, false); click(&mut visual, point(px(30.), px(14.))); assert_eq!(*clicks.borrow(), 1, "the main zone fires on_click"); assert!( changes.borrow().is_empty(), "the menu must stay closed: {:?}", changes.borrow() ); draw(&mut visual, window); assert!(visual.debug_bounds("menu-panel").is_none()); } /// Split form: only the chevron zone opens the menu, and an activated row /// closes it. #[test] fn chevron_opens_and_row_activation_closes() { let (_cx, mut visual, window, changes, clicks, activated) = dropdown_window(true, false); let chevron = center(&mut visual, "test-dd-chevron"); click(&mut visual, chevron); assert_eq!(*changes.borrow(), vec![true]); draw(&mut visual, window); let row = center(&mut visual, "menu-item-0"); click(&mut visual, row); assert_eq!(*activated.borrow(), vec!["png"]); assert_eq!(*changes.borrow(), vec![true, false]); assert_eq!(*clicks.borrow(), 0, "row activation never fires on_click"); } /// Menu-only form: the whole button opens the menu on click, like a menu /// button. #[test] fn plain_button_activates_the_menu() { let (_cx, mut visual, _window, changes, clicks, _activated) = dropdown_window(false, false); click(&mut visual, point(px(30.), px(14.))); assert_eq!(*changes.borrow(), vec![true]); assert_eq!(*clicks.borrow(), 0); } /// Keyboard focus returns to the trigger when the menu closes, so the down /// arrow opens it again. #[test] fn escape_returns_focus_to_the_trigger() { let (_cx, mut visual, window, changes, _clicks, _activated) = dropdown_window(true, false); let chevron = center(&mut visual, "test-dd-chevron"); click(&mut visual, chevron); draw(&mut visual, window); visual.simulate_keystrokes("escape"); assert_eq!(*changes.borrow(), vec![true, false]); draw(&mut visual, window); visual.simulate_keystrokes("down"); assert_eq!( *changes.borrow(), vec![true, false, true], "the focused trigger reopened the menu" ); } /// Enter and Space fire the split button's action while it is focused, like /// any push button. #[test] fn enter_and_space_fire_the_action_while_focused() { let (_cx, mut visual, window, changes, clicks, _activated) = dropdown_window(true, false); click(&mut visual, point(px(30.), px(14.))); draw(&mut visual, window); for key in ["enter", "space"] { visual.simulate_event(KeyDownEvent { keystroke: Keystroke::parse(key).unwrap(), is_held: false, prefer_character_input: false, }); visual.simulate_event(KeyUpEvent { keystroke: Keystroke::parse(key).unwrap(), }); } assert_eq!(*clicks.borrow(), 3, "the pointer click, Enter, and Space"); assert!(changes.borrow().is_empty()); } /// Enter opens the menu while a menu-only trigger is focused. #[test] fn enter_opens_the_menu_only_trigger() { let (_cx, mut visual, window, changes, _clicks, _activated) = dropdown_window(false, false); // Opening hands focus to the panel; dismissing hands it back to the // trigger, whose Enter then reopens the menu. click(&mut visual, point(px(30.), px(14.))); draw(&mut visual, window); visual.simulate_keystrokes("escape"); draw(&mut visual, window); { let key = "enter"; visual.simulate_event(KeyDownEvent { keystroke: Keystroke::parse(key).unwrap(), is_held: false, prefer_character_input: false, }); visual.simulate_event(KeyUpEvent { keystroke: Keystroke::parse(key).unwrap(), }); } assert_eq!(*changes.borrow(), vec![true, false, true]); } /// Action rows sit a half-step apart so the rounded selection highlight /// reads as its own chip instead of a continuous block. #[test] fn action_rows_sit_a_half_step_apart() { let (_cx, mut visual, window, _changes, _clicks, _activated) = dropdown_window(true, false); let chevron = center(&mut visual, "test-dd-chevron"); click(&mut visual, chevron); draw(&mut visual, window); let first = visual .debug_bounds("menu-item-0") .expect("first row should have been laid out"); let second = visual .debug_bounds("menu-item-1") .expect("second row should have been laid out"); let gap = second.origin.y - (first.origin.y + first.size.height); // Painted bounds are snapped to device pixels; allow half a pixel. assert!( (gap - px(2.)).abs() <= px(0.5), "rows should sit 2px apart, got {gap:?}" ); } /// A pointer-opened menu arms no row, so enter cannot fire one; the first /// down arms a row and enter fires it. #[test] fn pointer_opens_arm_nothing() { let (_cx, mut visual, window, changes, _clicks, activated) = dropdown_window(true, false); let chevron = center(&mut visual, "test-dd-chevron"); click(&mut visual, chevron); draw(&mut visual, window); visual.simulate_keystrokes("enter"); assert!( activated.borrow().is_empty(), "no row is armed on pointer open" ); assert_eq!(*changes.borrow(), vec![true], "the menu stays open"); visual.simulate_keystrokes("down enter"); assert_eq!(*activated.borrow(), vec!["png"]); assert_eq!(*changes.borrow(), vec![true, false]); } /// A keyboard-opened menu (down arrow from the focused trigger) arms the /// first selectable row, so enter fires it right away. #[test] fn keyboard_opens_arm_the_first_row() { let (_cx, mut visual, window, changes, _clicks, activated) = dropdown_window(false, false); click(&mut visual, point(px(30.), px(14.))); draw(&mut visual, window); visual.simulate_keystrokes("escape"); draw(&mut visual, window); visual.simulate_keystrokes("down"); draw(&mut visual, window); visual.simulate_keystrokes("enter"); assert_eq!(*activated.borrow(), vec!["png"]); assert_eq!(*changes.borrow(), vec![true, false, true, false]); } /// A disabled trigger ignores both zones. #[test] fn disabled_trigger_ignores_clicks() { let (_cx, mut visual, window, changes, clicks, _activated) = dropdown_window(true, true); click(&mut visual, point(px(30.), px(14.))); let chevron = center(&mut visual, "test-dd-chevron"); click(&mut visual, chevron); draw(&mut visual, window); assert_eq!(*clicks.borrow(), 0); assert!(changes.borrow().is_empty()); assert!(visual.debug_bounds("menu-panel").is_none()); }