From 9c5e598da4b90c3ef1847c868972f346f2f34fa3 Mon Sep 17 00:00:00 2001 From: Gustave Date: Tue, 6 Oct 2026 21:49:37 +0200 Subject: [PATCH 1/4] kit: Add `click_with_modifiers` to the window test harness The pointer helpers sent every mouse move, down and up event with `Modifiers::default()`, so a UI test could not reach code that reads `event.modifiers()`, such as a List row confirming with `secondary: e.modifiers().secondary()`. `click_with_modifiers` left-clicks a target with the given modifiers on the move, down and up events. It mirrors GPUI's `VisualTestContext::simulate_click(position, modifiers)` and is available on both `TestWindowExt` and `ScopedWindow`. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PRpJSFk3zTwdgAJHehdZFm --- crates/kit/src/test.rs | 122 ++++++++++++++++++++---- crates/kit/tests/collections.rs | 63 +++++++++++- crates/kit/tests/interactions.rs | 68 ++++++++++++- skills/gpui-kit/references/gpui/test.md | 1 + website/docs/test.md | 4 +- website/zh-CN/docs/test.md | 5 +- 6 files changed, 239 insertions(+), 24 deletions(-) diff --git a/crates/kit/src/test.rs b/crates/kit/src/test.rs index 65c67db259..80c05625a9 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, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, Pixels, Point, + ScrollDelta, ScrollWheelEvent, TestAppContext, Window, point, px, }; use std::time::Duration; @@ -34,6 +34,14 @@ 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); + /// Left-clicks while holding modifiers, such as `Modifiers::secondary_key()`. + /// The pointer move, mouse-down and mouse-up events all carry them. + fn click_with_modifiers( + &mut self, + id: impl Into, + modifiers: Modifiers, + cx: &mut App, + ); 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); @@ -88,13 +96,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 +116,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 +138,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(), @@ -143,6 +154,7 @@ fn mouse_up( window.render_frame(cx); } +#[allow(clippy::too_many_arguments)] fn click_target( window: &mut Window, scope: &[ElementId], @@ -150,21 +162,22 @@ fn click_target( offset: Option>, button: MouseButton, count: usize, + modifiers: Modifiers, cx: &mut App, ) { 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 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 +189,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, @@ -221,16 +234,69 @@ 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(), + None, + MouseButton::Left, + 1, + Modifiers::default(), + 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(), + Some(offset), + MouseButton::Left, + 1, + Modifiers::default(), + cx, + ); + } + fn click_with_modifiers( + &mut self, + id: impl Into, + modifiers: Modifiers, + cx: &mut App, + ) { + click_target( + self, + &[], + id.into(), + None, + MouseButton::Left, + 1, + modifiers, + 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(), + None, + MouseButton::Right, + 1, + Modifiers::default(), + 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(), + None, + MouseButton::Left, + 2, + Modifiers::default(), + cx, + ); } fn hover(&mut self, id: impl Into, cx: &mut App) { hover_target(self, &[], id.into(), cx); @@ -243,8 +309,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 +320,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 = @@ -298,6 +365,7 @@ impl ScopedWindow<'_> { None, MouseButton::Left, 1, + Modifiers::default(), cx, ); } @@ -309,6 +377,24 @@ impl ScopedWindow<'_> { Some(offset), MouseButton::Left, 1, + Modifiers::default(), + cx, + ); + } + pub fn click_with_modifiers( + &mut self, + id: impl Into, + modifiers: Modifiers, + cx: &mut App, + ) { + click_target( + self.window, + &self.scope, + id.into(), + None, + MouseButton::Left, + 1, + modifiers, cx, ); } @@ -320,6 +406,7 @@ impl ScopedWindow<'_> { None, MouseButton::Right, 1, + Modifiers::default(), cx, ); } @@ -331,6 +418,7 @@ impl ScopedWindow<'_> { None, MouseButton::Left, 2, + Modifiers::default(), 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..6db61b827b 100644 --- a/crates/kit/tests/interactions.rs +++ b/crates/kit/tests/interactions.rs @@ -1,8 +1,8 @@ mod common; use gpui_kit::test::{TestAppContextExt, TestSupportExt, TestWindowExt}; use gpui_kit::{ - AppContext, Context, MouseButton, ScrollDelta, ScrollHandle, TestAppContext, Window, div, - point, prelude::*, px, size, + AppContext, Context, Modifiers, MouseButton, ScrollDelta, ScrollHandle, TestAppContext, Window, + div, point, prelude::*, px, size, }; use std::{cell::RefCell, rc::Rc, time::Duration}; @@ -108,6 +108,70 @@ 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 { + 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(); + div() + .id("surface") + .test_support() + .size(px(80.)) + .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, _) = common::open_window(cx, None, |_, cx| { + cx.new(|_| ModifiedClick { + events: events.clone(), + }) + }); + cx.update_window(handle.into(), |_, window, cx| { + window.click_with_modifiers("surface", Modifiers::secondary_key(), cx); + }) + .unwrap(); + let recorded = std::mem::take(&mut *events.borrow_mut()); + 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 + .within("panel") + .click_with_modifiers("surface", Modifiers::shift(), cx); + }) + .unwrap(); + let recorded = events.borrow(); + assert!( + recorded + .iter() + .any(|(event, modifiers)| *event == "click" && *modifiers == Modifiers::shift()), + "scoped click did not carry shift: {recorded:?}" + ); +} + struct Scrolling { scroll: ScrollHandle, } diff --git a/skills/gpui-kit/references/gpui/test.md b/skills/gpui-kit/references/gpui/test.md index f5c2ced4c4..4c56d7eed6 100644 --- a/skills/gpui-kit/references/gpui/test.md +++ b/skills/gpui-kit/references/gpui/test.md @@ -179,6 +179,7 @@ Import `TestWindowExt` and, for custom registration, `TestSupportExt` from | `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. | +| `click_with_modifiers(id, modifiers, cx)` | Left-clicks while holding modifiers, e.g. `Modifiers::secondary_key()`. | | `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. | diff --git a/website/docs/test.md b/website/docs/test.md index b4bdcd2bf4..c70569ce16 100644 --- a/website/docs/test.md +++ b/website/docs/test.md @@ -228,6 +228,7 @@ Import `gpui_kit::test::TestWindowExt` for the following methods: | `window.try_find(id)` | Returns `None` when absent; ambiguity still panics. | | `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)` | Left click while holding modifiers, such as `Modifiers::secondary_key()`; the move, down and up events all carry them. | | `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. | @@ -237,7 +238,8 @@ Import `gpui_kit::test::TestWindowExt` for the following methods: | `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`. +`click_with_modifiers`, `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..cbd03f7d52 100644 --- a/website/zh-CN/docs/test.md +++ b/website/zh-CN/docs/test.md @@ -190,6 +190,7 @@ assert!(save.visible()); | `window.try_find(id)` | 缺失时返回 `None`,歧义仍会 panic。 | | `window.click(id, cx)` | 在中心发送原生鼠标移动、按下与释放。 | | `window.click_at(id, offset, cx)` | 相对于目标左上角的像素偏移点击,适合部分裁剪。 | +| `window.click_with_modifiers(id, modifiers, cx)` | 按住修饰键左键点击,例如 `Modifiers::secondary_key()`;移动、按下与释放事件都会携带这些修饰键。 | | `window.right_click(id, cx)` / `double_click(id, cx)` | 原生右键或两次点击序列。 | | `window.hover(id, cx)` | 移动指针,不按键。 | | `window.scroll(id, delta, cx)` | 原生滚轮事件,`ScrollDelta` 保留 GPUI 的方向与单位。 | @@ -198,8 +199,8 @@ 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`。 +作用域支持 `find`、`try_find`、嵌套 `within`、`click`、`click_at`、`click_with_modifiers`、 +`right_click`、`double_click`、`hover`、`scroll`、`drag_to`、`press` 和 `input`。 `drag_to` 的两个 ID 都在当前作用域中解析。跨作用域拖拽或指定偏移时,可查询目标后 将窗口坐标传给 `window.drag`。 From 5a557495323e6990903e59e76145cff0a97947ba Mon Sep 17 00:00:00 2001 From: Gustave Date: Tue, 6 Oct 2026 21:57:31 +0200 Subject: [PATCH 2/4] kit: Add `find_all` to the window test harness `find` and `try_find` panic when an ID matches more than one element, and `within` needs a single scope. A test could not count repeated elements whose paths differ only by generated IDs, such as a stack of notifications that each render a `notification` toast. `find_all` returns every match in the window or scope, or an empty `Vec`. The registry is a `HashMap`, so matches are sorted top to bottom, then left to right by bounds origin, with the path as the tie breaker. `find` now reuses the same lookup and keeps its ambiguity assertion. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PRpJSFk3zTwdgAJHehdZFm --- crates/base/src/test_support.rs | 41 ++++++++++++++++--------- crates/kit/src/test.rs | 8 +++++ crates/kit/tests/overlays.rs | 26 ++++++++++++++++ crates/kit/tests/window.rs | 17 ++++++++++ skills/gpui-kit/references/gpui/test.md | 1 + website/docs/test.md | 7 +++-- website/zh-CN/docs/test.md | 5 +-- 7 files changed, 86 insertions(+), 19 deletions(-) diff --git a/crates/base/src/test_support.rs b/crates/base/src/test_support.rs index 27cb13b44e..0ecc8cbd2d 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. Matches at the same origin are ordered by path. +#[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,14 @@ 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_by_cached_key(|entry| { + let origin = entry.bounds.origin; + (origin.y, origin.x, format!("{:?}", entry.path)) + }); + matches } #[doc(hidden)] diff --git a/crates/kit/src/test.rs b/crates/kit/src/test.rs index 80c05625a9..44ef42aa1c 100644 --- a/crates/kit/src/test.rs +++ b/crates/kit/src/test.rs @@ -27,6 +27,8 @@ 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 every match, top to bottom then left to right; empty when absent. + 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. @@ -222,6 +224,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 { @@ -350,6 +355,9 @@ impl ScopedWindow<'_> { pub fn try_find(&self, id: impl Into) -> Option { observation::find(self.window, &self.scope, &id.into()) } + 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 { 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 4c56d7eed6..2d651d5075 100644 --- a/skills/gpui-kit/references/gpui/test.md +++ b/skills/gpui-kit/references/gpui/test.md @@ -176,6 +176,7 @@ Import `TestWindowExt` and, for custom registration, `TestSupportExt` from | --- | --- | | `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 every match, top to bottom then left to right; 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. | diff --git a/website/docs/test.md b/website/docs/test.md index c70569ce16..200d70d319 100644 --- a/website/docs/test.md +++ b/website/docs/test.md @@ -226,6 +226,7 @@ Import `gpui_kit::test::TestWindowExt` for the following methods: | --- | --- | | `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 every match, top to bottom then left to right, or an empty `Vec`; use it to count repeated elements. | | `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)` | Left click while holding modifiers, such as `Modifiers::secondary_key()`; the move, down and up events all carry them. | @@ -237,9 +238,9 @@ 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`, -`click_with_modifiers`, `right_click`, `double_click`, `hover`, `scroll`, `drag_to`, -`press` and `input`. +Scoped queries support `find`, `try_find`, `find_all`, nested `within`, `click`, +`click_at`, `click_with_modifiers`, `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 cbd03f7d52..80f7d8c3bd 100644 --- a/website/zh-CN/docs/test.md +++ b/website/zh-CN/docs/test.md @@ -188,6 +188,7 @@ assert!(save.visible()); | --- | --- | | `window.find(id)` | 严格返回最近完成帧的 `ElementSnapshot`;缺失时 panic,列出注册路径与排查提示。 | | `window.try_find(id)` | 缺失时返回 `None`,歧义仍会 panic。 | +| `window.find_all(id)` | 按从上到下、从左到右的顺序返回所有匹配项,缺失时返回空 `Vec`;适合统计重复元素的数量。 | | `window.click(id, cx)` | 在中心发送原生鼠标移动、按下与释放。 | | `window.click_at(id, offset, cx)` | 相对于目标左上角的像素偏移点击,适合部分裁剪。 | | `window.click_with_modifiers(id, modifiers, cx)` | 按住修饰键左键点击,例如 `Modifiers::secondary_key()`;移动、按下与释放事件都会携带这些修饰键。 | @@ -199,8 +200,8 @@ assert!(save.visible()); | `window.press("backspace", cx)` | 使用 GPUI 按键解析器,为特殊键或快捷键发送原生 key-down/key-up 事件。 | | `window.input(text, cx)` | 向当前焦点逐字符输入,不自动聚焦或替换整个值。 | -作用域支持 `find`、`try_find`、嵌套 `within`、`click`、`click_at`、`click_with_modifiers`、 -`right_click`、`double_click`、`hover`、`scroll`、`drag_to`、`press` 和 `input`。 +作用域支持 `find`、`try_find`、`find_all`、嵌套 `within`、`click`、`click_at`、 +`click_with_modifiers`、`right_click`、`double_click`、`hover`、`scroll`、`drag_to`、`press` 和 `input`。 `drag_to` 的两个 ID 都在当前作用域中解析。跨作用域拖拽或指定偏移时,可查询目标后 将窗口坐标传给 `window.drag`。 From 1033802504060e9873a134943789e54345d81437 Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Wed, 7 Oct 2026 13:37:49 +0800 Subject: [PATCH 3/4] kit: Preserve testing API compatibility and compose clicks Written with OpenAI Codex assistance. --- crates/base/src/test_support.rs | 7 +- crates/kit/src/test.rs | 259 +++++++++++++++--------- crates/kit/tests/collections.rs | 2 +- crates/kit/tests/interactions.rs | 104 +++++++++- crates/kit/tests/overlays.rs | 2 +- crates/kit/tests/window.rs | 2 +- skills/gpui-kit/references/gpui/test.md | 18 +- website/docs/test.md | 18 +- website/zh-CN/docs/test.md | 17 +- 9 files changed, 307 insertions(+), 122 deletions(-) diff --git a/crates/base/src/test_support.rs b/crates/base/src/test_support.rs index 0ecc8cbd2d..57ccc4e724 100644 --- a/crates/base/src/test_support.rs +++ b/crates/base/src/test_support.rs @@ -155,7 +155,7 @@ pub fn find(window: &Window, scope: &[ElementId], id: &ElementId) -> Option Vec { let mut matches: Vec<_> = REGISTRY.with(|registry| { @@ -175,10 +175,7 @@ pub fn find_all(window: &Window, scope: &[ElementId], id: &ElementId) -> Vec) -> ElementSnapshot; /// Returns None for an absent target; ambiguous IDs still require a scope. fn try_find(&self, id: impl Into) -> Option; - /// Returns every match, top to bottom then left to right; empty when absent. - 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. @@ -36,14 +34,6 @@ 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); - /// Left-clicks while holding modifiers, such as `Modifiers::secondary_key()`. - /// The pointer move, mouse-down and mouse-up events all carry them. - fn click_with_modifiers( - &mut self, - id: impl Into, - modifiers: Modifiers, - cx: &mut App, - ); 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); @@ -60,6 +50,108 @@ pub trait TestWindowExt { fn input(&mut self, text: &str, cx: &mut App); } +/// Collection queries, separate from `TestWindowExt` so existing implementations remain valid. +pub trait TestWindowQueryExt { + /// 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; +} +impl TestWindowQueryExt for Window { + fn find_all(&self, id: impl Into) -> Vec { + observation::find_all(self, &[], &id.into()) + } +} + +/// 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 + } +} + +/// Configurable clicks, separate from `TestWindowExt` to preserve existing implementations. +pub trait TestWindowClickExt { + /// 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); + } +} +impl TestWindowClickExt for Window { + fn click_with_options( + &mut self, + id: impl Into, + options: ClickOptions, + cx: &mut App, + ) { + click_with_options_target(self, &[], id.into(), options, cx); + } +} + 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)) @@ -156,17 +248,19 @@ fn mouse_up( window.render_frame(cx); } -#[allow(clippy::too_many_arguments)] fn click_target( window: &mut Window, scope: &[ElementId], id: ElementId, - offset: Option>, - button: MouseButton, - count: usize, - modifiers: Modifiers, + 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, modifiers, cx); @@ -176,6 +270,36 @@ fn click_target( } } +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); @@ -224,9 +348,6 @@ 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 { @@ -239,43 +360,14 @@ 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, - Modifiers::default(), - 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, - Modifiers::default(), - cx, - ); - } - fn click_with_modifiers( - &mut self, - id: impl Into, - modifiers: Modifiers, - cx: &mut App, - ) { - click_target( - self, - &[], - id.into(), - None, - MouseButton::Left, - 1, - modifiers, + ClickOptions::new().with_offset(offset), cx, ); } @@ -284,24 +376,12 @@ impl TestWindowExt for Window { self, &[], id.into(), - None, - MouseButton::Right, - 1, - Modifiers::default(), + 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, - Modifiers::default(), - 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); @@ -355,6 +435,8 @@ 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()) } @@ -366,55 +448,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, - Modifiers::default(), - 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, - Modifiers::default(), + 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, ) { - click_target( - self.window, - &self.scope, - id.into(), - None, - MouseButton::Left, - 1, - modifiers, - cx, - ); + 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, - Modifiers::default(), + ClickOptions::new().with_button(MouseButton::Right), cx, ); } @@ -423,10 +491,7 @@ impl ScopedWindow<'_> { self.window, &self.scope, id.into(), - None, - MouseButton::Left, - 2, - Modifiers::default(), + ClickOptions::new().with_count(2), cx, ); } diff --git a/crates/kit/tests/collections.rs b/crates/kit/tests/collections.rs index b82b93262d..9a58ebf677 100644 --- a/crates/kit/tests/collections.rs +++ b/crates/kit/tests/collections.rs @@ -5,7 +5,7 @@ use gpui_kit::component::{ table::{Column, DataTable, TableDelegate, TableSelection, TableState}, tree::{Tree, TreeItem, TreeState}, }; -use gpui_kit::test::TestWindowExt; +use gpui_kit::test::{TestWindowClickExt, TestWindowExt}; use gpui_kit::{ App, AppContext, Context, Entity, Focusable, Modifiers, TestAppContext, Window, div, prelude::*, px, size, diff --git a/crates/kit/tests/interactions.rs b/crates/kit/tests/interactions.rs index 6db61b827b..d9d7fea78e 100644 --- a/crates/kit/tests/interactions.rs +++ b/crates/kit/tests/interactions.rs @@ -1,8 +1,11 @@ mod common; -use gpui_kit::test::{TestAppContextExt, TestSupportExt, TestWindowExt}; +use gpui_kit::test::{ + ClickOptions, TestAppContextExt, TestSupportExt, TestWindowClickExt, TestWindowExt, +}; use gpui_kit::{ - AppContext, Context, Modifiers, 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}; @@ -109,6 +112,7 @@ fn hover_right_click_and_double_click_dispatch_native_pointer_events(cx: &mut Te } struct ModifiedClick { + focus: FocusHandle, events: Rc>>, } impl Render for ModifiedClick { @@ -118,10 +122,15 @@ impl Render for ModifiedClick { 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)) }) @@ -138,16 +147,25 @@ impl Render for ModifiedClick { #[gpui_kit::test] fn modified_clicks_carry_modifiers_on_every_pointer_event(cx: &mut TestAppContext) { let events = Rc::new(RefCell::new(vec![])); - let (handle, _) = common::open_window(cx, None, |_, cx| { - cx.new(|_| ModifiedClick { + 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 @@ -158,9 +176,18 @@ fn modified_clicks_carry_modifiers_on_every_pointer_event(cx: &mut TestAppContex } 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(); @@ -172,6 +199,73 @@ fn modified_clicks_carry_modifiers_on_every_pointer_event(cx: &mut TestAppContex ); } +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 28d89d9669..0457da3839 100644 --- a/crates/kit/tests/overlays.rs +++ b/crates/kit/tests/overlays.rs @@ -6,7 +6,7 @@ use gpui_kit::component::{ input::{Input, InputState}, notification::Notification, }; -use gpui_kit::test::{TestAppContextExt, TestWindowExt}; +use gpui_kit::test::{TestAppContextExt, TestWindowExt, TestWindowQueryExt}; use gpui_kit::{ AppContext, Context, Entity, Focusable, TestAppContext, Window, div, prelude::*, px, size, }; diff --git a/crates/kit/tests/window.rs b/crates/kit/tests/window.rs index fd7fd4714b..d1ddd834f6 100644 --- a/crates/kit/tests/window.rs +++ b/crates/kit/tests/window.rs @@ -1,5 +1,5 @@ mod common; -use gpui_kit::test::{TestSupportExt, TestWindowExt}; +use gpui_kit::test::{TestSupportExt, TestWindowExt, TestWindowQueryExt}; use gpui_kit::{AppContext, Context, TestAppContext, Window, div, prelude::*, px, size}; struct Example { diff --git a/skills/gpui-kit/references/gpui/test.md b/skills/gpui-kit/references/gpui/test.md index 2d651d5075..93677c473b 100644 --- a/skills/gpui-kit/references/gpui/test.md +++ b/skills/gpui-kit/references/gpui/test.md @@ -169,24 +169,34 @@ 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`, `TestWindowQueryExt` (collection queries), and +`TestWindowClickExt` (configurable clicks) 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 every match, top to bottom then left to right; empty when absent. | +| `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. | -| `click_with_modifiers(id, modifiers, cx)` | Left-clicks while holding modifiers, e.g. `Modifiers::secondary_key()`. | +| `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 200d70d319..b5fc33a560 100644 --- a/website/docs/test.md +++ b/website/docs/test.md @@ -220,16 +220,18 @@ IDs such as `("row", record_id)` preserve record identity after reordering. ## Interact and assert -Import `gpui_kit::test::TestWindowExt` for the following methods: +Import `TestWindowExt` for existing operations, `TestWindowQueryExt` for `find_all`, +and `TestWindowClickExt` for configurable clicks from `gpui_kit::test`: | 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 every match, top to bottom then left to right, or an empty `Vec`; use it to count repeated elements. | +| `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)` | Left click while holding modifiers, such as `Modifiers::secondary_key()`; the move, down and up events all carry them. | +| `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. | @@ -238,8 +240,16 @@ 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. | +`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`, `right_click`, `double_click`, `hover`, `scroll`, +`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 80f7d8c3bd..04154d3d02 100644 --- a/website/zh-CN/docs/test.md +++ b/website/zh-CN/docs/test.md @@ -182,16 +182,18 @@ assert!(save.visible()); ## 操作与断言 -导入 `gpui_kit::test::TestWindowExt` 后使用以下方法: +从 `gpui_kit::test` 导入 `TestWindowExt` 使用已有操作,导入 +`TestWindowQueryExt` 使用 `find_all`,导入 `TestWindowClickExt` 使用可配置点击: | API | 行为 | | --- | --- | | `window.find(id)` | 严格返回最近完成帧的 `ElementSnapshot`;缺失时 panic,列出注册路径与排查提示。 | | `window.try_find(id)` | 缺失时返回 `None`,歧义仍会 panic。 | -| `window.find_all(id)` | 按从上到下、从左到右的顺序返回所有匹配项,缺失时返回空 `Vec`;适合统计重复元素的数量。 | +| `window.find_all(id)` | 按当前帧边界原点的 y、x 排序,返回所有已注册匹配项,包括不可见元素;同坐标顺序不保证,缺失时返回空 `Vec`。 | | `window.click(id, cx)` | 在中心发送原生鼠标移动、按下与释放。 | | `window.click_at(id, offset, cx)` | 相对于目标左上角的像素偏移点击,适合部分裁剪。 | -| `window.click_with_modifiers(id, modifiers, cx)` | 按住修饰键左键点击,例如 `Modifiers::secondary_key()`;移动、按下与释放事件都会携带这些修饰键。 | +| `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 的方向与单位。 | @@ -200,8 +202,15 @@ assert!(save.visible()); | `window.press("backspace", cx)` | 使用 GPUI 按键解析器,为特殊键或快捷键发送原生 key-down/key-up 事件。 | | `window.input(text, cx)` | 向当前焦点逐字符输入,不自动聚焦或替换整个值。 | +`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`、`right_click`、`double_click`、`hover`、`scroll`、`drag_to`、`press` 和 `input`。 +`click_with_modifiers`、`click_with_options`、`right_click`、`double_click`、`hover`、`scroll`、`drag_to`、`press` 和 `input`。 `drag_to` 的两个 ID 都在当前作用域中解析。跨作用域拖拽或指定偏移时,可查询目标后 将窗口坐标传给 `window.drag`。 From 53fbbc883556ece403d178a32162ad28c81ca502 Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Wed, 7 Oct 2026 13:42:04 +0800 Subject: [PATCH 4/4] kit: Keep window testing operations in one extension trait Written with OpenAI Codex assistance. --- crates/kit/src/test.rs | 66 ++++++++++--------------- crates/kit/tests/collections.rs | 2 +- crates/kit/tests/interactions.rs | 4 +- crates/kit/tests/overlays.rs | 2 +- crates/kit/tests/window.rs | 2 +- skills/gpui-kit/references/gpui/test.md | 5 +- website/docs/test.md | 3 +- website/zh-CN/docs/test.md | 3 +- 8 files changed, 35 insertions(+), 52 deletions(-) diff --git a/crates/kit/src/test.rs b/crates/kit/src/test.rs index 94c77fe808..5d10229d64 100644 --- a/crates/kit/src/test.rs +++ b/crates/kit/src/test.rs @@ -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,18 +66,6 @@ pub trait TestWindowExt { fn input(&mut self, text: &str, cx: &mut App); } -/// Collection queries, separate from `TestWindowExt` so existing implementations remain valid. -pub trait TestWindowQueryExt { - /// 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; -} -impl TestWindowQueryExt for Window { - fn find_all(&self, id: impl Into) -> Vec { - observation::find_all(self, &[], &id.into()) - } -} - /// 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)] @@ -125,33 +129,6 @@ impl ClickOptions { } } -/// Configurable clicks, separate from `TestWindowExt` to preserve existing implementations. -pub trait TestWindowClickExt { - /// 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); - } -} -impl TestWindowClickExt for Window { - fn click_with_options( - &mut self, - id: impl Into, - options: ClickOptions, - cx: &mut App, - ) { - click_with_options_target(self, &[], id.into(), options, cx); - } -} - 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)) @@ -348,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 { @@ -371,6 +351,14 @@ impl TestWindowExt for Window { 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, diff --git a/crates/kit/tests/collections.rs b/crates/kit/tests/collections.rs index 9a58ebf677..b82b93262d 100644 --- a/crates/kit/tests/collections.rs +++ b/crates/kit/tests/collections.rs @@ -5,7 +5,7 @@ use gpui_kit::component::{ table::{Column, DataTable, TableDelegate, TableSelection, TableState}, tree::{Tree, TreeItem, TreeState}, }; -use gpui_kit::test::{TestWindowClickExt, TestWindowExt}; +use gpui_kit::test::TestWindowExt; use gpui_kit::{ App, AppContext, Context, Entity, Focusable, Modifiers, TestAppContext, Window, div, prelude::*, px, size, diff --git a/crates/kit/tests/interactions.rs b/crates/kit/tests/interactions.rs index d9d7fea78e..1fe9dbe4ca 100644 --- a/crates/kit/tests/interactions.rs +++ b/crates/kit/tests/interactions.rs @@ -1,7 +1,5 @@ mod common; -use gpui_kit::test::{ - ClickOptions, TestAppContextExt, TestSupportExt, TestWindowClickExt, TestWindowExt, -}; +use gpui_kit::test::{ClickOptions, TestAppContextExt, TestSupportExt, TestWindowExt}; use gpui_kit::{ AppContext, Context, FocusHandle, InputEvent, Modifiers, ModifiersChangedEvent, MouseButton, MouseDownEvent, ScrollDelta, ScrollHandle, TestAppContext, Window, div, point, prelude::*, px, diff --git a/crates/kit/tests/overlays.rs b/crates/kit/tests/overlays.rs index 0457da3839..28d89d9669 100644 --- a/crates/kit/tests/overlays.rs +++ b/crates/kit/tests/overlays.rs @@ -6,7 +6,7 @@ use gpui_kit::component::{ input::{Input, InputState}, notification::Notification, }; -use gpui_kit::test::{TestAppContextExt, TestWindowExt, TestWindowQueryExt}; +use gpui_kit::test::{TestAppContextExt, TestWindowExt}; use gpui_kit::{ AppContext, Context, Entity, Focusable, TestAppContext, Window, div, prelude::*, px, size, }; diff --git a/crates/kit/tests/window.rs b/crates/kit/tests/window.rs index d1ddd834f6..fd7fd4714b 100644 --- a/crates/kit/tests/window.rs +++ b/crates/kit/tests/window.rs @@ -1,5 +1,5 @@ mod common; -use gpui_kit::test::{TestSupportExt, TestWindowExt, TestWindowQueryExt}; +use gpui_kit::test::{TestSupportExt, TestWindowExt}; use gpui_kit::{AppContext, Context, TestAppContext, Window, div, prelude::*, px, size}; struct Example { diff --git a/skills/gpui-kit/references/gpui/test.md b/skills/gpui-kit/references/gpui/test.md index 93677c473b..23c59cf833 100644 --- a/skills/gpui-kit/references/gpui/test.md +++ b/skills/gpui-kit/references/gpui/test.md @@ -169,9 +169,8 @@ fn saves_a_profile_through_the_ui(cx: &mut TestAppContext) { ## Queries and interactions -Import `TestWindowExt`, `TestWindowQueryExt` (collection queries), and -`TestWindowClickExt` (configurable clicks) from `gpui_kit::test`. For custom -registration, import `TestSupportExt`. Use normal Rust assertions 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 | | --- | --- | diff --git a/website/docs/test.md b/website/docs/test.md index b5fc33a560..f7aa5fbf95 100644 --- a/website/docs/test.md +++ b/website/docs/test.md @@ -220,8 +220,7 @@ IDs such as `("row", record_id)` preserve record identity after reordering. ## Interact and assert -Import `TestWindowExt` for existing operations, `TestWindowQueryExt` for `find_all`, -and `TestWindowClickExt` for configurable clicks from `gpui_kit::test`: +Import `gpui_kit::test::TestWindowExt` for queries and interactions: | API | Behavior | | --- | --- | diff --git a/website/zh-CN/docs/test.md b/website/zh-CN/docs/test.md index 04154d3d02..9362c56192 100644 --- a/website/zh-CN/docs/test.md +++ b/website/zh-CN/docs/test.md @@ -182,8 +182,7 @@ assert!(save.visible()); ## 操作与断言 -从 `gpui_kit::test` 导入 `TestWindowExt` 使用已有操作,导入 -`TestWindowQueryExt` 使用 `find_all`,导入 `TestWindowClickExt` 使用可配置点击: +从 `gpui_kit::test` 导入 `TestWindowExt` 使用查询和交互方法: | API | 行为 | | --- | --- |