diff --git a/crates/base/src/test_support.rs b/crates/base/src/test_support.rs index 27cb13b44e..57ccc4e724 100644 --- a/crates/base/src/test_support.rs +++ b/crates/base/src/test_support.rs @@ -145,10 +145,26 @@ impl Drop for Registration { /// Internal lookup used by Kit's testing API. Scope follows GPUI's element path. #[doc(hidden)] pub fn find(window: &Window, scope: &[ElementId], id: &ElementId) -> Option { - REGISTRY.with(|registry| { + let mut matches = find_all(window, scope, id); + assert!( + matches.len() <= 1, + "ambiguous ElementId {id:?}; use within(...) to select a scope. Matches: {:?}", + matches.iter().map(|entry| &entry.path).collect::>() + ); + matches.pop() +} + +/// Every match for `id` below `scope`, top to bottom, then left to right by +/// bounds origin. Includes invisible registrations; equal-origin order is unspecified. +#[doc(hidden)] +pub fn find_all(window: &Window, scope: &[ElementId], id: &ElementId) -> Vec { + let mut matches: Vec<_> = REGISTRY.with(|registry| { let registry = registry.borrow(); - let entries = registry.get(&(std::sync::Arc::as_ptr(window.text_system()) as usize))?; - let matches: Vec<_> = entries + let Some(entries) = registry.get(&(std::sync::Arc::as_ptr(window.text_system()) as usize)) + else { + return Vec::new(); + }; + entries .values() .filter_map(Weak::upgrade) .filter(|entry| { @@ -156,17 +172,11 @@ pub fn find(window: &Window, scope: &[ElementId], id: &ElementId) -> Option>() - ); - matches.first().map(|entry| entry.facts.borrow().clone()) - }) + .map(|entry| entry.facts.borrow().clone()) + .collect() + }); + matches.sort_unstable_by_key(|entry| (entry.bounds.origin.y, entry.bounds.origin.x)); + matches } #[doc(hidden)] diff --git a/crates/kit/src/test.rs b/crates/kit/src/test.rs index 65c67db259..5d10229d64 100644 --- a/crates/kit/src/test.rs +++ b/crates/kit/src/test.rs @@ -12,8 +12,8 @@ //! external changes, or use [`TestAppContextExt::wait_for`] for asynchronous UI. use crate::{ AnyWindowHandle, App, AppContext, ElementId, InputEvent, KeyDownEvent, KeyUpEvent, Keystroke, - MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, Pixels, Point, ScrollDelta, - ScrollWheelEvent, TestAppContext, Window, point, px, + Modifiers, ModifiersChangedEvent, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, + Pixels, Point, ScrollDelta, ScrollWheelEvent, TestAppContext, Window, point, px, }; use std::time::Duration; @@ -27,6 +27,9 @@ pub trait TestWindowExt { fn find(&self, id: impl Into) -> ElementSnapshot; /// Returns None for an absent target; ambiguous IDs still require a scope. fn try_find(&self, id: impl Into) -> Option; + /// Returns all registered matches from the last completed frame, including invisible ones. + /// Ordered by bounds origin (y, then x); equal-origin order is unspecified. + fn find_all(&self, id: impl Into) -> Vec; /// Restricts queries to a GPUI identity scope; no additional layout wrapper is needed. fn within(&mut self, id: impl Into) -> ScopedWindow<'_>; /// Invalidates cached facts and completes a frame. @@ -34,6 +37,19 @@ pub trait TestWindowExt { fn click(&mut self, id: impl Into, cx: &mut App); /// Clicks at a local offset from the target's top-left corner. fn click_at(&mut self, id: impl Into, offset: Point, cx: &mut App); + /// Dispatches modifiers-changed when necessary, then move/down/up, and restores + /// the previous modifier state with another modifiers-changed event after the click. + /// Each step renders a frame; caps lock is preserved. + fn click_with_options(&mut self, id: impl Into, options: ClickOptions, cx: &mut App); + /// A centered left click with modifiers; restores the previous modifier state. + fn click_with_modifiers( + &mut self, + id: impl Into, + modifiers: Modifiers, + cx: &mut App, + ) { + self.click_with_options(id, ClickOptions::new().with_modifiers(modifiers), cx); + } fn right_click(&mut self, id: impl Into, cx: &mut App); fn double_click(&mut self, id: impl Into, cx: &mut App); fn hover(&mut self, id: impl Into, cx: &mut App); @@ -50,6 +66,69 @@ pub trait TestWindowExt { fn input(&mut self, text: &str, cx: &mut App); } +/// Click configuration owned by the caller. Defaults to one left click at the center, +/// without modifiers. Offsets are relative to the target's top-left corner. +#[derive(Clone, Copy, Debug)] +pub struct ClickOptions { + offset: Option>, + button: MouseButton, + count: usize, + modifiers: Modifiers, +} +impl Default for ClickOptions { + fn default() -> Self { + Self { + offset: None, + button: MouseButton::Left, + count: 1, + modifiers: Modifiers::default(), + } + } +} +impl ClickOptions { + /// Creates one centered left click without modifiers. + pub fn new() -> Self { + Self::default() + } + /// Sets an offset from the target bounds origin; validated when clicking. + pub fn with_offset(mut self, offset: Point) -> Self { + self.offset = Some(offset); + self + } + /// Returns the local offset, or `None` for the center. + pub fn offset(&self) -> Option> { + self.offset + } + /// Sets the mouse button. + pub fn with_button(mut self, button: MouseButton) -> Self { + self.button = button; + self + } + /// Returns the mouse button. + pub fn button(&self) -> MouseButton { + self.button + } + /// Sets the number of clicks. Panics if `count` is zero. + pub fn with_count(mut self, count: usize) -> Self { + assert!(count > 0, "click count must be positive"); + self.count = count; + self + } + /// Returns the positive number of clicks. + pub fn count(&self) -> usize { + self.count + } + /// Sets the modifier state for the click sequence. + pub fn with_modifiers(mut self, modifiers: Modifiers) -> Self { + self.modifiers = modifiers; + self + } + /// Returns the modifier state for the click sequence. + pub fn modifiers(&self) -> Modifiers { + self.modifiers + } +} + fn require(window: &Window, scope: &[ElementId], id: &ElementId) -> ElementSnapshot { observation::find(window, scope, id).unwrap_or_else(|| { panic!("missing ElementId {id:?} in scope {scope:?}. Registered paths: {}. Check the ID, observation, and completed frame.", observation::registered_paths(window)) @@ -88,13 +167,14 @@ fn move_pointer( window: &mut Window, position: Point, pressed_button: Option, + modifiers: Modifiers, cx: &mut App, ) { window.dispatch_event( MouseMoveEvent { position, pressed_button, - modifiers: Default::default(), + modifiers, } .to_platform_input(), cx, @@ -107,13 +187,14 @@ fn mouse_down( position: Point, button: MouseButton, click_count: usize, + modifiers: Modifiers, cx: &mut App, ) { window.dispatch_event( MouseDownEvent { button, position, - modifiers: Default::default(), + modifiers, click_count, first_mouse: false, } @@ -128,13 +209,14 @@ fn mouse_up( position: Point, button: MouseButton, click_count: usize, + modifiers: Modifiers, cx: &mut App, ) { window.dispatch_event( MouseUpEvent { button, position, - modifiers: Default::default(), + modifiers, click_count, } .to_platform_input(), @@ -147,24 +229,58 @@ fn click_target( window: &mut Window, scope: &[ElementId], id: ElementId, - offset: Option>, - button: MouseButton, - count: usize, + options: ClickOptions, cx: &mut App, ) { + let ClickOptions { + offset, + button, + count, + modifiers, + } = options; window.render_frame(cx); let position = target_position(window, scope, &id, offset); - move_pointer(window, position, None, cx); + move_pointer(window, position, None, modifiers, cx); for click_count in 1..=count { - mouse_down(window, position, button, click_count, cx); - mouse_up(window, position, button, click_count, cx); + mouse_down(window, position, button, click_count, modifiers, cx); + mouse_up(window, position, button, click_count, modifiers, cx); + } +} + +fn change_modifiers(window: &mut Window, modifiers: Modifiers, cx: &mut App) { + if window.modifiers() != modifiers { + window.dispatch_event( + ModifiersChangedEvent { + modifiers, + capslock: window.capslock(), + } + .to_platform_input(), + cx, + ); + window.render_frame(cx); } } +fn click_with_options_target( + window: &mut Window, + scope: &[ElementId], + id: ElementId, + options: ClickOptions, + cx: &mut App, +) { + // Validate before changing modifier state, so a missing target leaves it intact. + window.render_frame(cx); + target_position(window, scope, &id, options.offset); + let previous = window.modifiers(); + change_modifiers(window, options.modifiers, cx); + click_target(window, scope, id, options, cx); + change_modifiers(window, previous, cx); +} + fn hover_target(window: &mut Window, scope: &[ElementId], id: ElementId, cx: &mut App) { window.render_frame(cx); let position = target_position(window, scope, &id, None); - move_pointer(window, position, None, cx); + move_pointer(window, position, None, Modifiers::default(), cx); } fn scroll_target( @@ -176,7 +292,7 @@ fn scroll_target( ) { window.render_frame(cx); let position = target_position(window, scope, &id, None); - move_pointer(window, position, None, cx); + move_pointer(window, position, None, Modifiers::default(), cx); window.dispatch_event( ScrollWheelEvent { position, @@ -209,6 +325,9 @@ impl TestWindowExt for Window { fn try_find(&self, id: impl Into) -> Option { observation::find(self, &[], &id.into()) } + fn find_all(&self, id: impl Into) -> Vec { + observation::find_all(self, &[], &id.into()) + } fn within(&mut self, id: impl Into) -> ScopedWindow<'_> { let scope = observation::scope(self, &[], &id.into()); ScopedWindow { @@ -221,16 +340,36 @@ impl TestWindowExt for Window { self.draw(cx).clear(cx); } fn click(&mut self, id: impl Into, cx: &mut App) { - click_target(self, &[], id.into(), None, MouseButton::Left, 1, cx); + click_target(self, &[], id.into(), ClickOptions::new(), cx); } fn click_at(&mut self, id: impl Into, offset: Point, cx: &mut App) { - click_target(self, &[], id.into(), Some(offset), MouseButton::Left, 1, cx); + click_target( + self, + &[], + id.into(), + ClickOptions::new().with_offset(offset), + cx, + ); + } + fn click_with_options( + &mut self, + id: impl Into, + options: ClickOptions, + cx: &mut App, + ) { + click_with_options_target(self, &[], id.into(), options, cx); } fn right_click(&mut self, id: impl Into, cx: &mut App) { - click_target(self, &[], id.into(), None, MouseButton::Right, 1, cx); + click_target( + self, + &[], + id.into(), + ClickOptions::new().with_button(MouseButton::Right), + cx, + ); } fn double_click(&mut self, id: impl Into, cx: &mut App) { - click_target(self, &[], id.into(), None, MouseButton::Left, 2, cx); + click_target(self, &[], id.into(), ClickOptions::new().with_count(2), cx); } fn hover(&mut self, id: impl Into, cx: &mut App) { hover_target(self, &[], id.into(), cx); @@ -243,8 +382,8 @@ impl TestWindowExt for Window { } fn drag(&mut self, from: Point, to: Point, cx: &mut App) { self.render_frame(cx); - move_pointer(self, from, None, cx); - mouse_down(self, from, MouseButton::Left, 1, cx); + move_pointer(self, from, None, Modifiers::default(), cx); + mouse_down(self, from, MouseButton::Left, 1, Modifiers::default(), cx); for step in 1..=8 { let fraction = step as f32 / 8.; move_pointer( @@ -254,10 +393,11 @@ impl TestWindowExt for Window { from.y + (to.y - from.y) * fraction, ), Some(MouseButton::Left), + Modifiers::default(), cx, ); } - mouse_up(self, to, MouseButton::Left, 1, cx); + mouse_up(self, to, MouseButton::Left, 1, Modifiers::default(), cx); } fn press(&mut self, key: &str, cx: &mut App) { let key = @@ -283,6 +423,11 @@ impl ScopedWindow<'_> { pub fn try_find(&self, id: impl Into) -> Option { observation::find(self.window, &self.scope, &id.into()) } + /// All registered descendants, including invisible ones, by current-frame bounds. + /// Equal-origin order is unspecified. + pub fn find_all(&self, id: impl Into) -> Vec { + observation::find_all(self.window, &self.scope, &id.into()) + } pub fn within(&mut self, id: impl Into) -> ScopedWindow<'_> { let scope = observation::scope(self.window, &self.scope, &id.into()); ScopedWindow { @@ -291,35 +436,41 @@ impl ScopedWindow<'_> { } } pub fn click(&mut self, id: impl Into, cx: &mut App) { - click_target( - self.window, - &self.scope, - id.into(), - None, - MouseButton::Left, - 1, - cx, - ); + click_target(self.window, &self.scope, id.into(), ClickOptions::new(), cx); } pub fn click_at(&mut self, id: impl Into, offset: Point, cx: &mut App) { click_target( self.window, &self.scope, id.into(), - Some(offset), - MouseButton::Left, - 1, + ClickOptions::new().with_offset(offset), cx, ); } + /// Configurable click inside this scope; restores the previous modifier state. + pub fn click_with_options( + &mut self, + id: impl Into, + options: ClickOptions, + cx: &mut App, + ) { + click_with_options_target(self.window, &self.scope, id.into(), options, cx); + } + /// Centered left click with modifiers; restores the previous modifier state. + pub fn click_with_modifiers( + &mut self, + id: impl Into, + modifiers: Modifiers, + cx: &mut App, + ) { + self.click_with_options(id, ClickOptions::new().with_modifiers(modifiers), cx); + } pub fn right_click(&mut self, id: impl Into, cx: &mut App) { click_target( self.window, &self.scope, id.into(), - None, - MouseButton::Right, - 1, + ClickOptions::new().with_button(MouseButton::Right), cx, ); } @@ -328,9 +479,7 @@ impl ScopedWindow<'_> { self.window, &self.scope, id.into(), - None, - MouseButton::Left, - 2, + ClickOptions::new().with_count(2), cx, ); } diff --git a/crates/kit/tests/collections.rs b/crates/kit/tests/collections.rs index 902cd7f754..b82b93262d 100644 --- a/crates/kit/tests/collections.rs +++ b/crates/kit/tests/collections.rs @@ -1,12 +1,14 @@ mod common; use gpui_kit::component::{ - list::ListItem, + IndexPath, + list::{List, ListDelegate, ListItem, ListState}, table::{Column, DataTable, TableDelegate, TableSelection, TableState}, tree::{Tree, TreeItem, TreeState}, }; use gpui_kit::test::TestWindowExt; use gpui_kit::{ - App, AppContext, Context, Entity, Focusable, TestAppContext, Window, div, prelude::*, px, size, + App, AppContext, Context, Entity, Focusable, Modifiers, TestAppContext, Window, div, + prelude::*, px, size, }; struct Files { @@ -256,3 +258,60 @@ fn table_keyboard_leaves_rows_unselected_when_rows_are_not_selectable(cx: &mut T }) .unwrap(); } + +struct Choices { + confirmed: Vec, +} +impl ListDelegate for Choices { + type Item = ListItem; + + fn items_count(&self, _: usize, _: &App) -> usize { + 2 + } + + fn render_item( + &mut self, + ix: IndexPath, + _: &mut Window, + _: &mut Context>, + ) -> Option { + Some(ListItem::new(("choice", ix.row)).child(format!("Choice {}", ix.row))) + } + + fn set_selected_index( + &mut self, + _: Option, + _: &mut Window, + _: &mut Context>, + ) { + } + + fn confirm(&mut self, secondary: bool, _: &mut Window, _: &mut Context>) { + self.confirmed.push(secondary); + } +} +struct Picker { + list: Entity>, +} +impl Render for Picker { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + div().size_full().child(List::new(&self.list)) + } +} +#[gpui_kit::test] +fn list_click_confirms_as_secondary_with_the_secondary_modifier(cx: &mut TestAppContext) { + cx.update(gpui_kit::init); + let (handle, picker) = common::open_window(cx, Some(size(px(320.), px(240.))), |window, cx| { + cx.new(|cx| Picker { + list: cx.new(|cx| ListState::new(Choices { confirmed: vec![] }, window, cx)), + }) + }); + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + window.click(("choice", 0usize), cx); + window.click_with_modifiers(("choice", 1usize), Modifiers::secondary_key(), cx); + let list = picker.read(cx).list.clone(); + assert_eq!(list.read(cx).delegate().confirmed, [false, true]); + }) + .unwrap(); +} diff --git a/crates/kit/tests/interactions.rs b/crates/kit/tests/interactions.rs index 290325e828..1fe9dbe4ca 100644 --- a/crates/kit/tests/interactions.rs +++ b/crates/kit/tests/interactions.rs @@ -1,8 +1,9 @@ mod common; -use gpui_kit::test::{TestAppContextExt, TestSupportExt, TestWindowExt}; +use gpui_kit::test::{ClickOptions, TestAppContextExt, TestSupportExt, TestWindowExt}; use gpui_kit::{ - AppContext, Context, MouseButton, ScrollDelta, ScrollHandle, TestAppContext, Window, div, - point, prelude::*, px, size, + AppContext, Context, FocusHandle, InputEvent, Modifiers, ModifiersChangedEvent, MouseButton, + MouseDownEvent, ScrollDelta, ScrollHandle, TestAppContext, Window, div, point, prelude::*, px, + size, }; use std::{cell::RefCell, rc::Rc, time::Duration}; @@ -108,6 +109,161 @@ fn hover_right_click_and_double_click_dispatch_native_pointer_events(cx: &mut Te assert!(events.windows(2).any(|pair| pair == ["left:1", "left:2"])); } +struct ModifiedClick { + focus: FocusHandle, + events: Rc>>, +} +impl Render for ModifiedClick { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + div().id("panel").child({ + let moved = self.events.clone(); + let down = self.events.clone(); + let up = self.events.clone(); + let click = self.events.clone(); + let changed = self.events.clone(); + div() + .id("surface") + .test_support() + .track_focus(&self.focus) + .size(px(80.)) + .on_modifiers_changed(move |event, _, _| { + changed.borrow_mut().push(("modifiers", event.modifiers)); + }) + .on_mouse_move(move |event, _, _| { + moved.borrow_mut().push(("move", event.modifiers)) + }) + .on_mouse_down(MouseButton::Left, move |event, _, _| { + down.borrow_mut().push(("down", event.modifiers)) + }) + .on_mouse_up(MouseButton::Left, move |event, _, _| { + up.borrow_mut().push(("up", event.modifiers)) + }) + .on_click(move |event, _, _| click.borrow_mut().push(("click", event.modifiers()))) + }) + } +} +#[gpui_kit::test] +fn modified_clicks_carry_modifiers_on_every_pointer_event(cx: &mut TestAppContext) { + let events = Rc::new(RefCell::new(vec![])); + let (handle, view) = common::open_window(cx, None, |_, cx| { + cx.new(|cx| ModifiedClick { + focus: cx.focus_handle(), + events: events.clone(), + }) + }); + cx.update_window(handle.into(), |_, window, cx| { + let focus = view.read(cx).focus.clone(); + window.focus(&focus, cx); + window.click_with_modifiers("surface", Modifiers::secondary_key(), cx); + assert_eq!(window.modifiers(), Modifiers::default()); + }) + .unwrap(); + let recorded = std::mem::take(&mut *events.borrow_mut()); + assert_eq!( + recorded.first(), + Some(&("modifiers", Modifiers::secondary_key())) + ); + assert_eq!(recorded.last(), Some(&("modifiers", Modifiers::default()))); + for kind in ["move", "down", "up", "click"] { + assert!( + recorded + .iter() + .any(|(event, modifiers)| *event == kind && modifiers.secondary()), + "{kind} did not carry the secondary modifier: {recorded:?}" + ); + } + + cx.update_window(handle.into(), |_, window, cx| { + window.dispatch_event( + ModifiersChangedEvent { + modifiers: Modifiers::alt(), + capslock: window.capslock(), + } + .to_platform_input(), + cx, + ); + window + .within("panel") + .click_with_modifiers("surface", Modifiers::shift(), cx); + assert_eq!(window.modifiers(), Modifiers::alt()); + }) + .unwrap(); + let recorded = events.borrow(); + assert!( + recorded + .iter() + .any(|(event, modifiers)| *event == "click" && *modifiers == Modifiers::shift()), + "scoped click did not carry shift: {recorded:?}" + ); +} + +struct ConfiguredClick { + down: Rc>>, +} +impl Render for ConfiguredClick { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + let down = self.down.clone(); + div().id("panel").child( + div() + .id("surface") + .test_support() + .size(px(80.)) + .on_mouse_down(MouseButton::Right, move |event, _, _| { + down.borrow_mut().push(event.clone()) + }), + ) + } +} +#[gpui_kit::test] +fn click_options_combine_offset_button_count_and_modifiers(cx: &mut TestAppContext) { + let down = Rc::new(RefCell::new(vec![])); + let (handle, _) = common::open_window(cx, None, |_, cx| { + cx.new(|_| ConfiguredClick { down: down.clone() }) + }); + let options = ClickOptions::new() + .with_offset(point(px(7.), px(11.))) + .with_button(MouseButton::Right) + .with_count(2) + .with_modifiers(Modifiers::shift()); + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + let position = window.find("surface").bounds().origin + options.offset().unwrap(); + window.click_with_options("surface", options, cx); + window + .within("panel") + .click_with_options("surface", options, cx); + let events = down.borrow(); + assert_eq!(events.len(), 4); + for (event, count) in events.iter().zip([1, 2, 1, 2]) { + assert_eq!(event.button, MouseButton::Right); + assert_eq!(event.click_count, count); + assert_eq!(event.position, position); + assert_eq!(event.modifiers, Modifiers::shift()); + } + assert_eq!(window.modifiers(), Modifiers::default()); + }) + .unwrap(); +} + +#[test] +fn test_click_options_builder() { + let defaults = ClickOptions::new(); + assert_eq!(defaults.offset(), None); + assert_eq!(defaults.button(), MouseButton::Left); + assert_eq!(defaults.count(), 1); + assert_eq!(defaults.modifiers(), Modifiers::default()); + let options = defaults + .with_offset(point(px(7.), px(11.))) + .with_button(MouseButton::Right) + .with_count(2) + .with_modifiers(Modifiers::shift()); + assert_eq!(options.offset(), Some(point(px(7.), px(11.)))); + assert_eq!(options.button(), MouseButton::Right); + assert_eq!(options.count(), 2); + assert_eq!(options.modifiers(), Modifiers::shift()); + assert!(std::panic::catch_unwind(|| ClickOptions::new().with_count(0)).is_err()); +} + struct Scrolling { scroll: ScrollHandle, } diff --git a/crates/kit/tests/overlays.rs b/crates/kit/tests/overlays.rs index 09f5e10ee0..28d89d9669 100644 --- a/crates/kit/tests/overlays.rs +++ b/crates/kit/tests/overlays.rs @@ -145,6 +145,32 @@ async fn dialog_validates_scoped_input_saves_and_dismisses_notification(cx: &mut .await; } +#[gpui_kit::test] +async fn find_all_counts_stacked_notifications(cx: &mut TestAppContext) { + cx.update(gpui_kit::init); + let (handle, _) = common::open_window(cx, Some(size(px(800.), px(700.))), |window, cx| { + cx.new(|cx| Workspace { + saved: cx.new(|cx| InputState::new(window, cx)), + draft: cx.new(|cx| InputState::new(window, cx)), + }) + }); + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + window.click("notify", cx); + window.click("notify", cx); + }) + .unwrap(); + cx.wait_for(handle.into(), Duration::from_secs(1), |window, _| { + window.find_all("notification").len() == 2 + }) + .await; + cx.update_window(handle.into(), |_, window, _| { + let toasts = window.find_all("notification"); + assert_ne!(toasts[0].path(), toasts[1].path()); + }) + .unwrap(); +} + #[gpui_kit::test] async fn escape_dismisses_dialog_and_sheet_and_restores_focus(cx: &mut TestAppContext) { cx.update(gpui_kit::init); diff --git a/crates/kit/tests/window.rs b/crates/kit/tests/window.rs index 8f95810595..fd7fd4714b 100644 --- a/crates/kit/tests/window.rs +++ b/crates/kit/tests/window.rs @@ -172,6 +172,23 @@ fn duplicate_local_ids_fail_clearly(cx: &mut TestAppContext) { .unwrap(); } +#[gpui_kit::test] +fn find_all_returns_every_match_in_scope(cx: &mut TestAppContext) { + let (handle, _) = common::open_window(cx, None, |_, cx| cx.new(|_| Duplicate)); + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + let all = window.find_all("duplicate"); + assert_eq!(all.len(), 2); + assert_ne!(all[0].path(), all[1].path()); + assert!(all[0].bounds().top() < all[1].bounds().top()); + let scoped = window.within("two").find_all("duplicate"); + assert_eq!(scoped.len(), 1); + assert_eq!(scoped[0].path(), all[1].path()); + assert!(window.find_all("missing").is_empty()); + }) + .unwrap(); +} + struct Cached { child: gpui_kit::Entity, } diff --git a/skills/gpui-kit/references/gpui/test.md b/skills/gpui-kit/references/gpui/test.md index f5c2ced4c4..23c59cf833 100644 --- a/skills/gpui-kit/references/gpui/test.md +++ b/skills/gpui-kit/references/gpui/test.md @@ -169,22 +169,33 @@ fn saves_a_profile_through_the_ui(cx: &mut TestAppContext) { ## Queries and interactions -Import `TestWindowExt` and, for custom registration, `TestSupportExt` from -`gpui_kit::test`. Use normal Rust `assert!` and `assert_eq!` with snapshots. +Import `TestWindowExt` for queries and interactions from `gpui_kit::test`. +For custom registration, import `TestSupportExt`. Use normal Rust assertions with snapshots. | API | Behavior | | --- | --- | | `window.find(id)` | Requires a unique observed target; errors list registered paths. | | `window.try_find(id)` | Returns `None` when absent; ambiguous IDs still panic. | +| `window.find_all(id)` | Returns all registered matches, including invisible ones, by current-frame bounds origin (y, then x); equal-origin order is unspecified. Empty when absent. | | `window.within(id)` | Resolves a native GPUI identity scope, including an unobserved ancestor. | | `click`, `right_click`, `double_click`, `hover` | Dispatch real pointer events at the target center. | | `click_at(id, offset, cx)` | Uses an offset from the target bounds' top-left corner. | +| `window.click_with_modifiers(id, modifiers, cx)` | Centered left click with modifiers; sends modifier changes and restores the previous modifier state afterward. | +| `window.click_with_options(id, options, cx)` | Combines offset, button, count, and modifiers using `ClickOptions`; restores the previous modifier state afterward. | | `scroll(id, delta, cx)` | Dispatches a GPUI `ScrollDelta` wheel event. | | `drag_to(from_id, to_id, cx)` | Drags between target centers within the current scope. | | `window.drag(from, to, cx)` | Drags between window-local points; use `bounds()` for precise or cross-scope geometry. | | `press(key, cx)` | Sends named GPUI keys such as `backspace`, `escape` or `secondary-a`. | | `input(text, cx)` | Types Unicode characters at current focus; click the input first. | +`ClickOptions::new()` defaults to one left click at the center without modifiers. +For example, `ClickOptions::new().with_offset(point(px(8.), px(8.))) +.with_button(MouseButton::Right).with_count(2).with_modifiers(Modifiers::shift())` +combines a local offset, right double-click, and Shift. Counts must be positive. +Configurable clicks preserve caps lock. `find_all` ordering is geometry for the +current frame, not stable identity, paint order, or business order; filter with +`visible()` when counting visible matches. + GPUI IDs need only be unique within their native scope. Use `window.within("dialog").find("name")` for repeated local IDs. Scoped windows also provide pointer operations, `press` and `input`; keyboard helpers require diff --git a/website/docs/test.md b/website/docs/test.md index b4bdcd2bf4..f7aa5fbf95 100644 --- a/website/docs/test.md +++ b/website/docs/test.md @@ -220,14 +220,17 @@ IDs such as `("row", record_id)` preserve record identity after reordering. ## Interact and assert -Import `gpui_kit::test::TestWindowExt` for the following methods: +Import `gpui_kit::test::TestWindowExt` for queries and interactions: | API | Behavior | | --- | --- | | `window.find(id)` | Requires an `ElementSnapshot` from the last completed frame; missing targets panic with registered paths and troubleshooting hints. | | `window.try_find(id)` | Returns `None` when absent; ambiguity still panics. | +| `window.find_all(id)` | Returns all registered matches, including invisible ones, by current-frame bounds origin (y, then x); equal-origin order is unspecified. Empty when absent. | | `window.click(id, cx)` | Native mouse move/down/up at the target center. | | `window.click_at(id, offset, cx)` | Click at a pixel offset from the target's top-left corner, useful for partial clipping. | +| `window.click_with_modifiers(id, modifiers, cx)` | Centered left click with modifiers; sends modifier changes and restores the previous modifier state afterward. | +| `window.click_with_options(id, options, cx)` | Combines offset, button, count, and modifiers using `ClickOptions`; restores the previous modifier state afterward. | | `window.right_click(id, cx)` / `double_click(id, cx)` | Native right-button or two-click sequences. | | `window.hover(id, cx)` | Move the pointer without pressing a button. | | `window.scroll(id, delta, cx)` | Native wheel event; `ScrollDelta` retains GPUI units and sign. | @@ -236,8 +239,17 @@ Import `gpui_kit::test::TestWindowExt` for the following methods: | `window.press("backspace", cx)` | Native key-down/key-up for a named key or shortcut using GPUI's keystroke parser. | | `window.input(text, cx)` | Per-character text input to the current focus; does not focus or replace the whole value. | -Scoped queries support `find`, `try_find`, nested `within`, `click`, `click_at`, -`right_click`, `double_click`, `hover`, `scroll`, `drag_to`, `press` and `input`. +`ClickOptions::new()` defaults to one left click at the center without modifiers. +For example, `ClickOptions::new().with_offset(point(px(8.), px(8.))) +.with_button(MouseButton::Right).with_count(2).with_modifiers(Modifiers::shift())` +combines a local offset, right double-click, and Shift. Counts must be positive. +Configurable clicks preserve caps lock. `find_all` ordering is geometry for the +current frame, not stable identity, paint order, or business order; filter with +`visible()` when counting visible matches. + +Scoped queries support `find`, `try_find`, `find_all`, nested `within`, `click`, +`click_at`, `click_with_modifiers`, `click_with_options`, `right_click`, `double_click`, `hover`, `scroll`, +`drag_to`, `press` and `input`. `drag_to` resolves both IDs within the scope. For cross-scope drags or custom offsets, query the targets and pass window-local points to `window.drag`. diff --git a/website/zh-CN/docs/test.md b/website/zh-CN/docs/test.md index 44ce95859e..9362c56192 100644 --- a/website/zh-CN/docs/test.md +++ b/website/zh-CN/docs/test.md @@ -182,14 +182,17 @@ assert!(save.visible()); ## 操作与断言 -导入 `gpui_kit::test::TestWindowExt` 后使用以下方法: +从 `gpui_kit::test` 导入 `TestWindowExt` 使用查询和交互方法: | API | 行为 | | --- | --- | | `window.find(id)` | 严格返回最近完成帧的 `ElementSnapshot`;缺失时 panic,列出注册路径与排查提示。 | | `window.try_find(id)` | 缺失时返回 `None`,歧义仍会 panic。 | +| `window.find_all(id)` | 按当前帧边界原点的 y、x 排序,返回所有已注册匹配项,包括不可见元素;同坐标顺序不保证,缺失时返回空 `Vec`。 | | `window.click(id, cx)` | 在中心发送原生鼠标移动、按下与释放。 | | `window.click_at(id, offset, cx)` | 相对于目标左上角的像素偏移点击,适合部分裁剪。 | +| `window.click_with_modifiers(id, modifiers, cx)` | 携带修饰键在中心左键点击;发送修饰键变化事件,结束后恢复之前的修饰键状态。 | +| `window.click_with_options(id, options, cx)` | 用 `ClickOptions` 组合偏移、鼠标键、点击次数和修饰键;结束后恢复之前的修饰键状态。 | | `window.right_click(id, cx)` / `double_click(id, cx)` | 原生右键或两次点击序列。 | | `window.hover(id, cx)` | 移动指针,不按键。 | | `window.scroll(id, delta, cx)` | 原生滚轮事件,`ScrollDelta` 保留 GPUI 的方向与单位。 | @@ -198,8 +201,15 @@ assert!(save.visible()); | `window.press("backspace", cx)` | 使用 GPUI 按键解析器,为特殊键或快捷键发送原生 key-down/key-up 事件。 | | `window.input(text, cx)` | 向当前焦点逐字符输入,不自动聚焦或替换整个值。 | -作用域支持 `find`、`try_find`、嵌套 `within`、`click`、`click_at`、`right_click`、 -`double_click`、`hover`、`scroll`、`drag_to`、`press` 和 `input`。 +`ClickOptions::new()` 默认在中心单击左键,不带修饰键。例如, +`ClickOptions::new().with_offset(point(px(8.), px(8.))) +.with_button(MouseButton::Right).with_count(2).with_modifiers(Modifiers::shift())` +组合局部偏移、右键双击和 Shift。点击次数必须大于零,可配置点击保留 Caps Lock 状态。 +`find_all` 的顺序只表示当前帧几何位置,不表示稳定身份、绘制顺序或业务顺序; +统计可见匹配项时,用 `visible()` 过滤。 + +作用域支持 `find`、`try_find`、`find_all`、嵌套 `within`、`click`、`click_at`、 +`click_with_modifiers`、`click_with_options`、`right_click`、`double_click`、`hover`、`scroll`、`drag_to`、`press` 和 `input`。 `drag_to` 的两个 ID 都在当前作用域中解析。跨作用域拖拽或指定偏移时,可查询目标后 将窗口坐标传给 `window.drag`。