From 9383804653c13421c415928011661ab1ddbb6554 Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Sat, 26 Sep 2026 22:29:06 +0800 Subject: [PATCH 1/5] test: Expand input UI regression coverage (known failures) --- Cargo.lock | 1 + crates/kit/Cargo.toml | 1 + crates/kit/TESTING.md | 5 + crates/kit/tests/input.rs | 14 + crates/kit/tests/input/README.md | 99 ++++++ crates/kit/tests/input/completions.rs | 241 ++++++++++++++ crates/kit/tests/input/constraints.rs | 444 ++++++++++++++++++++++++++ crates/kit/tests/input/editing.rs | 355 ++++++++++++++++++++ crates/kit/tests/input/editor.rs | 347 ++++++++++++++++++++ crates/kit/tests/input/history.rs | 281 ++++++++++++++++ crates/kit/tests/input/lifecycle.rs | 207 ++++++++++++ crates/kit/tests/input/textarea.rs | 333 +++++++++++++++++++ website/docs/test.md | 8 + website/zh-CN/docs/test.md | 7 + 14 files changed, 2343 insertions(+) create mode 100644 crates/kit/tests/input/README.md create mode 100644 crates/kit/tests/input/completions.rs create mode 100644 crates/kit/tests/input/constraints.rs create mode 100644 crates/kit/tests/input/editing.rs create mode 100644 crates/kit/tests/input/editor.rs create mode 100644 crates/kit/tests/input/history.rs create mode 100644 crates/kit/tests/input/lifecycle.rs create mode 100644 crates/kit/tests/input/textarea.rs diff --git a/Cargo.lock b/Cargo.lock index a3be1f0621..f36fa06e8a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3947,6 +3947,7 @@ dependencies = [ "gpui-pre", "gpui-pre-platform", "gpui-pre-web", + "lsp-types 0.97.0", ] [[package]] diff --git a/crates/kit/Cargo.toml b/crates/kit/Cargo.toml index 5ab16a5685..241ac9c1aa 100644 --- a/crates/kit/Cargo.toml +++ b/crates/kit/Cargo.toml @@ -81,6 +81,7 @@ gpui_web.workspace = true [dev-dependencies] chrono = "0.4.38" gpui = { workspace = true, features = ["test-support"] } +lsp-types.workspace = true [[test]] name = "window" diff --git a/crates/kit/TESTING.md b/crates/kit/TESTING.md index 342cd451e2..aa8ac311b3 100644 --- a/crates/kit/TESTING.md +++ b/crates/kit/TESTING.md @@ -14,6 +14,11 @@ setup, a compiled application workflow, the control coverage matrix, scoped IDs, state assertions, mouse/keyboard/scroll/drag operations, async waits and CI. The [Chinese guide](../../website/zh-CN/docs/test.md) covers the same API. +For a component regression example, start with the +[Input, Textarea and Editor suite](tests/input/README.md). It groups real editing +workflows by behavior and explains how to add a case without bypassing focus, +keyboard bindings or pointer dispatch. + ## Core semantics - `find` requires a target and explains missing/ambiguous paths; `try_find` permits absence. diff --git a/crates/kit/tests/input.rs b/crates/kit/tests/input.rs index 1a2e86d3df..f9b9d45a60 100644 --- a/crates/kit/tests/input.rs +++ b/crates/kit/tests/input.rs @@ -1,4 +1,18 @@ mod common; +#[path = "input/completions.rs"] +mod completions; +#[path = "input/constraints.rs"] +mod constraints; +#[path = "input/editing.rs"] +mod editing; +#[path = "input/editor.rs"] +mod editor; +#[path = "input/history.rs"] +mod history; +#[path = "input/lifecycle.rs"] +mod lifecycle; +#[path = "input/textarea.rs"] +mod textarea; use gpui::{ AppContext, Context, Entity, TestAppContext, Window, WindowHandle, div, prelude::*, px, }; diff --git a/crates/kit/tests/input/README.md b/crates/kit/tests/input/README.md new file mode 100644 index 0000000000..6cd90e79f2 --- /dev/null +++ b/crates/kit/tests/input/README.md @@ -0,0 +1,99 @@ +# Input UI regression tests + +These tests mount the production `Input`, `Textarea` and `Editor` components +under the normal window `Root`. They exercise pointer hit testing, keyboard +bindings, focus routing and rendered state together. The test target is +[`input.rs`](../input.rs); the modules here group related user workflows. + +Run the complete suite from the repository root: + +```sh +cargo test -p gpui-kit --features test-support --test input --locked +``` + +To investigate one area, add its module name after `--`, for example: + +```sh +cargo test -p gpui-kit --features test-support --test input --locked -- history:: +``` + +The existing CI test matrix runs this target on Linux, macOS and Windows. +Keyboard cases use each platform's actual command bindings. A local Linux +pass does not replace the macOS and Windows jobs. + +## Recorded workflows + +| Module | Interaction contracts | +| --- | --- | +| [`editing.rs`](editing.rs) | Selection direction and replacement; Home/End; deletion boundaries; clipboard normalization; double-click and drag; Enter events; Tab through decorated inputs | +| [`history.rs`](history.rs) | Typing groups; cursor and blur boundaries; atomic paste; restored selections; no-op edits preserving redo; redo branch invalidation; emoji and combining-mark boundaries | +| [`constraints.rs`](constraints.rs) | Read-only/disabled transitions; rejected edits and event counts; owner updates; masking and password privacy; validation; mask formatting; clear affordance | +| [`textarea.rs`](textarea.rs) | Enter versus submit; multiline clipboard and CRLF; wrapped-row navigation; caret reveal after scrolling; bounded auto-grow; resize reflow | +| [`editor.rs`](editor.rs) | Generated pairs and closers; Unicode inside quotes; language differences; paired deletion and undo; indentation; multi-cursor replacement; search and replace overlays | +| [`completions.rs`](completions.rs) | Provider requests through typed input; popup acceptance/cancellation; keyboard selection; filtered suggestions; completion undo boundaries | +| [`lifecycle.rs`](lifecycle.rs) | Focus routing across all three controls; selection across parent renders; read-only transitions; unmount/remount with retained state | + +The original cases in [`input.rs`](../input.rs) also cover scoped duplicate IDs, +cross-scope keyboard rejection and masked values. The table is an index to +concrete tests, not a claim of exhaustive coverage. Inline tokens, touch selection, +InputGroup composition and language-service providers retain their separate +Base/component tests; they are not all exercised by this target. The completion +fixture supplies deterministic responses through the public provider interface; +it does not connect to a language-server process. + +## Writing a regression case + +Start with the smallest user sequence that demonstrates the bug. Mount the +real component with retained state and a stable ID, click it, send keyboard or +pointer events, and assert the result after each meaningful step. See +[`lifecycle.rs`](lifecycle.rs) for a workflow applied to all three controls. + +Use `window.input` for typing and `window.press` for commands such as Enter, +Backspace and Undo. Prepare clipboard data through the test application's +clipboard, then send the Paste shortcut. Calling `set_value`, `replace_all`, +`undo` or a private event handler to perform the interaction would bypass +the routing this suite is intended to protect. Public setters are appropriate +for initial fixtures and explicit external-owner updates. + +Observe two sides of important interactions: a fresh `window.find(...)` +snapshot for exposed value/focus, and public state or an owner event for +selection, cursor, text or callback behavior. Snapshots are immutable; never +reuse one to assert a later frame. Password snapshots intentionally omit text; +read the retained state only to prove editing still happened without exposing +the secret through accessibility. + +Leave `update_window` before checking deferred owner callbacks. Use +`cx.run_until_parked()` for queued work or `wait_for` for a bounded asynchronous +condition. Do not add wall-clock sleeps. For geometry, check relationships +such as caret containment, scroll direction or relative height, rather than +font-dependent pixel constants. + +For a bug fix, confirm the new test fails with the bug present and passes with +the fix. Keep its name tied to the user-visible contract, and include the +boundary that caused the failure: Unicode, selection direction, a read-only +transition, a no-op edit, or a focus change. + +## What a green run establishes + +This suite establishes the recorded interaction contracts for the tested +configurations. Existing Base tests continue to cover editing algorithms, +IME composition state transitions and language-specific parsing cases. +Neither set exhausts every document, language, configuration or event order. + +Changes to native input-method integration, accessibility adapters, or drawing +still need the corresponding platform checks: + +- Full OS IME composition, candidate windows and keyboard layouts need a real + platform input method. Typing Unicode into the test window does not exercise + those facilities. +- An exposed value/focus snapshot does not dispatch an OS accessibility action. + Test packaged-app Focus/SetValue and screen-reader behavior using the + [accessibility testing guide](../../../../docs/ACCESSIBILITY-UI-TESTING.md). +- Layout/state assertions do not compare pixels. The separate macOS Metal + `rendering` target checks selected drawing contracts. +- Real clipboard integration and system context menus also need platform + coverage when those adapters change. + +For ordinary editing changes, use the suite plus the existing Base regressions +and review the changed behavior's case. For platform changes, add the relevant +platform evidence to the PR so reviewers know what has actually been checked. diff --git a/crates/kit/tests/input/completions.rs b/crates/kit/tests/input/completions.rs new file mode 100644 index 0000000000..1d1df491ae --- /dev/null +++ b/crates/kit/tests/input/completions.rs @@ -0,0 +1,241 @@ +//! Completion workflows through the styled editor, its real popup, and native input. +//! The popup has no TestWindowExt observation; text, focus, and provider requests +//! are the public evidence, with acceptance proving that menu actions were routed. + +use std::{cell::RefCell, rc::Rc}; + +use gpui_kit::{ + App, AppContext, Context, Entity, Result, Task, TestAppContext, Window, WindowHandle, + component::input::{CompletionProvider, Editor, EditorState, Rope}, + div, + prelude::*, + px, size, + test::TestWindowExt, +}; +use lsp_types::{ + CompletionContext, CompletionItem, CompletionResponse, CompletionTextEdit, + CompletionTriggerKind, Position, Range, TextEdit, +}; + +use crate::common; + +#[derive(Debug, PartialEq)] +struct CompletionRequest { + text: String, + offset: usize, + trigger: CompletionContext, +} + +#[derive(Default)] +struct Suggestions { + requests: RefCell>, +} + +impl CompletionProvider for Suggestions { + fn completions( + &self, + text: &Rope, + offset: usize, + trigger: CompletionContext, + _: &mut Window, + _: &mut App, + ) -> Task> { + let text = text.to_string(); + // This fixture uses single-line ASCII identifiers; LSP character + // positions and byte offsets therefore coincide. + let prefix = &text[..offset]; + let items = ["print", "println", "private"] + .into_iter() + .filter(|label| label.starts_with(prefix)) + .map(|label| CompletionItem { + label: label.into(), + text_edit: Some(CompletionTextEdit::Edit(TextEdit { + range: Range::new(Position::new(0, 0), Position::new(0, offset as u32)), + new_text: label.into(), + })), + ..Default::default() + }) + .collect(); + self.requests.borrow_mut().push(CompletionRequest { + text, + offset, + trigger, + }); + Task::ready(Ok(CompletionResponse::Array(items))) + } + + fn is_completion_trigger(&self, _: usize, new_text: &str, _: &mut App) -> bool { + !new_text.is_empty() && new_text.chars().all(|ch| ch.is_ascii_alphabetic()) + } +} + +struct CompletionEditor { + state: Entity, +} + +impl Render for CompletionEditor { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + div() + .size_full() + .p_4() + .child(Editor::new(&self.state).size_full()) + } +} + +struct Fixture { + handle: WindowHandle, + state: Entity, + provider: Rc, +} + +impl Fixture { + fn new(cx: &mut TestAppContext) -> Self { + cx.update(gpui_kit::init); + let provider = Rc::new(Suggestions::default()); + let (handle, view) = + common::open_window(cx, Some(size(px(800.), px(480.))), |window, cx| { + cx.new(|cx| CompletionEditor { + state: cx.new(|cx| { + let mut state = EditorState::new(window, cx).language("plaintext"); + state.lsp_mut().completion_provider = Some(provider.clone()); + state + }), + }) + }); + let state = cx.update(|cx| view.read(cx).state.clone()); + let fixture = Self { + handle, + state, + provider, + }; + cx.update_window(handle.into(), |_, window, cx| { + window.click(("input", fixture.state.entity_id()), cx); + }) + .unwrap(); + fixture.settle(cx); + fixture.assert_editor("", cx); + fixture + } + + fn settle(&self, cx: &mut TestAppContext) { + // Provider responses and popup acceptance update entities asynchronously. + // Drain them outside a borrowed window, then refresh native observations. + cx.run_until_parked(); + cx.update_window(self.handle.into(), |_, window, cx| window.render_frame(cx)) + .unwrap(); + cx.run_until_parked(); + } + + fn input(&self, text: &str, cx: &mut TestAppContext) { + cx.update_window(self.handle.into(), |_, window, cx| window.input(text, cx)) + .unwrap(); + self.settle(cx); + } + + fn press(&self, key: &str, cx: &mut TestAppContext) { + cx.update_window(self.handle.into(), |_, window, cx| window.press(key, cx)) + .unwrap(); + self.settle(cx); + } + + fn assert_editor(&self, value: &str, cx: &mut TestAppContext) { + cx.update_window(self.handle.into(), |_, window, cx| { + window.render_frame(cx); + let input = window.find(("input", self.state.entity_id())); + assert_eq!(input.value(), Some(value)); + assert_eq!(input.focused(), Some(true)); + assert_eq!(self.state.read(cx).value(), value); + }) + .unwrap(); + } + + fn start_completion(&self, cx: &mut TestAppContext) { + self.input("p", cx); + self.assert_editor("p", cx); + assert_eq!( + *self.provider.requests.borrow(), + vec![CompletionRequest { + text: "p".into(), + offset: 1, + trigger: CompletionContext { + trigger_kind: CompletionTriggerKind::TRIGGER_CHARACTER, + trigger_character: Some("p".into()), + }, + }] + ); + } +} + +#[gpui_kit::test] +fn typing_opens_completion_and_enter_accepts_without_a_newline(cx: &mut TestAppContext) { + let fixture = Fixture::new(cx); + fixture.start_completion(cx); + // Enter is the production menu-acceptance key. Tab is used for indentation + // and inline completion, so it is deliberately not treated as an alias. + fixture.press("enter", cx); + fixture.assert_editor("print", cx); + assert_eq!(fixture.provider.requests.borrow().len(), 1); + fixture.input("!", cx); + fixture.assert_editor("print!", cx); +} + +#[gpui_kit::test] +fn escape_cancels_completion_and_preserves_editor_text_and_focus(cx: &mut TestAppContext) { + let fixture = Fixture::new(cx); + fixture.start_completion(cx); + fixture.press("escape", cx); + fixture.assert_editor("p", cx); + // Enter now edits the document, proving the dismissed menu cannot accept. + fixture.press("enter", cx); + fixture.assert_editor("p\n", cx); + fixture.input("!", cx); + fixture.assert_editor("p\n!", cx); + assert_eq!(fixture.provider.requests.borrow().len(), 1); +} + +#[gpui_kit::test] +fn arrow_navigation_accepts_the_selected_completion(cx: &mut TestAppContext) { + let fixture = Fixture::new(cx); + fixture.start_completion(cx); + fixture.press("down", cx); + fixture.press("down", cx); + fixture.press("up", cx); + fixture.assert_editor("p", cx); + fixture.press("enter", cx); + fixture.assert_editor("println", cx); + assert_eq!(fixture.provider.requests.borrow().len(), 1); +} + +#[gpui_kit::test] +fn continued_typing_refreshes_provider_filtered_suggestions(cx: &mut TestAppContext) { + let fixture = Fixture::new(cx); + fixture.start_completion(cx); + fixture.press("down", cx); + fixture.input("riv", cx); + fixture.assert_editor("priv", cx); + { + let requests = fixture.provider.requests.borrow(); + let request = requests.last().expect("completion requested after typing"); + assert_eq!(request.text, "priv"); + assert_eq!(request.offset, 4); + assert_eq!(request.trigger.trigger_character.as_deref(), Some("priv")); + } + // Only "private" matches now; acceptance also checks that the previous + // selection does not leave the refreshed one-item list out of bounds. + fixture.press("enter", cx); + fixture.assert_editor("private", cx); +} + +#[gpui_kit::test] +fn accepted_completion_is_one_undo_separate_from_the_typed_prefix(cx: &mut TestAppContext) { + let fixture = Fixture::new(cx); + fixture.start_completion(cx); + fixture.press("enter", cx); + fixture.assert_editor("print", cx); + fixture.press("secondary-z", cx); + fixture.assert_editor("p", cx); + fixture.press("secondary-z", cx); + fixture.assert_editor("", cx); + // History replay must not issue new completion requests. + assert_eq!(fixture.provider.requests.borrow().len(), 1); +} diff --git a/crates/kit/tests/input/constraints.rs b/crates/kit/tests/input/constraints.rs new file mode 100644 index 0000000000..044ef43937 --- /dev/null +++ b/crates/kit/tests/input/constraints.rs @@ -0,0 +1,444 @@ +//! Application-facing constraints exercised through the rendered input. +//! GPUI's `Window::handle_a11y_action` is private: native SetValue dispatch +//! cannot currently be covered through the public test API. + +use crate::common; +use gpui_kit::{ + App, AppContext, ClipboardItem, Context, Entity, Subscription, TestAppContext, Window, + WindowHandle, + base::Root, + component::input::{Input, InputContentType, InputEvent, InputState}, + div, + prelude::*, + test::TestWindowExt, +}; + +struct Constraints { + input: Entity, + readonly: bool, + disabled: bool, + cleanable: bool, + mask_toggle: bool, + content_type: Option, + changes: usize, + _subscription: Subscription, +} + +impl Render for Constraints { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + div().size_full().p_4().child( + Input::new(&self.input) + .id("constrained") + .w_64() + .readonly(self.readonly) + .disabled(self.disabled) + .cleanable(self.cleanable) + .when(self.mask_toggle, |input| input.mask_toggle()) + .when_some(self.content_type, |input, kind| input.content_type(kind)), + ) + } +} + +fn fixture( + cx: &mut TestAppContext, + configure: impl FnOnce(InputState) -> InputState, +) -> (WindowHandle, Entity) { + cx.update(gpui_kit::init); + common::open_window(cx, None, |window, cx| { + cx.new(|cx| { + let input = cx.new(|cx| configure(InputState::new(window, cx))); + let subscription = cx.subscribe(&input, |this: &mut Constraints, _, event, _| { + if matches!(event, InputEvent::Change) { + this.changes += 1; + } + }); + Constraints { + input, + readonly: false, + disabled: false, + cleanable: false, + mask_toggle: false, + content_type: None, + changes: 0, + _subscription: subscription, + } + }) + }) +} + +fn ui( + handle: WindowHandle, + cx: &mut TestAppContext, + action: impl FnOnce(&mut Window, &mut App), +) { + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + action(window, cx); + }) + .unwrap(); +} + +fn shortcut(window: &mut Window, key: &str, cx: &mut App) { + let modifier = if cfg!(target_os = "macos") { + "cmd" + } else { + "ctrl" + }; + window.press(&format!("{modifier}-{key}"), cx); +} + +fn clipboard(cx: &mut App, value: &str) { + cx.write_to_clipboard(ClipboardItem::new_string(value.to_owned())); +} + +fn assert_clipboard(cx: &mut App, expected: &str) { + assert_eq!( + cx.read_from_clipboard() + .and_then(|item| item.text()) + .as_deref(), + Some(expected) + ); +} + +fn assert_owner( + handle: WindowHandle, + view: &Entity, + cx: &mut TestAppContext, + expected: &str, + changes: usize, +) { + // Leave the input's update before inspecting subscription effects. + common::update_content(handle, view, cx, |view, _, cx| { + assert_eq!(view.input.read(cx).value(), expected); + assert_eq!(view.changes, changes); + }) + .unwrap(); +} + +fn protected_after_focus(cx: &mut TestAppContext, disabled: bool) { + let (handle, view) = fixture(cx, |input| input); + ui(handle, cx, |window, cx| { + window.click("constrained", cx); + clipboard(cx, "edit"); + shortcut(window, "v", cx); + shortcut(window, "a", cx); + assert_eq!(window.find("constrained").focused(), Some(true)); + }); + assert_owner(handle, &view, cx, "edit", 1); + common::update_content(handle, &view, cx, |view, _, cx| { + assert_eq!(view.input.read(cx).selected_range(), 0..4); + view.disabled = disabled; + view.readonly = !disabled; + cx.notify(); + }) + .unwrap(); + + for key in ["v", "x", "backspace", "delete", "z"] { + ui(handle, cx, |window, cx| { + clipboard(cx, "sentinel"); + if key.len() == 1 { + shortcut(window, key, cx); + } else { + window.press(key, cx); + } + assert_eq!(window.find("constrained").value(), Some("edit")); + assert_clipboard(cx, "sentinel"); + }); + assert_owner(handle, &view, cx, "edit", 1); + } + ui(handle, cx, |window, cx| window.input("ignored", cx)); + assert_owner(handle, &view, cx, "edit", 1); + common::update_content(handle, &view, cx, |view, _, cx| { + assert_eq!(view.input.read(cx).selected_range(), 0..4); + view.disabled = false; + view.readonly = false; + cx.notify(); + }) + .unwrap(); + ui(handle, cx, |window, cx| { + window.click("constrained", cx); + shortcut(window, "z", cx); + assert_eq!(window.find("constrained").value(), Some("")); + window.input("R", cx); + assert_eq!(window.find("constrained").value(), Some("R")); + }); + assert_owner(handle, &view, cx, "R", 3); +} + +#[gpui_kit::test] +fn readonly_after_focus_rejects_edits_without_consuming_history(cx: &mut TestAppContext) { + protected_after_focus(cx, false); +} + +#[gpui_kit::test] +fn disabled_after_focus_rejects_edits_without_consuming_history(cx: &mut TestAppContext) { + protected_after_focus(cx, true); +} + +#[gpui_kit::test] +fn readonly_allows_selection_copy_and_silent_owner_replacement(cx: &mut TestAppContext) { + let (handle, view) = fixture(cx, |input| input.default_value("initial")); + ui(handle, cx, |window, cx| window.click("constrained", cx)); + common::update_content(handle, &view, cx, |view, _, cx| { + view.readonly = true; + cx.notify(); + }) + .unwrap(); + ui(handle, cx, |window, cx| { + shortcut(window, "a", cx); + shortcut(window, "c", cx); + assert_clipboard(cx, "initial"); + assert_eq!(window.find("constrained").focused(), Some(true)); + }); + for (disabled, expected) in [(false, "owner"), (true, "disabled owner")] { + common::update_content(handle, &view, cx, |view, _, cx| { + view.disabled = disabled; + view.readonly = !disabled; + cx.notify(); + }) + .unwrap(); + // Apply the owner flags to the rendered component before replacing text. + ui(handle, cx, |_, _| {}); + common::update_content(handle, &view, cx, |view, window, cx| { + view.input + .update(cx, |input, cx| input.set_value(expected, window, cx)); + }) + .unwrap(); + ui(handle, cx, |window, cx| { + assert_eq!(window.find("constrained").value(), Some(expected)); + window.input("ignored", cx); + }); + assert_owner(handle, &view, cx, expected, 0); + } +} + +#[gpui_kit::test] +fn masking_blocks_clipboard_until_revealed_and_preserves_the_value(cx: &mut TestAppContext) { + let (handle, view) = fixture(cx, |input| input.masked(true)); + common::update_content(handle, &view, cx, |view, _, cx| { + view.mask_toggle = true; + cx.notify(); + }) + .unwrap(); + ui(handle, cx, |window, cx| { + window.click("constrained", cx); + clipboard(cx, "sëcret🦀"); + shortcut(window, "v", cx); + shortcut(window, "a", cx); + clipboard(cx, "sentinel"); + for key in ["c", "x"] { + shortcut(window, key, cx); + assert_clipboard(cx, "sentinel"); + assert_eq!(window.find("constrained").value(), None); + } + window.within("constrained").click("toggle-mask", cx); + assert_eq!(window.find("constrained").value(), Some("sëcret🦀")); + window.click("constrained", cx); + shortcut(window, "a", cx); + shortcut(window, "c", cx); + assert_clipboard(cx, "sëcret🦀"); + window.within("constrained").click("toggle-mask", cx); + assert_eq!(window.find("constrained").value(), None); + }); + assert_owner(handle, &view, cx, "sëcret🦀", 1); +} + +#[gpui_kit::test] +fn password_content_types_hide_accessibility_values_even_when_revealed(cx: &mut TestAppContext) { + let (handle, view) = fixture(cx, |input| input.default_value("secret").masked(true)); + for kind in [InputContentType::Password, InputContentType::NewPassword] { + common::update_content(handle, &view, cx, |view, _, cx| { + view.content_type = Some(kind); + view.mask_toggle = true; + cx.notify(); + }) + .unwrap(); + ui(handle, cx, |window, cx| { + assert_eq!( + window.find("constrained").role(), + Some(gpui_kit::Role::PasswordInput) + ); + assert_eq!(window.find("constrained").value(), None); + window.within("constrained").click("toggle-mask", cx); + assert_eq!(window.find("constrained").value(), None); + window.click("constrained", cx); + shortcut(window, "a", cx); + shortcut(window, "c", cx); + // Clipboard protection follows masking, independently of content type. + assert_clipboard(cx, "secret"); + window.within("constrained").click("toggle-mask", cx); + clipboard(cx, "sentinel"); + window.click("constrained", cx); + shortcut(window, "a", cx); + shortcut(window, "c", cx); + assert_clipboard(cx, "sentinel"); + assert_eq!(window.find("constrained").value(), None); + }); + assert_owner(handle, &view, cx, "secret", 0); + } +} + +#[gpui_kit::test] +fn validation_rejects_typing_without_moving_the_selection_or_notifying_owner( + cx: &mut TestAppContext, +) { + let (handle, view) = fixture(cx, |input| { + input + .default_value("12") + .validate(|value, _| value.bytes().all(|c| c.is_ascii_digit())) + }); + ui(handle, cx, |window, cx| { + window.click("constrained", cx); + shortcut(window, "a", cx); + window.input("x", cx); + assert_eq!(window.find("constrained").value(), Some("12")); + }); + assert_owner(handle, &view, cx, "12", 0); + common::update_content(handle, &view, cx, |view, _, cx| { + assert_eq!(view.input.read(cx).selected_range(), 0..2); + }) + .unwrap(); + ui(handle, cx, |window, cx| { + window.input("7", cx); + assert_eq!(window.find("constrained").value(), Some("7")); + }); + assert_owner(handle, &view, cx, "7", 1); +} + +#[gpui_kit::test] +fn invalid_paste_is_atomic_and_does_not_add_an_undo_entry(cx: &mut TestAppContext) { + let (handle, view) = fixture(cx, |input| { + input.validate(|value, _| value.len() <= 3 && value.bytes().all(|c| c.is_ascii_digit())) + }); + ui(handle, cx, |window, cx| { + window.click("constrained", cx); + clipboard(cx, "12"); + shortcut(window, "v", cx); + shortcut(window, "a", cx); + }); + assert_owner(handle, &view, cx, "12", 1); + for rejected in ["3x", "1234"] { + ui(handle, cx, |window, cx| { + clipboard(cx, rejected); + shortcut(window, "v", cx); + assert_eq!(window.find("constrained").value(), Some("12")); + assert_clipboard(cx, rejected); + }); + assert_owner(handle, &view, cx, "12", 1); + } + ui(handle, cx, |window, cx| { + shortcut(window, "z", cx); + assert_eq!(window.find("constrained").value(), Some("")); + }); + assert_owner(handle, &view, cx, "", 2); +} + +#[gpui_kit::test] +fn mask_formats_paste_rejects_overflow_and_round_trips_history(cx: &mut TestAppContext) { + let (handle, view) = fixture(cx, |input| input.mask_pattern("99-99")); + ui(handle, cx, |window, cx| { + window.click("constrained", cx); + clipboard(cx, "1234"); + shortcut(window, "v", cx); + assert_eq!(window.find("constrained").value(), Some("12-34")); + }); + assert_owner(handle, &view, cx, "12-34", 1); + common::update_content(handle, &view, cx, |view, _, cx| { + assert_eq!(view.input.read(cx).unmask_value(), "1234"); + assert_eq!(view.input.read(cx).cursor(), 5); + }) + .unwrap(); + ui(handle, cx, |window, cx| { + window.input("5x", cx); + assert_eq!(window.find("constrained").value(), Some("12-34")); + }); + assert_owner(handle, &view, cx, "12-34", 1); + ui(handle, cx, |window, cx| { + shortcut(window, "z", cx); + assert_eq!(window.find("constrained").value(), Some("")); + }); + assert_owner(handle, &view, cx, "", 2); + ui(handle, cx, |window, cx| { + shortcut( + window, + if cfg!(target_os = "macos") { + "shift-z" + } else { + "y" + }, + cx, + ); + assert_eq!(window.find("constrained").value(), Some("12-34")); + }); + assert_owner(handle, &view, cx, "12-34", 3); +} + +#[gpui_kit::test] +fn invalid_initial_value_can_be_repaired_then_validation_applies(cx: &mut TestAppContext) { + let (handle, view) = fixture(cx, |input| { + input + .default_value("bad") + .validate(|value, _| value.bytes().all(|c| c.is_ascii_digit())) + }); + ui(handle, cx, |window, cx| { + window.click("constrained", cx); + shortcut(window, "a", cx); + window.input("7", cx); + assert_eq!(window.find("constrained").value(), Some("7")); + }); + assert_owner(handle, &view, cx, "7", 1); + ui(handle, cx, |window, cx| { + window.input("x", cx); + assert_eq!(window.find("constrained").value(), Some("7")); + shortcut(window, "a", cx); + window.press("backspace", cx); + assert_eq!(window.find("constrained").value(), Some("")); + }); + assert_owner(handle, &view, cx, "", 2); +} + +#[gpui_kit::test] +fn clear_affordance_tracks_editability_and_emits_one_change(cx: &mut TestAppContext) { + let (handle, view) = fixture(cx, |input| input.default_value("clear me")); + common::update_content(handle, &view, cx, |view, _, cx| { + view.cleanable = true; + cx.notify(); + }) + .unwrap(); + ui(handle, cx, |window, cx| { + window.click("constrained", cx); + assert!(window.within("constrained").find("clean").visible()); + }); + for (readonly, disabled) in [(true, false), (false, true)] { + common::update_content(handle, &view, cx, |view, _, cx| { + view.readonly = readonly; + view.disabled = disabled; + cx.notify(); + }) + .unwrap(); + ui(handle, cx, |window, _| { + assert!(window.within("constrained").try_find("clean").is_none()); + assert_eq!(window.find("constrained").value(), Some("clear me")); + }); + assert_owner(handle, &view, cx, "clear me", 0); + } + common::update_content(handle, &view, cx, |view, _, cx| { + view.disabled = false; + view.readonly = false; + cx.notify(); + }) + .unwrap(); + ui(handle, cx, |window, cx| { + window.within("constrained").click("clean", cx); + assert_eq!(window.find("constrained").value(), Some("")); + assert_eq!(window.find("constrained").focused(), Some(true)); + assert!(window.within("constrained").try_find("clean").is_none()); + }); + assert_owner(handle, &view, cx, "", 1); + ui(handle, cx, |window, cx| { + window.input("A", cx); + assert_eq!(window.find("constrained").value(), Some("A")); + assert!(window.within("constrained").find("clean").visible()); + }); + assert_owner(handle, &view, cx, "A", 2); +} diff --git a/crates/kit/tests/input/editing.rs b/crates/kit/tests/input/editing.rs new file mode 100644 index 0000000000..926c9f085b --- /dev/null +++ b/crates/kit/tests/input/editing.rs @@ -0,0 +1,355 @@ +use crate::common; +use gpui_kit::{ + App, AppContext, ClipboardItem, Context, Entity, IntoElement, Render, SharedString, + Subscription, TestAppContext, Window, WindowHandle, + base::Root, + component::input::{Input, InputEvent, InputState}, + div, point, + prelude::*, + px, size, + test::TestWindowExt, +}; +use std::ops::Range; + +struct EditingForm { + first: Entity, + second: Entity, + submissions: Vec<(SharedString, bool, bool)>, + _subscription: Subscription, +} + +impl Render for EditingForm { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + div() + .size_full() + .flex() + .flex_col() + .p_4() + .gap_4() + .child( + Input::new(&self.first) + .id("editing-first") + .w_80() + .prefix(div().child("Name")) + .suffix(div().child("Required")), + ) + .child(Input::new(&self.second).id("editing-second").w_80()) + } +} + +fn editing_form(cx: &mut TestAppContext) -> (WindowHandle, Entity) { + cx.update(gpui_kit::init); + common::open_window(cx, Some(size(px(640.), px(360.))), |window, cx| { + cx.new(|cx| { + let first = cx.new(|cx| InputState::new(window, cx)); + let second = cx.new(|cx| InputState::new(window, cx)); + let subscription = cx.subscribe(&first, |this: &mut EditingForm, input, event, cx| { + if let InputEvent::PressEnter { secondary, shift } = event { + this.submissions + .push((input.read(cx).value(), *secondary, *shift)); + } + }); + EditingForm { + first, + second, + submissions: Vec::new(), + _subscription: subscription, + } + }) + }) +} + +fn assert_edit( + input: &Entity, + value: &str, + selection: Range, + cursor: usize, + cx: &App, +) { + let input = input.read(cx); + assert_eq!(input.value().as_ref(), value); + assert_eq!(input.selected_range(), selection); + assert_eq!(input.cursor(), cursor); +} + +#[gpui_kit::test] +fn shift_arrows_reverse_selection_and_typing_replaces_it(cx: &mut TestAppContext) { + let (handle, form) = editing_form(cx); + cx.update_window(handle.into(), |_, window, cx| { + let input = form.read(cx).first.clone(); + window.click("editing-first", cx); + window.input("abcd", cx); + window.press("shift-left", cx); + window.press("shift-left", cx); + assert_edit(&input, "abcd", 2..4, 2, cx); + window.press("shift-right", cx); + assert_edit(&input, "abcd", 3..4, 3, cx); + window.input("X", cx); + assert_edit(&input, "abcX", 4..4, 4, cx); + assert_eq!(window.find("editing-first").value(), Some("abcX")); + + window.press("home", cx); + window.press("shift-right", cx); + window.press("shift-right", cx); + assert_edit(&input, "abcX", 0..2, 2, cx); + window.input("Y", cx); + assert_edit(&input, "YcX", 1..1, 1, cx); + }) + .unwrap(); +} + +#[gpui_kit::test] +fn home_end_and_shift_select_line_boundaries(cx: &mut TestAppContext) { + let (handle, form) = editing_form(cx); + cx.update_window(handle.into(), |_, window, cx| { + let input = form.read(cx).first.clone(); + window.click("editing-first", cx); + window.input("alpha beta", cx); + window.press("home", cx); + assert_edit(&input, "alpha beta", 0..0, 0, cx); + window.press("right", cx); + window.press("shift-end", cx); + assert_edit(&input, "alpha beta", 1..10, 10, cx); + window.input("!", cx); + assert_edit(&input, "a!", 2..2, 2, cx); + window.press("shift-home", cx); + assert_edit(&input, "a!", 0..2, 0, cx); + window.press("end", cx); + assert_edit(&input, "a!", 2..2, 2, cx); + window.input("?", cx); + assert_eq!(window.find("editing-first").value(), Some("a!?")); + }) + .unwrap(); +} + +#[gpui_kit::test] +fn backspace_and_delete_remove_opposite_sides_and_stop_at_boundaries(cx: &mut TestAppContext) { + let (handle, form) = editing_form(cx); + cx.update_window(handle.into(), |_, window, cx| { + let input = form.read(cx).first.clone(); + window.click("editing-first", cx); + window.input("abcd", cx); + window.press("left", cx); + window.press("left", cx); + assert_edit(&input, "abcd", 2..2, 2, cx); + window.press("backspace", cx); + assert_edit(&input, "acd", 1..1, 1, cx); + window.press("delete", cx); + assert_edit(&input, "ad", 1..1, 1, cx); + window.press("home", cx); + window.press("backspace", cx); + assert_edit(&input, "ad", 0..0, 0, cx); + window.press("end", cx); + window.press("delete", cx); + assert_edit(&input, "ad", 2..2, 2, cx); + window.press("shift-home", cx); + window.press("delete", cx); + assert_edit(&input, "", 0..0, 0, cx); + window.press("backspace", cx); + window.press("delete", cx); + assert_eq!(window.find("editing-first").value(), Some("")); + window.input("abcd", cx); + window.press("left", cx); + window.press("shift-left", cx); + window.press("shift-left", cx); + assert_edit(&input, "abcd", 1..3, 1, cx); + window.press("backspace", cx); + assert_edit(&input, "ad", 1..1, 1, cx); + }) + .unwrap(); +} + +#[gpui_kit::test] +fn copy_preserves_selection_and_pastes_only_selected_text_into_another_input( + cx: &mut TestAppContext, +) { + let (handle, form) = editing_form(cx); + cx.update_window(handle.into(), |_, window, cx| { + let input = form.read(cx).first.clone(); + cx.write_to_clipboard(ClipboardItem::new_string("stale".into())); + window.click("editing-first", cx); + window.input("keep copy", cx); + for _ in 0..4 { + window.press("shift-left", cx); + } + window.press("secondary-c", cx); + assert_edit(&input, "keep copy", 5..9, 5, cx); + assert_eq!( + cx.read_from_clipboard().unwrap().text().as_deref(), + Some("copy") + ); + window.click("editing-second", cx); + window.press("secondary-v", cx); + assert_eq!(window.find("editing-second").value(), Some("copy")); + assert_eq!(input.read(cx).value(), "keep copy"); + assert_eq!(form.read(cx).second.read(cx).value(), "copy"); + }) + .unwrap(); +} + +#[gpui_kit::test] +fn cut_removes_selection_and_paste_replaces_destination_selection(cx: &mut TestAppContext) { + let (handle, form) = editing_form(cx); + cx.update_window(handle.into(), |_, window, cx| { + let input = form.read(cx).first.clone(); + window.click("editing-first", cx); + window.input("keep cut", cx); + for _ in 0..3 { + window.press("shift-left", cx); + } + window.press("secondary-x", cx); + assert_edit(&input, "keep ", 5..5, 5, cx); + assert_eq!( + cx.read_from_clipboard().unwrap().text().as_deref(), + Some("cut") + ); + window.click("editing-second", cx); + window.input("replace", cx); + window.press("secondary-a", cx); + window.press("secondary-v", cx); + assert_edit(&form.read(cx).second, "cut", 3..3, 3, cx); + assert_eq!(window.find("editing-first").value(), Some("keep ")); + assert_eq!(window.find("editing-second").value(), Some("cut")); + }) + .unwrap(); +} + +#[gpui_kit::test] +fn multiline_clipboard_normalization_preserves_surrounding_text(cx: &mut TestAppContext) { + let (handle, form) = editing_form(cx); + for payload in ["one\ntwo", "one\r\ntwo", "one\rtwo", "\none\r\ntwo\n"] { + cx.update_window(handle.into(), |_, window, cx| { + let input = form.read(cx).first.clone(); + window.click("editing-first", cx); + window.press("secondary-a", cx); + window.input("[old]", cx); + window.press("left", cx); + for _ in 0..3 { + window.press("shift-left", cx); + } + assert_edit(&input, "[old]", 1..4, 1, cx); + cx.write_to_clipboard(ClipboardItem::new_string(payload.into())); + window.press("secondary-v", cx); + assert_edit(&input, "[onetwo]", 7..7, 7, cx); + assert_eq!(window.find("editing-first").value(), Some("[onetwo]")); + assert_eq!( + cx.read_from_clipboard().unwrap().text().as_deref(), + Some(payload) + ); + }) + .unwrap(); + } +} + +#[gpui_kit::test] +fn double_click_selects_a_word_for_replacement(cx: &mut TestAppContext) { + let (handle, form) = editing_form(cx); + cx.update_window(handle.into(), |_, window, cx| { + let input = form.read(cx).second.clone(); + window.click("editing-second", cx); + // A word wider than the field keeps the center over actual glyphs, + // including after horizontal scrolling to the typing cursor. + let word = "word".repeat(24); + window.input(&word, cx); + window.double_click("editing-second", cx); + assert_eq!(input.read(cx).selected_range(), 0..96); + assert_eq!(input.read(cx).selected_value().as_ref(), word.as_str()); + window.input("replacement", cx); + assert_edit(&input, "replacement", 11..11, 11, cx); + assert_eq!(window.find("editing-second").value(), Some("replacement")); + }) + .unwrap(); +} + +#[gpui_kit::test] +fn pointer_drag_selects_text_and_click_collapses_selection(cx: &mut TestAppContext) { + let (handle, form) = editing_form(cx); + cx.update_window(handle.into(), |_, window, cx| { + let input = form.read(cx).second.clone(); + window.click("editing-second", cx); + window.input("drag me", cx); + let text = input.read(cx).text_bounds().expect("rendered text bounds"); + let control = window.find("editing-second").bounds(); + let start = point(text.left(), text.center().y); + let end = point(control.center().x, start.y); + window.drag(start, end, cx); + assert_eq!(input.read(cx).selected_range(), 0..7); + assert_eq!(input.read(cx).selected_value(), "drag me"); + window.click("editing-second", cx); + assert_edit(&input, "drag me", 7..7, 7, cx); + window.drag(end, start, cx); + assert_edit(&input, "drag me", 0..7, 0, cx); + window.input("done", cx); + assert_edit(&input, "done", 4..4, 4, cx); + assert_eq!(window.find("editing-second").value(), Some("done")); + }) + .unwrap(); +} + +#[gpui_kit::test] +fn enter_reports_modifiers_without_inserting_newlines_or_replacing_selection( + cx: &mut TestAppContext, +) { + let (handle, form) = editing_form(cx); + cx.update_window(handle.into(), |_, window, cx| { + window.click("editing-first", cx); + window.input("submit", cx); + window.press("shift-left", cx); + }) + .unwrap(); + for (key, secondary, shift) in [ + ("enter", false, false), + ("shift-enter", false, true), + ("secondary-enter", true, false), + ] { + let previous_count = cx.update(|cx| form.read(cx).submissions.len()); + cx.update_window(handle.into(), |_, window, cx| { + window.press(key, cx); + assert_edit(&form.read(cx).first, "submit", 5..6, 5, cx); + assert_eq!(window.find("editing-first").value(), Some("submit")); + }) + .unwrap(); + cx.run_until_parked(); + common::update_content(handle, &form, cx, |view, _, _| { + assert_eq!(view.submissions.len(), previous_count + 1); + assert_eq!( + view.submissions.last(), + Some(&(SharedString::from("submit"), secondary, shift)) + ); + }) + .unwrap(); + } +} + +#[gpui_kit::test] +fn tab_moves_between_inputs_with_passive_prefix_and_suffix(cx: &mut TestAppContext) { + let (handle, form) = editing_form(cx); + cx.update_window(handle.into(), |_, window, cx| { + window.click("editing-first", cx); + window.input("first", cx); + window.press("tab", cx); + }) + .unwrap(); + cx.run_until_parked(); + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + assert_eq!(window.find("editing-first").focused(), Some(false)); + assert_eq!(window.find("editing-second").focused(), Some(true)); + window.input("second", cx); + window.press("shift-tab", cx); + }) + .unwrap(); + cx.run_until_parked(); + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + assert_eq!(window.find("editing-first").focused(), Some(true)); + assert_eq!(window.find("editing-second").focused(), Some(false)); + window.press("end", cx); + window.input("!", cx); + assert_eq!(window.find("editing-first").value(), Some("first!")); + assert_eq!(window.find("editing-second").value(), Some("second")); + assert_eq!(form.read(cx).first.read(cx).value(), "first!"); + assert_eq!(form.read(cx).second.read(cx).value(), "second"); + }) + .unwrap(); +} diff --git a/crates/kit/tests/input/editor.rs b/crates/kit/tests/input/editor.rs new file mode 100644 index 0000000000..7bb3c19243 --- /dev/null +++ b/crates/kit/tests/input/editor.rs @@ -0,0 +1,347 @@ +//! Styled editor workflows driven through the production window and native events. + +use gpui_kit::{ + AppContext, Context, Entity, TestAppContext, Window, WindowHandle, + component::input::{Editor, EditorState}, + div, + prelude::*, + px, size, + test::TestWindowExt, +}; + +use crate::common; + +struct EditorFixture { + state: Entity, +} + +impl Render for EditorFixture { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + div() + .size_full() + .p_4() + .child(Editor::new(&self.state).size_full()) + } +} + +fn editor( + cx: &mut TestAppContext, + language: &'static str, + value: &'static str, +) -> (WindowHandle, Entity) { + cx.update(gpui_kit::init); + let (handle, view) = common::open_window(cx, Some(size(px(800.), px(480.))), |window, cx| { + cx.new(|cx| EditorFixture { + state: cx.new(|cx| { + EditorState::new(window, cx) + .language(language) + .default_value(value) + }), + }) + }); + let state = cx.update(|cx| view.read(cx).state.clone()); + (handle, state) +} + +fn add_cursor_below() -> &'static str { + if cfg!(target_os = "macos") { + "cmd-alt-down" + } else if cfg!(target_os = "windows") { + "ctrl-alt-down" + } else { + "shift-alt-down" + } +} + +fn replace_shortcut() -> &'static str { + if cfg!(target_os = "macos") { + "cmd-shift-f" + } else { + "ctrl-h" + } +} + +#[gpui_kit::test] +fn nested_json_pairs_skip_their_generated_closers(cx: &mut TestAppContext) { + let (handle, state) = editor(cx, "json", ""); + cx.update_window(handle.into(), |_, window, cx| { + window.click(("input", state.entity_id()), cx); + window.input("[", cx); + assert_eq!(state.read(cx).value(), "[]"); + assert_eq!(state.read(cx).selected_range(), 1..1); + + window.input("{", cx); + assert_eq!(state.read(cx).value(), "[{}]"); + assert_eq!(state.read(cx).selected_range(), 2..2); + window.input("}", cx); + assert_eq!(state.read(cx).value(), "[{}]"); + assert_eq!(state.read(cx).selected_range(), 3..3); + window.input("]", cx); + assert_eq!(state.read(cx).value(), "[{}]"); + assert_eq!(state.read(cx).selected_range(), 4..4); + window.input("!", cx); + assert_eq!( + window.find(("input", state.entity_id())).value(), + Some("[{}]!") + ); + }) + .unwrap(); +} + +#[gpui_kit::test] +fn quoted_unicode_text_keeps_one_closer_and_a_collapsed_caret(cx: &mut TestAppContext) { + let (handle, state) = editor(cx, "json", ""); + cx.update_window(handle.into(), |_, window, cx| { + window.click(("input", state.entity_id()), cx); + window.input("\"", cx); + assert_eq!(state.read(cx).value(), "\"\""); + assert_eq!(state.read(cx).selected_range(), 1..1); + window.input("中🦀", cx); + assert_eq!(state.read(cx).value(), "\"中🦀\""); + assert_eq!(state.read(cx).selected_range(), 8..8); + window.input("\"", cx); + assert_eq!(state.read(cx).value(), "\"中🦀\""); + assert_eq!(state.read(cx).selected_range(), 9..9); + window.input(",", cx); + assert_eq!(state.read(cx).value(), "\"中🦀\","); + }) + .unwrap(); +} + +#[gpui_kit::test] +fn plaintext_language_does_not_auto_close_or_split_brackets(cx: &mut TestAppContext) { + let (handle, state) = editor(cx, "plaintext", ""); + cx.update_window(handle.into(), |_, window, cx| { + window.click(("input", state.entity_id()), cx); + window.input("{", cx); + assert_eq!(state.read(cx).value(), "{"); + window.input("}", cx); + window.press("left", cx); + window.press("enter", cx); + assert_eq!(state.read(cx).value(), "{\n}"); + assert_eq!(state.read(cx).selected_range(), 2..2); + window.input("\"", cx); + assert_eq!(state.read(cx).value(), "{\n\"}"); + }) + .unwrap(); +} + +#[gpui_kit::test] +fn paired_backspace_and_undo_preserve_unicode_neighbors(cx: &mut TestAppContext) { + let (handle, state) = editor(cx, "json", "中 "); + cx.update_window(handle.into(), |_, window, cx| { + window.click(("input", state.entity_id()), cx); + window.press("secondary-a", cx); + window.press("right", cx); + window.input("[", cx); + assert_eq!(state.read(cx).value(), "中 []"); + assert_eq!(state.read(cx).selected_range(), 5..5); + // Navigation establishes a distinct undo boundary before deletion. + window.press("left", cx); + window.press("right", cx); + window.press("backspace", cx); + assert_eq!(state.read(cx).value(), "中 "); + assert_eq!(state.read(cx).selected_range(), 4..4); + window.press("secondary-z", cx); + assert_eq!(state.read(cx).value(), "中 []"); + assert_eq!(state.read(cx).selected_range(), 5..5); + window.input("]", cx); + assert_eq!(state.read(cx).value(), "中 []"); + assert_eq!(state.read(cx).selected_range(), 6..6); + }) + .unwrap(); +} + +#[gpui_kit::test] +fn enter_between_braces_indents_the_body_and_retains_the_closing_line(cx: &mut TestAppContext) { + let (handle, state) = editor(cx, "json", "{}"); + cx.update_window(handle.into(), |_, window, cx| { + window.click(("input", state.entity_id()), cx); + window.press("secondary-a", cx); + window.press("left", cx); + window.press("right", cx); + window.press("enter", cx); + assert_eq!(state.read(cx).value(), "{\n \n}"); + assert_eq!(state.read(cx).selected_range(), 4..4); + window.input("\"key\": 1", cx); + assert_eq!(state.read(cx).value(), "{\n \"key\": 1\n}"); + window.press("down", cx); + window.press("end", cx); + window.press("enter", cx); + assert_eq!(state.read(cx).value(), "{\n \"key\": 1\n}\n"); + assert_eq!(state.read(cx).selected_range(), 15..15); + }) + .unwrap(); +} + +#[gpui_kit::test] +fn python_enter_uses_colon_indent_and_outdents_before_a_closer(cx: &mut TestAppContext) { + let (handle, state) = editor(cx, "python", "if ready:"); + cx.update_window(handle.into(), |_, window, cx| { + window.click(("input", state.entity_id()), cx); + window.press("secondary-a", cx); + window.press("right", cx); + window.press("enter", cx); + assert_eq!(state.read(cx).value(), "if ready:\n "); + assert_eq!(state.read(cx).selected_range(), 12..12); + window.input("value)", cx); + window.press("left", cx); + window.press("enter", cx); + assert_eq!(state.read(cx).value(), "if ready:\n value\n)"); + assert_eq!(state.read(cx).selected_range(), 18..18); + }) + .unwrap(); +} + +#[gpui_kit::test] +fn tab_and_shift_tab_preserve_multiline_selection_and_undo(cx: &mut TestAppContext) { + let (handle, state) = editor(cx, "rust", "one\n two"); + cx.update_window(handle.into(), |_, window, cx| { + window.click(("input", state.entity_id()), cx); + window.press("secondary-a", cx); + assert_eq!(state.read(cx).selected_range(), 0..9); + window.press("tab", cx); + assert_eq!(state.read(cx).value(), " one\n two"); + assert_eq!(state.read(cx).selected_range(), 2..13); + window.press("shift-tab", cx); + assert_eq!(state.read(cx).value(), "one\n two"); + assert_eq!(state.read(cx).selected_range(), 0..9); + window.press("secondary-z", cx); + assert_eq!(state.read(cx).value(), " one\n two"); + assert_eq!(state.read(cx).selected_range(), 2..13); + window.press("secondary-z", cx); + assert_eq!(state.read(cx).value(), "one\n two"); + assert_eq!(state.read(cx).selected_range(), 0..9); + }) + .unwrap(); +} + +#[gpui_kit::test] +fn keyboard_multicursor_replacement_undo_and_escape_keep_the_active_cursor( + cx: &mut TestAppContext, +) { + let (handle, state) = editor(cx, "rust", "ab\nab\nab"); + cx.update_window(handle.into(), |_, window, cx| { + window.click(("input", state.entity_id()), cx); + window.press("secondary-a", cx); + window.press("left", cx); + window.press("right", cx); + window.press(add_cursor_below(), cx); + window.press(add_cursor_below(), cx); + window.press("shift-right", cx); + // Additional cursors do not replace the original primary selection. + assert_eq!(state.read(cx).selected_range(), 1..2); + window.input("X", cx); + assert_eq!(state.read(cx).value(), "aX\naX\naX"); + window.press("secondary-z", cx); + assert_eq!(state.read(cx).value(), "ab\nab\nab"); + assert_eq!(state.read(cx).selected_range(), 1..2); + // Replacing again proves undo restored all three selections. + window.input("Y", cx); + assert_eq!(state.read(cx).value(), "aY\naY\naY"); + window.press("escape", cx); + window.input("!", cx); + assert_eq!(state.read(cx).value(), "aY!\naY\naY"); + assert_eq!(state.read(cx).selected_range(), 3..3); + }) + .unwrap(); +} + +#[gpui_kit::test] +fn search_keyboard_navigation_wraps_and_escape_returns_editor_focus(cx: &mut TestAppContext) { + let (handle, state) = editor(cx, "plaintext", "one two one"); + cx.update_window(handle.into(), |_, window, cx| { + window.click(("input", state.entity_id()), cx); + window.press("secondary-a", cx); + window.press("left", cx); + window.press("secondary-f", cx); + }) + .unwrap(); + cx.run_until_parked(); + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + assert!(window.within("search-panel").find("next").visible()); + window.input("one", cx); + }) + .unwrap(); + cx.run_until_parked(); + cx.update_window(handle.into(), |_, window, cx| { + assert_eq!(state.read(cx).value(), "one two one"); + assert_eq!(state.read(cx).search_session().query, "one"); + assert_eq!( + state + .read(cx) + .search_session() + .matcher + .matched_ranges() + .as_ref(), + &[0..3, 8..11] + ); + assert_eq!(state.read(cx).search_session().matcher.current(), Some(0)); + window.press("enter", cx); + assert_eq!(state.read(cx).search_session().matcher.current(), Some(1)); + window.press("enter", cx); + assert_eq!(state.read(cx).search_session().matcher.current(), Some(0)); + window.press("shift-enter", cx); + assert_eq!(state.read(cx).search_session().matcher.current(), Some(1)); + window.press("escape", cx); + assert!(!state.read(cx).search_session().open); + assert!(window.try_find("next").is_none()); + assert_eq!( + window.find(("input", state.entity_id())).focused(), + Some(true) + ); + window.input("!", cx); + assert_eq!(state.read(cx).value(), "!one two one"); + }) + .unwrap(); +} + +#[gpui_kit::test] +fn replace_overlay_tabs_to_replacement_and_replace_all_is_one_undo(cx: &mut TestAppContext) { + let (handle, state) = editor(cx, "plaintext", "cat dog cat"); + cx.update_window(handle.into(), |_, window, cx| { + window.click(("input", state.entity_id()), cx); + window.press("secondary-a", cx); + window.press("left", cx); + window.press(replace_shortcut(), cx); + }) + .unwrap(); + cx.run_until_parked(); + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + assert!(state.read(cx).search_session().replace_mode); + assert!(window.within("search-panel").find("replace-all").visible()); + window.input("cat", cx); + }) + .unwrap(); + cx.run_until_parked(); + cx.update_window(handle.into(), |_, window, cx| { + assert_eq!(state.read(cx).search_session().query, "cat"); + assert_eq!(state.read(cx).search_session().matcher.len(), 2); + window.press("tab", cx); + window.input("fox", cx); + window.press("shift-tab", cx); + // Retyping the query proves Shift-Tab returned focus to search; if + // focus stayed in replacement, Replace All would insert "cat". + window.press("secondary-a", cx); + window.input("cat", cx); + }) + .unwrap(); + cx.run_until_parked(); + cx.update_window(handle.into(), |_, window, cx| { + assert_eq!(state.read(cx).search_session().query, "cat"); + assert_eq!(state.read(cx).value(), "cat dog cat"); + window.within("search-panel").click("replace-all", cx); + assert_eq!(state.read(cx).value(), "fox dog fox"); + assert_eq!(state.read(cx).search_session().matcher.len(), 0); + window.within("search-panel").click("close", cx); + assert_eq!( + window.find(("input", state.entity_id())).focused(), + Some(true) + ); + window.press("secondary-z", cx); + assert_eq!(state.read(cx).value(), "cat dog cat"); + }) + .unwrap(); +} diff --git a/crates/kit/tests/input/history.rs b/crates/kit/tests/input/history.rs new file mode 100644 index 0000000000..1d75d43c59 --- /dev/null +++ b/crates/kit/tests/input/history.rs @@ -0,0 +1,281 @@ +use gpui_kit::{ + AppContext, ClipboardItem, Context, Entity, TestAppContext, Window, WindowHandle, + component::input::{Input, InputState}, + div, + prelude::*, + test::TestWindowExt, +}; + +use crate::common; + +#[cfg(target_os = "macos")] +const UNDO: &str = "cmd-z"; +#[cfg(not(target_os = "macos"))] +const UNDO: &str = "ctrl-z"; +#[cfg(target_os = "macos")] +const REDO: &str = "cmd-shift-z"; +#[cfg(not(target_os = "macos"))] +const REDO: &str = "ctrl-y"; +#[cfg(target_os = "macos")] +const PASTE: &str = "cmd-v"; +#[cfg(not(target_os = "macos"))] +const PASTE: &str = "ctrl-v"; + +struct HistoryInputs { + text: Entity, + other: Entity, +} + +impl Render for HistoryInputs { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + div() + .size_full() + .flex() + .flex_col() + .p_4() + .gap_4() + .child(Input::new(&self.text).id("text").w_64()) + .child(Input::new(&self.other).id("other").w_64()) + } +} + +fn inputs(cx: &mut TestAppContext) -> (WindowHandle, Entity) { + cx.update(gpui_kit::init); + common::open_window(cx, None, |window, cx| { + cx.new(|cx| HistoryInputs { + text: cx.new(|cx| InputState::new(window, cx)), + other: cx.new(|cx| InputState::new(window, cx)), + }) + }) +} + +#[gpui_kit::test] +fn consecutive_typing_undoes_and_redoes_as_one_group(cx: &mut TestAppContext) { + let (handle, content) = inputs(cx); + cx.update_window(handle.into(), |_, window, cx| { + window.click("text", cx); + window.input("ab", cx); + window.input("cd", cx); + assert_eq!(window.find("text").value(), Some("abcd")); + window.press(UNDO, cx); + assert_eq!(window.find("text").value(), Some("")); + assert_eq!(content.read(cx).text.read(cx).cursor(), 0); + window.press(UNDO, cx); + assert_eq!(window.find("text").value(), Some("")); + window.press(REDO, cx); + assert_eq!(window.find("text").value(), Some("abcd")); + assert_eq!(content.read(cx).text.read(cx).selected_range(), 4..4); + window.press(REDO, cx); + assert_eq!(window.find("text").value(), Some("abcd")); + }) + .unwrap(); +} + +#[gpui_kit::test] +fn moving_away_and_back_splits_typing_groups(cx: &mut TestAppContext) { + let (handle, content) = inputs(cx); + cx.update_window(handle.into(), |_, window, cx| { + window.click("text", cx); + window.input("ab", cx); + window.press("left", cx); + assert_eq!(content.read(cx).text.read(cx).cursor(), 1); + window.press("right", cx); + window.input("cd", cx); + assert_eq!(window.find("text").value(), Some("abcd")); + window.press(UNDO, cx); + assert_eq!(window.find("text").value(), Some("ab")); + assert_eq!(content.read(cx).text.read(cx).cursor(), 2); + window.press(UNDO, cx); + assert_eq!(window.find("text").value(), Some("")); + window.press(REDO, cx); + assert_eq!(window.find("text").value(), Some("ab")); + window.press(REDO, cx); + assert_eq!(window.find("text").value(), Some("abcd")); + }) + .unwrap(); +} + +#[gpui_kit::test] +fn paste_is_atomic_and_separate_from_surrounding_typing(cx: &mut TestAppContext) { + let (handle, _) = inputs(cx); + cx.update_window(handle.into(), |_, window, cx| { + window.click("text", cx); + window.input("before", cx); + cx.write_to_clipboard(ClipboardItem::new_string("🦀中文".to_owned())); + window.press(PASTE, cx); + assert_eq!(window.find("text").value(), Some("before🦀中文")); + window.input("after", cx); + assert_eq!(window.find("text").value(), Some("before🦀中文after")); + for value in ["before🦀中文", "before", ""] { + window.press(UNDO, cx); + assert_eq!(window.find("text").value(), Some(value)); + } + for value in ["before", "before🦀中文", "before🦀中文after"] { + window.press(REDO, cx); + assert_eq!(window.find("text").value(), Some(value)); + } + }) + .unwrap(); +} + +#[gpui_kit::test] +fn undo_selection_replacement_restores_range_and_active_end(cx: &mut TestAppContext) { + let (handle, content) = inputs(cx); + cx.update_window(handle.into(), |_, window, cx| { + window.click("text", cx); + window.input("abcd", cx); + window.press("shift-left", cx); + window.press("shift-left", cx); + let state = content.read(cx).text.read(cx); + assert_eq!(state.selected_range(), 2..4); + assert_eq!(state.cursor(), 2); + window.input("X", cx); + assert_eq!(window.find("text").value(), Some("abX")); + assert_eq!(content.read(cx).text.read(cx).selected_range(), 3..3); + window.press(UNDO, cx); + assert_eq!(window.find("text").value(), Some("abcd")); + let state = content.read(cx).text.read(cx); + assert_eq!(state.selected_range(), 2..4); + assert_eq!(state.cursor(), 2); + assert_eq!(state.selected_value(), "cd"); + window.press(REDO, cx); + assert_eq!(window.find("text").value(), Some("abX")); + assert_eq!(content.read(cx).text.read(cx).selected_range(), 3..3); + }) + .unwrap(); +} + +#[gpui_kit::test] +fn blur_splits_typing_without_moving_the_caret(cx: &mut TestAppContext) { + let (handle, content) = inputs(cx); + cx.update_window(handle.into(), |_, window, cx| { + window.click("text", cx); + window.input("ab", cx); + // Keyboard focus traversal preserves the caret, isolating blur from + // the transaction boundary introduced by clicking inside the text. + window.press("tab", cx); + assert_eq!(window.find("other").focused(), Some(true)); + assert_eq!(window.find("text").focused(), Some(false)); + }) + .unwrap(); + cx.run_until_parked(); + cx.update_window(handle.into(), |_, window, cx| { + window.press("shift-tab", cx); + assert_eq!(window.find("text").focused(), Some(true)); + assert_eq!(content.read(cx).text.read(cx).cursor(), 2); + window.input("cd", cx); + assert_eq!(window.find("text").value(), Some("abcd")); + window.press(UNDO, cx); + assert_eq!(window.find("text").value(), Some("ab")); + window.press(UNDO, cx); + assert_eq!(window.find("text").value(), Some("")); + window.press(REDO, cx); + assert_eq!(window.find("text").value(), Some("ab")); + window.press(REDO, cx); + assert_eq!(window.find("text").value(), Some("abcd")); + assert_eq!(window.find("other").value(), Some("")); + }) + .unwrap(); +} + +#[gpui_kit::test] +fn backspace_at_start_preserves_redo(cx: &mut TestAppContext) { + let (handle, content) = inputs(cx); + cx.update_window(handle.into(), |_, window, cx| { + window.click("text", cx); + window.input("abc", cx); + window.press(UNDO, cx); + assert_eq!(window.find("text").value(), Some("")); + window.press("backspace", cx); + assert_eq!(window.find("text").value(), Some("")); + assert_eq!(content.read(cx).text.read(cx).cursor(), 0); + window.press(REDO, cx); + assert_eq!(window.find("text").value(), Some("abc")); + assert_eq!(content.read(cx).text.read(cx).cursor(), 3); + window.press(UNDO, cx); + assert_eq!(window.find("text").value(), Some("")); + }) + .unwrap(); +} + +#[gpui_kit::test] +fn new_edit_after_undo_discards_the_redo_branch(cx: &mut TestAppContext) { + let (handle, _) = inputs(cx); + cx.update_window(handle.into(), |_, window, cx| { + window.click("text", cx); + window.input("ab", cx); + window.press("left", cx); + window.press("right", cx); + window.input("old", cx); + assert_eq!(window.find("text").value(), Some("abold")); + window.press(UNDO, cx); + assert_eq!(window.find("text").value(), Some("ab")); + window.input("new", cx); + assert_eq!(window.find("text").value(), Some("abnew")); + window.press(REDO, cx); + assert_eq!(window.find("text").value(), Some("abnew")); + window.press(UNDO, cx); + assert_eq!(window.find("text").value(), Some("ab")); + window.press(REDO, cx); + assert_eq!(window.find("text").value(), Some("abnew")); + window.press(REDO, cx); + assert_eq!(window.find("text").value(), Some("abnew")); + }) + .unwrap(); +} + +#[gpui_kit::test] +fn emoji_navigation_and_both_delete_directions_round_trip(cx: &mut TestAppContext) { + let (handle, content) = inputs(cx); + cx.update_window(handle.into(), |_, window, cx| { + window.click("text", cx); + window.input("a🦀b", cx); + window.press("left", cx); + assert_eq!(content.read(cx).text.read(cx).cursor(), "a🦀".len()); + window.press("left", cx); + assert_eq!(content.read(cx).text.read(cx).cursor(), "a".len()); + window.press("delete", cx); + assert_eq!(window.find("text").value(), Some("ab")); + window.press(UNDO, cx); + assert_eq!(window.find("text").value(), Some("a🦀b")); + assert_eq!(content.read(cx).text.read(cx).cursor(), "a".len()); + window.press(REDO, cx); + assert_eq!(window.find("text").value(), Some("ab")); + window.press(UNDO, cx); + window.press("right", cx); + assert_eq!(content.read(cx).text.read(cx).cursor(), "a🦀".len()); + window.press("backspace", cx); + assert_eq!(window.find("text").value(), Some("ab")); + window.press(UNDO, cx); + assert_eq!(window.find("text").value(), Some("a🦀b")); + assert_eq!(content.read(cx).text.read(cx).cursor(), "a🦀".len()); + window.press(REDO, cx); + assert_eq!(window.find("text").value(), Some("ab")); + }) + .unwrap(); +} + +#[gpui_kit::test] +fn combining_mark_scalar_navigation_and_deletion_round_trip(cx: &mut TestAppContext) { + let (handle, content) = inputs(cx); + cx.update_window(handle.into(), |_, window, cx| { + window.click("text", cx); + window.input("e\u{301}x", cx); + window.press("left", cx); + assert_eq!(content.read(cx).text.read(cx).cursor(), "e\u{301}".len()); + // Input currently navigates Unicode scalars, not extended graphemes. + window.press("left", cx); + assert_eq!(content.read(cx).text.read(cx).cursor(), "e".len()); + window.press("right", cx); + assert_eq!(content.read(cx).text.read(cx).cursor(), "e\u{301}".len()); + window.press("backspace", cx); + assert_eq!(window.find("text").value(), Some("ex")); + assert_eq!(content.read(cx).text.read(cx).cursor(), "e".len()); + window.press(UNDO, cx); + assert_eq!(window.find("text").value(), Some("e\u{301}x")); + assert_eq!(content.read(cx).text.read(cx).cursor(), "e\u{301}".len()); + window.press(REDO, cx); + assert_eq!(window.find("text").value(), Some("ex")); + }) + .unwrap(); +} diff --git a/crates/kit/tests/input/lifecycle.rs b/crates/kit/tests/input/lifecycle.rs new file mode 100644 index 0000000000..a514c9c8df --- /dev/null +++ b/crates/kit/tests/input/lifecycle.rs @@ -0,0 +1,207 @@ +//! Shared contracts at the styled Input, Textarea and Editor boundaries. +use gpui_kit::{ + App, AppContext, Context, ElementId, Entity, Render, TestAppContext, Window, WindowHandle, + base::Root, + component::input::{Editor, EditorState, Input, InputState, Textarea, TextareaState}, + div, + prelude::*, + px, size, + test::TestWindowExt, +}; + +struct Fields { + input: Entity, + textarea: Entity, + editor: Entity, + mounted: bool, + readonly: bool, + revision: usize, +} + +impl Fields { + fn ids(&self) -> [ElementId; 3] { + [ + ("input", self.input.entity_id()).into(), + ("input", self.textarea.entity_id()).into(), + ("input", self.editor.entity_id()).into(), + ] + } + + fn values(&self, cx: &App) -> [String; 3] { + [ + self.input.read(cx).value().to_string(), + self.textarea.read(cx).value().to_string(), + self.editor.read(cx).value().to_string(), + ] + } +} + +impl Render for Fields { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + div() + .size_full() + .flex() + .flex_col() + .gap_2() + .child(format!("Revision {}", self.revision)) + .when(self.mounted, |this| { + this.child(Input::new(&self.input).readonly(self.readonly)) + .child(Textarea::new(&self.textarea).h_24().readonly(self.readonly)) + .child(Editor::new(&self.editor).h_24().readonly(self.readonly)) + }) + } +} + +fn mount(cx: &mut TestAppContext) -> (WindowHandle, Entity, [ElementId; 3]) { + cx.update(gpui_kit::init); + // Fixed window bounds are the test's viewport, not production control styling. + let (window, fields) = + crate::common::open_window(cx, Some(size(px(480.), px(480.))), |window, cx| { + cx.new(|cx| Fields { + input: cx.new(|cx| InputState::new(window, cx)), + textarea: cx.new(|cx| TextareaState::new(window, cx)), + editor: cx.new(|cx| EditorState::new(window, cx)), + mounted: true, + readonly: false, + revision: 0, + }) + }); + let ids = fields.read_with(cx, |fields, _| fields.ids()); + (window, fields, ids) +} + +fn select_all(window: &mut Window, cx: &mut App) { + window.press( + if cfg!(target_os = "macos") { + "cmd-a" + } else { + "ctrl-a" + }, + cx, + ); +} + +#[gpui_kit::test] +fn switching_between_input_textarea_and_editor_routes_text_to_current_focus( + cx: &mut TestAppContext, +) { + let (handle, fields, ids) = mount(cx); + cx.update_window(handle.into(), |_, window, cx| { + for (ix, id) in ids.iter().enumerate() { + window.click(id.clone(), cx); + window.input(["name", "正文", "code"][ix], cx); + for (other, other_id) in ids.iter().enumerate() { + assert_eq!(window.find(other_id.clone()).focused(), Some(other == ix)); + } + } + assert_eq!(fields.read(cx).values(cx), ["name", "正文", "code"]); + for (id, expected) in ids.iter().zip(["name", "正文", "code"]) { + assert_eq!(window.find(id.clone()).value(), Some(expected)); + } + }) + .unwrap(); +} + +#[gpui_kit::test] +fn parent_rerender_preserves_each_controls_focus_and_unicode_selection(cx: &mut TestAppContext) { + let (handle, fields, ids) = mount(cx); + for id in ids { + cx.update_window(handle.into(), |_, window, cx| { + window.click(id.clone(), cx); + window.input("A🦀", cx); + window.press("shift-left", cx); + fields.update(cx, |fields, cx| { + fields.revision += 1; + cx.notify(); + }); + window.render_frame(cx); + assert_eq!(window.find(id.clone()).focused(), Some(true)); + window.input("z", cx); + assert_eq!(window.find(id).value(), Some("Az")); + }) + .unwrap(); + } + fields.read_with(cx, |fields, cx| { + assert_eq!(fields.values(cx), ["Az", "Az", "Az"]) + }); +} + +#[gpui_kit::test] +fn readonly_transition_keeps_selection_copyable_and_reenable_restores_editing( + cx: &mut TestAppContext, +) { + let (handle, fields, ids) = mount(cx); + for id in ids { + cx.update_window(handle.into(), |_, window, cx| { + window.click(id.clone(), cx); + window.input("retained", cx); + select_all(window, cx); + fields.update(cx, |fields, cx| { + fields.readonly = true; + cx.notify(); + }); + window.render_frame(cx); + window.input("rejected", cx); + window.press("backspace", cx); + window.press( + if cfg!(target_os = "macos") { + "cmd-c" + } else { + "ctrl-c" + }, + cx, + ); + assert_eq!( + cx.read_from_clipboard().and_then(|item| item.text()), + Some("retained".into()) + ); + assert_eq!(window.find(id.clone()).value(), Some("retained")); + assert_eq!(window.find(id.clone()).focused(), Some(true)); + fields.update(cx, |fields, cx| { + fields.readonly = false; + cx.notify(); + }); + window.render_frame(cx); + window.input("editable", cx); + assert_eq!(window.find(id).value(), Some("editable")); + }) + .unwrap(); + } +} + +#[gpui_kit::test] +fn unmounting_focused_controls_removes_targets_and_remount_keeps_retained_values( + cx: &mut TestAppContext, +) { + let (handle, fields, ids) = mount(cx); + for id in ids.iter() { + cx.update_window(handle.into(), |_, window, cx| { + window.click(id.clone(), cx); + select_all(window, cx); + window.input("saved", cx); + fields.update(cx, |fields, cx| { + fields.mounted = false; + cx.notify(); + }); + window.render_frame(cx); + for id in &ids { + assert!(window.try_find(id.clone()).is_none()); + } + window.input("orphan", cx); + fields.update(cx, |fields, cx| { + fields.mounted = true; + cx.notify(); + }); + window.render_frame(cx); + assert_eq!(window.find(id.clone()).value(), Some("saved")); + window.click(id.clone(), cx); + select_all(window, cx); + window.input("restored", cx); + assert_eq!(window.find(id.clone()).value(), Some("restored")); + }) + .unwrap(); + } + fields.read_with(cx, |fields, cx| { + assert_eq!(fields.values(cx), ["restored", "restored", "restored"]) + }); +} diff --git a/crates/kit/tests/input/textarea.rs b/crates/kit/tests/input/textarea.rs new file mode 100644 index 0000000000..d159b0f3e8 --- /dev/null +++ b/crates/kit/tests/input/textarea.rs @@ -0,0 +1,333 @@ +//! Application-facing multiline editing and viewport regressions. +use gpui_kit::{ + AppContext, ClipboardItem, Context, ElementId, Entity, ScrollDelta, Subscription, + TestAppContext, Window, WindowHandle, + component::input::{InputEvent, Textarea, TextareaState}, + div, point, + prelude::*, + px, size, + test::TestWindowExt, +}; + +use crate::common; + +#[cfg(target_os = "macos")] +const START: &str = "cmd-up"; +#[cfg(not(target_os = "macos"))] +const START: &str = "ctrl-home"; +#[cfg(target_os = "macos")] +const END: &str = "cmd-down"; +#[cfg(not(target_os = "macos"))] +const END: &str = "ctrl-end"; + +struct Composer { + text: Entity, + enters: Vec<(bool, bool, String)>, + _subscription: Subscription, +} + +impl Render for Composer { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + div() + .size_full() + .p_4() + .child(Textarea::new(&self.text).w_full()) + } +} + +fn composer( + cx: &mut TestAppContext, + configure: impl FnOnce(TextareaState) -> TextareaState, +) -> ( + WindowHandle, + Entity, + Entity, +) { + cx.update(gpui_kit::init); + let (window, view) = common::open_window(cx, Some(size(px(480.), px(480.))), |window, cx| { + cx.new(|cx| { + let text = cx.new(|cx| configure(TextareaState::new(window, cx))); + let subscription = cx.subscribe(&text, |this: &mut Composer, text, event, cx| { + if let InputEvent::PressEnter { secondary, shift } = event { + this.enters + .push((*secondary, *shift, text.read(cx).value().to_string())); + } + }); + Composer { + text, + enters: Vec::new(), + _subscription: subscription, + } + }) + }); + let text = cx.update(|cx| view.read(cx).text.clone()); + (window, view, text) +} + +fn target(text: &Entity) -> ElementId { + ("input", text.entity_id()).into() +} + +fn assert_caret_visible(state: &TextareaState) { + // cursor_layout is in unscrolled coordinates; apply the viewport offset. + let (caret, _) = state.cursor_layout().expect("laid-out caret"); + let viewport = state.input_bounds(); + let top = caret.top() + state.scroll_offset().y; + assert!(top >= viewport.top(), "caret above viewport"); + assert!( + top + caret.size.height <= viewport.bottom(), + "caret below viewport" + ); +} + +#[gpui_kit::test] +fn enter_and_shift_enter_insert_plain_newlines(cx: &mut TestAppContext) { + let (handle, owner, text) = composer(cx, |state| state.rows(4)); + for (key, typed, expected) in [ + ("enter", "first", "first\n"), + ("shift-enter", "中🦀", "first\n中🦀\n"), + ] { + cx.update_window(handle.into(), |_, window, cx| { + if text.read(cx).value().is_empty() { + window.click(target(&text), cx); + } + window.input(typed, cx); + window.press(key, cx); + assert_eq!(text.read(cx).value(), expected); + assert_eq!(text.read(cx).cursor(), expected.len()); + assert_eq!(window.find(target(&text)).value(), Some(expected)); + }) + .unwrap(); + cx.run_until_parked(); + } + cx.update(|cx| { + assert_eq!( + owner.read(cx).enters, + vec![ + (false, false, "first\n".into()), + (false, true, "first\n中🦀\n".into()), + ] + ) + }); +} + +#[gpui_kit::test] +fn submit_on_enter_preserves_text_but_shift_enter_inserts(cx: &mut TestAppContext) { + let (handle, owner, text) = composer(cx, |state| state.submit_on_enter(true)); + cx.update_window(handle.into(), |_, window, cx| { + window.click(target(&text), cx); + window.input("send", cx); + window.press("enter", cx); + assert_eq!(text.read(cx).value(), "send"); + assert_eq!(text.read(cx).cursor(), 4); + }) + .unwrap(); + cx.run_until_parked(); + cx.update(|cx| assert_eq!(owner.read(cx).enters, vec![(false, false, "send".into())])); + cx.update_window(handle.into(), |_, window, cx| { + window.press("shift-enter", cx); + assert_eq!(text.read(cx).value(), "send\n"); + assert_eq!(text.read(cx).cursor(), 5); + }) + .unwrap(); + cx.run_until_parked(); + cx.update(|cx| { + assert_eq!( + owner.read(cx).enters, + vec![ + (false, false, "send".into()), + (false, true, "send\n".into()), + ] + ) + }); +} + +#[gpui_kit::test] +fn keyboard_selection_cuts_and_pastes_across_unicode_lines(cx: &mut TestAppContext) { + let (handle, _, text) = composer(cx, |state| state.default_value("a🦀\n中b")); + cx.update_window(handle.into(), |_, window, cx| { + window.click(target(&text), cx); + window.press(END, cx); + for _ in 0..3 { + window.press("shift-left", cx); + } + assert_eq!( + text.read(cx).selected_range(), + "a🦀".len().."a🦀\n中b".len() + ); + window.press("secondary-c", cx); + assert_eq!( + cx.read_from_clipboard().unwrap().text().as_deref(), + Some("\n中b") + ); + assert_eq!(text.read(cx).value(), "a🦀\n中b"); + window.press("secondary-x", cx); + assert_eq!(text.read(cx).value(), "a🦀"); + assert_eq!(text.read(cx).selected_range(), 5..5); + window.press("secondary-v", cx); + assert_eq!(text.read(cx).value(), "a🦀\n中b"); + assert_eq!(text.read(cx).cursor(), "a🦀\n中b".len()); + }) + .unwrap(); +} + +#[gpui_kit::test] +fn pasted_crlf_is_one_navigation_and_deletion_boundary(cx: &mut TestAppContext) { + let (handle, _, text) = composer(cx, |state| state); + cx.update_window(handle.into(), |_, window, cx| { + window.click(target(&text), cx); + cx.write_to_clipboard(ClipboardItem::new_string("é\r\n中".into())); + window.press("secondary-v", cx); + assert_eq!(text.read(cx).value(), "é\r\n中"); + window.press(START, cx); + window.press("right", cx); + assert_eq!(text.read(cx).cursor(), "é".len()); + window.press("right", cx); + assert_eq!(text.read(cx).cursor(), "é\r\n".len()); + window.press("backspace", cx); + assert_eq!(text.read(cx).value(), "é中"); + assert_eq!(text.read(cx).cursor(), "é".len()); + }) + .unwrap(); +} + +#[gpui_kit::test] +fn vertical_arrows_follow_soft_wrapped_rows(cx: &mut TestAppContext) { + let value = "word ".repeat(80); + let (handle, _, text) = composer(cx, |state| state.rows(6).default_value(value.clone())); + cx.update_window(handle.into(), |_, window, cx| { + window.click(target(&text), cx); + window.press(START, cx); + let first = text.read(cx).cursor_layout().unwrap().0; + window.press("down", cx); + let state = text.read(cx); + assert!(state.cursor() > 0 && state.cursor() < value.len()); + assert_eq!( + state.cursor_position().line, + 0, + "soft wrap is not a buffer newline" + ); + assert!(state.cursor_layout().unwrap().0.top() > first.top()); + assert_caret_visible(state); + window.press("up", cx); + assert_eq!(text.read(cx).cursor(), 0); + assert_eq!(text.read(cx).cursor_layout().unwrap().0.top(), first.top()); + assert_eq!(text.read(cx).value(), value); + }) + .unwrap(); +} + +#[gpui_kit::test] +fn document_navigation_reveals_both_ends_of_a_fixed_viewport(cx: &mut TestAppContext) { + let value = (0..40).map(|n| format!("line {n}\n")).collect::(); + let (handle, _, text) = composer(cx, |state| state.rows(3).default_value(value.clone())); + cx.update_window(handle.into(), |_, window, cx| { + window.click(target(&text), cx); + window.press(START, cx); + let initial_height = window.find(target(&text)).bounds().size.height; + window.press(END, cx); + assert_eq!(text.read(cx).cursor(), value.len()); + assert!(text.read(cx).scroll_offset().y < px(0.)); + assert_caret_visible(text.read(cx)); + window.press(START, cx); + assert_eq!(text.read(cx).cursor(), 0); + assert_eq!(text.read(cx).scroll_offset().y, px(0.)); + assert_caret_visible(text.read(cx)); + assert_eq!( + window.find(target(&text)).bounds().size.height, + initial_height + ); + }) + .unwrap(); +} + +#[gpui_kit::test] +fn typing_reveals_caret_after_user_scrolls_away(cx: &mut TestAppContext) { + let value = (0..40).map(|n| format!("line {n}\n")).collect::(); + let (handle, _, text) = composer(cx, |state| { + state.auto_grow(1, 4).default_value(value.clone()) + }); + cx.update_window(handle.into(), |_, window, cx| { + window.click(target(&text), cx); + window.press(END, cx); + let at_end = text.read(cx).scroll_offset().y; + window.scroll(target(&text), ScrollDelta::Lines(point(0., 100.)), cx); + assert!( + text.read(cx).scroll_offset().y > at_end, + "wheel must move the viewport" + ); + assert_eq!( + text.read(cx).cursor(), + value.len(), + "wheel must not move the caret" + ); + window.input("X", cx); + assert_eq!(text.read(cx).value(), format!("{value}X")); + assert_eq!(text.read(cx).cursor(), value.len() + 1); + assert!(text.read(cx).scroll_offset().y < px(0.)); + assert_caret_visible(text.read(cx)); + }) + .unwrap(); +} + +#[gpui_kit::test] +fn auto_grow_obeys_minimum_and_maximum_then_shrinks_after_delete(cx: &mut TestAppContext) { + let (handle, _, text) = composer(cx, |state| state.auto_grow(2, 4)); + cx.update_window(handle.into(), |_, window, cx| { + window.click(target(&text), cx); + let minimum = window.find(target(&text)).bounds().size.height; + window.input("one", cx); + window.press("enter", cx); + assert_eq!(window.find(target(&text)).bounds().size.height, minimum); + window.press("enter", cx); + let three_rows = window.find(target(&text)).bounds().size.height; + assert!(three_rows > minimum); + window.press("enter", cx); + let maximum = window.find(target(&text)).bounds().size.height; + assert!(maximum > three_rows); + for _ in 0..8 { + window.press("enter", cx); + } + assert_eq!(window.find(target(&text)).bounds().size.height, maximum); + assert_caret_visible(text.read(cx)); + window.press("secondary-a", cx); + window.press("backspace", cx); + assert_eq!(text.read(cx).value(), ""); + assert_eq!(text.read(cx).cursor(), 0); + assert_eq!(window.find(target(&text)).bounds().size.height, minimum); + }) + .unwrap(); +} + +#[gpui_kit::test] +fn resizing_reflows_auto_grow_without_changing_text_or_caret(cx: &mut TestAppContext) { + let value = "word ".repeat(32); + let (handle, _, text) = composer(cx, |state| { + state.auto_grow(1, 30).default_value(value.clone()) + }); + let initial = cx + .update_window(handle.into(), |_, window, cx| { + window.click(target(&text), cx); + window.press(END, cx); + window.find(target(&text)).bounds().size + }) + .unwrap(); + cx.simulate_window_resize(handle.into(), size(px(240.), px(720.))); + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + let narrow = window.find(target(&text)).bounds().size; + assert!(narrow.width < initial.width); + assert!(narrow.height > initial.height); + assert_eq!(text.read(cx).value(), value); + assert_eq!(text.read(cx).cursor(), value.len()); + }) + .unwrap(); + cx.simulate_window_resize(handle.into(), size(px(480.), px(480.))); + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + assert_eq!(window.find(target(&text)).bounds().size, initial); + assert_eq!(text.read(cx).cursor(), value.len()); + assert_eq!(text.read(cx).value(), value); + }) + .unwrap(); +} diff --git a/website/docs/test.md b/website/docs/test.md index 782f7d9857..057f12153f 100644 --- a/website/docs/test.md +++ b/website/docs/test.md @@ -341,6 +341,7 @@ that every option or combination of every component has been exhaustively tested | Suite | Behavior exercised | | --- | --- | | `test_macro.rs` | Published `#[gpui_kit::test]` sync/async compatibility alongside ordinary Rust tests; the independent Kit-only recipes package runs the same contract | +| `input.rs` and `input/` | Input, Textarea and Editor editing, clipboard, selection, history, read-only transitions, Unicode, multiline viewport behavior, search/replace, completion acceptance and retained state across renders | | `search.rs` | Command disabled-item skipping, wraparound, Unicode keywords, empty results, Action dispatch and original-index callbacks, two-stage Escape; Combobox search, single/multi selection, clearing, empty-result recovery, disabled behavior and exactly one Confirm on close | | `disclosure.rs` | Accordion exclusive expansion/collapse and actual panel geometry; Stepper content navigation; disabled disclosure/steps; Slider track click, thumb drag and disabled behavior | | `collections.rs` | Tree pointer expansion, keyboard collapse/expansion and selection; DataTable row selection, keyboard virtualization and wheel scrolling | @@ -354,6 +355,13 @@ suites remain in place. Pure presentation components need geometry or pixel asse not invented interaction state. Custom parts register their existing native elements; unsupported properties remain unavailable, with no manual test-only override. +The [Input regression example](https://github.com/longbridge/gpui-kit/tree/main/crates/kit/tests/input) +shows how to turn a manual editing sequence into a repeatable UI test. Run it with +`cargo test -p gpui-kit --features test-support --test input --locked`. +Each workflow drives the rendered component and checks its result at meaningful +steps. The guide explains when public state reads supplement native snapshots and +which platform checks remain necessary for IME, accessibility actions and pixels. + Views that open dialogs, sheets or notifications through `WindowExt` need a `Root` as the window's root view. `Root` always renders all three overlay layers above application content, including cached views. No manual layer mounting is needed. diff --git a/website/zh-CN/docs/test.md b/website/zh-CN/docs/test.md index d3b2a2274d..7aab3d7894 100644 --- a/website/zh-CN/docs/test.md +++ b/website/zh-CN/docs/test.md @@ -289,6 +289,7 @@ Base motion 则可以响应公开的 `cx.set_reduce_motion(true)` 偏好,用 | 测试文件 | 验证行为 | | --- | --- | | `test_macro.rs` | 普通 `#[test]` 与同步/异步 `#[gpui_kit::test]` 共存;独立、仅依赖 Kit 的 recipes 包复用相同契约 | +| `input.rs` 与 `input/` | Input、Textarea、Editor 的编辑、剪贴板、选区、历史记录、只读切换、Unicode、多行视口、搜索替换、补全确认,以及重绘后的状态保留 | | `search.rs` | Command 禁用项跳过、循环导航、中文关键词、空结果、Action 与原始索引回调、两阶段 Escape;Combobox 搜索、单选/多选、清除、空结果恢复、禁用行为及关闭时仅一次 Confirm | | `disclosure.rs` | Accordion 互斥展开、折叠与实际面板几何;Stepper 内容导航;禁用展开与步骤操作;Slider 轨道点击、滑块拖动与禁用行为 | | `collections.rs` | Tree 点击展开、键盘展开/折叠与选择;DataTable 行选择、键盘虚拟滚动与滚轮滚动 | @@ -301,6 +302,12 @@ Base motion 则可以响应公开的 `cx.set_reduce_motion(true)` 偏好,用 纯展示组件通过几何或像素断言验证,不虚构交互状态。自定义部件观察已有原生元素; 不支持的属性保持不可用,不提供手填测试值的覆盖入口。 +[Input 回归测试范例](https://github.com/longbridge/gpui-kit/tree/main/crates/kit/tests/input) +展示了如何把手工编辑步骤变成可重复的 UI 测试。运行命令: +`cargo test -p gpui-kit --features test-support --test input --locked`。 +每个流程都操作实际渲染的组件,并在关键步骤检查结果。范例说明了何时用公开状态读取 +补充原生快照,以及输入法、辅助功能动作和像素显示仍需哪些平台验证。 + 通过 `WindowExt` 打开 Dialog、Sheet 或 Notification 的视图,需要窗口的根视图是 `Root`。 `Root` 始终将这三类浮层渲染在应用内容之上,缓存视图也一样,无需手动挂载。 From cd5fd7a28f4340d74c57202b648cc50ac7e6d993 Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Sat, 26 Sep 2026 23:36:24 +0800 Subject: [PATCH 2/5] test: Cover input workflows and correct command key simulation --- crates/kit/TESTING.md | 18 ++- crates/kit/src/test.rs | 38 ++++- crates/kit/tests/input.rs | 6 + crates/kit/tests/input/README.md | 84 ++++++++-- crates/kit/tests/input/completions.rs | 181 ++++++++++++++++++++- crates/kit/tests/input/constraints.rs | 225 +++++++++++++++++++++++++- crates/kit/tests/input/editing.rs | 42 ++++- crates/kit/tests/input/editor.rs | 40 ++++- crates/kit/tests/input/history.rs | 68 ++++++-- crates/kit/tests/input/lifecycle.rs | 41 +++++ crates/kit/tests/input/textarea.rs | 43 +++++ crates/kit/tests/input_focus.rs | 20 +-- crates/kit/tests/interactions.rs | 75 +++++++++ website/docs/test.md | 31 +++- website/zh-CN/docs/test.md | 28 +++- 15 files changed, 864 insertions(+), 76 deletions(-) diff --git a/crates/kit/TESTING.md b/crates/kit/TESTING.md index aa8ac311b3..d4a8956aa4 100644 --- a/crates/kit/TESTING.md +++ b/crates/kit/TESTING.md @@ -17,7 +17,20 @@ The [Chinese guide](../../website/zh-CN/docs/test.md) covers the same API. For a component regression example, start with the [Input, Textarea and Editor suite](tests/input/README.md). It groups real editing workflows by behavior and explains how to add a case without bypassing focus, -keyboard bindings or pointer dispatch. +keyboard bindings or pointer dispatch. The separate `input_focus` target covers +Tab/Shift-Tab traversal, addon button focus and activation, and Textarea/Editor +body clicks. Run both from the repository root: + +```sh +cargo test -p gpui-kit --features test-support --test input --test input_focus --locked +``` + +For a focused investigation, select a module such as +`--test input --locked -- history::`, or an exact case as shown in the suite README. +Append `-- --list` to the combined command to inspect discovery without running +cases. These are reproduction commands, not recorded test results. A passing run +establishes only the asserted workflows on that revision and platform; OS IME, +accessibility actions, system clipboard adapters and pixels need separate evidence. ## Core semantics @@ -34,6 +47,9 @@ keyboard bindings or pointer dispatch. before `.track_focus(&handle)`. - `render_frame` refreshes external changes. Synchronous interactions refresh their frames; deferred/async effects use `wait_for` outside a window update. +- `press` sends native key-down/key-up events for commands; Enter must not be + emulated by injecting IME newline text. `input` supplies text and does not model + a full OS input method. - Clicks use real hit testing. `click_at` provides a local offset for clipped targets. - Instrumentation adds no layout container, but evaluates computed style an extra time. Snapshots cannot infer an unobserved ancestor's opacity or inspect pixels. diff --git a/crates/kit/src/test.rs b/crates/kit/src/test.rs index 22a1ad4998..7f8025de16 100644 --- a/crates/kit/src/test.rs +++ b/crates/kit/src/test.rs @@ -11,9 +11,9 @@ //! [`ElementSnapshot`] is immutable. Call [`TestWindowExt::render_frame`] after //! external changes, or use [`TestAppContextExt::wait_for`] for asynchronous UI. use gpui::{ - AnyWindowHandle, App, AppContext, ElementId, InputEvent, Keystroke, MouseButton, - MouseDownEvent, MouseMoveEvent, MouseUpEvent, Pixels, Point, ScrollDelta, ScrollWheelEvent, - TestAppContext, Window, point, px, + AnyWindowHandle, App, AppContext, ElementId, InputEvent, KeyDownEvent, KeyUpEvent, Keystroke, + MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, Pixels, Point, ScrollDelta, + ScrollWheelEvent, TestAppContext, Window, point, px, }; use std::time::Duration; @@ -43,7 +43,8 @@ pub trait TestWindowExt { fn drag(&mut self, from: Point, to: Point, cx: &mut App); /// Drags between two observed element centers, with native hit testing. fn drag_to(&mut self, from: impl Into, to: impl Into, cx: &mut App); - /// Sends a parsed GPUI keystroke, such as "backspace" or "cmd-a". + /// Sends key-down and key-up for a parsed GPUI key, such as "backspace" or "cmd-a". + /// Enter and Tab run key bindings without additionally inserting text. fn press(&mut self, key: &str, cx: &mut App); /// Sends text to the current focus; does not focus a target or replace its whole value. fn input(&mut self, text: &str, cx: &mut App); @@ -262,7 +263,7 @@ impl TestWindowExt for Window { let key = Keystroke::parse(key).unwrap_or_else(|error| panic!("invalid test keystroke: {error}")); self.render_frame(cx); - self.dispatch_keystroke(key, cx); + press_key(self, key, cx); self.render_frame(cx); } fn input(&mut self, text: &str, cx: &mut App) { @@ -350,7 +351,7 @@ impl ScopedWindow<'_> { Keystroke::parse(key).unwrap_or_else(|error| panic!("invalid test keystroke: {error}")); self.window.render_frame(cx); require_scope_focus(self.window, &self.scope); - self.window.dispatch_keystroke(key, cx); + press_key(self.window, key, cx); self.window.render_frame(cx); } /// Checks scope membership before every character, including after focus-changing handlers. @@ -367,6 +368,31 @@ fn require_scope_focus(window: &Window, scope: &[ElementId]) { ); } +fn press_key(window: &mut Window, key: Keystroke, cx: &mut App) { + // GPUI's simulated IME supplies text for Enter and Tab. Native control + // keys should only dispatch their bindings: an intentionally propagated + // submit/completion action must not insert an extra newline afterward. + let key = if matches!(key.key.as_str(), "enter" | "tab") { + window.dispatch_event( + KeyDownEvent { + keystroke: key.clone(), + is_held: false, + prefer_character_input: false, + } + .to_platform_input(), + cx, + ); + key + } else { + let key = key.with_simulated_ime(); + window.dispatch_keystroke(key.clone(), cx); + key + }; + // Buttons activate on key-up, so every press must complete the pair even + // if its key-down handler consumed the event or changed focus. + window.dispatch_event(KeyUpEvent { keystroke: key }.to_platform_input(), cx); +} + fn input_text(window: &mut Window, text: &str, scope: Option<&[ElementId]>, cx: &mut App) { window.render_frame(cx); for character in text.chars() { diff --git a/crates/kit/tests/input.rs b/crates/kit/tests/input.rs index f9b9d45a60..dbee8c3da4 100644 --- a/crates/kit/tests/input.rs +++ b/crates/kit/tests/input.rs @@ -191,6 +191,12 @@ fn scoped_keyboard_uses_the_focused_input_in_the_selected_scope(cx: &mut TestApp dialog.input("Ada中", cx); dialog.press("backspace", cx); assert_eq!(dialog.find("name").value(), Some("Ada")); + // Printable keys retain GPUI's text fallback, while named control + // keys use the same scoped command routing without synthetic text. + dialog.press("shift-a", cx); + assert_eq!(dialog.find("name").value(), Some("AdaA")); + window.press("space", cx); + assert_eq!(window.within("right").find("name").value(), Some("AdaA ")); assert_eq!(window.within("left").find("name").value(), Some("")); }) .unwrap(); diff --git a/crates/kit/tests/input/README.md b/crates/kit/tests/input/README.md index 6cd90e79f2..dac9f6ce97 100644 --- a/crates/kit/tests/input/README.md +++ b/crates/kit/tests/input/README.md @@ -5,10 +5,10 @@ under the normal window `Root`. They exercise pointer hit testing, keyboard bindings, focus routing and rendered state together. The test target is [`input.rs`](../input.rs); the modules here group related user workflows. -Run the complete suite from the repository root: +Run both editing and focus targets from the repository root: ```sh -cargo test -p gpui-kit --features test-support --test input --locked +cargo test -p gpui-kit --features test-support --test input --test input_focus --locked ``` To investigate one area, add its module name after `--`, for example: @@ -17,10 +17,45 @@ To investigate one area, add its module name after `--`, for example: cargo test -p gpui-kit --features test-support --test input --locked -- history:: ``` -The existing CI test matrix runs this target on Linux, macOS and Windows. +To list the selected cases without executing them, append `-- --list` to the +combined command above. To rerun one workflow with an exact name: + +```sh +cargo test -p gpui-kit --features test-support --test input --locked -- history::paste_is_atomic_and_separate_from_surrounding_typing --exact +cargo test -p gpui-kit --features test-support --test input_focus --locked -- reverse_tab_cycles_three_inputs_with_passive_addons --exact +``` + +These commands are reproduction instructions, not a record of passing results. +Record the revision, platform, command and actual outcome when reporting a run. + +The existing CI test matrix runs these targets on Linux, macOS and Windows. Keyboard cases use each platform's actual command bindings. A local Linux pass does not replace the macOS and Windows jobs. +## Known failing contracts + +The current branch is not fully passing. Two confirmed defects remain +intentionally red, without `#[ignore]`, pending separate production fixes. +Component source remains untouched by this coverage work. + +- `constraints::disabled_single_and_double_click_do_not_focus_the_editor`: + single and double clicks on a disabled input must not focus its editor. + Both currently focus the editor. Enabling is covered separately by + `constraints::enabling_a_disabled_input_allows_mouse_focus_and_replacement`. +- `textarea::selection_across_soft_wraps_copies_and_replaces_buffer_text`: + Shift-Down should retain the selection anchor and reach the same visual row as + Down. It currently selects the entire logical line instead of one wrapped row. + +Reproduce each contract independently from the repository root: + +```sh +cargo test -p gpui-kit --features test-support --test input --locked -- constraints::disabled_single_and_double_click_do_not_focus_the_editor --exact +cargo test -p gpui-kit --features test-support --test input --locked -- textarea::selection_across_soft_wraps_copies_and_replaces_buffer_text --exact +``` + +These failures remain visible in the full `input` run. The workflow index below +describes asserted contracts, not a claim that every contract currently passes. + ## Recorded workflows | Module | Interaction contracts | @@ -36,21 +71,43 @@ pass does not replace the macOS and Windows jobs. The original cases in [`input.rs`](../input.rs) also cover scoped duplicate IDs, cross-scope keyboard rejection and masked values. The table is an index to concrete tests, not a claim of exhaustive coverage. Inline tokens, touch selection, -InputGroup composition and language-service providers retain their separate -Base/component tests; they are not all exercised by this target. The completion +InputGroup composition and language-service providers are outside this target's +workflow matrix; inspect their separate Base/component tests for relevant coverage. +Touch selection also has a separate Kit `touch_selection` target. The completion fixture supplies deterministic responses through the public provider interface; it does not connect to a language-server process. +The separate [`input_focus.rs`](../input_focus.rs) target covers repeated Tab and +Shift-Tab cycles with passive prefixes/suffixes, focus and activation of addon +buttons, and clicking the body of Textarea/Editor before editing. Run it alongside +`input` when changing focus routing; a module filter on `input` does not select it. + +## Example workflows + +- History: type a prefix, paste from the test clipboard, then type a suffix; + Undo/Redo should preserve the asserted edit boundaries. See + `history::paste_is_atomic_and_separate_from_surrounding_typing`. +- Textarea: enable submit-on-Enter, press Enter, then Shift-Enter; check the + emitted submit event and the resulting text separately. See + `textarea::submit_on_enter_preserves_text_but_shift_enter_inserts`. +- Editor: type a completion trigger, inspect the popup, accept with Enter, + then verify text and Undo boundaries. See `completions.rs`. +- Focus: repeatedly traverse decorated inputs in both directions, allow queued + focus callbacks to settle, then type into the destination. See `input_focus.rs`. + ## Writing a regression case Start with the smallest user sequence that demonstrates the bug. Mount the -real component with retained state and a stable ID, click it, send keyboard or +real component through `gpui_kit::open_window` after `gpui_kit::init`, retain its +state and give it a stable ID. Click it, send keyboard or pointer events, and assert the result after each meaningful step. See [`lifecycle.rs`](lifecycle.rs) for a workflow applied to all three controls. Use `window.input` for typing and `window.press` for commands such as Enter, -Backspace and Undo. Prepare clipboard data through the test application's -clipboard, then send the Paste shortcut. Calling `set_value`, `replace_all`, +Backspace and Undo. Command presses use native key-down/key-up events; do not +simulate Enter by injecting a newline through an IME text callback. Unicode +typing through `window.input` does not establish OS IME coverage. Prepare clipboard +data through the test application's clipboard, then send the Paste shortcut. Calling `set_value`, `replace_all`, `undo` or a private event handler to perform the interaction would bypass the routing this suite is intended to protect. Public setters are appropriate for initial fixtures and explicit external-owner updates. @@ -64,7 +121,11 @@ the secret through accessibility. Leave `update_window` before checking deferred owner callbacks. Use `cx.run_until_parked()` for queued work or `wait_for` for a bounded asynchronous -condition. Do not add wall-clock sleeps. For geometry, check relationships +condition. History tests that depend on focus/blur callbacks must first activate +the window with `window.activate_window()` inside `cx.update_window`, then allow +queued work to settle. See `history::blur_splits_typing_without_moving_the_caret`; +assigning a focus handle alone does not establish an active-window callback flow. +Do not add wall-clock sleeps. For geometry, check relationships such as caret containment, scroll direction or relative height, rather than font-dependent pixel constants. @@ -75,8 +136,9 @@ transition, a no-op edit, or a focus change. ## What a green run establishes -This suite establishes the recorded interaction contracts for the tested -configurations. Existing Base tests continue to cover editing algorithms, +A passing run would establish the recorded interaction contracts for the tested +configurations; the current failures are listed above. Existing Base tests +continue to cover editing algorithms, IME composition state transitions and language-specific parsing cases. Neither set exhausts every document, language, configuration or event order. diff --git a/crates/kit/tests/input/completions.rs b/crates/kit/tests/input/completions.rs index 1d1df491ae..462c2f07a4 100644 --- a/crates/kit/tests/input/completions.rs +++ b/crates/kit/tests/input/completions.rs @@ -2,11 +2,16 @@ //! The popup has no TestWindowExt observation; text, focus, and provider requests //! are the public evidence, with acceptance proving that menu actions were routed. -use std::{cell::RefCell, rc::Rc}; +use std::{ + cell::RefCell, + future::poll_fn, + rc::Rc, + task::{Poll, Waker}, +}; use gpui_kit::{ App, AppContext, Context, Entity, Result, Task, TestAppContext, Window, WindowHandle, - component::input::{CompletionProvider, Editor, EditorState, Rope}, + component::input::{CompletionProvider, Editor, EditorState, Input, InputState, Rope}, div, prelude::*, px, size, @@ -29,6 +34,40 @@ struct CompletionRequest { #[derive(Default)] struct Suggestions { requests: RefCell>, + deferred: bool, + pending: RefCell>>>, +} + +#[derive(Default)] +struct PendingResponse { + response: Option, + waker: Option, +} + +impl Suggestions { + fn respond(&self, index: usize, label: Option<&str>) { + let pending = self.pending.borrow()[index].clone(); + let mut pending = pending.borrow_mut(); + pending.response = Some(CompletionResponse::Array( + label + .into_iter() + .map(|label| CompletionItem { + label: label.into(), + text_edit: Some(CompletionTextEdit::Edit(TextEdit { + range: Range::new( + Position::new(0, 0), + Position::new(0, self.requests.borrow()[index].offset as u32), + ), + new_text: label.into(), + })), + ..Default::default() + }) + .collect(), + )); + if let Some(waker) = pending.waker.take() { + waker.wake(); + } + } } impl CompletionProvider for Suggestions { @@ -38,7 +77,7 @@ impl CompletionProvider for Suggestions { offset: usize, trigger: CompletionContext, _: &mut Window, - _: &mut App, + cx: &mut App, ) -> Task> { let text = text.to_string(); // This fixture uses single-line ASCII identifiers; LSP character @@ -61,7 +100,25 @@ impl CompletionProvider for Suggestions { offset, trigger, }); - Task::ready(Ok(CompletionResponse::Array(items))) + if self.deferred { + let pending = Rc::new(RefCell::new(PendingResponse::default())); + self.pending.borrow_mut().push(pending.clone()); + cx.spawn(async move |_| { + poll_fn(move |cx| { + let mut pending = pending.borrow_mut(); + match pending.response.take() { + Some(response) => Poll::Ready(Ok(response)), + None => { + pending.waker = Some(cx.waker().clone()); + Poll::Pending + } + } + }) + .await + }) + } else { + Task::ready(Ok(CompletionResponse::Array(items))) + } } fn is_completion_trigger(&self, _: usize, new_text: &str, _: &mut App) -> bool { @@ -71,6 +128,7 @@ impl CompletionProvider for Suggestions { struct CompletionEditor { state: Entity, + other: Entity, } impl Render for CompletionEditor { @@ -78,7 +136,10 @@ impl Render for CompletionEditor { div() .size_full() .p_4() - .child(Editor::new(&self.state).size_full()) + .flex() + .flex_col() + .child(Input::new(&self.other).id("other")) + .child(Editor::new(&self.state).flex_1()) } } @@ -90,11 +151,26 @@ struct Fixture { impl Fixture { fn new(cx: &mut TestAppContext) -> Self { + Self::with_provider(cx, Suggestions::default()) + } + + fn deferred(cx: &mut TestAppContext) -> Self { + Self::with_provider( + cx, + Suggestions { + deferred: true, + ..Default::default() + }, + ) + } + + fn with_provider(cx: &mut TestAppContext, provider: Suggestions) -> Self { cx.update(gpui_kit::init); - let provider = Rc::new(Suggestions::default()); + let provider = Rc::new(provider); let (handle, view) = common::open_window(cx, Some(size(px(800.), px(480.))), |window, cx| { cx.new(|cx| CompletionEditor { + other: cx.new(|cx| InputState::new(window, cx)), state: cx.new(|cx| { let mut state = EditorState::new(window, cx).language("plaintext"); state.lsp_mut().completion_provider = Some(provider.clone()); @@ -239,3 +315,96 @@ fn accepted_completion_is_one_undo_separate_from_the_typed_prefix(cx: &mut TestA // History replay must not issue new completion requests. assert_eq!(fixture.provider.requests.borrow().len(), 1); } + +// Each response is released explicitly. run_until_parked drains runnable work +// without advancing timers or waiting for a response that the test still owns. +#[gpui_kit::test] +fn older_completion_response_cannot_replace_newer_suggestions(cx: &mut TestAppContext) { + let fixture = Fixture::deferred(cx); + fixture.start_completion(cx); + fixture.input("r", cx); + assert_eq!(fixture.provider.requests.borrow().len(), 2); + fixture.provider.respond(1, Some("private")); + fixture.settle(cx); + fixture.provider.respond(0, Some("print")); + fixture.settle(cx); + fixture.press("enter", cx); + fixture.assert_editor("private", cx); +} + +#[gpui_kit::test] +fn empty_newer_response_cannot_be_reopened_by_older_suggestions(cx: &mut TestAppContext) { + let fixture = Fixture::deferred(cx); + fixture.start_completion(cx); + fixture.input("z", cx); + assert_eq!(fixture.provider.requests.borrow().len(), 2); + fixture.provider.respond(1, None); + fixture.settle(cx); + fixture.provider.respond(0, Some("print")); + fixture.settle(cx); + fixture.press("enter", cx); + fixture.assert_editor("pz\n", cx); +} + +#[gpui_kit::test] +fn completion_response_after_focus_loss_cannot_reopen_on_refocus(cx: &mut TestAppContext) { + let fixture = Fixture::deferred(cx); + fixture.start_completion(cx); + cx.update_window(fixture.handle.into(), |_, window, cx| { + window.click("other", cx); + window.input("other field", cx); + assert_eq!( + window.find(("input", fixture.state.entity_id())).focused(), + Some(false) + ); + }) + .unwrap(); + fixture.provider.respond(0, Some("print")); + fixture.settle(cx); + cx.update_window(fixture.handle.into(), |_, window, cx| { + assert_eq!(window.find("other").value(), Some("other field")); + assert_eq!(window.find("other").focused(), Some(true)); + window.click(("input", fixture.state.entity_id()), cx); + }) + .unwrap(); + fixture.settle(cx); + fixture.press("enter", cx); + fixture.assert_editor("p\n", cx); +} + +#[gpui_kit::test] +fn closing_window_disposes_editor_with_completion_in_flight(cx: &mut TestAppContext) { + let fixture = Fixture::deferred(cx); + fixture.start_completion(cx); + let editor = fixture.state.downgrade(); + cx.update_window(fixture.handle.into(), |_, window, _| window.remove_window()) + .unwrap(); + let provider = fixture.provider.clone(); + drop(fixture); + cx.run_until_parked(); + assert!(editor.upgrade().is_none(), "closed editor must be released"); + provider.respond(0, Some("print")); + cx.run_until_parked(); + assert!(editor.upgrade().is_none()); +} + +#[gpui_kit::test] +fn focus_round_trip_invalidates_completion_requested_before_blur(cx: &mut TestAppContext) { + let fixture = Fixture::deferred(cx); + fixture.start_completion(cx); + cx.update_window(fixture.handle.into(), |_, window, cx| { + window.click("other", cx); + assert_eq!(window.find("other").focused(), Some(true)); + }) + .unwrap(); + fixture.settle(cx); + cx.update_window(fixture.handle.into(), |_, window, cx| { + window.click(("input", fixture.state.entity_id()), cx); + }) + .unwrap(); + fixture.settle(cx); + fixture.provider.respond(0, Some("print")); + fixture.settle(cx); + fixture.press("enter", cx); + fixture.assert_editor("p\n", cx); +} diff --git a/crates/kit/tests/input/constraints.rs b/crates/kit/tests/input/constraints.rs index 044ef43937..184494331a 100644 --- a/crates/kit/tests/input/constraints.rs +++ b/crates/kit/tests/input/constraints.rs @@ -4,8 +4,8 @@ use crate::common; use gpui_kit::{ - App, AppContext, ClipboardItem, Context, Entity, Subscription, TestAppContext, Window, - WindowHandle, + App, AppContext, ClipboardItem, Context, Entity, Focusable, Subscription, TestAppContext, + Window, WindowHandle, base::Root, component::input::{Input, InputContentType, InputEvent, InputState}, div, @@ -442,3 +442,224 @@ fn clear_affordance_tracks_editability_and_emits_one_change(cx: &mut TestAppCont }); assert_owner(handle, &view, cx, "A", 2); } + +#[gpui_kit::test] +fn readonly_mouse_selection_copies_and_becomes_editable_again(cx: &mut TestAppContext) { + // Keep the center of the field over a word regardless of font metrics. + let word = "word".repeat(24); + let (handle, view) = fixture(cx, |input| input.default_value(word.clone())); + common::update_content(handle, &view, cx, |view, _, cx| { + view.readonly = true; + cx.notify(); + }) + .unwrap(); + ui(handle, cx, |window, cx| { + window.double_click("constrained", cx); + assert_eq!(window.find("constrained").focused(), Some(true)); + shortcut(window, "c", cx); + assert_clipboard(cx, &word); + window.input("ignored", cx); + }); + assert_owner(handle, &view, cx, &word, 0); + common::update_content(handle, &view, cx, |view, _, cx| { + assert_eq!(view.input.read(cx).selected_range(), 0..word.len()); + view.readonly = false; + cx.notify(); + }) + .unwrap(); + ui(handle, cx, |window, cx| window.input("replacement", cx)); + assert_owner(handle, &view, cx, "replacement", 11); +} + +#[gpui_kit::test] +fn disabled_single_and_double_click_do_not_focus_the_editor(cx: &mut TestAppContext) { + let (handle, view) = fixture(cx, |input| input.default_value("fixed")); + common::update_content(handle, &view, cx, |view, _, cx| { + view.disabled = true; + cx.notify(); + }) + .unwrap(); + ui(handle, cx, |window, cx| { + let input = view.read(cx).input.clone(); + let focus = input.read(cx).focus_handle(cx); + let mut editor_focused = Vec::new(); + let mut frame_focused = Vec::new(); + for double_click in [false, true] { + window.blur(cx); + window.render_frame(cx); + assert!(!focus.is_focused(window)); + assert_ne!(window.find("constrained").focused(), Some(true)); + if double_click { + window.double_click("constrained", cx); + } else { + window.click("constrained", cx); + } + editor_focused.push(focus.is_focused(window)); + frame_focused.push(window.find("constrained").focused()); + } + assert_eq!(input.read(cx).value(), "fixed"); + assert_eq!( + editor_focused, + [false, false], + "editor focus after [single click, double click]; frame snapshots: {frame_focused:?}" + ); + assert!(frame_focused.iter().all(|focused| *focused != Some(true))); + }); + assert_owner(handle, &view, cx, "fixed", 0); +} + +#[gpui_kit::test] +fn enabling_a_disabled_input_allows_mouse_focus_and_replacement(cx: &mut TestAppContext) { + let (handle, view) = fixture(cx, |input| input.default_value("fixed")); + common::update_content(handle, &view, cx, |view, _, cx| { + view.disabled = true; + cx.notify(); + }) + .unwrap(); + // Render the disabled state before enabling the same retained input. + ui(handle, cx, |window, cx| window.blur(cx)); + common::update_content(handle, &view, cx, |view, _, cx| { + view.disabled = false; + cx.notify(); + }) + .unwrap(); + ui(handle, cx, |window, cx| { + window.click("constrained", cx); + assert!( + view.read(cx) + .input + .read(cx) + .focus_handle(cx) + .is_focused(window) + ); + shortcut(window, "a", cx); + window.input("enabled", cx); + }); + assert_owner(handle, &view, cx, "enabled", 7); +} + +#[gpui_kit::test] +fn masked_mouse_selection_replaces_whole_secret_and_reveal_keeps_history(cx: &mut TestAppContext) { + let (handle, view) = fixture(cx, |input| { + input.default_value("first second third").masked(true) + }); + common::update_content(handle, &view, cx, |view, _, cx| { + view.mask_toggle = true; + cx.notify(); + }) + .unwrap(); + ui(handle, cx, |window, cx| { + window.double_click("constrained", cx); + clipboard(cx, "sentinel"); + shortcut(window, "c", cx); + assert_clipboard(cx, "sentinel"); + clipboard(cx, "new secret"); + shortcut(window, "v", cx); + assert_eq!(window.find("constrained").value(), None); + window.within("constrained").click("toggle-mask", cx); + assert_eq!(window.find("constrained").value(), Some("new secret")); + window.click("constrained", cx); + shortcut(window, "z", cx); + assert_eq!( + window.find("constrained").value(), + Some("first second third") + ); + window.within("constrained").click("toggle-mask", cx); + assert_eq!(window.find("constrained").value(), None); + }); + assert_owner(handle, &view, cx, "first second third", 2); +} + +#[gpui_kit::test] +fn validation_rejects_partial_deletion_but_allows_clear_and_reentry(cx: &mut TestAppContext) { + let (handle, view) = fixture(cx, |input| { + input + .default_value("12") + .validate(|value, _| value.len() >= 2) + }); + common::update_content(handle, &view, cx, |view, _, cx| { + view.cleanable = true; + cx.notify(); + }) + .unwrap(); + ui(handle, cx, |window, cx| { + window.click("constrained", cx); + shortcut(window, "a", cx); + window.press("right", cx); + window.press("backspace", cx); + assert_eq!(window.find("constrained").value(), Some("12")); + shortcut(window, "a", cx); + window.press("left", cx); + window.press("delete", cx); + assert_eq!(window.find("constrained").value(), Some("12")); + }); + assert_owner(handle, &view, cx, "12", 0); + ui(handle, cx, |window, cx| { + // Empty is deliberately accepted independently of the validator. + window.within("constrained").click("clean", cx); + assert_eq!(window.find("constrained").value(), Some("")); + assert_eq!(window.find("constrained").focused(), Some(true)); + assert!(window.within("constrained").try_find("clean").is_none()); + }); + assert_owner(handle, &view, cx, "", 1); + ui(handle, cx, |window, cx| { + window.input("1", cx); + assert_eq!(window.find("constrained").value(), Some("")); + clipboard(cx, "34"); + shortcut(window, "v", cx); + }); + assert_owner(handle, &view, cx, "34", 2); +} + +#[gpui_kit::test] +fn validation_policy_changes_apply_to_the_existing_selection(cx: &mut TestAppContext) { + let (handle, view) = fixture(cx, |input| { + input + .default_value("12") + .validate(|value, _| value.bytes().all(|c| c.is_ascii_digit())) + }); + ui(handle, cx, |window, cx| { + window.click("constrained", cx); + shortcut(window, "a", cx); + window.input("letters", cx); + }); + assert_owner(handle, &view, cx, "12", 0); + common::update_content(handle, &view, cx, |view, _, cx| { + view.input + .update(cx, |input, cx| input.set_validator(|_, _| true, cx)); + }) + .unwrap(); + ui(handle, cx, |window, cx| window.input("letters", cx)); + assert_owner(handle, &view, cx, "letters", 7); + common::update_content(handle, &view, cx, |view, _, cx| { + view.input.update(cx, |input, cx| { + input.set_validator(|value, _| value.bytes().all(|c| c.is_ascii_digit()), cx); + }); + }) + .unwrap(); + ui(handle, cx, |window, cx| { + shortcut(window, "a", cx); + clipboard(cx, "bad"); + shortcut(window, "v", cx); + // Existing invalid text stays editable so the user can repair it. + assert_eq!(window.find("constrained").value(), Some("bad")); + }); + assert_owner(handle, &view, cx, "bad", 8); + ui(handle, cx, |window, cx| { + shortcut(window, "a", cx); + clipboard(cx, "34"); + shortcut(window, "v", cx); + }); + assert_owner(handle, &view, cx, "34", 9); + ui(handle, cx, |window, cx| { + shortcut(window, "a", cx); + clipboard(cx, "bad"); + shortcut(window, "v", cx); + assert_eq!(window.find("constrained").value(), Some("34")); + }); + common::update_content(handle, &view, cx, |view, _, cx| { + assert_eq!(view.input.read(cx).selected_range(), 0..2); + }) + .unwrap(); + assert_owner(handle, &view, cx, "34", 9); +} diff --git a/crates/kit/tests/input/editing.rs b/crates/kit/tests/input/editing.rs index 926c9f085b..ee9b5b1496 100644 --- a/crates/kit/tests/input/editing.rs +++ b/crates/kit/tests/input/editing.rs @@ -3,7 +3,7 @@ use gpui_kit::{ App, AppContext, ClipboardItem, Context, Entity, IntoElement, Render, SharedString, Subscription, TestAppContext, Window, WindowHandle, base::Root, - component::input::{Input, InputEvent, InputState}, + component::input::{Enter, Input, InputEvent, InputState}, div, point, prelude::*, px, size, @@ -15,12 +15,17 @@ struct EditingForm { first: Entity, second: Entity, submissions: Vec<(SharedString, bool, bool)>, + propagated_submissions: Vec<(bool, bool)>, _subscription: Subscription, } impl Render for EditingForm { - fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { div() + .on_action(cx.listener(|this, action: &Enter, _, _| { + this.propagated_submissions + .push((action.secondary, action.shift)); + })) .size_full() .flex() .flex_col() @@ -53,6 +58,7 @@ fn editing_form(cx: &mut TestAppContext) -> (WindowHandle, Entity, + readonly: bool, } impl Render for EditorFixture { @@ -20,7 +21,7 @@ impl Render for EditorFixture { div() .size_full() .p_4() - .child(Editor::new(&self.state).size_full()) + .child(Editor::new(&self.state).readonly(self.readonly).size_full()) } } @@ -28,10 +29,20 @@ fn editor( cx: &mut TestAppContext, language: &'static str, value: &'static str, +) -> (WindowHandle, Entity) { + editor_with_readonly(cx, language, value, false) +} + +fn editor_with_readonly( + cx: &mut TestAppContext, + language: &'static str, + value: &'static str, + readonly: bool, ) -> (WindowHandle, Entity) { cx.update(gpui_kit::init); let (handle, view) = common::open_window(cx, Some(size(px(800.), px(480.))), |window, cx| { cx.new(|cx| EditorFixture { + readonly, state: cx.new(|cx| { EditorState::new(window, cx) .language(language) @@ -216,6 +227,33 @@ fn tab_and_shift_tab_preserve_multiline_selection_and_undo(cx: &mut TestAppConte .unwrap(); } +#[gpui_kit::test] +fn readonly_editor_rejects_indentation_and_keeps_multiline_text_copyable(cx: &mut TestAppContext) { + let value = " one\n 中🦀"; + let (handle, state) = editor_with_readonly(cx, "rust", value, true); + cx.update_window(handle.into(), |_, window, cx| { + for key in ["tab", "shift-tab"] { + window.click(("input", state.entity_id()), cx); + window.press("secondary-a", cx); + window.press(key, cx); + assert_eq!(state.read(cx).value(), value, "read-only {key}"); + assert_eq!(state.read(cx).selected_range(), 0..value.len()); + assert_eq!( + window.find(("input", state.entity_id())).value(), + Some(value) + ); + } + window.click(("input", state.entity_id()), cx); + window.press("secondary-a", cx); + window.press("secondary-c", cx); + assert_eq!( + cx.read_from_clipboard().unwrap().text().as_deref(), + Some(value) + ); + }) + .unwrap(); +} + #[gpui_kit::test] fn keyboard_multicursor_replacement_undo_and_escape_keep_the_active_cursor( cx: &mut TestAppContext, diff --git a/crates/kit/tests/input/history.rs b/crates/kit/tests/input/history.rs index 1d75d43c59..7457258acb 100644 --- a/crates/kit/tests/input/history.rs +++ b/crates/kit/tests/input/history.rs @@ -1,6 +1,6 @@ use gpui_kit::{ AppContext, ClipboardItem, Context, Entity, TestAppContext, Window, WindowHandle, - component::input::{Input, InputState}, + component::input::{Input, InputEvent, InputState}, div, prelude::*, test::TestWindowExt, @@ -148,6 +148,20 @@ fn undo_selection_replacement_restores_range_and_active_end(cx: &mut TestAppCont #[gpui_kit::test] fn blur_splits_typing_without_moving_the_caret(cx: &mut TestAppContext) { let (handle, content) = inputs(cx); + let blur_count = std::rc::Rc::new(std::cell::Cell::new(0)); + let _subscription = cx.update(|cx| { + let blur_count = blur_count.clone(); + let text = content.read(cx).text.clone(); + cx.subscribe(&text, move |_, event, _| { + if matches!(event, InputEvent::Blur) { + blur_count.set(blur_count.get() + 1); + } + }) + }); + // GPUI only delivers focus/blur callbacks for an active platform window. + cx.update_window(handle.into(), |_, window, _| window.activate_window()) + .unwrap(); + cx.run_until_parked(); cx.update_window(handle.into(), |_, window, cx| { window.click("text", cx); window.input("ab", cx); @@ -159,6 +173,7 @@ fn blur_splits_typing_without_moving_the_caret(cx: &mut TestAppContext) { }) .unwrap(); cx.run_until_parked(); + assert_eq!(blur_count.get(), 1); cx.update_window(handle.into(), |_, window, cx| { window.press("shift-tab", cx); assert_eq!(window.find("text").focused(), Some(true)); @@ -198,6 +213,30 @@ fn backspace_at_start_preserves_redo(cx: &mut TestAppContext) { .unwrap(); } +#[gpui_kit::test] +fn empty_paste_at_caret_preserves_redo(cx: &mut TestAppContext) { + let (handle, content) = inputs(cx); + cx.update_window(handle.into(), |_, window, cx| { + window.click("text", cx); + window.input("keep", cx); + cx.write_to_clipboard(ClipboardItem::new_string("🦀".into())); + window.press(PASTE, cx); + window.press(UNDO, cx); + assert_eq!(window.find("text").value(), Some("keep")); + cx.write_to_clipboard(ClipboardItem::new_string(String::new())); + window.press(PASTE, cx); + assert_eq!(window.find("text").value(), Some("keep")); + assert_eq!(content.read(cx).text.read(cx).selected_range(), 4..4); + window.press(REDO, cx); + assert_eq!(window.find("text").value(), Some("keep🦀")); + window.press(UNDO, cx); + assert_eq!(window.find("text").value(), Some("keep")); + window.press(UNDO, cx); + assert_eq!(window.find("text").value(), Some("")); + }) + .unwrap(); +} + #[gpui_kit::test] fn new_edit_after_undo_discards_the_redo_branch(cx: &mut TestAppContext) { let (handle, _) = inputs(cx); @@ -256,26 +295,25 @@ fn emoji_navigation_and_both_delete_directions_round_trip(cx: &mut TestAppContex } #[gpui_kit::test] -fn combining_mark_scalar_navigation_and_deletion_round_trip(cx: &mut TestAppContext) { +fn unicode_selection_deletion_restores_text_and_active_end_on_undo(cx: &mut TestAppContext) { let (handle, content) = inputs(cx); cx.update_window(handle.into(), |_, window, cx| { window.click("text", cx); - window.input("e\u{301}x", cx); - window.press("left", cx); - assert_eq!(content.read(cx).text.read(cx).cursor(), "e\u{301}".len()); - // Input currently navigates Unicode scalars, not extended graphemes. - window.press("left", cx); - assert_eq!(content.read(cx).text.read(cx).cursor(), "e".len()); - window.press("right", cx); - assert_eq!(content.read(cx).text.read(cx).cursor(), "e\u{301}".len()); + let text = "e\u{301}👩‍💻中文"; + window.input(text, cx); + window.press("shift-home", cx); + assert_eq!(content.read(cx).text.read(cx).selected_value(), text); window.press("backspace", cx); - assert_eq!(window.find("text").value(), Some("ex")); - assert_eq!(content.read(cx).text.read(cx).cursor(), "e".len()); + assert_eq!(window.find("text").value(), Some("")); window.press(UNDO, cx); - assert_eq!(window.find("text").value(), Some("e\u{301}x")); - assert_eq!(content.read(cx).text.read(cx).cursor(), "e\u{301}".len()); + assert_eq!(window.find("text").value(), Some(text)); + let state = content.read(cx).text.read(cx); + assert_eq!(state.selected_range(), 0..text.len()); + assert_eq!(state.selected_value(), text); + assert_eq!(state.cursor(), 0); window.press(REDO, cx); - assert_eq!(window.find("text").value(), Some("ex")); + assert_eq!(window.find("text").value(), Some("")); + assert_eq!(content.read(cx).text.read(cx).selected_range(), 0..0); }) .unwrap(); } diff --git a/crates/kit/tests/input/lifecycle.rs b/crates/kit/tests/input/lifecycle.rs index a514c9c8df..0388e8f068 100644 --- a/crates/kit/tests/input/lifecycle.rs +++ b/crates/kit/tests/input/lifecycle.rs @@ -187,7 +187,9 @@ fn unmounting_focused_controls_removes_targets_and_remount_keeps_retained_values for id in &ids { assert!(window.try_find(id.clone()).is_none()); } + let retained = fields.read(cx).values(cx); window.input("orphan", cx); + assert_eq!(fields.read(cx).values(cx), retained); fields.update(cx, |fields, cx| { fields.mounted = true; cx.notify(); @@ -205,3 +207,42 @@ fn unmounting_focused_controls_removes_targets_and_remount_keeps_retained_values assert_eq!(fields.values(cx), ["restored", "restored", "restored"]) }); } + +#[gpui_kit::test] +fn closing_window_releases_input_textarea_and_editor_states(cx: &mut TestAppContext) { + let (handle, fields, ids) = mount(cx); + let (input, textarea, editor) = fields.read_with(cx, |fields, _| { + ( + fields.input.downgrade(), + fields.textarea.downgrade(), + fields.editor.downgrade(), + ) + }); + cx.update_window(handle.into(), |_, window, cx| { + for id in ids { + window.click(id, cx); + window.input("retained until close", cx); + } + }) + .unwrap(); + cx.run_until_parked(); + // Release the fixture's strong owner before closing the window. GPUI + // disposes dropped entities while flushing an app update; draining the + // executor alone does not dispose a parent dropped after that update. + drop(fields); + cx.update_window(handle.into(), |_, window, _| window.remove_window()) + .unwrap(); + cx.run_until_parked(); + assert!( + input.upgrade().is_none(), + "closed Input state must be released" + ); + assert!( + textarea.upgrade().is_none(), + "closed Textarea state must be released" + ); + assert!( + editor.upgrade().is_none(), + "closed Editor state must be released" + ); +} diff --git a/crates/kit/tests/input/textarea.rs b/crates/kit/tests/input/textarea.rs index d159b0f3e8..1129d9ce30 100644 --- a/crates/kit/tests/input/textarea.rs +++ b/crates/kit/tests/input/textarea.rs @@ -217,6 +217,49 @@ fn vertical_arrows_follow_soft_wrapped_rows(cx: &mut TestAppContext) { .unwrap(); } +#[gpui_kit::test] +fn selection_across_soft_wraps_copies_and_replaces_buffer_text(cx: &mut TestAppContext) { + let value = "中🦀 word ".repeat(80); + let (handle, _, text) = composer(cx, |state| state.rows(6).default_value(value.clone())); + cx.update_window(handle.into(), |_, window, cx| { + window.click(target(&text), cx); + window.press(START, cx); + let first_row = text.read(cx).cursor_layout().unwrap().0.top(); + // Measure the native Down destination in this layout instead of + // assuming a font-dependent wrap offset. Shift should retain the + // anchor while reaching the same destination. + window.press("down", cx); + let next_row_cursor = text.read(cx).cursor(); + assert!(next_row_cursor > 0 && next_row_cursor < value.len()); + assert!(text.read(cx).cursor_layout().unwrap().0.top() > first_row); + window.press(START, cx); + window.press("shift-down", cx); + let selected = text.read(cx).selected_range(); + assert_eq!( + selected, + 0..next_row_cursor, + "Shift-Down should extend selection to the same visual row as Down" + ); + assert_eq!(text.read(cx).cursor_position().line, 0); + assert!(text.read(cx).cursor_layout().unwrap().0.top() > first_row); + window.press("secondary-c", cx); + assert_eq!( + cx.read_from_clipboard().unwrap().text().as_deref(), + Some(&value[selected.clone()]) + ); + assert_eq!(text.read(cx).value(), value); + window.input("X", cx); + let expected = format!("X{}", &value[selected.end..]); + assert_eq!(text.read(cx).value(), expected); + assert_eq!(text.read(cx).selected_range(), 1..1); + assert_eq!(window.find(target(&text)).value(), Some(expected.as_str())); + window.press("secondary-z", cx); + assert_eq!(text.read(cx).value(), value); + assert_eq!(text.read(cx).selected_range(), selected); + }) + .unwrap(); +} + #[gpui_kit::test] fn document_navigation_reveals_both_ends_of_a_fixed_viewport(cx: &mut TestAppContext) { let value = (0..40).map(|n| format!("line {n}\n")).collect::(); diff --git a/crates/kit/tests/input_focus.rs b/crates/kit/tests/input_focus.rs index 1ddbf3f494..5922b50a22 100644 --- a/crates/kit/tests/input_focus.rs +++ b/crates/kit/tests/input_focus.rs @@ -162,25 +162,7 @@ fn tab_cycles_keep_prefix_and_suffix_buttons_focused(cx: &mut TestAppContext) { press(handle, key, cx); assert_focus(handle, &view, destination, cx); if destination.ends_with("-button") { - // Native button activation completes on key-up. `press` - // dispatches only the keystroke, so send both events here. - cx.update_window(handle.into(), |_, window, cx| { - let keystroke = gpui_kit::Keystroke::parse("enter").unwrap(); - window.dispatch_event( - gpui_kit::PlatformInput::KeyDown(gpui_kit::KeyDownEvent { - keystroke: keystroke.clone(), - is_held: false, - prefer_character_input: false, - }), - cx, - ); - window.dispatch_event( - gpui_kit::PlatformInput::KeyUp(gpui_kit::KeyUpEvent { keystroke }), - cx, - ); - }) - .unwrap(); - cx.run_until_parked(); + press(handle, "enter", cx); assert_focus(handle, &view, destination, cx); } else { cx.update_window(handle.into(), |_, window, cx| window.input("x", cx)) diff --git a/crates/kit/tests/interactions.rs b/crates/kit/tests/interactions.rs index a5726cbe36..290325e828 100644 --- a/crates/kit/tests/interactions.rs +++ b/crates/kit/tests/interactions.rs @@ -558,6 +558,81 @@ struct KeyboardFrames { renders: Rc>, keys: Rc>>, } + +struct KeyboardEvents { + focus: gpui_kit::FocusHandle, + events: Rc>>, + clicks: Rc>, +} + +impl Render for KeyboardEvents { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + let down = self.events.clone(); + let up = self.events.clone(); + let clicks = self.clicks.clone(); + div() + .id("keyboard-scope") + .child( + div() + .id("keys") + .test_support() + .track_focus(&self.focus) + .size(px(40.)) + .on_key_down(move |event, _, cx| { + down.borrow_mut().push((true, event.keystroke.clone())); + cx.stop_propagation(); + }) + .on_key_up(move |event, _, _| { + up.borrow_mut().push((false, event.keystroke.clone())); + }), + ) + .child( + gpui_kit::base::Button::new("activate") + .size(px(40.)) + .on_click(move |_, _, _| clicks.set(clicks.get() + 1)), + ) + } +} + +#[gpui_kit::test] +fn press_sends_key_down_and_up_without_synthetic_enter_or_tab_text(cx: &mut TestAppContext) { + let events = Rc::new(RefCell::new(Vec::new())); + let clicks = Rc::new(std::cell::Cell::new(0)); + let (handle, _) = common::open_window(cx, None, |_, cx| { + cx.new(|cx| KeyboardEvents { + focus: cx.focus_handle(), + events: events.clone(), + clicks: clicks.clone(), + }) + }); + cx.update_window(handle.into(), |_, window, cx| { + window.click("keys", cx); + for scoped in [false, true] { + for key in ["enter", "shift-enter", "tab", "shift-tab"] { + events.borrow_mut().clear(); + if scoped { + window.within("keyboard-scope").press(key, cx); + } else { + window.press(key, cx); + } + let events = events.borrow(); + assert_eq!(events.len(), 2, "{key}, scoped={scoped}"); + assert!(events[0].0); + assert!(!events[1].0); + assert_eq!(events[0].1, gpui_kit::Keystroke::parse(key).unwrap()); + assert_eq!(events[0].1, events[1].1); + assert_eq!(events[0].1.key_char, None); + } + } + window.click("activate", cx); + clicks.set(0); + window.press("enter", cx); + assert_eq!(clicks.get(), 1); + window.within("keyboard-scope").press("space", cx); + assert_eq!(clicks.get(), 2); + }) + .unwrap(); +} impl Render for KeyboardFrames { fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { self.renders.set(self.renders.get() + 1); diff --git a/website/docs/test.md b/website/docs/test.md index 057f12153f..875dd523b6 100644 --- a/website/docs/test.md +++ b/website/docs/test.md @@ -233,7 +233,7 @@ Import `gpui_kit::test::TestWindowExt` for the following methods: | `window.scroll(id, delta, cx)` | Native wheel event; `ScrollDelta` retains GPUI units and sign. | | `window.drag_to(from_id, to_id, cx)` | Resolve both targets and drag between their centers using native hit testing. | | `window.drag(from, to, cx)` | Left-button drag between window-local points, through GPUI drag creation and drop hit testing. | -| `window.press("backspace", cx)` | Named key or shortcut using GPUI's keystroke parser. | +| `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`, @@ -279,7 +279,8 @@ Assert native properties and application results together. Checking saved model state or an emitted result is a useful part of an integration test; it should not replace verifying the relevant visible control state. -Text input does not model complete OS IME composition. Masked inputs report +Command presses, including Enter, must not inject newline text through an IME +callback. Text input does not model complete OS IME composition. Masked inputs report no value; verify sensitive results through application state. ## Complete the frame before querying @@ -342,6 +343,7 @@ that every option or combination of every component has been exhaustively tested | --- | --- | | `test_macro.rs` | Published `#[gpui_kit::test]` sync/async compatibility alongside ordinary Rust tests; the independent Kit-only recipes package runs the same contract | | `input.rs` and `input/` | Input, Textarea and Editor editing, clipboard, selection, history, read-only transitions, Unicode, multiline viewport behavior, search/replace, completion acceptance and retained state across renders | +| `input_focus.rs` | Repeated Tab/Shift-Tab traversal with passive addons, addon button focus and activation, and Textarea/Editor body-click focus followed by editing | | `search.rs` | Command disabled-item skipping, wraparound, Unicode keywords, empty results, Action dispatch and original-index callbacks, two-stage Escape; Combobox search, single/multi selection, clearing, empty-result recovery, disabled behavior and exactly one Confirm on close | | `disclosure.rs` | Accordion exclusive expansion/collapse and actual panel geometry; Stepper content navigation; disabled disclosure/steps; Slider track click, thumb drag and disabled behavior | | `collections.rs` | Tree pointer expansion, keyboard collapse/expansion and selection; DataTable row selection, keyboard virtualization and wheel scrolling | @@ -356,11 +358,26 @@ not invented interaction state. Custom parts register their existing native elem unsupported properties remain unavailable, with no manual test-only override. The [Input regression example](https://github.com/longbridge/gpui-kit/tree/main/crates/kit/tests/input) -shows how to turn a manual editing sequence into a repeatable UI test. Run it with -`cargo test -p gpui-kit --features test-support --test input --locked`. -Each workflow drives the rendered component and checks its result at meaningful -steps. The guide explains when public state reads supplement native snapshots and -which platform checks remain necessary for IME, accessibility actions and pixels. +shows how to turn a manual editing sequence into a repeatable UI test. From the +repository root, run both editing and focus targets, or select one workflow: + +```sh +cargo test -p gpui-kit --features test-support --test input --test input_focus --locked +cargo test -p gpui-kit --features test-support --test input --locked -- history::paste_is_atomic_and_separate_from_surrounding_typing --exact +cargo test -p gpui-kit --features test-support --test input_focus --locked -- reverse_tab_cycles_three_inputs_with_passive_addons --exact +``` + +Append `-- --list` to the combined command to list cases without executing them. +These commands are reproduction instructions, not recorded passing results. Report +the revision, platform, command and observed result for each run. + +Example workflows include typing → paste → typing → Undo/Redo, Textarea Enter +submission versus Shift-Enter insertion, and Editor completion → acceptance → Undo. +Each checks fresh snapshots plus public state or owner events where needed. +Completion responses come from a deterministic provider, not a live language server. +The separate `input_focus` target exercises focus callbacks after window updates. +These cases do not establish full OS IME, accessibility action, system clipboard +or pixel correctness; use the corresponding platform checks for those boundaries. Views that open dialogs, sheets or notifications through `WindowExt` need a `Root` as the window's root view. `Root` always renders all three overlay layers above diff --git a/website/zh-CN/docs/test.md b/website/zh-CN/docs/test.md index 7aab3d7894..4dce4ba65b 100644 --- a/website/zh-CN/docs/test.md +++ b/website/zh-CN/docs/test.md @@ -195,7 +195,7 @@ assert!(save.visible()); | `window.scroll(id, delta, cx)` | 原生滚轮事件,`ScrollDelta` 保留 GPUI 的方向与单位。 | | `window.drag_to(from_id, to_id, cx)` | 定位两个目标,在其中心之间通过真实命中测试拖拽。 | | `window.drag(from, to, cx)` | 窗口坐标之间的左键拖拽,经过真实拖拽创建与放置命中测试。 | -| `window.press("backspace", cx)` | 使用 GPUI 按键解析器发送特殊键或快捷键。 | +| `window.press("backspace", cx)` | 使用 GPUI 按键解析器,为特殊键或快捷键发送原生 key-down/key-up 事件。 | | `window.input(text, cx)` | 向当前焦点逐字符输入,不自动聚焦或替换整个值。 | 作用域支持 `find`、`try_find`、嵌套 `within`、`click`、`click_at`、`right_click`、 @@ -235,7 +235,8 @@ assert_eq!(window.find("agree").checked(), Some(true)); // New frame. ``` 同时断言界面状态与业务结果。验证保存的模型或发出的事件也是集成测试的一部分, -但不能取代相关控件可见状态的验证。文本输入不模拟完整的系统 IME 组合输入; +但不能取代相关控件可见状态的验证。包括 Enter 在内的命令按键不应通过 IME 回调注入换行文字。 +文本输入不模拟完整的系统 IME 组合输入; 密码输入框不报告值,需要时通过应用状态验证结果。 ## 查询前完成一帧 @@ -290,6 +291,7 @@ Base motion 则可以响应公开的 `cx.set_reduce_motion(true)` 偏好,用 | --- | --- | | `test_macro.rs` | 普通 `#[test]` 与同步/异步 `#[gpui_kit::test]` 共存;独立、仅依赖 Kit 的 recipes 包复用相同契约 | | `input.rs` 与 `input/` | Input、Textarea、Editor 的编辑、剪贴板、选区、历史记录、只读切换、Unicode、多行视口、搜索替换、补全确认,以及重绘后的状态保留 | +| `input_focus.rs` | 带静态前后缀的输入框反复 Tab/Shift-Tab 遍历、前后缀按钮的焦点与激活,以及点击 Textarea/Editor 正文后的焦点和编辑 | | `search.rs` | Command 禁用项跳过、循环导航、中文关键词、空结果、Action 与原始索引回调、两阶段 Escape;Combobox 搜索、单选/多选、清除、空结果恢复、禁用行为及关闭时仅一次 Confirm | | `disclosure.rs` | Accordion 互斥展开、折叠与实际面板几何;Stepper 内容导航;禁用展开与步骤操作;Slider 轨道点击、滑块拖动与禁用行为 | | `collections.rs` | Tree 点击展开、键盘展开/折叠与选择;DataTable 行选择、键盘虚拟滚动与滚轮滚动 | @@ -303,10 +305,24 @@ Base motion 则可以响应公开的 `cx.set_reduce_motion(true)` 偏好,用 不支持的属性保持不可用,不提供手填测试值的覆盖入口。 [Input 回归测试范例](https://github.com/longbridge/gpui-kit/tree/main/crates/kit/tests/input) -展示了如何把手工编辑步骤变成可重复的 UI 测试。运行命令: -`cargo test -p gpui-kit --features test-support --test input --locked`。 -每个流程都操作实际渲染的组件,并在关键步骤检查结果。范例说明了何时用公开状态读取 -补充原生快照,以及输入法、辅助功能动作和像素显示仍需哪些平台验证。 +展示了如何把手工编辑步骤变成可重复的 UI 测试。在仓库根目录运行编辑和焦点两个目标, +或选择单个流程: + +```sh +cargo test -p gpui-kit --features test-support --test input --test input_focus --locked +cargo test -p gpui-kit --features test-support --test input --locked -- history::paste_is_atomic_and_separate_from_surrounding_typing --exact +cargo test -p gpui-kit --features test-support --test input_focus --locked -- reverse_tab_cycles_three_inputs_with_passive_addons --exact +``` + +在合并运行命令后追加 `-- --list`,可列出用例而不执行。 +这些命令用于复现,不代表已有通过记录。报告运行结果时,请注明代码版本、平台、命令和实际结果。 + +示例流程包括输入 → 粘贴 → 输入 → Undo/Redo、Textarea 的 Enter 提交与 Shift-Enter 换行, +以及 Editor 补全 → 接受 → Undo。每个流程读取新的快照,必要时结合公开状态或应用事件验证。 +补全使用提供固定响应的 provider,不连接实际语言服务器。 +独立的 `input_focus` 目标在 window update 结束后验证焦点回调。 +这些用例不证明完整的系统 IME、辅助功能动作、系统剪贴板或像素显示正确; +这些边界需要相应的平台验证。 通过 `WindowExt` 打开 Dialog、Sheet 或 Notification 的视图,需要窗口的根视图是 `Root`。 `Root` 始终将这三类浮层渲染在应用内容之上,缓存视图也一样,无需手动挂载。 From 09ecb1c49f341351132413790a1e1fa1542da1d9 Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Sat, 26 Sep 2026 23:52:55 +0800 Subject: [PATCH 3/5] fix: Preserve disabled input focus and visual row selection --- crates/base/src/input/base/state.rs | 53 +++++++++------ crates/component/src/input/input.rs | 3 + crates/kit/tests/input/README.md | 22 +++---- crates/kit/tests/input/constraints.rs | 95 ++++++++++++++++++--------- crates/kit/tests/input/editor.rs | 95 ++++++++++++++++++++++++++- crates/kit/tests/input/textarea.rs | 88 +++++++++++++++++++++++++ 6 files changed, 293 insertions(+), 63 deletions(-) diff --git a/crates/base/src/input/base/state.rs b/crates/base/src/input/base/state.rs index 722a78c0cd..d508e307e1 100644 --- a/crates/base/src/input/base/state.rs +++ b/crates/base/src/input/base/state.rs @@ -1315,13 +1315,14 @@ impl InputBaseState { if self.is_single_line() { return; } - self.undo_manager.break_transaction_coalescing(); - self.select_all_cursors_to( + self.select_all_cursors_to_with_affinity( |s, sel| { - let offset = s - .start_of_line_at(sel.cursor_offset(), s.line_end_affinity_for(sel)) - .saturating_sub(1); - s.previous_boundary(offset) + s.vertical_target( + sel.cursor_offset(), + sel.column_anchor, + s.line_end_affinity_for(sel), + -1, + ) }, cx, ); @@ -1331,14 +1332,14 @@ impl InputBaseState { if self.is_single_line() { return; } - self.undo_manager.break_transaction_coalescing(); - let len = self.text.len(); - self.select_all_cursors_to( + self.select_all_cursors_to_with_affinity( |s, sel| { - let offset = (s.end_of_line_at(sel.cursor_offset(), s.line_end_affinity_for(sel)) - + 1) - .min(len); - s.next_boundary(offset) + s.vertical_target( + sel.cursor_offset(), + sel.column_anchor, + s.line_end_affinity_for(sel), + 1, + ) }, cx, ); @@ -3115,16 +3116,34 @@ impl InputBaseState { &mut self, f: impl Fn(&Self, &CursorSelection) -> usize, cx: &mut Context, + ) { + self.select_all_cursors_to_with_affinity(|s, sel| (f(s, sel), false), cx); + if self.active_selection().is_empty() { + self.update_preferred_column(); + } + } + + /// Extend selections with caret affinity, preserving their column anchors even + /// when vertical movement collapses a selection to a cursor. + fn select_all_cursors_to_with_affinity( + &mut self, + f: impl Fn(&Self, &CursorSelection) -> (usize, bool), + cx: &mut Context, ) { self.pause_blink_cursor(cx); self.undo_manager.break_transaction_coalescing(); M::clear_inline_completion(self, cx); + let mut active_affinity = false; let new_selections: Vec = self .selections .iter() .map(|sel| { - let offset = self.cursor_boundary(f(self, sel), Bias::Left); + let (offset, affinity) = f(self, sel); + if sel.id == self.active_selection().id { + active_affinity = affinity; + } + let offset = self.cursor_boundary(offset, Bias::Left); let mut new_sel = *sel; Self::extend_selection(&mut new_sel, offset, None); let range = self.normalize_token_range(new_sel.start..new_sel.end); @@ -3133,14 +3152,10 @@ impl InputBaseState { new_sel }) .collect(); - // Resolve targets using the old caret affinity before clearing it. - self.cursor_line_end_affinity = false; + self.cursor_line_end_affinity = active_affinity; self.selections.replace_all(new_selections); self.selections.merge_overlapping(); - if self.active_selection().is_empty() { - self.update_preferred_column(); - } self.scroll_to(self.cursor(), None, cx); cx.notify() } diff --git a/crates/component/src/input/input.rs b/crates/component/src/input/input.rs index 69af2aa802..17c1a3aadc 100644 --- a/crates/component/src/input/input.rs +++ b/crates/component/src/input/input.rs @@ -714,6 +714,9 @@ impl RenderOnce for Input { .focused(focused) .disabled(disabled) .track_focus(&frame_focus_handle) + .when(disabled, |this| { + this.capture_any_mouse_down(|_, _, cx| cx.stop_propagation()) + }) .styles(|styles| { styles.focused(|style| { style.when( diff --git a/crates/kit/tests/input/README.md b/crates/kit/tests/input/README.md index dac9f6ce97..1bc6a44e8f 100644 --- a/crates/kit/tests/input/README.md +++ b/crates/kit/tests/input/README.md @@ -32,19 +32,15 @@ The existing CI test matrix runs these targets on Linux, macOS and Windows. Keyboard cases use each platform's actual command bindings. A local Linux pass does not replace the macOS and Windows jobs. -## Known failing contracts - -The current branch is not fully passing. Two confirmed defects remain -intentionally red, without `#[ignore]`, pending separate production fixes. -Component source remains untouched by this coverage work. +## Regressions found by this suite - `constraints::disabled_single_and_double_click_do_not_focus_the_editor`: single and double clicks on a disabled input must not focus its editor. - Both currently focus the editor. Enabling is covered separately by + Enabling is covered separately by `constraints::enabling_a_disabled_input_allows_mouse_focus_and_replacement`. - `textarea::selection_across_soft_wraps_copies_and_replaces_buffer_text`: Shift-Down should retain the selection anchor and reach the same visual row as - Down. It currently selects the entire logical line instead of one wrapped row. + Down, including when a logical line spans multiple wrapped rows. Reproduce each contract independently from the repository root: @@ -53,8 +49,7 @@ cargo test -p gpui-kit --features test-support --test input --locked -- constrai cargo test -p gpui-kit --features test-support --test input --locked -- textarea::selection_across_soft_wraps_copies_and_replaces_buffer_text --exact ``` -These failures remain visible in the full `input` run. The workflow index below -describes asserted contracts, not a claim that every contract currently passes. +Both regressions run as part of the full `input` target, without `#[ignore]`. ## Recorded workflows @@ -77,6 +72,11 @@ Touch selection also has a separate Kit `touch_selection` target. The completion fixture supplies deterministic responses through the public provider interface; it does not connect to a language-server process. +The multi-cursor vertical-selection case establishes preferred columns with +horizontal arrow keys after Alt-click. Alt-click currently leaves a new cursor's +column anchor unset; preserving its column on the first vertical move remains a +separate gap. A passing run does not establish that interaction. + The separate [`input_focus.rs`](../input_focus.rs) target covers repeated Tab and Shift-Tab cycles with passive prefixes/suffixes, focus and activation of addon buttons, and clicking the body of Textarea/Editor before editing. Run it alongside @@ -136,8 +136,8 @@ transition, a no-op edit, or a focus change. ## What a green run establishes -A passing run would establish the recorded interaction contracts for the tested -configurations; the current failures are listed above. Existing Base tests +A passing run establishes the recorded interaction contracts for the tested +configurations. Existing Base tests continue to cover editing algorithms, IME composition state transitions and language-specific parsing cases. Neither set exhausts every document, language, configuration or event order. diff --git a/crates/kit/tests/input/constraints.rs b/crates/kit/tests/input/constraints.rs index 184494331a..70066fc54a 100644 --- a/crates/kit/tests/input/constraints.rs +++ b/crates/kit/tests/input/constraints.rs @@ -4,17 +4,18 @@ use crate::common; use gpui_kit::{ - App, AppContext, ClipboardItem, Context, Entity, Focusable, Subscription, TestAppContext, - Window, WindowHandle, + App, AppContext, ClipboardItem, Context, Entity, FocusHandle, Focusable, Subscription, + TestAppContext, Window, WindowHandle, base::Root, component::input::{Input, InputContentType, InputEvent, InputState}, - div, + div, point, prelude::*, test::TestWindowExt, }; struct Constraints { input: Entity, + previous_focus: Option, readonly: bool, disabled: bool, cleanable: bool, @@ -26,16 +27,22 @@ struct Constraints { impl Render for Constraints { fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { - div().size_full().p_4().child( - Input::new(&self.input) - .id("constrained") - .w_64() - .readonly(self.readonly) - .disabled(self.disabled) - .cleanable(self.cleanable) - .when(self.mask_toggle, |input| input.mask_toggle()) - .when_some(self.content_type, |input, kind| input.content_type(kind)), - ) + div() + .size_full() + .p_4() + .when_some(self.previous_focus.as_ref(), |this, focus| { + this.track_focus(focus) + }) + .child( + Input::new(&self.input) + .id("constrained") + .w_64() + .readonly(self.readonly) + .disabled(self.disabled) + .cleanable(self.cleanable) + .when(self.mask_toggle, |input| input.mask_toggle()) + .when_some(self.content_type, |input, kind| input.content_type(kind)), + ) } } @@ -54,6 +61,7 @@ fn fixture( }); Constraints { input, + previous_focus: None, readonly: false, disabled: false, cleanable: false, @@ -474,36 +482,59 @@ fn readonly_mouse_selection_copies_and_becomes_editable_again(cx: &mut TestAppCo #[gpui_kit::test] fn disabled_single_and_double_click_do_not_focus_the_editor(cx: &mut TestAppContext) { let (handle, view) = fixture(cx, |input| input.default_value("fixed")); + ui(handle, cx, |window, cx| { + window.click("constrained", cx); + shortcut(window, "a", cx); + window.press("left", cx); + window.press("right", cx); + window.press("shift-right", cx); + window.press("shift-right", cx); + assert_eq!(view.read(cx).input.read(cx).selected_range(), 1..3); + }); common::update_content(handle, &view, cx, |view, _, cx| { view.disabled = true; + view.previous_focus = Some(cx.focus_handle()); cx.notify(); }) .unwrap(); ui(handle, cx, |window, cx| { let input = view.read(cx).input.clone(); let focus = input.read(cx).focus_handle(cx); - let mut editor_focused = Vec::new(); - let mut frame_focused = Vec::new(); - for double_click in [false, true] { - window.blur(cx); - window.render_frame(cx); - assert!(!focus.is_focused(window)); - assert_ne!(window.find("constrained").focused(), Some(true)); - if double_click { - window.double_click("constrained", cx); - } else { - window.click("constrained", cx); + let previous_focus = view.read(cx).previous_focus.clone().unwrap(); + for preserve_focus in [false, true] { + for target in ["body", "double-click", "padding"] { + if preserve_focus { + previous_focus.focus(window, cx); + } else { + window.blur(cx); + } + window.render_frame(cx); + let before = window.focused(cx); + assert_eq!(previous_focus.is_focused(window), preserve_focus); + assert!(!focus.is_focused(window)); + match target { + "body" => window.click("constrained", cx), + "double-click" => window.double_click("constrained", cx), + "padding" => { + let frame = window.find("constrained").bounds(); + let body = input.read(cx).text_bounds().expect("rendered text bounds"); + let position = point((frame.left() + body.left()) / 2., body.center().y); + assert!(frame.contains(&position) && !body.contains(&position)); + window.click_at("constrained", position - frame.origin, cx); + } + _ => unreachable!(), + } + assert_eq!(window.focused(cx), before, "focus after {target}"); + assert!(!focus.is_focused(window), "editor focus after {target}"); + assert_ne!(window.find("constrained").focused(), Some(true)); + assert_eq!( + input.read(cx).selected_range(), + 1..3, + "selection after {target}" + ); } - editor_focused.push(focus.is_focused(window)); - frame_focused.push(window.find("constrained").focused()); } assert_eq!(input.read(cx).value(), "fixed"); - assert_eq!( - editor_focused, - [false, false], - "editor focus after [single click, double click]; frame snapshots: {frame_focused:?}" - ); - assert!(frame_focused.iter().all(|focused| *focused != Some(true))); }); assert_owner(handle, &view, cx, "fixed", 0); } diff --git a/crates/kit/tests/input/editor.rs b/crates/kit/tests/input/editor.rs index ce5209bd4e..69c08585ec 100644 --- a/crates/kit/tests/input/editor.rs +++ b/crates/kit/tests/input/editor.rs @@ -1,7 +1,8 @@ //! Styled editor workflows driven through the production window and native events. use gpui_kit::{ - AppContext, Context, Entity, TestAppContext, Window, WindowHandle, + AppContext, Context, Entity, InputEvent, Modifiers, MouseButton, MouseDownEvent, + MouseMoveEvent, MouseUpEvent, TestAppContext, Window, WindowHandle, component::input::{Editor, EditorState}, div, prelude::*, @@ -285,6 +286,98 @@ fn keyboard_multicursor_replacement_undo_and_escape_keep_the_active_cursor( .unwrap(); } +#[gpui_kit::test] +fn multicursor_vertical_selection_replaces_and_undoes_each_range(cx: &mut TestAppContext) { + let value = "abcd\nabcd\nabcd\nabcd\nabcd\nabcd"; + let (handle, state) = editor(cx, "plaintext", value); + cx.update_window(handle.into(), |_, window, cx| { + window.click(("input", state.entity_id()), cx); + window.press("secondary-a", cx); + window.press("left", cx); + window.press("right", cx); + window.press("down", cx); + assert_eq!(state.read(cx).cursor(), 6); + let first = state.read(cx).cursor_layout().unwrap().0.center(); + for _ in 0..3 { + window.press("down", cx); + } + assert_eq!(state.read(cx).cursor(), 21); + let second = state.read(cx).cursor_layout().unwrap().0.center(); + assert_eq!(state.read(cx).scroll_offset().y, px(0.)); + + // Native clicks at measured carets keep the two ranges apart, so + // merging adjacent selections cannot hide a lost secondary cursor. + for (position, alt) in [(first, false), (second, true)] { + let modifiers = Modifiers { + alt, + ..Default::default() + }; + window.dispatch_event( + MouseMoveEvent { + position, + pressed_button: None, + modifiers, + } + .to_platform_input(), + cx, + ); + window.render_frame(cx); + window.dispatch_event( + MouseDownEvent { + position, + button: MouseButton::Left, + modifiers, + click_count: 1, + first_mouse: false, + } + .to_platform_input(), + cx, + ); + window.render_frame(cx); + window.dispatch_event( + MouseUpEvent { + position, + button: MouseButton::Left, + modifiers, + click_count: 1, + } + .to_platform_input(), + cx, + ); + window.render_frame(cx); + } + // Alt-click currently leaves the new cursor's preferred column unset. + // Initialize both columns through native movement, returning to 6 and 21, + // so this case isolates vertical selection from that existing defect. + window.press("right", cx); + window.press("left", cx); + assert_eq!(state.read(cx).selected_range(), 6..6); + assert_eq!(state.read(cx).cursor(), 6); + window.press("shift-up", cx); + assert_eq!(state.read(cx).selected_range(), 1..6); + assert_eq!(state.read(cx).cursor(), 1); + assert_eq!( + window.find(("input", state.entity_id())).value(), + Some(value) + ); + window.input("X", cx); + assert_eq!(state.read(cx).value(), "aXbcd\nabcd\naXbcd\nabcd"); + window.press("secondary-z", cx); + assert_eq!(state.read(cx).value(), value); + assert_eq!(state.read(cx).selected_range(), 1..6); + assert_eq!(state.read(cx).cursor(), 1); + // A second replacement proves Undo restored both reversed selections. + window.input("Y", cx); + let expected = "aYbcd\nabcd\naYbcd\nabcd"; + assert_eq!(state.read(cx).value(), expected); + assert_eq!( + window.find(("input", state.entity_id())).value(), + Some(expected) + ); + }) + .unwrap(); +} + #[gpui_kit::test] fn search_keyboard_navigation_wraps_and_escape_returns_editor_focus(cx: &mut TestAppContext) { let (handle, state) = editor(cx, "plaintext", "one two one"); diff --git a/crates/kit/tests/input/textarea.rs b/crates/kit/tests/input/textarea.rs index 1129d9ce30..c69ed405e7 100644 --- a/crates/kit/tests/input/textarea.rs +++ b/crates/kit/tests/input/textarea.rs @@ -260,6 +260,94 @@ fn selection_across_soft_wraps_copies_and_replaces_buffer_text(cx: &mut TestAppC .unwrap(); } +#[gpui_kit::test] +fn wrapped_selection_repeats_and_reverses_across_its_anchor(cx: &mut TestAppContext) { + let value = "中🦀 word ".repeat(80); + let (handle, _, text) = composer(cx, |state| state.rows(6).default_value(value.clone())); + cx.update_window(handle.into(), |_, window, cx| { + window.click(target(&text), cx); + window.press(START, cx); + // Measure four visual rows through native movement, without assuming + // a font-dependent wrap offset. Start selection on the third row. + let mut rows = vec![( + text.read(cx).cursor(), + text.read(cx).cursor_layout().unwrap().0, + )]; + for _ in 0..3 { + window.press("down", cx); + let state = text.read(cx); + let caret = state.cursor_layout().unwrap().0; + assert!(state.cursor() > rows.last().unwrap().0); + assert!(caret.top() > rows.last().unwrap().1.top()); + rows.push((state.cursor(), caret)); + } + window.press(START, cx); + window.press("down", cx); + window.press("down", cx); + let anchor = rows[2].0; + assert_eq!(text.read(cx).cursor(), anchor); + for (key, row) in [ + ("shift-up", 1), + ("shift-up", 0), + ("shift-down", 1), + ("shift-down", 2), + ("shift-down", 3), + ("shift-up", 2), + ] { + window.press(key, cx); + let state = text.read(cx); + let cursor = rows[row].0; + assert_eq!(state.cursor(), cursor, "{key} to visual row {row}"); + assert_eq!( + state.selected_range(), + anchor.min(cursor)..anchor.max(cursor) + ); + assert_eq!(state.cursor_layout().unwrap().0.top(), rows[row].1.top()); + assert_eq!(state.cursor_position().line, 0); + assert_caret_visible(state); + assert_eq!(window.find(target(&text)).value(), Some(value.as_str())); + } + }) + .unwrap(); +} + +#[gpui_kit::test] +fn vertical_selection_preserves_column_across_short_and_empty_lines(cx: &mut TestAppContext) { + let value = "abcdefghij\nx\n\nabcdefghij"; + let (handle, _, text) = composer(cx, |state| state.rows(4).default_value(value)); + cx.update_window(handle.into(), |_, window, cx| { + window.click(target(&text), cx); + window.press(START, cx); + for _ in 0..6 { + window.press("right", cx); + } + let anchor = 6; + assert_eq!(text.read(cx).cursor(), anchor); + let original_x = text.read(cx).cursor_layout().unwrap().0.left(); + // Clamp at the short/empty row, then recover column six in either + // direction. The selection anchor stays on the first line throughout. + for (key, cursor) in [ + ("shift-down", 12), + ("shift-down", 13), + ("shift-down", 20), + ("shift-up", 13), + ("shift-up", 12), + ("shift-up", 6), + ] { + window.press(key, cx); + let state = text.read(cx); + assert_eq!(state.cursor(), cursor, "{key}"); + assert_eq!(state.selected_range(), anchor..cursor); + if cursor == 20 || cursor == anchor { + assert_eq!(state.cursor_layout().unwrap().0.left(), original_x); + } + assert_caret_visible(state); + assert_eq!(window.find(target(&text)).value(), Some(value)); + } + }) + .unwrap(); +} + #[gpui_kit::test] fn document_navigation_reveals_both_ends_of_a_fixed_viewport(cx: &mut TestAppContext) { let value = (0..40).map(|n| format!("line {n}\n")).collect::(); From 76b004321b12b2387382f3342bb78b98090d6a5c Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Sat, 26 Sep 2026 23:49:07 +0800 Subject: [PATCH 4/5] test: Render story gallery examples in separate frames --- .../tests/story_gallery_host.rs | 75 +++++++++++++++++-- examples/js_story/fixtures/all-examples.js | 33 +++++--- 2 files changed, 90 insertions(+), 18 deletions(-) diff --git a/crates/component-shell/tests/story_gallery_host.rs b/crates/component-shell/tests/story_gallery_host.rs index 5a4a453f47..bb5a52f470 100644 --- a/crates/component-shell/tests/story_gallery_host.rs +++ b/crates/component-shell/tests/story_gallery_host.rs @@ -196,6 +196,36 @@ fn dock_story_materializes_real_panels_dock_and_tabs(cx: &mut TestAppContext) { #[gpui::test] fn every_registered_story_example_materializes(cx: &mut TestAppContext) { cx.update(gpui_component_shell::init); + let surfaces = std::rc::Rc::new(std::cell::RefCell::new(Vec::::new())); + let registered_surfaces = surfaces.clone(); + let selected = std::rc::Rc::new(std::cell::RefCell::new(None::)); + let selected_surface = selected.clone(); + gpui_shell::export_module( + gpui_shell::HostModule::new("story-gallery-fixture") + .function("register_surfaces", move |args| { + *registered_surfaces.borrow_mut() = args + .get(0) + .and_then(gpui_shell::HostValue::as_array) + .expect("fixture surface list") + .iter() + .map(|value| value.as_str().expect("surface name").to_owned()) + .collect(); + Ok(gpui_shell::HostValue::Null) + }) + .function("selected_surface", move |_| { + Ok(gpui_shell::HostValue::from( + selected_surface.borrow().clone(), + )) + }), + ) + .expect("register fixture host module"); + struct FixtureModule; + impl Drop for FixtureModule { + fn drop(&mut self) { + gpui_shell::clear_exported_modules(); + } + } + let _fixture_module = FixtureModule; let runtime = gpui_component_shell::new_isolated_runtime().expect("runtime"); let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../examples/js_story"); let loaded = runtime @@ -212,13 +242,42 @@ fn every_registered_story_example_materializes(cx: &mut TestAppContext) { ScriptRoot(view) }); let mut context = VisualTestContext::from_window(*window.deref(), cx); - context.update(|window, cx| window.draw(cx).clear(cx)); - context.run_until_parked(); - context.update(|window, cx| window.draw(cx).clear(cx)); - let view = mounted.borrow().clone().expect("mounted view"); - context.update(|_, cx| { - assert_eq!(view.read(cx).build_error(), None); - assert!(view.read(cx).snapshot().is_some()); - }); + let surfaces = surfaces.borrow().clone(); + assert!( + surfaces.len() > 1, + "fixture must enumerate registered surfaces" + ); + assert_eq!( + surfaces + .iter() + .collect::>() + .len(), + surfaces.len(), + "fixture surfaces must be unique" + ); + assert!(surfaces.iter().any(|surface| surface == "VirtualList")); + assert!(surfaces.iter().any(|surface| surface == "TabBar")); + assert!(!surfaces.iter().any(|surface| surface == "Tab")); + for surface in surfaces { + *selected.borrow_mut() = Some(surface.clone()); + context.update(|_, cx| view.update(cx, |view, cx| view.refresh(cx))); + context.update(|window, cx| window.draw(cx).clear(cx)); + context.run_until_parked(); + context.update(|window, cx| window.draw(cx).clear(cx)); + context.update(|_, cx| { + let view = view.read(cx); + assert_eq!(view.build_error(), None, "surface: {surface}"); + let tree = view.snapshot().expect("surface snapshot").debug_tree(); + if surface == "VirtualList" { + assert!(tree.contains("v_virtual_list"), "{surface}: {tree}"); + assert!(tree.contains("10,000 projects"), "{surface}: {tree}"); + } else { + assert!( + tree.contains(&format!("fixture-{surface}-")), + "{surface}: {tree}" + ); + } + }); + } } diff --git a/examples/js_story/fixtures/all-examples.js b/examples/js_story/fixtures/all-examples.js index f7ffa4041c..839dbc80e1 100644 --- a/examples/js_story/fixtures/all-examples.js +++ b/examples/js_story/fixtures/all-examples.js @@ -1,5 +1,6 @@ import { View, div } from "gpui-kit"; import { v_flex } from "gpui-base"; +import { register_surfaces, selected_surface } from "story-gallery-fixture"; import { coveredBy } from "../stories/coverage.js"; import { initializeRegisteredExamples, @@ -14,23 +15,35 @@ export default class AllRegisteredExamplesFixture extends View { init() { initializeRegisteredExamples(); this.virtualList = createVirtualListStory(); + register_surfaces([ + // Tab has no standalone examples; TabBar materializes its Tab children. + ...[...new Set(coveredBy.flatMap((entry) => entry.registrations))].filter( + (surface) => surface !== "Tab", + ), + "VirtualList", + ]); } render(cx) { - const surfaces = [...new Set(coveredBy.flatMap((entry) => entry.registrations))]; + // The host selects one surface per render so the complete inventory does + // not share a single frame's execution budget. + const surface = selected_surface(); + if (surface === null) return div(); + if (surface === "VirtualList") { + return renderVirtualListStory(this.virtualList, cx); + } + const examples = registeredExamples(surface, cx); + if (examples.length === 0) throw new Error(`No examples for ${surface}`); return v_flex() .w(900) .gap(16) .children( - surfaces.flatMap((surface) => - registeredExamples(surface, cx).map((example) => - div() - .id(`fixture-${surface}-${example.label}`) - .w_full() - .child(example.element), - ), + examples.map((example) => + div() + .id(`fixture-${surface}-${example.label}`) + .w_full() + .child(example.element), ), - ) - .child(renderVirtualListStory(this.virtualList, cx)); + ); } } From 56c625f2de4dd0703ccfbc3cf408bbeb922c0e2c Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Sun, 27 Sep 2026 00:32:02 +0800 Subject: [PATCH 5/5] Expand Input UI regressions and fix uncovered interaction failures --- crates/base/src/input/base/movement.rs | 37 +- crates/base/src/input/base/state.rs | 81 +- .../base/src/input/editor/lsp/completions.rs | 10 +- .../base/src/input/editor/lsp/definitions.rs | 40 +- crates/base/src/input/editor/lsp/mod.rs | 3 + crates/component/src/input/overlay.rs | 13 +- crates/component/src/input/search.rs | 4 + crates/kit/TESTING.md | 7 + crates/kit/tests/input.rs | 2 + crates/kit/tests/input/README.md | 87 +- crates/kit/tests/input/completions.rs | 814 +++++++++++++++++- crates/kit/tests/input/composition.rs | 154 ++++ crates/kit/tests/input/constraints.rs | 214 +++++ crates/kit/tests/input/editing.rs | 138 +++ crates/kit/tests/input/editor.rs | 732 +++++++++++++++- crates/kit/tests/input/history.rs | 82 ++ crates/kit/tests/input/lifecycle.rs | 188 +++- crates/kit/tests/input/textarea.rs | 638 +++++++++++++- script/test-input | 12 + website/docs/test.md | 5 + website/zh-CN/docs/test.md | 3 + 21 files changed, 3165 insertions(+), 99 deletions(-) create mode 100644 crates/kit/tests/input/composition.rs create mode 100755 script/test-input diff --git a/crates/base/src/input/base/movement.rs b/crates/base/src/input/base/movement.rs index 7a6c650ac4..10304df26d 100644 --- a/crates/base/src/input/base/movement.rs +++ b/crates/base/src/input/base/movement.rs @@ -22,7 +22,7 @@ impl InputBaseState { /// Like [`Self::preferred_column_for`], but resolves an offset on a soft wrap /// boundary to the row the caret is drawn on. - fn preferred_column_for_with_affinity( + pub(super) fn preferred_column_for_with_affinity( &self, offset: usize, line_end_affinity: bool, @@ -138,6 +138,8 @@ impl InputBaseState { display_point.column = 0; let mut new_offset = self.display_map.wrap_display_point_to_offset(display_point); + let column_anchor = column_anchor + .or_else(|| self.preferred_column_for_with_affinity(offset, line_end_affinity)); let mut new_affinity = false; if let Some((preferred_x, column)) = column_anchor { // Get display point again to update local_row. @@ -172,6 +174,30 @@ impl InputBaseState { (new_offset, new_affinity) } + /// Extend to the document edge when there is no further visual row. Plain + /// movement retains its column there, but selection must still reach the + /// remaining text on the first or last row. + pub(super) fn vertical_selection_target( + &self, + offset: usize, + column_anchor: Option<(Pixels, usize)>, + line_end_affinity: bool, + move_lines: isize, + ) -> (usize, bool) { + let target = self.vertical_target(offset, column_anchor, line_end_affinity, move_lines); + if self.last_layout.is_some() { + let row = |offset, affinity| { + self.display_map + .offset_to_wrap_display_point_with_affinity(offset, affinity) + .row + }; + if row(offset, line_end_affinity) == row(target.0, target.1) { + return (if move_lines < 0 { 0 } else { self.text.len() }, false); + } + } + target + } + /// Move every cursor through `f`, which maps each selection to a /// `(new_offset, column_anchor, line_end_affinity)`, collapsing each to a /// cursor. Overlapping cursors are merged, then the standard post-move @@ -246,8 +272,15 @@ impl InputBaseState { (e, s.preferred_column_for(e), false) } else { let e = s.next_boundary(sel.end.saturating_sub(1)); - (e, s.preferred_column_for(e), false) + let affinity = s.line_end_affinity_at(e); + ( + e, + s.preferred_column_for_with_affinity(e, affinity), + affinity, + ) }; + let anchor = + anchor.or_else(|| s.preferred_column_for_with_affinity(effective, affinity)); let (offset, affinity) = s.vertical_target(effective, anchor, affinity, move_lines); (offset, anchor, affinity) }, diff --git a/crates/base/src/input/base/state.rs b/crates/base/src/input/base/state.rs index d508e307e1..6c5cb2883f 100644 --- a/crates/base/src/input/base/state.rs +++ b/crates/base/src/input/base/state.rs @@ -1071,6 +1071,10 @@ impl InputBaseState { } self.disabled = disabled; + if disabled { + M::hide_context_menu(self, cx); + M::clear_inline_completion(self, cx); + } cx.notify(); } @@ -1094,6 +1098,8 @@ impl InputBaseState { self.readonly = readonly; if readonly { + M::hide_context_menu(self, cx); + M::clear_inline_completion(self, cx); self.search_session.replace_mode = false; } cx.notify(); @@ -1317,13 +1323,14 @@ impl InputBaseState { } self.select_all_cursors_to_with_affinity( |s, sel| { - s.vertical_target( + s.vertical_selection_target( sel.cursor_offset(), sel.column_anchor, s.line_end_affinity_for(sel), -1, ) }, + true, cx, ); } @@ -1334,13 +1341,14 @@ impl InputBaseState { } self.select_all_cursors_to_with_affinity( |s, sel| { - s.vertical_target( + s.vertical_selection_target( sel.cursor_offset(), sel.column_anchor, s.line_end_affinity_for(sel), 1, ) }, + true, cx, ); } @@ -1395,12 +1403,16 @@ impl InputBaseState { cx: &mut Context, ) { self.undo_manager.break_transaction_coalescing(); - self.select_all_cursors_to( - |s, sel| s.end_of_line_at(sel.cursor_offset(), s.line_end_affinity_for(sel)), + self.select_all_cursors_to_with_affinity( + |s, sel| { + ( + s.end_of_line_at(sel.cursor_offset(), s.line_end_affinity_for(sel)), + true, + ) + }, + false, cx, ); - // Mirrors MoveEnd: the caret belongs at the end of the visual row it is on. - self.cursor_line_end_affinity = true; } pub(super) fn select_to_previous_word( @@ -2040,6 +2052,9 @@ impl InputBaseState { return; } + // Escape also dismisses a request whose popup has not arrived yet. + M::hide_context_menu(self, cx); + // Collapse extra cursors back to the active one first. if !self.selections.is_single() { self.undo_manager.break_transaction_coalescing(); @@ -2053,6 +2068,7 @@ impl InputBaseState { M::clear_inline_completion(self, cx); return; // Consume the escape, don't propagate } + M::clear_inline_completion(self, cx); // The handles and the edit menu are the topmost surface to dismiss. if self.touch_selection().is_some() { @@ -2188,8 +2204,9 @@ impl InputBaseState { self.undo_manager.break_transaction_coalescing(); let id = self.selections.generate_id(); - self.selections - .add(CursorSelection::new(id, offset, offset)); + let mut selection = CursorSelection::new(id, offset, offset); + selection.column_anchor = self.preferred_column_for(offset); + self.selections.add(selection); cx.notify(); } @@ -3017,20 +3034,24 @@ impl InputBaseState { last_line_pos = Some(pos); } - // Mouse is below all visible lines, return end of text. A columnar selection - // still needs how far right the pointer was, so measure it against the last - // line rather than reporting a block that collapses at the bottom edge. - let columns_past_line_end = last_layout + // Clamp to the last laid-out row. Returning the end of the whole document + // would select unseen text before drag autoscroll has reached it. + let last_position = last_layout .lines .last() .zip(last_line_pos) - .map(|(line_layout, pos)| { + .zip(last_layout.visible_line_byte_offsets.last()) + .map(|((line_layout, pos), line_start)| { let last_row_top = (line_layout.size(line_height).height - line_height).max(px(0.)); - line_layout.columns_past_line_end(point(pos.x, last_row_top), last_layout) - }) - .unwrap_or(0); + let pos = point(pos.x, last_row_top); + ( + self.resolve_index(line_start + line_layout.len()), + false, + line_layout.columns_past_line_end(pos, last_layout), + ) + }); - (self.text.len(), false, columns_past_line_end) + last_position.unwrap_or((self.text.len(), false, 0)) } /// Map a display byte index back to a text offset, undoing the mask expansion when the input @@ -3117,10 +3138,7 @@ impl InputBaseState { f: impl Fn(&Self, &CursorSelection) -> usize, cx: &mut Context, ) { - self.select_all_cursors_to_with_affinity(|s, sel| (f(s, sel), false), cx); - if self.active_selection().is_empty() { - self.update_preferred_column(); - } + self.select_all_cursors_to_with_affinity(|s, sel| (f(s, sel), false), false, cx); } /// Extend selections with caret affinity, preserving their column anchors even @@ -3128,6 +3146,7 @@ impl InputBaseState { fn select_all_cursors_to_with_affinity( &mut self, f: impl Fn(&Self, &CursorSelection) -> (usize, bool), + preserve_column: bool, cx: &mut Context, ) { self.pause_blink_cursor(cx); @@ -3149,6 +3168,15 @@ impl InputBaseState { let range = self.normalize_token_range(new_sel.start..new_sel.end); new_sel.start = range.start; new_sel.end = range.end; + if !preserve_column { + new_sel.column_anchor = + self.preferred_column_for_with_affinity(new_sel.cursor_offset(), affinity); + } else if new_sel.column_anchor.is_none() { + new_sel.column_anchor = self.preferred_column_for_with_affinity( + sel.cursor_offset(), + self.line_end_affinity_for(sel), + ); + } new_sel }) .collect(); @@ -3264,6 +3292,7 @@ impl InputBaseState { return; } + M::hide_context_menu(self, cx); self.undo_manager.break_transaction_coalescing(); // NOTE: Do not cancel select, when blur. @@ -3550,6 +3579,11 @@ impl InputBaseState { return; } + // Every edit invalidates provider responses for the previous document, + // including deletion and indentation which do not trigger completion. + M::hide_context_menu(self, cx); + M::clear_inline_completion(self, cx); + // Sort descending by start so applying front-of-vec first edits the // highest offsets first, leaving lower offsets unchanged. let mut sorted: Vec<(Range, &str)> = edits @@ -3786,7 +3820,7 @@ impl EntityInputHandler for InputBaseState { ) -> Option { Some(UTF16Selection { range: self.range_to_utf16(&self.selected_range()), - reversed: false, + reversed: self.active_selection().reversed, }) } @@ -4063,6 +4097,9 @@ impl EntityInputHandler for InputBaseState { let starts_composition = self.ime_marked_range.is_none(); if starts_composition { + // Even a canceled preedit separates the typing gestures on either + // side; its no-op transaction must not reconnect those gestures. + self.undo_manager.break_transaction_coalescing(); self.undo_manager.begin_transaction(); } diff --git a/crates/base/src/input/editor/lsp/completions.rs b/crates/base/src/input/editor/lsp/completions.rs index 5e3113129c..5416997e91 100644 --- a/crates/base/src/input/editor/lsp/completions.rs +++ b/crates/base/src/input/editor/lsp/completions.rs @@ -255,15 +255,7 @@ impl InputBaseState { }; let handled = handler(kind, action, window, cx); if handled && closes_overlay { - match kind { - super::InputOverlayKind::Completion => { - self.extras.context_menu_content.completion.open = false - } - super::InputOverlayKind::CodeAction => { - self.extras.context_menu_content.code_action.open = false - } - } - cx.notify(); + self.hide_context_menu(cx); } handled } diff --git a/crates/base/src/input/editor/lsp/definitions.rs b/crates/base/src/input/editor/lsp/definitions.rs index 3f1a7d0307..68864ee6e0 100644 --- a/crates/base/src/input/editor/lsp/definitions.rs +++ b/crates/base/src/input/editor/lsp/definitions.rs @@ -28,7 +28,6 @@ pub(crate) struct HoverDefinition { /// The range of the symbol that triggered the hover. symbol_range: Range, pub(crate) locations: Rc>, - last_location: Option<(Range, Rc>)>, } impl HoverDefinition { @@ -47,10 +46,6 @@ impl HoverDefinition { } pub(crate) fn clear(&mut self) { - if !self.locations.is_empty() { - self.last_location = Some((self.symbol_range.clone(), self.locations.clone())); - } - self.symbol_range = 0..0; self.locations = Rc::new(vec![]); } @@ -113,16 +108,31 @@ impl InputBaseState { cx: &mut Context, ) { let offset = self.cursor(); - if let Some((symbol_range, locations)) = self.extras.hover_definition.last_location.clone() - { - if !(symbol_range.start..=symbol_range.end).contains(&offset) { - return; - } - - if let Some(location) = locations.first().cloned() { - self.go_to_definition(&location, window, cx); - } - } + // A keyboard action must also work before the symbol has been hovered. + let Some(provider) = self.extras.lsp.definition_provider.clone() else { + return; + }; + let text = self.text.clone(); + let response = provider.definitions(&text, offset, window, cx); + let blur_subscription = cx.on_blur(&self.focus_handle, window, |editor, _, _| { + editor.extras.lsp._definition_task = Task::ready(Ok(())); + }); + self.extras.lsp._definition_task = cx.spawn_in(window, async move |editor, cx| { + let _blur_subscription = blur_subscription; + let locations = response.await?; + editor.update_in(cx, |editor, window, cx| { + if editor.cursor() != offset + || editor.text != text + || !editor.focus_handle.is_focused(window) + { + return; + } + if let Some(location) = locations.first() { + editor.go_to_definition(location, window, cx); + } + })?; + Ok(()) + }); } /// Return true if handled. diff --git a/crates/base/src/input/editor/lsp/mod.rs b/crates/base/src/input/editor/lsp/mod.rs index aa5d8200d7..b194f44c5e 100644 --- a/crates/base/src/input/editor/lsp/mod.rs +++ b/crates/base/src/input/editor/lsp/mod.rs @@ -64,6 +64,7 @@ pub struct Lsp { /// take effect without a refetch. pub(crate) semantic_tokens: Vec<(lsp_types::Range, SharedString)>, pub(crate) _hover_task: Task>, + pub(crate) _definition_task: Task>, pub(crate) _document_color_task: Task<()>, pub(crate) _semantic_tokens_task: Task<()>, } @@ -82,6 +83,7 @@ impl Default for Lsp { document_colors: vec![], semantic_tokens: vec![], _hover_task: Task::ready(Ok(())), + _definition_task: Task::ready(Ok(())), _document_color_task: Task::ready(()), _semantic_tokens_task: Task::ready(()), } @@ -105,6 +107,7 @@ impl Lsp { self.document_colors.clear(); self.semantic_tokens.clear(); self._hover_task = Task::ready(Ok(())); + self._definition_task = Task::ready(Ok(())); self._document_color_task = Task::ready(()); self._semantic_tokens_task = Task::ready(()); } diff --git a/crates/component/src/input/overlay.rs b/crates/component/src/input/overlay.rs index 4ce4ab2781..45f63b9912 100644 --- a/crates/component/src/input/overlay.rs +++ b/crates/component/src/input/overlay.rs @@ -319,6 +319,7 @@ impl InputOverlayHost { ) }; + let panel_replace_mode = self.search.read(cx).replace_mode(); self.search .update(cx, |panel, _| panel.sync_session(&search_session)); @@ -330,15 +331,15 @@ impl InputOverlayHost { activation_revision, }; if search_signature != self.search_signature { - // The panel writes what the user types back into the session, which - // lands here as a changed query on the next frame. Re-showing the - // panel for that echo would select the field out from under them, so - // recognise it: the same open session, moved only by the query the - // panel itself already holds. + // The panel writes query and replacement-mode changes back into the + // session. Re-showing it for that echo would select the query or steal + // focus from replacement, so preserve changes the panel already holds. + // External mode changes still move focus out of a hidden replace field. let was = &self.search_signature; let query_echo = search_open && was.open - && was.replace_mode == search_signature.replace_mode + && (was.replace_mode == search_signature.replace_mode + || panel_replace_mode == search_signature.replace_mode) && was.anchor_offset == search_signature.anchor_offset && was.activation_revision == search_signature.activation_revision && self.search.read(cx).query(cx) == search_session.query; diff --git a/crates/component/src/input/search.rs b/crates/component/src/input/search.rs index b3c660f0ac..4b14f56fce 100644 --- a/crates/component/src/input/search.rs +++ b/crates/component/src/input/search.rs @@ -69,6 +69,10 @@ pub(super) struct SearchPanel { } impl SearchPanel { + pub(super) fn replace_mode(&self) -> bool { + self.session.replace_mode + } + pub(super) fn sync_session(&mut self, session: &gpui_base::input::SearchSession) { self.session = session.clone(); } diff --git a/crates/kit/TESTING.md b/crates/kit/TESTING.md index d4a8956aa4..4bddf51b09 100644 --- a/crates/kit/TESTING.md +++ b/crates/kit/TESTING.md @@ -32,6 +32,13 @@ cases. These are reproduction commands, not recorded test results. A passing run establishes only the asserted workflows on that revision and platform; OS IME, accessibility actions, system clipboard adapters and pixels need separate evidence. +For changes to Input, Textarea or Editor, run `script/test-input` from the +repository root. It combines Kit UI workflows with Base editing/IME/token +regressions and Component InputGroup tests. The [operation coverage and review +gate](tests/input/README.md#operation-coverage-and-review-gate) explains how to +use these tests when reviewing an input change and which native platform checks +remain necessary. + ## Core semantics - `find` requires a target and explains missing/ambiguous paths; `try_find` permits absence. diff --git a/crates/kit/tests/input.rs b/crates/kit/tests/input.rs index dbee8c3da4..8dcf15f8ea 100644 --- a/crates/kit/tests/input.rs +++ b/crates/kit/tests/input.rs @@ -1,6 +1,8 @@ mod common; #[path = "input/completions.rs"] mod completions; +#[path = "input/composition.rs"] +mod composition; #[path = "input/constraints.rs"] mod constraints; #[path = "input/editing.rs"] diff --git a/crates/kit/tests/input/README.md b/crates/kit/tests/input/README.md index 1bc6a44e8f..5d63f00e78 100644 --- a/crates/kit/tests/input/README.md +++ b/crates/kit/tests/input/README.md @@ -5,7 +5,18 @@ under the normal window `Root`. They exercise pointer hit testing, keyboard bindings, focus routing and rendered state together. The test target is [`input.rs`](../input.rs); the modules here group related user workflows. -Run both editing and focus targets from the repository root: +For an Input change, run the complete regression gate from the repository root +(Bash, including Git Bash on Windows): + +```sh +script/test-input +``` + +This runs Base editing/IME/token tests, Component InputGroup/overlay tests, +and Kit input, focus, touch-selection and keyboard-helper workflows. These +targets also run in the existing Linux, macOS and Windows CI matrix. + +For a quicker editing/focus iteration: ```sh cargo test -p gpui-kit --features test-support --test input --test input_focus --locked @@ -49,7 +60,18 @@ cargo test -p gpui-kit --features test-support --test input --locked -- constrai cargo test -p gpui-kit --features test-support --test input --locked -- textarea::selection_across_soft_wraps_copies_and_replaces_buffer_text --exact ``` -Both regressions run as part of the full `input` target, without `#[ignore]`. +These and the additional regressions below run in the full `input` target, +without ignored cases: + +- Selection at document edges, the first vertical move after Alt-click, + horizontal-to-vertical selection, and soft-wrap End affinity. +- Drag autoscroll extends into newly revealed text instead of jumping to EOF. +- Pending completion/inline requests cannot reappear after cancellation, + editing, blur, protection changes or popup acceptance. +- IME selection direction and canceled-preedit undo boundaries on all controls. +- Replacement-mode toggles preserve replacement focus through the next frame. +- Keyboard definition requests work without prior hover, survive pointer leave, + and reject superseded requests or changed text, caret and focus. ## Recorded workflows @@ -61,21 +83,62 @@ Both regressions run as part of the full `input` target, without `#[ignore]`. | [`textarea.rs`](textarea.rs) | Enter versus submit; multiline clipboard and CRLF; wrapped-row navigation; caret reveal after scrolling; bounded auto-grow; resize reflow | | [`editor.rs`](editor.rs) | Generated pairs and closers; Unicode inside quotes; language differences; paired deletion and undo; indentation; multi-cursor replacement; search and replace overlays | | [`completions.rs`](completions.rs) | Provider requests through typed input; popup acceptance/cancellation; keyboard selection; filtered suggestions; completion undo boundaries | +| [`composition.rs`](composition.rs) | IME bridge preedit, commit/cancel, UTF-16 selection direction and history boundaries | | [`lifecycle.rs`](lifecycle.rs) | Focus routing across all three controls; selection across parent renders; read-only transitions; unmount/remount with retained state | The original cases in [`input.rs`](../input.rs) also cover scoped duplicate IDs, cross-scope keyboard rejection and masked values. The table is an index to concrete tests, not a claim of exhaustive coverage. Inline tokens, touch selection, -InputGroup composition and language-service providers are outside this target's -workflow matrix; inspect their separate Base/component tests for relevant coverage. -Touch selection also has a separate Kit `touch_selection` target. The completion -fixture supplies deterministic responses through the public provider interface; -it does not connect to a language-server process. - -The multi-cursor vertical-selection case establishes preferred columns with -horizontal arrow keys after Alt-click. Alt-click currently leaves a new cursor's -column anchor unset; preserving its column on the first vertical move remains a -separate gap. A passing run does not establish that interaction. +InputGroup composition are covered by separate Base/component tests included in +`script/test-input`. +Touch selection also has a separate Kit `touch_selection` target. Completion, code-action, inline-suggestion and definition +fixtures supply deterministic responses through the public provider interfaces; +they do not connect to a language-server process. + +The composition fixture calls the public `ElementInputHandler` bridge for the +mounted state. It exercises the IME protocol methods and subsequent rendered +state, rather than the platform-installed handler or a real input method. +The in-flight completion disposal fixture is deliberately inactive because +GPUI TestPlatform retains the active window's input handler after removal; +active-window disposal needs separate platform evidence. + +The multi-cursor vertical-selection case starts directly after Alt-click and +checks both replacement ranges and their restoration by Undo, without an +intervening horizontal move to repair a missing preferred column. + +## Operation coverage and review gate + +| Operation family | Automated evidence | +| --- | --- | +| Typing, replacement, direction, empty boundaries | `editing`, `history`, `lifecycle`; text and selection endpoints | +| Character/word/line/document movement and deletion | `editing`, `textarea`, `editor`; platform bindings, Unicode scalar and CRLF boundaries | +| Visual-row and page movement | `textarea`; soft wrap, document edges, preferred columns, short rows and reversal | +| Pointer selection | `editing`, `textarea`, `editor`; clicks, Shift-click, word/paragraph selection, drag direction, autoscroll and multi-cursor placement | +| Clipboard and history | `history`, `constraints`, `editor`, `lifecycle`; sentinels, normalization, undo/redo branches, restored ranges | +| Protection, validation and masking | `constraints`, `lifecycle`; rejected edits, selection/history, callbacks, re-enabling and password privacy | +| Focus and lifecycle | `input_focus`, `lifecycle`; traversal, addons, Focus/Blur events, parent renders, unmount and release | +| Multiline layout | `textarea`; caret reveal, scrolling, resize, fixed rows and bounded auto-grow | +| Code editing | `editor`; pairs, language-specific indentation, block selection, folding, multiple cursors, literal search and replacement | +| Language providers | `completions`; providers, acceptance/cancellation, stale results, failure, focus/protection transitions inline suggestions and definition navigation | +| IME protocol | `composition` and Base tests; UTF-16 ranges, preedit replacement, commit/cancel, selection direction and undo boundaries on all three controls | +| Inline tokens and grouped inputs | Base `input::state::tests::test_inline_token_*` and Component `input::group::tests`; atomic edits, clipboard, IME geometry, addons and paste hooks | +| Touch selection | Kit `touch_selection` and Base touch tests; long press, handles, copying and scrolling | + +For a normal editing PR, require the platform CI matrix to pass and require a +regression case for its changed interaction. Check that the case fails before +the fix; a green suite alone cannot prove a new behavior. Review assertions for +text **and** caret/selection/focus or owner callbacks, as appropriate. This is +the normal gate instead of repeating a manual tour of every input operation. + +Do not hide a defect by inserting a click that cancels an asynchronous request, +reusing clipboard contents from an earlier control, or resetting the caret +before checking its direction. Focus-cancellation tests activate the window, +observe Blur, and refocus through Tab. Provider responses are released explicitly. + +Text movement currently follows Unicode scalar boundaries, with CRLF treated as +one newline; it does not promise grapheme-cluster movement for combining marks +or ZWJ emoji. Tests preserve that existing contract. Duplicate/move-line commands, +regex search and Shift-Page selection are not implemented operations. The separate [`input_focus.rs`](../input_focus.rs) target covers repeated Tab and Shift-Tab cycles with passive prefixes/suffixes, focus and activation of addon diff --git a/crates/kit/tests/input/completions.rs b/crates/kit/tests/input/completions.rs index 462c2f07a4..6cda71def3 100644 --- a/crates/kit/tests/input/completions.rs +++ b/crates/kit/tests/input/completions.rs @@ -3,22 +3,26 @@ //! are the public evidence, with acceptance proving that menu actions were routed. use std::{ - cell::RefCell, + cell::{Cell, RefCell}, future::poll_fn, rc::Rc, task::{Poll, Waker}, }; use gpui_kit::{ - App, AppContext, Context, Entity, Result, Task, TestAppContext, Window, WindowHandle, - component::input::{CompletionProvider, Editor, EditorState, Input, InputState, Rope}, + App, AppContext, Context, Entity, Result, SharedString, Subscription, Task, TestAppContext, + Window, WindowHandle, + component::input::{ + CodeActionProvider, CompletionProvider, Editor, EditorState, Input, InputEvent, InputState, + Rope, + }, div, prelude::*, px, size, test::TestWindowExt, }; use lsp_types::{ - CompletionContext, CompletionItem, CompletionResponse, CompletionTextEdit, + CodeAction, CompletionContext, CompletionItem, CompletionResponse, CompletionTextEdit, CompletionTriggerKind, Position, Range, TextEdit, }; @@ -40,15 +44,26 @@ struct Suggestions { #[derive(Default)] struct PendingResponse { - response: Option, + response: Option>, waker: Option, } impl Suggestions { + fn fail(&self, index: usize) { + let pending = self.pending.borrow()[index].clone(); + let mut pending = pending.borrow_mut(); + pending.response = Some(Err( + std::io::Error::other("synthetic provider failure").into() + )); + if let Some(waker) = pending.waker.take() { + waker.wake(); + } + } + fn respond(&self, index: usize, label: Option<&str>) { let pending = self.pending.borrow()[index].clone(); let mut pending = pending.borrow_mut(); - pending.response = Some(CompletionResponse::Array( + pending.response = Some(Ok(CompletionResponse::Array( label .into_iter() .map(|label| CompletionItem { @@ -63,7 +78,7 @@ impl Suggestions { ..Default::default() }) .collect(), - )); + ))); if let Some(waker) = pending.waker.take() { waker.wake(); } @@ -107,7 +122,7 @@ impl CompletionProvider for Suggestions { poll_fn(move |cx| { let mut pending = pending.borrow_mut(); match pending.response.take() { - Some(response) => Poll::Ready(Ok(response)), + Some(response) => Poll::Ready(response), None => { pending.waker = Some(cx.waker().clone()); Poll::Pending @@ -129,6 +144,8 @@ impl CompletionProvider for Suggestions { struct CompletionEditor { state: Entity, other: Entity, + readonly: bool, + disabled: bool, } impl Render for CompletionEditor { @@ -139,7 +156,12 @@ impl Render for CompletionEditor { .flex() .flex_col() .child(Input::new(&self.other).id("other")) - .child(Editor::new(&self.state).flex_1()) + .child( + Editor::new(&self.state) + .readonly(self.readonly) + .disabled(self.disabled) + .flex_1(), + ) } } @@ -147,6 +169,9 @@ struct Fixture { handle: WindowHandle, state: Entity, provider: Rc, + view: Entity, + blurs: Rc>, + _subscription: Subscription, } impl Fixture { @@ -165,11 +190,17 @@ impl Fixture { } fn with_provider(cx: &mut TestAppContext, provider: Suggestions) -> Self { + Self::with_activation(cx, provider, true) + } + + fn with_activation(cx: &mut TestAppContext, provider: Suggestions, activate: bool) -> Self { cx.update(gpui_kit::init); let provider = Rc::new(provider); let (handle, view) = common::open_window(cx, Some(size(px(800.), px(480.))), |window, cx| { cx.new(|cx| CompletionEditor { + readonly: false, + disabled: false, other: cx.new(|cx| InputState::new(window, cx)), state: cx.new(|cx| { let mut state = EditorState::new(window, cx).language("plaintext"); @@ -179,10 +210,27 @@ impl Fixture { }) }); let state = cx.update(|cx| view.read(cx).state.clone()); + let blurs = Rc::new(Cell::new(0)); + let subscription = cx.update(|cx| { + let blurs = blurs.clone(); + cx.subscribe(&state, move |_, event, _| { + if matches!(event, InputEvent::Blur) { + blurs.set(blurs.get() + 1); + } + }) + }); + if activate { + cx.update_window(handle.into(), |_, window, _| window.activate_window()) + .unwrap(); + cx.run_until_parked(); + } let fixture = Self { handle, state, provider, + view, + blurs, + _subscription: subscription, }; cx.update_window(handle.into(), |_, window, cx| { window.click(("input", fixture.state.entity_id()), cx); @@ -193,6 +241,15 @@ impl Fixture { fixture } + fn protect(&self, readonly: bool, disabled: bool, cx: &mut TestAppContext) { + self.view.update(cx, |view, cx| { + view.readonly = readonly; + view.disabled = disabled; + cx.notify(); + }); + self.settle(cx); + } + fn settle(&self, cx: &mut TestAppContext) { // Provider responses and popup acceptance update entities asynchronously. // Drain them outside a borrowed window, then refresh native observations. @@ -359,12 +416,14 @@ fn completion_response_after_focus_loss_cannot_reopen_on_refocus(cx: &mut TestAp ); }) .unwrap(); + fixture.settle(cx); + assert_eq!(fixture.blurs.get(), 1); fixture.provider.respond(0, Some("print")); fixture.settle(cx); cx.update_window(fixture.handle.into(), |_, window, cx| { assert_eq!(window.find("other").value(), Some("other field")); assert_eq!(window.find("other").focused(), Some(true)); - window.click(("input", fixture.state.entity_id()), cx); + window.press("tab", cx); }) .unwrap(); fixture.settle(cx); @@ -374,13 +433,33 @@ fn completion_response_after_focus_loss_cannot_reopen_on_refocus(cx: &mut TestAp #[gpui_kit::test] fn closing_window_disposes_editor_with_completion_in_flight(cx: &mut TestAppContext) { - let fixture = Fixture::deferred(cx); + // GPUI 0.3.6 TestPlatform retains its active TestWindow after remove_window. + // That window's PlatformInputHandler owns an ElementInputHandler + // with a strong Entity. Keep this disposal fixture inactive; the blur tests + // above explicitly activate their windows and observe InputEvent::Blur. + let fixture = Fixture::with_activation( + cx, + Suggestions { + deferred: true, + ..Default::default() + }, + false, + ); fixture.start_completion(cx); - let editor = fixture.state.downgrade(); - cx.update_window(fixture.handle.into(), |_, window, _| window.remove_window()) + let Fixture { + handle, + state, + provider, + view, + _subscription, + .. + } = fixture; + let editor = state.downgrade(); + // Release the test's owners before the window update flushes entity drops. + // Retaining the view until after that update leaves its child state queued. + drop((_subscription, view, state)); + cx.update_window(handle.into(), |_, window, _| window.remove_window()) .unwrap(); - let provider = fixture.provider.clone(); - drop(fixture); cx.run_until_parked(); assert!(editor.upgrade().is_none(), "closed editor must be released"); provider.respond(0, Some("print")); @@ -398,13 +477,716 @@ fn focus_round_trip_invalidates_completion_requested_before_blur(cx: &mut TestAp }) .unwrap(); fixture.settle(cx); + assert_eq!(fixture.blurs.get(), 1); cx.update_window(fixture.handle.into(), |_, window, cx| { - window.click(("input", fixture.state.entity_id()), cx); + window.press("tab", cx); }) .unwrap(); fixture.settle(cx); + fixture.assert_editor("p", cx); + fixture.provider.respond(0, Some("print")); + fixture.settle(cx); + fixture.press("enter", cx); + fixture.assert_editor("p\n", cx); +} + +#[gpui_kit::test] +fn escape_while_request_is_pending_rejects_its_late_response(cx: &mut TestAppContext) { + let fixture = Fixture::deferred(cx); + fixture.start_completion(cx); + fixture.press("escape", cx); + fixture.provider.respond(0, Some("print")); + fixture.settle(cx); + fixture.press("enter", cx); + fixture.assert_editor("p\n", cx); + assert_eq!(fixture.provider.requests.borrow().len(), 1); +} + +#[gpui_kit::test] +fn non_trigger_edit_invalidates_pending_completion(cx: &mut TestAppContext) { + let fixture = Fixture::deferred(cx); + fixture.start_completion(cx); + fixture.input("!", cx); + assert_eq!(fixture.provider.requests.borrow().len(), 1); + fixture.provider.respond(0, Some("print")); + fixture.settle(cx); + fixture.press("enter", cx); + fixture.assert_editor("p!\n", cx); +} + +#[gpui_kit::test] +fn deleting_prefix_invalidates_pending_completion(cx: &mut TestAppContext) { + let fixture = Fixture::deferred(cx); + fixture.start_completion(cx); + fixture.press("backspace", cx); + fixture.assert_editor("", cx); + fixture.provider.respond(0, Some("print")); + fixture.settle(cx); + fixture.press("enter", cx); + fixture.assert_editor("\n", cx); + assert_eq!(fixture.provider.requests.borrow().len(), 1); +} + +#[gpui_kit::test] +fn caret_round_trip_does_not_revive_pending_completion(cx: &mut TestAppContext) { + let fixture = Fixture::deferred(cx); + fixture.start_completion(cx); + fixture.press("left", cx); + fixture.press("right", cx); fixture.provider.respond(0, Some("print")); fixture.settle(cx); fixture.press("enter", cx); fixture.assert_editor("p\n", cx); } + +#[gpui_kit::test] +fn tab_indents_instead_of_accepting_popup_and_invalidates_its_response(cx: &mut TestAppContext) { + let fixture = Fixture::deferred(cx); + fixture.start_completion(cx); + fixture.press("tab", cx); + fixture.assert_editor("p ", cx); + fixture.provider.respond(0, Some("print")); + fixture.settle(cx); + fixture.press("enter", cx); + fixture.assert_editor("p \n", cx); +} + +#[gpui_kit::test] +fn tab_dismisses_visible_completion_without_accepting_it(cx: &mut TestAppContext) { + let fixture = Fixture::new(cx); + fixture.start_completion(cx); + fixture.press("tab", cx); + fixture.assert_editor("p ", cx); + fixture.press("enter", cx); + fixture.assert_editor("p \n", cx); +} + +fn protection_invalidates_completion(cx: &mut TestAppContext, disabled: bool) { + let fixture = Fixture::deferred(cx); + fixture.start_completion(cx); + fixture.protect(!disabled, disabled, cx); + fixture.provider.respond(0, Some("print")); + fixture.settle(cx); + fixture.press("enter", cx); + fixture.input("ignored", cx); + cx.update_window(fixture.handle.into(), |_, window, cx| { + assert_eq!( + window.find(("input", fixture.state.entity_id())).value(), + Some("p") + ); + assert_eq!(fixture.state.read(cx).value(), "p"); + }) + .unwrap(); + assert_eq!(fixture.provider.requests.borrow().len(), 1); + fixture.protect(false, false, cx); + // No click: re-enabling cannot rely on pointer cancellation to clear stale work. + fixture.press("enter", cx); + fixture.assert_editor("p\n", cx); +} + +#[gpui_kit::test] +fn readonly_transition_invalidates_inflight_completion(cx: &mut TestAppContext) { + protection_invalidates_completion(cx, false); +} + +#[gpui_kit::test] +fn disabled_transition_invalidates_inflight_completion(cx: &mut TestAppContext) { + protection_invalidates_completion(cx, true); +} + +#[gpui_kit::test] +fn provider_error_leaves_editor_usable_and_next_request_can_succeed(cx: &mut TestAppContext) { + let fixture = Fixture::deferred(cx); + fixture.start_completion(cx); + fixture.provider.fail(0); + fixture.settle(cx); + fixture.assert_editor("p", cx); + fixture.input("r", cx); + assert_eq!(fixture.provider.requests.borrow().len(), 2); + fixture.provider.respond(1, Some("private")); + fixture.settle(cx); + fixture.press("enter", cx); + fixture.assert_editor("private", cx); +} + +#[gpui_kit::test] +fn failed_refresh_dismisses_previous_items_and_rejects_older_response(cx: &mut TestAppContext) { + let fixture = Fixture::deferred(cx); + fixture.start_completion(cx); + fixture.provider.respond(0, Some("print")); + fixture.settle(cx); + fixture.input("r", cx); + fixture.input("i", cx); + assert_eq!(fixture.provider.requests.borrow().len(), 3); + fixture.provider.fail(2); + fixture.settle(cx); + fixture.provider.respond(1, Some("private")); + fixture.settle(cx); + fixture.press("enter", cx); + fixture.assert_editor("pri\n", cx); +} + +#[gpui_kit::test] +fn late_error_from_older_request_cannot_dismiss_newer_items(cx: &mut TestAppContext) { + let fixture = Fixture::deferred(cx); + fixture.start_completion(cx); + fixture.input("r", cx); + fixture.provider.respond(1, Some("private")); + fixture.settle(cx); + fixture.provider.fail(0); + fixture.settle(cx); + fixture.press("enter", cx); + fixture.assert_editor("private", cx); +} + +#[cfg(target_os = "macos")] +const CODE_ACTIONS: &str = "cmd-."; +#[cfg(not(target_os = "macos"))] +const CODE_ACTIONS: &str = "ctrl-."; + +struct Actions { + id: &'static str, + fail: bool, + requests: RefCell)>>, + performed: RefCell>, +} + +impl Actions { + fn new(id: &'static str, fail: bool) -> Rc { + Rc::new(Self { + id, + fail, + requests: RefCell::new(Vec::new()), + performed: RefCell::new(Vec::new()), + }) + } +} + +impl CodeActionProvider for Actions { + fn id(&self) -> SharedString { + self.id.into() + } + + fn code_actions( + &self, + state: Entity, + range: std::ops::Range, + _: &mut Window, + cx: &mut App, + ) -> Task>> { + self.requests + .borrow_mut() + .push((state.read(cx).value().to_string(), range)); + if self.fail { + return Task::ready(Err( + std::io::Error::other("synthetic code action failure").into() + )); + } + Task::ready(Ok(vec![CodeAction { + title: self.id.into(), + ..Default::default() + }])) + } + + fn perform_code_action( + &self, + _: Entity, + action: CodeAction, + push_to_history: bool, + _: &mut Window, + _: &mut App, + ) -> Task> { + self.performed + .borrow_mut() + .push((action.title, push_to_history)); + Task::ready(Ok(())) + } +} + +fn code_action_fixture(cx: &mut TestAppContext, providers: Vec>) -> Fixture { + let fixture = Fixture::new(cx); + fixture.state.update(cx, |state, _| { + state.lsp_mut().completion_provider = None; + state.lsp_mut().code_action_providers = providers + .into_iter() + .map(|provider| provider as Rc) + .collect(); + }); + fixture.input("value", cx); + fixture.press("shift-left", cx); + fixture.press("shift-left", cx); + fixture +} + +#[gpui_kit::test] +fn code_action_selection_routes_to_its_provider_with_selected_range(cx: &mut TestAppContext) { + let first = Actions::new("First action", false); + let second = Actions::new("Second action", false); + let fixture = code_action_fixture(cx, vec![first.clone(), second.clone()]); + fixture.press(CODE_ACTIONS, cx); + for provider in [&first, &second] { + assert_eq!(*provider.requests.borrow(), vec![("value".into(), 3..5)]); + } + fixture.press("down", cx); + fixture.press("enter", cx); + assert!(first.performed.borrow().is_empty()); + assert_eq!( + *second.performed.borrow(), + vec![("Second action".into(), true)] + ); + fixture.assert_editor("value", cx); + fixture.press("right", cx); + fixture.press("enter", cx); + fixture.assert_editor("value\n", cx); + assert_eq!(second.performed.borrow().len(), 1); +} + +#[gpui_kit::test] +fn escape_dismisses_code_actions_without_performing_them(cx: &mut TestAppContext) { + let provider = Actions::new("Action", false); + let fixture = code_action_fixture(cx, vec![provider.clone()]); + fixture.press(CODE_ACTIONS, cx); + assert_eq!(provider.requests.borrow().len(), 1); + fixture.press("escape", cx); + fixture.press("right", cx); + fixture.press("enter", cx); + fixture.assert_editor("value\n", cx); + assert!(provider.performed.borrow().is_empty()); +} + +#[gpui_kit::test] +fn failed_code_action_provider_does_not_hide_successful_provider(cx: &mut TestAppContext) { + let failed = Actions::new("Failed action", true); + let successful = Actions::new("Successful action", false); + let fixture = code_action_fixture(cx, vec![failed.clone(), successful.clone()]); + fixture.press(CODE_ACTIONS, cx); + fixture.press("enter", cx); + assert_eq!(failed.requests.borrow().len(), 1); + assert!(failed.performed.borrow().is_empty()); + assert_eq!( + *successful.performed.borrow(), + vec![("Successful action".into(), true)] + ); + fixture.assert_editor("value", cx); +} + +#[gpui_kit::test] +fn all_code_action_providers_failing_preserves_normal_enter(cx: &mut TestAppContext) { + let provider = Actions::new("Failed action", true); + let fixture = code_action_fixture(cx, vec![provider.clone()]); + fixture.press("right", cx); + fixture.press(CODE_ACTIONS, cx); + assert_eq!(provider.requests.borrow().len(), 1); + fixture.press("enter", cx); + fixture.assert_editor("value\n", cx); + assert!(provider.performed.borrow().is_empty()); +} + +fn protection_round_trip_invalidates_completion(cx: &mut TestAppContext, disabled: bool) { + let fixture = Fixture::deferred(cx); + fixture.start_completion(cx); + fixture.protect(!disabled, disabled, cx); + fixture.protect(false, false, cx); + fixture.assert_editor("p", cx); + fixture.provider.respond(0, Some("print")); + fixture.settle(cx); + fixture.press("enter", cx); + fixture.assert_editor("p\n", cx); + assert_eq!(fixture.provider.requests.borrow().len(), 1); +} + +#[gpui_kit::test] +fn readonly_round_trip_invalidates_response_released_after_reenable(cx: &mut TestAppContext) { + protection_round_trip_invalidates_completion(cx, false); +} + +#[gpui_kit::test] +fn disabled_round_trip_invalidates_response_released_after_reenable(cx: &mut TestAppContext) { + protection_round_trip_invalidates_completion(cx, true); +} + +#[derive(Default)] +struct InlineSuggestions { + requests: RefCell>, + fail: bool, +} + +impl CompletionProvider for InlineSuggestions { + fn completions( + &self, + _: &Rope, + _: usize, + _: CompletionContext, + _: &mut Window, + _: &mut App, + ) -> Task> { + panic!("inline-only provider must not receive popup requests") + } + + fn is_completion_trigger(&self, _: usize, _: &str, _: &mut App) -> bool { + false + } + + fn inline_completion_debounce(&self) -> std::time::Duration { + std::time::Duration::from_millis(1) + } + + fn inline_completion( + &self, + text: &Rope, + offset: usize, + trigger: lsp_types::InlineCompletionContext, + _: &mut Window, + _: &mut App, + ) -> Task> { + assert_eq!( + trigger.trigger_kind, + lsp_types::InlineCompletionTriggerKind::Automatic + ); + self.requests.borrow_mut().push((text.to_string(), offset)); + if self.fail { + return Task::ready(Err(std::io::Error::other("synthetic inline failure").into())); + } + Task::ready(Ok(lsp_types::InlineCompletionResponse::Array(vec![ + lsp_types::InlineCompletionItem { + insert_text: "rint".into(), + filter_text: None, + range: None, + command: None, + insert_text_format: None, + }, + ]))) + } +} + +fn inline_fixture(cx: &mut TestAppContext, fail: bool) -> (Fixture, Rc) { + let fixture = Fixture::new(cx); + let provider = Rc::new(InlineSuggestions { + fail, + ..Default::default() + }); + fixture.state.update(cx, |state, _| { + state.lsp_mut().completion_provider = Some(provider.clone()); + }); + fixture.input("p", cx); + // Advance the deterministic executor clock; never sleep or wait on wall time. + cx.background_executor + .advance_clock(std::time::Duration::from_millis(1)); + fixture.settle(cx); + assert_eq!(*provider.requests.borrow(), vec![("p".into(), 1)]); + fixture.assert_editor("p", cx); + (fixture, provider) +} + +#[gpui_kit::test] +fn inline_completion_tab_accepts_without_issuing_another_request(cx: &mut TestAppContext) { + let (fixture, provider) = inline_fixture(cx, false); + fixture.press("tab", cx); + fixture.assert_editor("print", cx); + assert_eq!(provider.requests.borrow().len(), 1); + fixture.press("enter", cx); + fixture.assert_editor("print\n", cx); +} + +#[gpui_kit::test] +fn escape_dismisses_inline_completion_and_tab_returns_to_indentation(cx: &mut TestAppContext) { + let (fixture, _) = inline_fixture(cx, false); + fixture.press("escape", cx); + fixture.press("tab", cx); + fixture.assert_editor("p ", cx); +} + +#[gpui_kit::test] +fn typing_clears_inline_suggestion_before_next_debounce(cx: &mut TestAppContext) { + let (fixture, _) = inline_fixture(cx, false); + fixture.input("!", cx); + fixture.press("tab", cx); + fixture.assert_editor("p! ", cx); +} + +#[gpui_kit::test] +fn inline_provider_error_preserves_tab_indentation(cx: &mut TestAppContext) { + let (fixture, _) = inline_fixture(cx, true); + fixture.press("tab", cx); + fixture.assert_editor("p ", cx); +} + +#[derive(Clone, Copy)] +enum DefinitionResponse { + Location, + Empty, + Error, +} + +struct Definitions { + response: Cell, + requests: RefCell>, + deferred: Cell, + pending: RefCell>>>, +} + +#[derive(Default)] +struct DefinitionGate { + released: bool, + waker: Option, +} + +impl Definitions { + fn release(&self, index: usize) { + let gate = self.pending.borrow()[index].clone(); + let mut gate = gate.borrow_mut(); + gate.released = true; + if let Some(waker) = gate.waker.take() { + waker.wake(); + } + } +} + +impl gpui_kit::component::input::DefinitionProvider for Definitions { + fn definitions( + &self, + text: &Rope, + offset: usize, + _: &mut Window, + cx: &mut App, + ) -> Task>> { + self.requests.borrow_mut().push((text.to_string(), offset)); + let response = match self.response.get() { + DefinitionResponse::Location => Ok(vec![lsp_types::LocationLink { + origin_selection_range: Some(Range::new(Position::new(0, 0), Position::new(0, 6))), + target_uri: "fixture:///definition".parse().unwrap(), + target_range: Range::new(Position::new(0, 0), Position::new(0, 13)), + target_selection_range: Range::new(Position::new(0, 7), Position::new(0, 13)), + }]), + DefinitionResponse::Empty => Ok(Vec::new()), + DefinitionResponse::Error => { + Err(std::io::Error::other("synthetic definition failure").into()) + } + }; + if !self.deferred.get() { + return Task::ready(response); + } + let gate = Rc::new(RefCell::new(DefinitionGate::default())); + self.pending.borrow_mut().push(gate.clone()); + cx.spawn(async move |_| { + poll_fn(move |cx| { + let mut gate = gate.borrow_mut(); + if gate.released { + Poll::Ready(()) + } else { + gate.waker = Some(cx.waker().clone()); + Poll::Pending + } + }) + .await; + response + }) + } +} + +fn definition_fixture( + cx: &mut TestAppContext, + response: DefinitionResponse, +) -> (Fixture, Rc) { + let fixture = Fixture::new(cx); + // GoToDefinition is public but has no default shortcut. Hosts can bind it; + // exercise that supported action path through a real key event. + cx.update(|cx| { + cx.bind_keys([gpui_kit::KeyBinding::new( + "f12", + gpui_kit::component::input::GoToDefinition, + Some("Input"), + )]) + }); + let provider = Rc::new(Definitions { + response: Cell::new(response), + requests: RefCell::new(Vec::new()), + deferred: Cell::new(false), + pending: RefCell::new(Vec::new()), + }); + fixture.state.update(cx, |state, _| { + state.lsp_mut().completion_provider = None; + state.lsp_mut().definition_provider = Some(provider.clone()); + }); + fixture.input("source target", cx); + for _ in 0..7 { + fixture.press("left", cx); + } + cx.update(|cx| assert_eq!(fixture.state.read(cx).cursor(), 6)); + (fixture, provider) +} + +#[gpui_kit::test] +fn go_to_definition_requests_current_symbol_without_hover_and_selects_target( + cx: &mut TestAppContext, +) { + let (fixture, provider) = definition_fixture(cx, DefinitionResponse::Location); + fixture.press("f12", cx); + assert_eq!( + *provider.requests.borrow(), + vec![("source target".into(), 6)] + ); + fixture.assert_editor("source target", cx); + cx.update(|cx| { + assert_eq!(fixture.state.read(cx).selected_range(), 7..13); + assert_eq!(fixture.state.read(cx).selected_value(), "target"); + }); +} + +#[gpui_kit::test] +fn go_to_definition_dispatches_show_document_and_respects_host_handling(cx: &mut TestAppContext) { + let (fixture, provider) = definition_fixture(cx, DefinitionResponse::Location); + let shown = Rc::new(RefCell::new(Vec::new())); + fixture.state.update(cx, |state, _| { + let shown = shown.clone(); + state.lsp_mut().show_document = Some(Rc::new(move |params, _, _| { + shown.borrow_mut().push(params.clone()); + true + })); + }); + fixture.press("f12", cx); + assert_eq!( + *provider.requests.borrow(), + vec![("source target".into(), 6)] + ); + let shown = shown.borrow(); + assert_eq!(shown.len(), 1); + assert_eq!(shown[0].uri.as_str(), "fixture:///definition"); + assert_eq!(shown[0].external, Some(false)); + assert_eq!(shown[0].take_focus, Some(true)); + assert_eq!( + shown[0].selection, + Some(Range::new(Position::new(0, 7), Position::new(0, 13))) + ); + cx.update(|cx| assert_eq!(fixture.state.read(cx).selected_range(), 6..6)); + fixture.assert_editor("source target", cx); +} + +#[gpui_kit::test] +fn empty_definition_response_preserves_caret_and_editing(cx: &mut TestAppContext) { + let (fixture, provider) = definition_fixture(cx, DefinitionResponse::Empty); + fixture.press("f12", cx); + assert_eq!( + *provider.requests.borrow(), + vec![("source target".into(), 6)] + ); + cx.update(|cx| assert_eq!(fixture.state.read(cx).selected_range(), 6..6)); + fixture.input("!", cx); + fixture.assert_editor("source! target", cx); +} + +#[gpui_kit::test] +fn definition_provider_error_preserves_caret_and_allows_retry(cx: &mut TestAppContext) { + let (fixture, provider) = definition_fixture(cx, DefinitionResponse::Error); + fixture.press("f12", cx); + assert_eq!( + *provider.requests.borrow(), + vec![("source target".into(), 6)] + ); + fixture.assert_editor("source target", cx); + cx.update(|cx| assert_eq!(fixture.state.read(cx).selected_range(), 6..6)); + provider.response.set(DefinitionResponse::Location); + fixture.press("f12", cx); + assert_eq!( + *provider.requests.borrow(), + vec![("source target".into(), 6), ("source target".into(), 6)] + ); + cx.update(|cx| assert_eq!(fixture.state.read(cx).selected_range(), 7..13)); + fixture.assert_editor("source target", cx); +} + +fn pending_definition_fixture(cx: &mut TestAppContext) -> (Fixture, Rc) { + let (fixture, provider) = definition_fixture(cx, DefinitionResponse::Location); + provider.deferred.set(true); + fixture.press("f12", cx); + assert_eq!( + *provider.requests.borrow(), + vec![("source target".into(), 6)] + ); + (fixture, provider) +} + +#[gpui_kit::test] +fn late_definition_response_does_not_move_changed_caret(cx: &mut TestAppContext) { + let (fixture, provider) = pending_definition_fixture(cx); + fixture.press("left", cx); + provider.release(0); + fixture.settle(cx); + fixture.assert_editor("source target", cx); + cx.update(|cx| assert_eq!(fixture.state.read(cx).selected_range(), 5..5)); +} + +#[gpui_kit::test] +fn late_definition_response_rejects_changed_text_even_at_original_caret(cx: &mut TestAppContext) { + let (fixture, provider) = pending_definition_fixture(cx); + fixture.input("!", cx); + fixture.press("left", cx); + cx.update(|cx| assert_eq!(fixture.state.read(cx).cursor(), 6)); + provider.release(0); + fixture.settle(cx); + fixture.assert_editor("source! target", cx); + cx.update(|cx| assert_eq!(fixture.state.read(cx).selected_range(), 6..6)); +} + +#[gpui_kit::test] +fn late_definition_response_cannot_navigate_after_blur_and_keyboard_refocus( + cx: &mut TestAppContext, +) { + let (fixture, provider) = pending_definition_fixture(cx); + cx.update_window(fixture.handle.into(), |_, window, cx| { + window.click("other", cx); + assert_eq!(window.find("other").focused(), Some(true)); + }) + .unwrap(); + fixture.settle(cx); + assert_eq!(fixture.blurs.get(), 1); + fixture.press("tab", cx); + fixture.assert_editor("source target", cx); + provider.release(0); + fixture.settle(cx); + fixture.assert_editor("source target", cx); + cx.update(|cx| assert_eq!(fixture.state.read(cx).selected_range(), 6..6)); +} + +#[gpui_kit::test] +fn pending_keyboard_definition_survives_pointer_leaving_editor(cx: &mut TestAppContext) { + let (fixture, provider) = pending_definition_fixture(cx); + cx.update_window(fixture.handle.into(), |_, window, cx| { + // Hover the other field without clicking: the pointer leaves the editor + // while the keyboard action's focus, document, and caret stay valid. + window.hover("other", cx); + assert_eq!(window.find("other").focused(), Some(false)); + assert_eq!( + window.find(("input", fixture.state.entity_id())).focused(), + Some(true) + ); + }) + .unwrap(); + fixture.settle(cx); + assert_eq!(fixture.blurs.get(), 0); + provider.release(0); + fixture.settle(cx); + fixture.assert_editor("source target", cx); + cx.update(|cx| assert_eq!(fixture.state.read(cx).selected_range(), 7..13)); + assert_eq!(provider.requests.borrow().len(), 1); +} + +#[gpui_kit::test] +fn second_keyboard_definition_cancels_older_request_at_same_caret(cx: &mut TestAppContext) { + let (fixture, provider) = pending_definition_fixture(cx); + // Keep the document and cursor identical, so only request ownership can + // reject the older location after the latest lookup reports no definition. + provider.response.set(DefinitionResponse::Empty); + fixture.press("f12", cx); + assert_eq!( + *provider.requests.borrow(), + vec![("source target".into(), 6), ("source target".into(), 6)] + ); + provider.release(1); + fixture.settle(cx); + cx.update(|cx| assert_eq!(fixture.state.read(cx).selected_range(), 6..6)); + provider.release(0); + fixture.settle(cx); + fixture.assert_editor("source target", cx); + cx.update(|cx| assert_eq!(fixture.state.read(cx).selected_range(), 6..6)); +} diff --git a/crates/kit/tests/input/composition.rs b/crates/kit/tests/input/composition.rs new file mode 100644 index 0000000000..5d65d5dffc --- /dev/null +++ b/crates/kit/tests/input/composition.rs @@ -0,0 +1,154 @@ +//! Simulated IME protocol coverage through GPUI's public ElementInputHandler. +//! +//! Focus, ordinary typing and history commands use Window events. Composition +//! calls use a public bridge to the rendered control's retained state because +//! GPUI keeps the installed platform handler private. These tests do not drive +//! a native OS IME, candidate window, or platform event translation. +use gpui_kit::{App, AppContext, InputHandler, TestAppContext, Window, test::TestWindowExt}; + +use super::lifecycle::mount; + +fn undo(window: &mut Window, cx: &mut App) { + window.press( + if cfg!(target_os = "macos") { + "cmd-z" + } else { + "ctrl-z" + }, + cx, + ); +} + +fn selection( + handler: &mut dyn InputHandler, + window: &mut Window, + cx: &mut App, +) -> std::ops::Range { + handler + .selected_text_range(false, window, cx) + .expect("focused text selection") + .range +} + +#[gpui_kit::test] +fn composition_replaces_marked_text_in_utf16_and_commits_as_one_undo_step(cx: &mut TestAppContext) { + let (handle, fields, ids) = mount(cx); + for (ix, id) in ids.into_iter().enumerate() { + cx.update_window(handle.into(), |_, window, cx| { + window.click(id.clone(), cx); + window.input("A🦀Z", cx); + let mut handler = fields.read(cx).handler(ix, window); + assert_eq!(selection(&mut *handler, window, cx), 4..4); + // Replace the astral character: UTF-16 1..3, UTF-8 1..5. + handler.replace_and_mark_text_in_range(Some(1..3), "に🦀", Some(1..3), window, cx); + window.render_frame(cx); + assert_eq!(window.find(id.clone()).value(), Some("Aに🦀Z")); + assert_eq!(handler.marked_text_range(window, cx), Some(1..4)); + assert_eq!(selection(&mut *handler, window, cx), 2..4); + let mut adjusted = None; + assert_eq!( + handler.text_for_range(2..4, &mut adjusted, window, cx), + Some("🦀".into()) + ); + assert_eq!(adjusted, Some(2..4)); + + // An omitted replacement range must replace the whole preedit, + // even when only part of it is selected. + handler.replace_and_mark_text_in_range(None, "日本", Some(2..2), window, cx); + window.render_frame(cx); + assert_eq!(window.find(id.clone()).value(), Some("A日本Z")); + assert_eq!(handler.marked_text_range(window, cx), Some(1..3)); + assert_eq!(selection(&mut *handler, window, cx), 3..3); + handler.replace_text_in_range(None, "日本語", window, cx); + window.render_frame(cx); + assert_eq!(window.find(id.clone()).value(), Some("A日本語Z")); + assert_eq!(handler.marked_text_range(window, cx), None); + assert_eq!(selection(&mut *handler, window, cx), 4..4); + + window.input("x", cx); + assert_eq!(window.find(id.clone()).value(), Some("A日本語xZ")); + undo(window, cx); + assert_eq!(window.find(id.clone()).value(), Some("A日本語Z")); + undo(window, cx); + assert_eq!(window.find(id.clone()).value(), Some("A🦀Z")); + }) + .unwrap(); + } +} + +#[gpui_kit::test] +fn cancelling_preedit_leaves_no_undo_entry_and_does_not_swallow_later_typing( + cx: &mut TestAppContext, +) { + let (handle, fields, ids) = mount(cx); + for (ix, id) in ids.into_iter().enumerate() { + cx.update_window(handle.into(), |_, window, cx| { + window.click(id.clone(), cx); + window.input("A🦀", cx); + let mut handler = fields.read(cx).handler(ix, window); + handler.replace_and_mark_text_in_range(None, "ni", Some(2..2), window, cx); + assert_eq!(handler.marked_text_range(window, cx), Some(3..5)); + // An empty preedit cancels the composition; unmark alone retains + // its text and is covered separately below. + handler.replace_and_mark_text_in_range(None, "", None, window, cx); + window.render_frame(cx); + assert_eq!(window.find(id.clone()).value(), Some("A🦀")); + assert_eq!(handler.marked_text_range(window, cx), None); + assert_eq!(selection(&mut *handler, window, cx), 3..3); + window.input("x", cx); + undo(window, cx); + assert_eq!(window.find(id.clone()).value(), Some("A🦀")); + undo(window, cx); + assert_eq!(window.find(id).value(), Some("")); + }) + .unwrap(); + } +} + +#[gpui_kit::test] +fn unmark_retains_preedit_and_separates_its_history_from_later_typing(cx: &mut TestAppContext) { + let (handle, fields, ids) = mount(cx); + for (ix, id) in ids.into_iter().enumerate() { + cx.update_window(handle.into(), |_, window, cx| { + window.click(id.clone(), cx); + let mut handler = fields.read(cx).handler(ix, window); + handler.replace_and_mark_text_in_range(None, "に", None, window, cx); + handler.replace_and_mark_text_in_range(None, "日本", None, window, cx); + handler.unmark_text(window, cx); + window.render_frame(cx); + assert_eq!(handler.marked_text_range(window, cx), None); + assert_eq!(selection(&mut *handler, window, cx), 2..2); + assert_eq!(window.find(id.clone()).value(), Some("日本")); + window.input("x", cx); + undo(window, cx); + assert_eq!(window.find(id.clone()).value(), Some("日本")); + undo(window, cx); + assert_eq!(window.find(id).value(), Some("")); + }) + .unwrap(); + } +} + +#[gpui_kit::test] +fn input_handler_reports_backward_unicode_selection_direction(cx: &mut TestAppContext) { + let (handle, fields, ids) = mount(cx); + for (ix, id) in ids.into_iter().enumerate() { + cx.update_window(handle.into(), |_, window, cx| { + window.click(id, cx); + window.input("A🦀", cx); + window.press("shift-left", cx); + let mut handler = fields.read(cx).handler(ix, window); + let selected = handler.selected_text_range(false, window, cx).unwrap(); + assert_eq!(selected.range, 1..3); + assert!( + selected.reversed, + "Shift-Left leaves the caret at the start of the UTF-16 selection" + ); + window.press("right", cx); + let selected = handler.selected_text_range(false, window, cx).unwrap(); + assert_eq!(selected.range, 3..3); + assert!(!selected.reversed); + }) + .unwrap(); + } +} diff --git a/crates/kit/tests/input/constraints.rs b/crates/kit/tests/input/constraints.rs index 70066fc54a..752d4392f7 100644 --- a/crates/kit/tests/input/constraints.rs +++ b/crates/kit/tests/input/constraints.rs @@ -13,6 +13,8 @@ use gpui_kit::{ test::TestWindowExt, }; +use std::{cell::RefCell, rc::Rc}; + struct Constraints { input: Entity, previous_focus: Option, @@ -22,6 +24,8 @@ struct Constraints { mask_toggle: bool, content_type: Option, changes: usize, + consume_paste: Option, + paste_payloads: Rc>>, _subscription: Subscription, } @@ -40,6 +44,13 @@ impl Render for Constraints { .readonly(self.readonly) .disabled(self.disabled) .cleanable(self.cleanable) + .when_some(self.consume_paste, |input, consume| { + let payloads = self.paste_payloads.clone(); + input.on_paste(move |item, _, _| { + payloads.borrow_mut().push(item.text().unwrap_or_default()); + consume + }) + }) .when(self.mask_toggle, |input| input.mask_toggle()) .when_some(self.content_type, |input, kind| input.content_type(kind)), ) @@ -68,6 +79,8 @@ fn fixture( mask_toggle: false, content_type: None, changes: 0, + consume_paste: None, + paste_payloads: Rc::default(), _subscription: subscription, } }) @@ -694,3 +707,204 @@ fn validation_policy_changes_apply_to_the_existing_selection(cx: &mut TestAppCon .unwrap(); assert_owner(handle, &view, cx, "34", 9); } + +#[gpui_kit::test] +fn paste_hook_consumption_preserves_selection_and_redo_then_fallback_normalizes( + cx: &mut TestAppContext, +) { + let (handle, view) = fixture(cx, |input| input.default_value("old")); + ui(handle, cx, |window, cx| { + window.click("constrained", cx); + shortcut(window, "a", cx); + clipboard(cx, "new"); + shortcut(window, "v", cx); + shortcut(window, "z", cx); + }); + assert_owner(handle, &view, cx, "old", 2); + common::update_content(handle, &view, cx, |view, _, cx| { + view.consume_paste = Some(true); + cx.notify(); + }) + .unwrap(); + let payload = "中\r\n\t文"; + ui(handle, cx, |window, cx| { + clipboard(cx, payload); + shortcut(window, "v", cx); + assert_eq!(window.find("constrained").value(), Some("old")); + assert_clipboard(cx, payload); + }); + assert_owner(handle, &view, cx, "old", 2); + common::update_content(handle, &view, cx, |view, _, cx| { + assert_eq!(view.input.read(cx).selected_range(), 0..3); + assert_eq!(&*view.paste_payloads.borrow(), &[payload]); + }) + .unwrap(); + ui(handle, cx, |window, cx| { + shortcut( + window, + if cfg!(target_os = "macos") { + "shift-z" + } else { + "y" + }, + cx, + ); + assert_eq!(window.find("constrained").value(), Some("new")); + shortcut(window, "z", cx); + }); + common::update_content(handle, &view, cx, |view, _, cx| { + view.consume_paste = Some(false); + cx.notify(); + }) + .unwrap(); + ui(handle, cx, |window, cx| { + shortcut(window, "v", cx); + assert_eq!(window.find("constrained").value(), Some("中\t文")); + shortcut(window, "z", cx); + assert_eq!(window.find("constrained").value(), Some("old")); + }); + assert_owner(handle, &view, cx, "old", 6); + common::update_content(handle, &view, cx, |view, _, cx| { + assert_eq!(&*view.paste_payloads.borrow(), &[payload, payload]); + assert_eq!(view.input.read(cx).selected_range(), 0..3); + }) + .unwrap(); +} + +#[gpui_kit::test] +fn protected_input_does_not_invoke_paste_hook_and_reenable_restores_it(cx: &mut TestAppContext) { + let (handle, view) = fixture(cx, |input| input.default_value("keep")); + common::update_content(handle, &view, cx, |view, _, cx| { + view.consume_paste = Some(false); + cx.notify(); + }) + .unwrap(); + ui(handle, cx, |window, cx| { + window.click("constrained", cx); + shortcut(window, "a", cx); + clipboard(cx, "replacement"); + }); + for disabled in [false, true] { + common::update_content(handle, &view, cx, |view, _, cx| { + view.readonly = !disabled; + view.disabled = disabled; + cx.notify(); + }) + .unwrap(); + ui(handle, cx, |window, cx| { + shortcut(window, "v", cx); + assert_eq!(window.find("constrained").value(), Some("keep")); + }); + assert_owner(handle, &view, cx, "keep", 0); + common::update_content(handle, &view, cx, |view, _, cx| { + assert!(view.paste_payloads.borrow().is_empty()); + view.readonly = false; + view.disabled = false; + cx.notify(); + }) + .unwrap(); + } + ui(handle, cx, |window, cx| { + window.click("constrained", cx); + shortcut(window, "a", cx); + shortcut(window, "v", cx); + assert_eq!(window.find("constrained").value(), Some("replacement")); + }); + assert_owner(handle, &view, cx, "replacement", 1); + common::update_content(handle, &view, cx, |view, _, _| { + assert_eq!(&*view.paste_payloads.borrow(), &["replacement"]); + }) + .unwrap(); +} + +#[gpui_kit::test] +fn masked_word_delete_takes_the_secret_and_undo_preserves_privacy(cx: &mut TestAppContext) { + let (handle, view) = fixture(cx, |input| input.default_value("first second").masked(true)); + ui(handle, cx, |window, cx| { + window.click("constrained", cx); + window.press("end", cx); + let modifier = if cfg!(target_os = "macos") { + "alt" + } else { + "ctrl" + }; + window.press(&format!("{modifier}-backspace"), cx); + assert_eq!(window.find("constrained").value(), None); + }); + assert_owner(handle, &view, cx, "", 1); + ui(handle, cx, |window, cx| { + shortcut(window, "z", cx); + window.press("home", cx); + let modifier = if cfg!(target_os = "macos") { + "alt" + } else { + "ctrl" + }; + window.press(&format!("{modifier}-delete"), cx); + assert_eq!(window.find("constrained").value(), None); + }); + assert_owner(handle, &view, cx, "", 3); + ui(handle, cx, |window, cx| { + shortcut(window, "z", cx); + clipboard(cx, "public"); + shortcut(window, "a", cx); + shortcut(window, "c", cx); + assert_clipboard(cx, "public"); + assert_eq!(window.find("constrained").value(), None); + }); + assert_owner(handle, &view, cx, "first second", 4); +} + +#[gpui_kit::test] +fn validation_rejection_preserves_redo_and_replacement_selection(cx: &mut TestAppContext) { + let (handle, view) = fixture(cx, |input| { + input + .default_value("12") + .validate(|value, _| value.len() <= 3 && value.bytes().all(|c| c.is_ascii_digit())) + }); + ui(handle, cx, |window, cx| { + window.click("constrained", cx); + window.press("end", cx); + clipboard(cx, "3"); + shortcut(window, "v", cx); + shortcut(window, "z", cx); + window.press("shift-home", cx); + window.input("x", cx); + clipboard(cx, "4567"); + shortcut(window, "v", cx); + assert_eq!(window.find("constrained").value(), Some("12")); + }); + assert_owner(handle, &view, cx, "12", 2); + common::update_content(handle, &view, cx, |view, _, cx| { + assert_eq!(view.input.read(cx).selected_range(), 0..2); + assert_eq!(view.input.read(cx).cursor(), 0); + }) + .unwrap(); + ui(handle, cx, |window, cx| { + shortcut( + window, + if cfg!(target_os = "macos") { + "shift-z" + } else { + "y" + }, + cx, + ); + assert_eq!(window.find("constrained").value(), Some("123")); + shortcut(window, "z", cx); + shortcut(window, "a", cx); + clipboard(cx, "45"); + shortcut(window, "v", cx); + shortcut( + window, + if cfg!(target_os = "macos") { + "shift-z" + } else { + "y" + }, + cx, + ); + assert_eq!(window.find("constrained").value(), Some("45")); + }); + assert_owner(handle, &view, cx, "45", 5); +} diff --git a/crates/kit/tests/input/editing.rs b/crates/kit/tests/input/editing.rs index ee9b5b1496..564c2aea8e 100644 --- a/crates/kit/tests/input/editing.rs +++ b/crates/kit/tests/input/editing.rs @@ -391,3 +391,141 @@ fn tab_moves_between_inputs_with_passive_prefix_and_suffix(cx: &mut TestAppConte }) .unwrap(); } + +fn word_key(window: &mut Window, key: &str, cx: &mut App) { + let modifier = if cfg!(target_os = "macos") { + "alt" + } else { + "ctrl" + }; + window.press(&format!("{modifier}-{key}"), cx); +} + +#[gpui_kit::test] +fn word_navigation_and_selection_keep_the_original_anchor(cx: &mut TestAppContext) { + let (handle, form) = editing_form(cx); + cx.update_window(handle.into(), |_, window, cx| { + let input = form.read(cx).first.clone(); + window.click("editing-first", cx); + window.input("alpha beta gamma", cx); + word_key(window, "left", cx); + assert_edit(&input, "alpha beta gamma", 11..11, 11, cx); + word_key(window, "left", cx); + assert_edit(&input, "alpha beta gamma", 6..6, 6, cx); + word_key(window, "shift-left", cx); + assert_edit(&input, "alpha beta gamma", 0..6, 0, cx); + word_key(window, "shift-right", cx); + assert_edit(&input, "alpha beta gamma", 5..6, 5, cx); + word_key(window, "shift-right", cx); + assert_edit(&input, "alpha beta gamma", 6..10, 10, cx); + window.input("B", cx); + assert_edit(&input, "alpha B gamma", 7..7, 7, cx); + word_key(window, "right", cx); + assert_edit(&input, "alpha B gamma", 13..13, 13, cx); + word_key(window, "right", cx); + assert_edit(&input, "alpha B gamma", 13..13, 13, cx); + }) + .unwrap(); +} + +#[gpui_kit::test] +fn word_deletion_respects_selection_and_empty_boundaries(cx: &mut TestAppContext) { + let (handle, form) = editing_form(cx); + cx.update_window(handle.into(), |_, window, cx| { + let input = form.read(cx).first.clone(); + window.click("editing-first", cx); + window.input("alpha beta gamma", cx); + word_key(window, "backspace", cx); + assert_edit(&input, "alpha beta ", 11..11, 11, cx); + window.press("home", cx); + word_key(window, "backspace", cx); + assert_edit(&input, "alpha beta ", 0..0, 0, cx); + word_key(window, "delete", cx); + assert_edit(&input, " beta ", 0..0, 0, cx); + window.press("right", cx); + window.press("shift-right", cx); + word_key(window, "delete", cx); + assert_edit(&input, " eta ", 1..1, 1, cx); + window.press("secondary-a", cx); + word_key(window, "backspace", cx); + for key in [ + "left", + "right", + "shift-left", + "shift-right", + "backspace", + "delete", + ] { + word_key(window, key, cx); + assert_edit(&input, "", 0..0, 0, cx); + } + window.input("recovered", cx); + assert_eq!(window.find("editing-first").value(), Some("recovered")); + }) + .unwrap(); +} + +#[gpui_kit::test] +fn shift_home_and_end_cross_the_anchor_without_resetting_it(cx: &mut TestAppContext) { + let (handle, form) = editing_form(cx); + cx.update_window(handle.into(), |_, window, cx| { + let input = form.read(cx).first.clone(); + window.click("editing-first", cx); + window.input("abcde", cx); + window.press("left", cx); + window.press("left", cx); + for (key, range, cursor) in [ + ("shift-home", 0..3, 0), + ("shift-end", 3..5, 5), + ("shift-home", 0..3, 0), + ("shift-right", 1..3, 1), + ("shift-right", 2..3, 2), + ("shift-right", 3..3, 3), + ("shift-right", 3..4, 4), + ] { + window.press(key, cx); + assert_edit(&input, "abcde", range, cursor, cx); + } + window.input("X", cx); + assert_eq!(window.find("editing-first").value(), Some("abcXe")); + }) + .unwrap(); +} + +#[gpui_kit::test] +fn clipboard_normalization_preserves_tabs_and_unicode_graphemes(cx: &mut TestAppContext) { + let (handle, form) = editing_form(cx); + cx.update_window(handle.into(), |_, window, cx| { + let input = form.read(cx).first.clone(); + window.click("editing-first", cx); + window.input("[]", cx); + window.press("left", cx); + let payload = "e\u{301}\r\n\t👩‍💻\r中\n"; + cx.write_to_clipboard(ClipboardItem::new_string(payload.into())); + window.press("secondary-v", cx); + let value = "[e\u{301}\t👩‍💻中]"; + let end = value.len() - 1; + assert_edit(&input, value, end..end, end, cx); + window.press("left", cx); + // Arrow selection advances by Unicode scalar, so select the woman, + // joiner and laptop before replacing the complete pasted sequence. + for _ in 0..3 { + window.press("shift-left", cx); + } + assert_eq!(input.read(cx).selected_value(), "👩‍💻"); + window.input("X", cx); + assert_eq!( + window.find("editing-first").value(), + Some("[e\u{301}\tX中]") + ); + window.press("home", cx); + window.press("right", cx); + // The base letter and combining accent are two scalar steps. + window.press("shift-right", cx); + window.press("shift-right", cx); + assert_eq!(input.read(cx).selected_value(), "e\u{301}"); + window.press("delete", cx); + assert_eq!(window.find("editing-first").value(), Some("[\tX中]")); + }) + .unwrap(); +} diff --git a/crates/kit/tests/input/editor.rs b/crates/kit/tests/input/editor.rs index 69c08585ec..45f5be5362 100644 --- a/crates/kit/tests/input/editor.rs +++ b/crates/kit/tests/input/editor.rs @@ -1,9 +1,9 @@ //! Styled editor workflows driven through the production window and native events. use gpui_kit::{ - AppContext, Context, Entity, InputEvent, Modifiers, MouseButton, MouseDownEvent, + AppContext, ClipboardItem, Context, Entity, InputEvent, Modifiers, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, TestAppContext, Window, WindowHandle, - component::input::{Editor, EditorState}, + component::input::{Editor, EditorState, FoldRange}, div, prelude::*, px, size, @@ -73,6 +73,28 @@ fn replace_shortcut() -> &'static str { } } +fn platform_shortcut(macos: &'static str, other: &'static str) -> &'static str { + if cfg!(target_os = "macos") { + macos + } else { + other + } +} + +fn add_cursor_above() -> &'static str { + if cfg!(target_os = "macos") { + "cmd-alt-up" + } else if cfg!(target_os = "windows") { + "ctrl-alt-up" + } else { + "shift-alt-up" + } +} + +fn redo_shortcut() -> &'static str { + platform_shortcut("cmd-shift-z", "ctrl-y") +} + #[gpui_kit::test] fn nested_json_pairs_skip_their_generated_closers(cx: &mut TestAppContext) { let (handle, state) = editor(cx, "json", ""); @@ -346,11 +368,8 @@ fn multicursor_vertical_selection_replaces_and_undoes_each_range(cx: &mut TestAp ); window.render_frame(cx); } - // Alt-click currently leaves the new cursor's preferred column unset. - // Initialize both columns through native movement, returning to 6 and 21, - // so this case isolates vertical selection from that existing defect. - window.press("right", cx); - window.press("left", cx); + // Vertical selection must honor the column established by Alt-click, + // without a horizontal movement to initialize the secondary anchor. assert_eq!(state.read(cx).selected_range(), 6..6); assert_eq!(state.read(cx).cursor(), 6); window.press("shift-up", cx); @@ -476,3 +495,702 @@ fn replace_overlay_tabs_to_replacement_and_replace_all_is_one_undo(cx: &mut Test }) .unwrap(); } + +#[gpui_kit::test] +fn block_indent_outdent_preserves_reversed_selection_and_redo(cx: &mut TestAppContext) { + let (handle, state) = editor(cx, "plaintext", "one\n two"); + cx.update_window(handle.into(), |_, window, cx| { + window.click(("input", state.entity_id()), cx); + window.press(platform_shortcut("cmd-down", "ctrl-end"), cx); + window.press(platform_shortcut("cmd-shift-up", "ctrl-shift-home"), cx); + assert_eq!(state.read(cx).selected_range(), 0..9); + assert_eq!(state.read(cx).cursor(), 0); + window.press("secondary-]", cx); + assert_eq!(state.read(cx).value(), " one\n two"); + assert_eq!(state.read(cx).selected_range(), 2..13); + assert_eq!(state.read(cx).cursor(), 2); + window.press("secondary-[", cx); + assert_eq!(state.read(cx).value(), "one\n two"); + assert_eq!(state.read(cx).selected_range(), 0..9); + assert_eq!(state.read(cx).cursor(), 0); + window.press("secondary-z", cx); + assert_eq!(state.read(cx).value(), " one\n two"); + assert_eq!(state.read(cx).cursor(), 2); + window.press(redo_shortcut(), cx); + assert_eq!(state.read(cx).value(), "one\n two"); + assert_eq!(state.read(cx).selected_range(), 0..9); + assert_eq!(state.read(cx).cursor(), 0); + // Moving the head right proves history kept the selection direction. + window.press("shift-right", cx); + assert_eq!(state.read(cx).selected_range(), 1..9); + assert_eq!(state.read(cx).cursor(), 1); + }) + .unwrap(); +} + +#[gpui_kit::test] +fn block_indent_at_a_caret_changes_only_its_line(cx: &mut TestAppContext) { + let (handle, state) = editor(cx, "plaintext", "one\ntwo\nthree"); + cx.update_window(handle.into(), |_, window, cx| { + window.click(("input", state.entity_id()), cx); + window.press(platform_shortcut("cmd-up", "ctrl-home"), cx); + window.press("right", cx); + window.press("down", cx); + assert_eq!(state.read(cx).cursor(), 5); + window.press("secondary-]", cx); + assert_eq!(state.read(cx).value(), "one\n two\nthree"); + assert_eq!(state.read(cx).selected_range(), 7..7); + window.press("secondary-[", cx); + assert_eq!(state.read(cx).value(), "one\ntwo\nthree"); + assert_eq!(state.read(cx).selected_range(), 5..5); + window.press("secondary-[", cx); + assert_eq!(state.read(cx).value(), "one\ntwo\nthree"); + assert_eq!(state.read(cx).cursor(), 5); + }) + .unwrap(); +} + +#[gpui_kit::test] +fn word_and_document_movement_select_and_replace_across_lines(cx: &mut TestAppContext) { + let value = "alpha beta\ngamma delta"; + let (handle, state) = editor(cx, "plaintext", value); + cx.update_window(handle.into(), |_, window, cx| { + window.click(("input", state.entity_id()), cx); + window.press(platform_shortcut("cmd-up", "ctrl-home"), cx); + window.press(platform_shortcut("alt-right", "ctrl-right"), cx); + assert_eq!(state.read(cx).selected_range(), 5..5); + window.press(platform_shortcut("alt-shift-right", "ctrl-shift-right"), cx); + assert_eq!(state.read(cx).selected_range(), 5..10); + window.press(platform_shortcut("alt-left", "ctrl-left"), cx); + assert_eq!(state.read(cx).selected_range(), 6..6); + window.press(platform_shortcut("alt-shift-left", "ctrl-shift-left"), cx); + assert_eq!(state.read(cx).selected_range(), 0..6); + assert_eq!(state.read(cx).cursor(), 0); + window.press(platform_shortcut("cmd-down", "ctrl-end"), cx); + assert_eq!(state.read(cx).selected_range(), value.len()..value.len()); + window.press(platform_shortcut("cmd-shift-up", "ctrl-shift-home"), cx); + assert_eq!(state.read(cx).selected_range(), 0..value.len()); + assert_eq!(state.read(cx).cursor(), 0); + window.press("left", cx); + window.press(platform_shortcut("cmd-shift-down", "ctrl-shift-end"), cx); + assert_eq!(state.read(cx).cursor(), value.len()); + assert_eq!(state.read(cx).selected_range(), 0..value.len()); + window.input("中🦀", cx); + assert_eq!(state.read(cx).value(), "中🦀"); + // Native input dispatches one keystroke per character. Replacing the + // selection with 中 is atomic; typing 🦀 starts a separate typing run. + window.press("secondary-z", cx); + assert_eq!(state.read(cx).value(), "中"); + assert_eq!(state.read(cx).selected_range(), 3..3); + window.press("secondary-z", cx); + assert_eq!(state.read(cx).value(), value); + assert_eq!(state.read(cx).selected_range(), 0..value.len()); + assert_eq!(state.read(cx).cursor(), value.len()); + }) + .unwrap(); +} + +#[gpui_kit::test] +fn page_movement_preserves_column_and_clamps_at_document_edges(cx: &mut TestAppContext) { + let (handle, state) = editor(cx, "plaintext", ""); + let value = vec!["abcd"; 100].join("\n"); + cx.update_window(handle.into(), |_, window, cx| { + state.update(cx, |state, cx| state.set_value(value.clone(), window, cx)); + window.render_frame(cx); + window.click(("input", state.entity_id()), cx); + window.press(platform_shortcut("cmd-up", "ctrl-home"), cx); + window.press("right", cx); + window.press("right", cx); + window.press("pagedown", cx); + let next_page = state.read(cx).cursor(); + assert!(next_page > 7 && next_page < value.len() - 5); + assert_eq!(next_page % 5, 2); + assert!(state.read(cx).scroll_offset().y < px(0.)); + window.press("pageup", cx); + assert_eq!(state.read(cx).selected_range(), 2..2); + window.press("pageup", cx); + assert_eq!(state.read(cx).cursor(), 2); + window.press(platform_shortcut("cmd-down", "ctrl-end"), cx); + window.press("left", cx); + window.press("left", cx); + window.press("pagedown", cx); + assert_eq!(state.read(cx).cursor(), value.len() - 2); + assert_eq!(state.read(cx).value(), value); + }) + .unwrap(); +} + +#[gpui_kit::test] +fn gutter_fold_and_unfold_change_vertical_navigation_without_editing(cx: &mut TestAppContext) { + let value = "start\ninside\nend\ntail"; + let (handle, state) = editor(cx, "plaintext", value); + cx.run_until_parked(); + cx.update_window(handle.into(), |_, window, cx| { + window.click(("input", state.entity_id()), cx); + window.press(platform_shortcut("cmd-up", "ctrl-home"), cx); + // Supply a deterministic language-provider fixture; folding itself is + // exercised through the production gutter's native mouse handler. + state.update(cx, |state, cx| { + state.apply_highlighter_fold_candidates(vec![FoldRange::new(0, 2)], cx); + }); + window.render_frame(cx); + window.click(("fold-icon", 0usize), cx); + window.press("down", cx); + assert_eq!(state.read(cx).cursor(), 13); + assert_eq!(state.read(cx).value(), value); + window.click(("fold-icon", 0usize), cx); + window.press(platform_shortcut("cmd-up", "ctrl-home"), cx); + window.press("down", cx); + assert_eq!(state.read(cx).cursor(), 6); + window.input("!", cx); + assert_eq!(state.read(cx).value(), "start\n!inside\nend\ntail"); + window.press("secondary-z", cx); + assert_eq!(state.read(cx).value(), value); + }) + .unwrap(); +} + +#[gpui_kit::test] +fn multicursor_copy_cut_paste_and_redo_restore_every_selection(cx: &mut TestAppContext) { + let value = "ab\ncd\nef"; + let (handle, state) = editor(cx, "plaintext", value); + cx.update_window(handle.into(), |_, window, cx| { + window.click(("input", state.entity_id()), cx); + // Add upwards so clipboard order cannot accidentally follow creation order. + window.press(platform_shortcut("cmd-down", "ctrl-end"), cx); + window.press("home", cx); + window.press(add_cursor_above(), cx); + window.press(add_cursor_above(), cx); + window.press("shift-right", cx); + window.press("secondary-c", cx); + assert_eq!( + cx.read_from_clipboard().unwrap().text().as_deref(), + Some("a\nc\ne") + ); + assert_eq!(state.read(cx).value(), value); + window.press("secondary-x", cx); + assert_eq!(state.read(cx).value(), "b\nd\nf"); + assert_eq!( + cx.read_from_clipboard().unwrap().text().as_deref(), + Some("a\nc\ne") + ); + window.press("secondary-z", cx); + assert_eq!(state.read(cx).value(), value); + window.press(redo_shortcut(), cx); + assert_eq!(state.read(cx).value(), "b\nd\nf"); + window.press("secondary-v", cx); + assert_eq!(state.read(cx).value(), value); + window.press("secondary-z", cx); + assert_eq!(state.read(cx).value(), "b\nd\nf"); + window.press(redo_shortcut(), cx); + assert_eq!(state.read(cx).value(), value); + window.press("escape", cx); + window.input("!", cx); + assert_eq!(state.read(cx).value(), "ab\ncd\ne!f"); + }) + .unwrap(); +} + +#[gpui_kit::test] +fn multicursor_paste_broadcasts_when_clipboard_line_count_differs(cx: &mut TestAppContext) { + let (handle, state) = editor(cx, "plaintext", "ab\ncd\nef"); + cx.update_window(handle.into(), |_, window, cx| { + window.click(("input", state.entity_id()), cx); + window.press(platform_shortcut("cmd-up", "ctrl-home"), cx); + window.press("right", cx); + window.press(add_cursor_below(), cx); + window.press(add_cursor_below(), cx); + cx.write_to_clipboard(ClipboardItem::new_string("中\n🦀".into())); + window.press("secondary-v", cx); + assert_eq!(state.read(cx).value(), "a中\n🦀b\nc中\n🦀d\ne中\n🦀f"); + window.press("secondary-z", cx); + assert_eq!(state.read(cx).value(), "ab\ncd\nef"); + window.input("!", cx); + assert_eq!(state.read(cx).value(), "a!b\nc!d\ne!f"); + }) + .unwrap(); +} + +#[gpui_kit::test] +fn readonly_editor_rejects_native_mutations_and_keeps_search_available(cx: &mut TestAppContext) { + let value = "alpha\nbeta"; + let (handle, state) = editor_with_readonly(cx, "plaintext", value, true); + cx.update_window(handle.into(), |_, window, cx| { + window.click(("input", state.entity_id()), cx); + window.press("secondary-a", cx); + cx.write_to_clipboard(ClipboardItem::new_string("replacement".into())); + window.input("ignored", cx); + assert_eq!(state.read(cx).value(), value); + for key in [ + "backspace", + "delete", + "enter", + "secondary-v", + "secondary-x", + "secondary-]", + "secondary-[", + "secondary-z", + redo_shortcut(), + ] { + window.press(key, cx); + assert_eq!(state.read(cx).value(), value, "readonly {key}"); + assert_eq!( + state.read(cx).selected_range(), + 0..value.len(), + "readonly {key}" + ); + } + window.press("left", cx); + window.press("down", cx); + assert_eq!(state.read(cx).cursor(), 6); + window.press("secondary-f", cx); + }) + .unwrap(); + cx.run_until_parked(); + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + assert!(window.try_find("replace-mode").is_none()); + window.input("beta", cx); + }) + .unwrap(); + cx.run_until_parked(); + cx.update_window(handle.into(), |_, window, cx| { + assert_eq!( + state + .read(cx) + .search_session() + .matcher + .matched_ranges() + .as_ref(), + &[6..10] + ); + window.press(replace_shortcut(), cx); + assert!(!state.read(cx).search_session().replace_mode); + assert!(window.try_find("replace-all").is_none()); + window.press("escape", cx); + assert_eq!( + window.find(("input", state.entity_id())).focused(), + Some(true) + ); + assert_eq!(state.read(cx).value(), value); + }) + .unwrap(); +} + +#[gpui_kit::test] +fn search_case_toggle_recomputes_matches_and_buttons_wrap(cx: &mut TestAppContext) { + let (handle, state) = editor(cx, "plaintext", "Cat cat CAT"); + cx.update_window(handle.into(), |_, window, cx| { + window.click(("input", state.entity_id()), cx); + window.press(platform_shortcut("cmd-up", "ctrl-home"), cx); + window.press("secondary-f", cx); + }) + .unwrap(); + cx.run_until_parked(); + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + window.input("cat", cx); + }) + .unwrap(); + cx.run_until_parked(); + cx.update_window(handle.into(), |_, window, cx| { + assert_eq!( + state + .read(cx) + .search_session() + .matcher + .matched_ranges() + .as_ref(), + &[0..3, 4..7, 8..11] + ); + window.within("search-panel").click("prev", cx); + assert_eq!(state.read(cx).search_session().matcher.current(), Some(2)); + window.within("search-panel").click("next", cx); + assert_eq!(state.read(cx).search_session().matcher.current(), Some(0)); + window.within("search-panel").click("case-insensitive", cx); + assert!(!state.read(cx).search_session().case_insensitive); + assert_eq!( + state + .read(cx) + .search_session() + .matcher + .matched_ranges() + .as_ref(), + &[4..7] + ); + window.within("search-panel").click("next", cx); + assert_eq!(state.read(cx).search_session().matcher.current(), Some(0)); + window.within("search-panel").click("case-insensitive", cx); + assert!(state.read(cx).search_session().case_insensitive); + assert_eq!(state.read(cx).search_session().matcher.len(), 3); + assert_eq!(state.read(cx).value(), "Cat cat CAT"); + }) + .unwrap(); +} + +#[gpui_kit::test] +fn search_treats_regex_metacharacters_literally_and_handles_no_matches(cx: &mut TestAppContext) { + let (handle, state) = editor(cx, "plaintext", "a.b axb a.b"); + cx.update_window(handle.into(), |_, window, cx| { + window.click(("input", state.entity_id()), cx); + window.press(platform_shortcut("cmd-up", "ctrl-home"), cx); + window.press("secondary-f", cx); + }) + .unwrap(); + cx.run_until_parked(); + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + window.input("a.b", cx); + }) + .unwrap(); + cx.run_until_parked(); + cx.update_window(handle.into(), |_, window, cx| { + assert_eq!( + state + .read(cx) + .search_session() + .matcher + .matched_ranges() + .as_ref(), + &[0..3, 8..11] + ); + window.press("secondary-a", cx); + window.input("[", cx); + }) + .unwrap(); + cx.run_until_parked(); + cx.update_window(handle.into(), |_, window, cx| { + assert!(state.read(cx).search_session().matcher.is_empty()); + window.within("search-panel").click("next", cx); + window.within("search-panel").click("prev", cx); + assert_eq!(state.read(cx).search_session().query, "["); + assert_eq!(state.read(cx).value(), "a.b axb a.b"); + // Scoped dispatch verifies an observed input retains focus, without + // assuming the panel or buttons expose focus/disabled snapshots. + window.within("search-panel").press("enter", cx); + window.within("search-panel").press("shift-enter", cx); + assert_eq!(state.read(cx).search_session().matcher.current(), None); + window.within("search-panel").press("secondary-a", cx); + window.within("search-panel").press("backspace", cx); + }) + .unwrap(); + cx.run_until_parked(); + cx.update_window(handle.into(), |_, window, cx| { + assert_eq!(state.read(cx).search_session().query, ""); + assert!(state.read(cx).search_session().matcher.is_empty()); + window.press("escape", cx); + assert_eq!(state.read(cx).value(), "a.b axb a.b"); + }) + .unwrap(); +} + +#[gpui_kit::test] +fn replace_one_updates_unicode_match_offsets_and_undoes_atomically(cx: &mut TestAppContext) { + let value = "🦀 cat cat 中"; + let (handle, state) = editor(cx, "plaintext", value); + cx.update_window(handle.into(), |_, window, cx| { + window.click(("input", state.entity_id()), cx); + window.press(platform_shortcut("cmd-up", "ctrl-home"), cx); + window.press(replace_shortcut(), cx); + }) + .unwrap(); + cx.run_until_parked(); + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + window.input("cat", cx); + }) + .unwrap(); + cx.run_until_parked(); + cx.update_window(handle.into(), |_, window, cx| { + assert_eq!( + state + .read(cx) + .search_session() + .matcher + .matched_ranges() + .as_ref(), + &[5..8, 9..12] + ); + window.press("tab", cx); + window.input("猫", cx); + window.within("search-panel").click("replace-one", cx); + assert_eq!(state.read(cx).value(), "🦀 猫 cat 中"); + assert_eq!( + state + .read(cx) + .search_session() + .matcher + .matched_ranges() + .as_ref(), + &[9..12] + ); + window.within("search-panel").click("replace-one", cx); + assert_eq!(state.read(cx).value(), "🦀 猫 猫 中"); + assert!(state.read(cx).search_session().matcher.is_empty()); + window.within("search-panel").click("replace-one", cx); + assert_eq!(state.read(cx).value(), "🦀 猫 猫 中"); + assert!(state.read(cx).search_session().matcher.is_empty()); + window.within("search-panel").press("tab", cx); + window.within("search-panel").click("close", cx); + window.press("secondary-z", cx); + assert_eq!(state.read(cx).value(), "🦀 猫 cat 中"); + window.press("secondary-z", cx); + assert_eq!(state.read(cx).value(), value); + window.press(redo_shortcut(), cx); + assert_eq!(state.read(cx).value(), "🦀 猫 cat 中"); + }) + .unwrap(); +} + +#[gpui_kit::test] +fn replace_all_with_empty_replacement_preserves_unicode_suffix(cx: &mut TestAppContext) { + let value = "🦀 cat cat 中"; + let (handle, state) = editor(cx, "plaintext", value); + cx.update_window(handle.into(), |_, window, cx| { + window.click(("input", state.entity_id()), cx); + window.press(platform_shortcut("cmd-up", "ctrl-home"), cx); + window.press(replace_shortcut(), cx); + }) + .unwrap(); + cx.run_until_parked(); + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + window.input("cat", cx); + }) + .unwrap(); + cx.run_until_parked(); + cx.update_window(handle.into(), |_, window, cx| { + assert_eq!(state.read(cx).search_session().matcher.len(), 2); + window.within("search-panel").click("replace-all", cx); + assert_eq!(state.read(cx).value(), "🦀 中"); + assert!(state.read(cx).search_session().matcher.is_empty()); + window.within("search-panel").click("close", cx); + window.press("secondary-z", cx); + assert_eq!(state.read(cx).value(), value); + window.press(redo_shortcut(), cx); + assert_eq!(state.read(cx).value(), "🦀 中"); + }) + .unwrap(); +} + +#[gpui_kit::test] +fn multicursor_block_indent_outdent_is_one_history_entry(cx: &mut TestAppContext) { + let value = "ab\ncd\nef"; + let (handle, state) = editor(cx, "plaintext", value); + cx.update_window(handle.into(), |_, window, cx| { + window.click(("input", state.entity_id()), cx); + window.press(platform_shortcut("cmd-up", "ctrl-home"), cx); + window.press("right", cx); + window.press(add_cursor_below(), cx); + window.press(add_cursor_below(), cx); + window.press("secondary-]", cx); + assert_eq!(state.read(cx).value(), " ab\n cd\n ef"); + assert_eq!(state.read(cx).cursor(), 3); + window.press("secondary-[", cx); + assert_eq!(state.read(cx).value(), value); + assert_eq!(state.read(cx).cursor(), 1); + window.press("secondary-z", cx); + assert_eq!(state.read(cx).value(), " ab\n cd\n ef"); + window.press("secondary-z", cx); + assert_eq!(state.read(cx).value(), value); + window.press(redo_shortcut(), cx); + assert_eq!(state.read(cx).value(), " ab\n cd\n ef"); + window.input("!", cx); + assert_eq!(state.read(cx).value(), " a!b\n c!d\n e!f"); + }) + .unwrap(); +} + +#[gpui_kit::test] +fn word_deletion_at_multiple_cursors_restores_ranges_on_undo(cx: &mut TestAppContext) { + let value = "one two\nred fox"; + let (handle, state) = editor(cx, "plaintext", value); + cx.update_window(handle.into(), |_, window, cx| { + window.click(("input", state.entity_id()), cx); + window.press(platform_shortcut("cmd-up", "ctrl-home"), cx); + window.press(add_cursor_below(), cx); + window.press(platform_shortcut("alt-delete", "ctrl-delete"), cx); + assert_eq!(state.read(cx).value(), " two\n fox"); + window.press("secondary-z", cx); + assert_eq!(state.read(cx).value(), value); + window.press("end", cx); + window.press(platform_shortcut("alt-backspace", "ctrl-backspace"), cx); + assert_eq!(state.read(cx).value(), "one \nred "); + window.press("secondary-z", cx); + assert_eq!(state.read(cx).value(), value); + window.input("!", cx); + assert_eq!(state.read(cx).value(), "one two!\nred fox!"); + }) + .unwrap(); +} + +#[gpui_kit::test] +fn unwrapped_editor_scrolls_horizontally_and_navigates_logical_lines(cx: &mut TestAppContext) { + let (handle, state) = editor(cx, "plaintext", ""); + let first_line = "abcdefghij".repeat(80); + let value = format!("{first_line}\nlast"); + cx.update_window(handle.into(), |_, window, cx| { + state.update(cx, |state, cx| { + state.set_soft_wrap(false, window, cx); + state.set_value(value.clone(), window, cx); + }); + window.render_frame(cx); + window.click(("input", state.entity_id()), cx); + window.press(platform_shortcut("cmd-up", "ctrl-home"), cx); + window.press("end", cx); + assert_eq!(state.read(cx).cursor(), first_line.len()); + assert!(state.read(cx).scroll_offset().x < px(0.)); + assert_eq!(state.read(cx).scroll_offset().y, px(0.)); + window.press("down", cx); + assert_eq!(state.read(cx).cursor(), value.len()); + window.press("up", cx); + assert_eq!(state.read(cx).cursor(), first_line.len()); + window.press("shift-home", cx); + assert_eq!(state.read(cx).selected_range(), 0..first_line.len()); + window.press("secondary-c", cx); + assert_eq!( + cx.read_from_clipboard().unwrap().text().as_deref(), + Some(first_line.as_str()) + ); + window.input("X", cx); + assert_eq!(state.read(cx).value(), "X\nlast"); + window.press("secondary-z", cx); + assert_eq!(state.read(cx).value(), value); + assert_eq!(state.read(cx).selected_range(), 0..first_line.len()); + }) + .unwrap(); +} + +#[gpui_kit::test] +fn open_replace_panel_tracks_live_readonly_changes(cx: &mut TestAppContext) { + cx.update(gpui_kit::init); + let (handle, view) = common::open_window(cx, Some(size(px(800.), px(480.))), |window, cx| { + cx.new(|cx| EditorFixture { + readonly: false, + state: cx.new(|cx| { + EditorState::new(window, cx) + .language("plaintext") + .default_value("cat cat") + }), + }) + }); + let state = cx.update(|cx| view.read(cx).state.clone()); + cx.update_window(handle.into(), |_, window, cx| { + window.click(("input", state.entity_id()), cx); + window.press(platform_shortcut("cmd-up", "ctrl-home"), cx); + window.press(replace_shortcut(), cx); + }) + .unwrap(); + cx.run_until_parked(); + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + window.input("cat", cx); + }) + .unwrap(); + cx.run_until_parked(); + cx.update_window(handle.into(), |_, window, cx| { + assert_eq!(state.read(cx).search_session().matcher.len(), 2); + window.press("tab", cx); + window.input("dog", cx); + assert!(window.within("search-panel").find("replace-all").visible()); + // An application may revoke edit permission while replacement has focus. + view.update(cx, |view, cx| { + view.readonly = true; + cx.notify(); + }); + window.render_frame(cx); + }) + .unwrap(); + cx.run_until_parked(); + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + assert!(state.read(cx).search_session().open); + assert!(!state.read(cx).search_session().replace_mode); + assert!(window.try_find("replace-mode").is_none()); + assert!(window.try_find("replace-one").is_none()); + assert!(window.try_find("replace-all").is_none()); + assert_eq!(state.read(cx).value(), "cat cat"); + window.within("search-panel").click("next", cx); + assert_eq!(state.read(cx).search_session().matcher.current(), Some(1)); + view.update(cx, |view, cx| { + view.readonly = false; + cx.notify(); + }); + window.render_frame(cx); + }) + .unwrap(); + cx.run_until_parked(); + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + window.within("search-panel").click("replace-mode", cx); + assert!(state.read(cx).search_session().replace_mode); + window.within("search-panel").press("secondary-a", cx); + window.within("search-panel").input("fox", cx); + window.within("search-panel").click("replace-all", cx); + assert_eq!(state.read(cx).value(), "fox fox"); + window.within("search-panel").click("close", cx); + window.press("secondary-z", cx); + assert_eq!(state.read(cx).value(), "cat cat"); + }) + .unwrap(); +} + +#[gpui_kit::test] +fn shift_end_then_down_preserves_editor_visual_row_end_affinity(cx: &mut TestAppContext) { + let value = "x".repeat(600); + let (handle, state) = editor(cx, "plaintext", ""); + cx.update_window(handle.into(), |_, window, cx| { + state.update(cx, |state, cx| { + state.set_soft_wrap(true, window, cx); + state.set_value(value.clone(), window, cx); + }); + window.render_frame(cx); + window.click(("input", state.entity_id()), cx); + let start = platform_shortcut("cmd-up", "ctrl-home"); + let end = platform_shortcut("cmd-right", "end"); + let select_end = platform_shortcut("cmd-shift-right", "shift-end"); + window.press(start, cx); + let first_row_top = state.read(cx).cursor_layout().unwrap().0.top(); + window.press(end, cx); + let first_end = state.read(cx).cursor(); + let first_caret = state.read(cx).cursor_layout().unwrap().0; + assert!(first_end > 3 && first_end < value.len()); + assert_eq!(first_caret.top(), first_row_top); + + // Measure the next visual row independently, starting at its left edge. + // No font width or wrap-column constant is part of the contract. + window.press(start, cx); + window.press("down", cx); + window.press(end, cx); + let second_end = state.read(cx).cursor(); + let second_caret = state.read(cx).cursor_layout().unwrap().0; + assert!(second_end > first_end && second_end < value.len()); + assert!(second_caret.top() > first_caret.top()); + + for down in ["shift-down", "down"] { + window.press(start, cx); + for _ in 0..3 { + window.press("right", cx); + } + window.press(select_end, cx); + assert_eq!(state.read(cx).selected_range(), 3..first_end); + assert_eq!(state.read(cx).cursor(), first_end); + assert_eq!(state.read(cx).cursor_layout().unwrap().0, first_caret); + window.press(down, cx); + assert_eq!(state.read(cx).cursor(), second_end, "{down}"); + assert_eq!( + state.read(cx).cursor_layout().unwrap().0, + second_caret, + "{down}" + ); + let expected = if down == "shift-down" { + 3..second_end + } else { + second_end..second_end + }; + assert_eq!(state.read(cx).selected_range(), expected, "{down}"); + } + assert_eq!(state.read(cx).value(), value); + }) + .unwrap(); +} diff --git a/crates/kit/tests/input/history.rs b/crates/kit/tests/input/history.rs index 7457258acb..24216f6f5b 100644 --- a/crates/kit/tests/input/history.rs +++ b/crates/kit/tests/input/history.rs @@ -317,3 +317,85 @@ fn unicode_selection_deletion_restores_text_and_active_end_on_undo(cx: &mut Test }) .unwrap(); } + +#[gpui_kit::test] +fn cut_then_paste_replacement_restore_each_selection_and_clipboard(cx: &mut TestAppContext) { + let (handle, content) = inputs(cx); + cx.update_window(handle.into(), |_, window, cx| { + window.click("text", cx); + window.input("abcDEF", cx); + for _ in 0..3 { + window.press("shift-left", cx); + } + window.press("secondary-x", cx); + assert_eq!(window.find("text").value(), Some("abc")); + window.press("home", cx); + window.press("shift-right", cx); + window.press("shift-right", cx); + window.press(PASTE, cx); + assert_eq!(window.find("text").value(), Some("DEFc")); + window.press(UNDO, cx); + assert_eq!(window.find("text").value(), Some("abc")); + let state = content.read(cx).text.read(cx); + assert_eq!(state.selected_range(), 0..2); + assert_eq!(state.cursor(), 2); + window.press(UNDO, cx); + assert_eq!(window.find("text").value(), Some("abcDEF")); + let state = content.read(cx).text.read(cx); + assert_eq!(state.selected_range(), 3..6); + assert_eq!(state.cursor(), 3); + for (value, cursor) in [("abc", 3), ("DEFc", 3)] { + window.press(REDO, cx); + assert_eq!(window.find("text").value(), Some(value)); + assert_eq!( + content.read(cx).text.read(cx).selected_range(), + cursor..cursor + ); + } + assert_eq!( + cx.read_from_clipboard().unwrap().text().as_deref(), + Some("DEF") + ); + }) + .unwrap(); +} + +#[gpui_kit::test] +fn normalized_paste_undo_restores_unicode_selection_in_both_directions(cx: &mut TestAppContext) { + let (handle, content) = inputs(cx); + for reversed in [false, true] { + cx.update_window(handle.into(), |_, window, cx| { + window.click("text", cx); + window.press("secondary-a", cx); + window.input("[e\u{301}🦀]", cx); + // Select the interior using line boundaries, excluding the ASCII + // brackets. This isolates history from scalar/grapheme step counts. + if reversed { + window.press("left", cx); + window.press("shift-home", cx); + window.press("shift-right", cx); + } else { + window.press("home", cx); + window.press("right", cx); + window.press("shift-end", cx); + window.press("shift-left", cx); + } + assert_eq!( + content.read(cx).text.read(cx).selected_value(), + "e\u{301}🦀" + ); + cx.write_to_clipboard(ClipboardItem::new_string("中\r\n\t文".into())); + window.press(PASTE, cx); + assert_eq!(window.find("text").value(), Some("[中\t文]")); + window.press(UNDO, cx); + assert_eq!(window.find("text").value(), Some("[e\u{301}🦀]")); + let state = content.read(cx).text.read(cx); + assert_eq!(state.selected_range(), 1..8); + assert_eq!(state.cursor(), if reversed { 1 } else { 8 }); + window.press(REDO, cx); + assert_eq!(window.find("text").value(), Some("[中\t文]")); + assert_eq!(content.read(cx).text.read(cx).selected_range(), 8..8); + }) + .unwrap(); + } +} diff --git a/crates/kit/tests/input/lifecycle.rs b/crates/kit/tests/input/lifecycle.rs index 0388e8f068..e796f12bac 100644 --- a/crates/kit/tests/input/lifecycle.rs +++ b/crates/kit/tests/input/lifecycle.rs @@ -1,24 +1,50 @@ //! Shared contracts at the styled Input, Textarea and Editor boundaries. use gpui_kit::{ - App, AppContext, Context, ElementId, Entity, Render, TestAppContext, Window, WindowHandle, + App, AppContext, ClipboardItem, Context, ElementId, ElementInputHandler, Entity, InputHandler, + Render, Subscription, TestAppContext, Window, WindowHandle, base::Root, - component::input::{Editor, EditorState, Input, InputState, Textarea, TextareaState}, + component::input::{ + Editor, EditorState, Input, InputEvent, InputState, Textarea, TextareaState, + }, div, prelude::*, px, size, test::TestWindowExt, }; -struct Fields { +pub(super) struct Fields { input: Entity, textarea: Entity, editor: Entity, mounted: bool, readonly: bool, + disabled: bool, revision: usize, + focus_events: [[usize; 2]; 3], + _subscriptions: Vec, } impl Fields { + // GPUI does not expose the installed platform handler. This public bridge + // exercises the same protocol against the rendered control's retained state. + pub(super) fn handler(&self, ix: usize, window: &Window) -> Box { + let bounds = window.find(self.ids()[ix].clone()).bounds(); + match ix { + 0 => Box::new(ElementInputHandler::new(bounds, self.input.clone())), + 1 => Box::new(ElementInputHandler::new(bounds, self.textarea.clone())), + 2 => Box::new(ElementInputHandler::new(bounds, self.editor.clone())), + _ => unreachable!(), + } + } + + fn record_focus(&mut self, ix: usize, event: &InputEvent) { + match event { + InputEvent::Focus => self.focus_events[ix][0] += 1, + InputEvent::Blur => self.focus_events[ix][1] += 1, + _ => {} + } + } + fn ids(&self) -> [ElementId; 3] { [ ("input", self.input.entity_id()).into(), @@ -45,14 +71,30 @@ impl Render for Fields { .gap_2() .child(format!("Revision {}", self.revision)) .when(self.mounted, |this| { - this.child(Input::new(&self.input).readonly(self.readonly)) - .child(Textarea::new(&self.textarea).h_24().readonly(self.readonly)) - .child(Editor::new(&self.editor).h_24().readonly(self.readonly)) + this.child( + Input::new(&self.input) + .readonly(self.readonly) + .disabled(self.disabled), + ) + .child( + Textarea::new(&self.textarea) + .h_24() + .readonly(self.readonly) + .disabled(self.disabled), + ) + .child( + Editor::new(&self.editor) + .h_24() + .readonly(self.readonly) + .disabled(self.disabled), + ) }) } } -fn mount(cx: &mut TestAppContext) -> (WindowHandle, Entity, [ElementId; 3]) { +pub(super) fn mount( + cx: &mut TestAppContext, +) -> (WindowHandle, Entity, [ElementId; 3]) { cx.update(gpui_kit::init); // Fixed window bounds are the test's viewport, not production control styling. let (window, fields) = @@ -63,9 +105,25 @@ fn mount(cx: &mut TestAppContext) -> (WindowHandle, Entity, [Eleme editor: cx.new(|cx| EditorState::new(window, cx)), mounted: true, readonly: false, + disabled: false, revision: 0, + focus_events: [[0; 2]; 3], + _subscriptions: Vec::new(), }) }); + fields.update(cx, |fields, cx| { + fields._subscriptions = vec![ + cx.subscribe(&fields.input, |fields, _, event, _| { + fields.record_focus(0, event) + }), + cx.subscribe(&fields.textarea, |fields, _, event, _| { + fields.record_focus(1, event) + }), + cx.subscribe(&fields.editor, |fields, _, event, _| { + fields.record_focus(2, event) + }), + ]; + }); let ids = fields.read_with(cx, |fields, _| fields.ids()); (window, fields, ids) } @@ -131,7 +189,7 @@ fn readonly_transition_keeps_selection_copyable_and_reenable_restores_editing( cx: &mut TestAppContext, ) { let (handle, fields, ids) = mount(cx); - for id in ids { + for (ix, id) in ids.into_iter().enumerate() { cx.update_window(handle.into(), |_, window, cx| { window.click(id.clone(), cx); window.input("retained", cx); @@ -143,6 +201,7 @@ fn readonly_transition_keeps_selection_copyable_and_reenable_restores_editing( window.render_frame(cx); window.input("rejected", cx); window.press("backspace", cx); + cx.write_to_clipboard(ClipboardItem::new_string(format!("uncopied-{ix}"))); window.press( if cfg!(target_os = "macos") { "cmd-c" @@ -169,6 +228,119 @@ fn readonly_transition_keeps_selection_copyable_and_reenable_restores_editing( } } +#[gpui_kit::test] +fn focus_and_blur_events_fire_once_per_transition_across_all_controls(cx: &mut TestAppContext) { + let (handle, fields, ids) = mount(cx); + // GPUI delivers focus/blur subscriptions only for an active platform window. + cx.update_window(handle.into(), |_, window, _| window.activate_window()) + .unwrap(); + cx.run_until_parked(); + let mut expected = [[0; 2]; 3]; + for ix in [0, 1, 2, 0] { + cx.update_window(handle.into(), |_, window, cx| { + window.click(ids[ix].clone(), cx); + }) + .unwrap(); + cx.run_until_parked(); + expected[ix][0] += 1; + assert_eq!( + fields.read_with(cx, |fields, _| fields.focus_events), + expected + ); + + cx.update_window(handle.into(), |_, window, cx| { + window.click(ids[ix].clone(), cx); + fields.update(cx, |fields, cx| { + fields.revision += 1; + cx.notify(); + }); + window.render_frame(cx); + assert_eq!(window.find(ids[ix].clone()).focused(), Some(true)); + }) + .unwrap(); + cx.run_until_parked(); + assert_eq!( + fields.read_with(cx, |fields, _| fields.focus_events), + expected + ); + + cx.update_window(handle.into(), |_, window, cx| window.blur(cx)) + .unwrap(); + cx.run_until_parked(); + expected[ix][1] += 1; + assert_eq!( + fields.read_with(cx, |fields, _| fields.focus_events), + expected + ); + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + for id in &ids { + assert_eq!(window.find(id.clone()).focused(), Some(false)); + } + let retained = fields.read(cx).values(cx); + window.input("unfocused", cx); + assert_eq!(fields.read(cx).values(cx), retained); + }) + .unwrap(); + } +} + +#[gpui_kit::test] +fn disabling_focused_controls_blocks_edits_and_click_focus_until_reenabled( + cx: &mut TestAppContext, +) { + let (handle, fields, ids) = mount(cx); + for id in ids { + cx.update_window(handle.into(), |_, window, cx| { + window.click(id.clone(), cx); + window.input("saved🦀", cx); + select_all(window, cx); + fields.update(cx, |fields, cx| { + fields.disabled = true; + cx.notify(); + }); + window.render_frame(cx); + window.input("blocked", cx); + window.press("backspace", cx); + cx.write_to_clipboard(ClipboardItem::new_string("blocked paste".into())); + window.press( + if cfg!(target_os = "macos") { + "cmd-v" + } else { + "ctrl-v" + }, + cx, + ); + assert_eq!(window.find(id.clone()).value(), Some("saved🦀")); + + window.blur(cx); + }) + .unwrap(); + cx.run_until_parked(); + cx.update_window(handle.into(), |_, window, cx| { + window.click(id.clone(), cx); + assert_eq!(window.find(id.clone()).focused(), Some(false)); + window.input("still blocked", cx); + assert_eq!(window.find(id.clone()).value(), Some("saved🦀")); + fields.update(cx, |fields, cx| { + fields.disabled = false; + cx.notify(); + }); + window.render_frame(cx); + window.click(id.clone(), cx); + select_all(window, cx); + window.input("enabled", cx); + assert_eq!(window.find(id.clone()).focused(), Some(true)); + assert_eq!(window.find(id).value(), Some("enabled")); + }) + .unwrap(); + } + assert_eq!( + fields.read_with(cx, |fields, cx| fields.values(cx)), + ["enabled"; 3] + ); +} + #[gpui_kit::test] fn unmounting_focused_controls_removes_targets_and_remount_keeps_retained_values( cx: &mut TestAppContext, diff --git a/crates/kit/tests/input/textarea.rs b/crates/kit/tests/input/textarea.rs index c69ed405e7..98db481c96 100644 --- a/crates/kit/tests/input/textarea.rs +++ b/crates/kit/tests/input/textarea.rs @@ -1,7 +1,8 @@ //! Application-facing multiline editing and viewport regressions. use gpui_kit::{ - AppContext, ClipboardItem, Context, ElementId, Entity, ScrollDelta, Subscription, - TestAppContext, Window, WindowHandle, + App, AppContext, ClipboardItem, Context, ElementId, Entity, InputEvent as _, Modifiers, + MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, Pixels, Point, ScrollDelta, + Subscription, TestAppContext, Window, WindowHandle, component::input::{InputEvent, Textarea, TextareaState}, div, point, prelude::*, @@ -20,6 +21,19 @@ const END: &str = "cmd-down"; #[cfg(not(target_os = "macos"))] const END: &str = "ctrl-end"; +#[cfg(target_os = "macos")] +const WORD_MODIFIER: &str = "alt"; +#[cfg(not(target_os = "macos"))] +const WORD_MODIFIER: &str = "ctrl"; +#[cfg(target_os = "macos")] +const LINE_END: &str = "cmd-right"; +#[cfg(not(target_os = "macos"))] +const LINE_END: &str = "end"; +#[cfg(target_os = "macos")] +const LINE_START: &str = "cmd-left"; +#[cfg(not(target_os = "macos"))] +const LINE_START: &str = "home"; + struct Composer { text: Entity, enters: Vec<(bool, bool, String)>, @@ -68,6 +82,63 @@ fn target(text: &Entity) -> ElementId { ("input", text.entity_id()).into() } +fn caret_point(state: &TextareaState) -> Point { + let caret = state.cursor_layout().expect("laid-out caret").0; + // Stay just before the insertion boundary: converting the window point + // back to line-local coordinates can round past the final glyph's origin, + // where GPUI's closest_index_for_x falls through to the end of the line. + let mut position = point(caret.left() - px(0.1), caret.center().y); + // Caret x already includes scrolling; y is in unscrolled content space. + position.y += state.scroll_offset().y; + position +} + +fn pointer_click( + window: &mut Window, + position: Point, + shift: bool, + count: usize, + cx: &mut App, +) { + let modifiers = Modifiers { + shift, + ..Default::default() + }; + window.dispatch_event( + MouseMoveEvent { + position, + pressed_button: None, + modifiers, + } + .to_platform_input(), + cx, + ); + window.render_frame(cx); + window.dispatch_event( + MouseDownEvent { + position, + button: MouseButton::Left, + modifiers, + click_count: count, + first_mouse: false, + } + .to_platform_input(), + cx, + ); + window.render_frame(cx); + window.dispatch_event( + MouseUpEvent { + position, + button: MouseButton::Left, + modifiers, + click_count: count, + } + .to_platform_input(), + cx, + ); + window.render_frame(cx); +} + fn assert_caret_visible(state: &TextareaState) { // cursor_layout is in unscrolled coordinates; apply the viewport offset. let (caret, _) = state.cursor_layout().expect("laid-out caret"); @@ -462,3 +533,566 @@ fn resizing_reflows_auto_grow_without_changing_text_or_caret(cx: &mut TestAppCon }) .unwrap(); } + +#[gpui_kit::test] +fn vertical_selection_reaches_document_edges_from_inside_the_only_row(cx: &mut TestAppContext) { + let (handle, _, text) = composer(cx, |state| state.rows(4).default_value("abcdef")); + cx.update_window(handle.into(), |_, window, cx| { + window.click(target(&text), cx); + for (key, range, cursor) in [("shift-up", 0..3, 0), ("shift-down", 3..6, 6)] { + window.press(START, cx); + for _ in 0..3 { + window.press("right", cx); + } + window.press(key, cx); + assert_eq!(text.read(cx).selected_range(), range, "{key}"); + assert_eq!(text.read(cx).cursor(), cursor); + window.press(key, cx); + assert_eq!(text.read(cx).selected_range(), range, "repeated {key}"); + window.press("secondary-c", cx); + assert_eq!( + cx.read_from_clipboard().unwrap().text().as_deref(), + Some(&"abcdef"[range]) + ); + } + assert_eq!(text.read(cx).value(), "abcdef"); + }) + .unwrap(); +} + +#[gpui_kit::test] +fn horizontal_selection_sets_the_column_for_vertical_extension(cx: &mut TestAppContext) { + let (handle, _, text) = composer(cx, |state| state.rows(4).default_value("abcdef\nabcdef")); + cx.update_window(handle.into(), |_, window, cx| { + window.click(target(&text), cx); + window.press(START, cx); + for (key, range, cursor) in [ + ("shift-right", 0..1, 1), + ("shift-down", 0..8, 8), + ("shift-left", 0..7, 7), + ("shift-up", 0..0, 0), + ("shift-down", 0..7, 7), + ] { + window.press(key, cx); + assert_eq!(text.read(cx).selected_range(), range, "{key}"); + assert_eq!(text.read(cx).cursor(), cursor, "{key}"); + } + window.press(END, cx); + window.press("shift-left", cx); + window.press("shift-up", cx); + assert_eq!(text.read(cx).selected_range(), 5..13); + assert_eq!(text.read(cx).cursor(), 5); + }) + .unwrap(); +} + +#[gpui_kit::test] +fn arrows_recover_preferred_column_after_short_rows_and_reset_it_after_horizontal_motion( + cx: &mut TestAppContext, +) { + let (handle, _, text) = composer(cx, |state| { + state.rows(4).default_value("abcdefghij\nx\n\nabcdefghij") + }); + cx.update_window(handle.into(), |_, window, cx| { + window.click(target(&text), cx); + window.press(START, cx); + for _ in 0..6 { + window.press("right", cx); + } + for (key, cursor) in [ + ("down", 12), + ("down", 13), + ("down", 20), + ("up", 13), + ("up", 12), + ("up", 6), + ] { + window.press(key, cx); + assert_eq!(text.read(cx).selected_range(), cursor..cursor, "{key}"); + } + window.press("down", cx); + window.press("left", cx); + assert_eq!(text.read(cx).cursor(), 11); + window.press("down", cx); + window.press("down", cx); + assert_eq!( + text.read(cx).cursor(), + 14, + "horizontal motion resets the preferred column" + ); + window.press("up", cx); + window.press("up", cx); + window.press("up", cx); + assert_eq!(text.read(cx).cursor(), 0); + }) + .unwrap(); +} + +#[gpui_kit::test] +fn document_selection_reverses_across_anchor_and_deletes_only_selected_text( + cx: &mut TestAppContext, +) { + let value = "ab\n中🦀\ncd"; + let anchor = "ab\n中".len(); + let (handle, _, text) = composer(cx, |state| state.rows(4).default_value(value)); + cx.update_window(handle.into(), |_, window, cx| { + window.click(target(&text), cx); + window.press(START, cx); + for _ in 0..4 { + window.press("right", cx); + } + assert_eq!(text.read(cx).cursor(), anchor); + for (key, range, cursor) in [ + (format!("shift-{START}"), 0..anchor, 0), + (format!("shift-{END}"), anchor..value.len(), value.len()), + (format!("shift-{START}"), 0..anchor, 0), + ] { + window.press(&key, cx); + assert_eq!(text.read(cx).selected_range(), range); + assert_eq!(text.read(cx).cursor(), cursor); + } + window.press("delete", cx); + assert_eq!(text.read(cx).value(), "🦀\ncd"); + assert_eq!(text.read(cx).selected_range(), 0..0); + window.press("secondary-z", cx); + assert_eq!(text.read(cx).value(), value); + assert_eq!(text.read(cx).selected_range(), 0..anchor); + }) + .unwrap(); +} + +#[gpui_kit::test] +fn word_navigation_selection_and_deletion_preserve_adjacent_lines(cx: &mut TestAppContext) { + let (handle, _, text) = composer(cx, |state| { + state.rows(3).default_value("one two\nthree four") + }); + cx.update_window(handle.into(), |_, window, cx| { + window.click(target(&text), cx); + window.press(START, cx); + window.press(&format!("{WORD_MODIFIER}-right"), cx); + assert_eq!(text.read(cx).cursor(), 3); + window.press(&format!("{WORD_MODIFIER}-shift-right"), cx); + assert_eq!(text.read(cx).selected_range(), 3..7); + window.press("delete", cx); + assert_eq!(text.read(cx).value(), "one\nthree four"); + window.press(END, cx); + window.press(&format!("{WORD_MODIFIER}-left"), cx); + assert_eq!(text.read(cx).cursor(), "one\nthree ".len()); + window.press(&format!("{WORD_MODIFIER}-delete"), cx); + assert_eq!(text.read(cx).value(), "one\nthree "); + window.press("backspace", cx); + window.press(&format!("{WORD_MODIFIER}-shift-left"), cx); + assert_eq!(text.read(cx).selected_range(), 4..9); + window.press(&format!("{WORD_MODIFIER}-backspace"), cx); + assert_eq!(text.read(cx).value(), "one\n"); + window.press(START, cx); + window.press(&format!("{WORD_MODIFIER}-right"), cx); + window.press(&format!("{WORD_MODIFIER}-backspace"), cx); + assert_eq!(text.read(cx).value(), "\n"); + assert_eq!(text.read(cx).cursor(), 0); + }) + .unwrap(); +} + +#[gpui_kit::test] +fn pointer_drag_across_unicode_rows_preserves_direction_and_replaces_selection( + cx: &mut TestAppContext, +) { + let value = "a🦀\n中b\nend"; + let (handle, _, text) = composer(cx, |state| state.auto_grow(4, 4).default_value(value)); + cx.update_window(handle.into(), |_, window, cx| { + window.click(target(&text), cx); + window.press(START, cx); + window.press("right", cx); + let from = caret_point(text.read(cx)); + for _ in 0..4 { + window.press("right", cx); + } + assert_eq!(text.read(cx).cursor(), "a🦀\n中b".len()); + let to = caret_point(text.read(cx)); + pointer_click(window, from, false, 1, cx); + assert_eq!( + text.read(cx).cursor(), + 1, + "click at the measured insertion boundary before the emoji" + ); + for (start, end, cursor) in [(from, to, 10), (to, from, 1)] { + window.drag(start, end, cx); + assert_eq!(text.read(cx).selected_range(), 1..10); + assert_eq!(text.read(cx).cursor(), cursor); + window.press("secondary-c", cx); + assert_eq!( + cx.read_from_clipboard().unwrap().text().as_deref(), + Some("🦀\n中b") + ); + } + window.input("X", cx); + assert_eq!(text.read(cx).value(), "aX\nend"); + assert_eq!(text.read(cx).selected_range(), 2..2); + }) + .unwrap(); +} + +#[gpui_kit::test] +fn page_navigation_preserves_column_and_clamps_at_document_boundaries(cx: &mut TestAppContext) { + let value = vec!["abcdef"; 30].join("\n"); + let (handle, _, text) = composer(cx, |state| state.rows(4).default_value(value.clone())); + cx.update_window(handle.into(), |_, window, cx| { + window.click(target(&text), cx); + window.press(START, cx); + for _ in 0..3 { + window.press("right", cx); + } + let state = text.read(cx); + let page_rows = + (state.input_bounds().size.height / state.cursor_layout().unwrap().1) as usize; + assert!( + page_rows > 0 && page_rows < 29, + "fixture must have a partial-document viewport" + ); + window.press("pagedown", cx); + let cursor = text.read(cx).cursor(); + assert_eq!( + cursor, + 3 + page_rows * 7, + "PageDown moves one visible page at the same column" + ); + assert_eq!(cursor % 7, 3); + assert_caret_visible(text.read(cx)); + window.press("pageup", cx); + assert_eq!(text.read(cx).cursor(), 3); + window.press(END, cx); + window.press("pagedown", cx); + assert_eq!(text.read(cx).selected_range(), value.len()..value.len()); + window.press(START, cx); + window.press("pageup", cx); + assert_eq!(text.read(cx).selected_range(), 0..0); + assert_eq!(text.read(cx).value(), value); + }) + .unwrap(); +} + +#[gpui_kit::test] +fn empty_document_navigation_and_forward_crlf_deletion_respect_scalar_boundaries( + cx: &mut TestAppContext, +) { + let (handle, _, text) = composer(cx, |state| state.rows(4)); + cx.update_window(handle.into(), |_, window, cx| { + window.click(target(&text), cx); + for key in [ + "up", + "down", + "left", + "right", + "shift-up", + "shift-down", + "pageup", + "pagedown", + "delete", + "backspace", + START, + END, + ] { + window.press(key, cx); + assert_eq!(text.read(cx).selected_range(), 0..0, "{key}"); + assert_eq!(text.read(cx).value(), ""); + } + cx.write_to_clipboard(ClipboardItem::new_string("e\u{301}\r\n🦀".into())); + window.press("secondary-v", cx); + window.press(START, cx); + // Text editing moves through Unicode scalar boundaries; CRLF is + // deliberately one boundary. A combining mark remains its own scalar. + window.press("right", cx); + assert_eq!(text.read(cx).cursor(), 1); + window.press("right", cx); + assert_eq!(text.read(cx).cursor(), "e\u{301}".len()); + window.press("shift-right", cx); + assert_eq!(text.read(cx).selected_range(), 3..5); + window.press("left", cx); + assert_eq!(text.read(cx).cursor(), 3); + window.press("delete", cx); + assert_eq!(text.read(cx).value(), "e\u{301}🦀"); + window.press("delete", cx); + assert_eq!(text.read(cx).value(), "e\u{301}"); + window.press("backspace", cx); + assert_eq!(text.read(cx).value(), "e"); + assert_eq!(text.read(cx).cursor(), 1); + window.press("backspace", cx); + assert_eq!(text.read(cx).value(), ""); + assert_eq!(text.read(cx).cursor(), 0); + }) + .unwrap(); +} + +#[gpui_kit::test] +fn wrapped_logical_line_end_and_last_row_selection_reach_document_end(cx: &mut TestAppContext) { + let value = "word ".repeat(80); + let (handle, _, text) = composer(cx, |state| state.rows(6).default_value(value.clone())); + cx.update_window(handle.into(), |_, window, cx| { + window.click(target(&text), cx); + window.press(START, cx); + window.press(LINE_END, cx); + assert_eq!( + text.read(cx).cursor(), + value.len(), + "Textarea End addresses the logical line" + ); + window.press(LINE_START, cx); + assert_eq!(text.read(cx).cursor(), 0); + window.press(LINE_END, cx); + window.press("left", cx); + let anchor = value.len() - 1; + window.press("shift-down", cx); + assert_eq!(text.read(cx).selected_range(), anchor..value.len()); + window.press("shift-down", cx); + assert_eq!(text.read(cx).selected_range(), anchor..value.len()); + assert_caret_visible(text.read(cx)); + }) + .unwrap(); +} + +#[gpui_kit::test] +fn line_boundary_selection_reverses_without_consuming_crlf(cx: &mut TestAppContext) { + let value = "first\r\na中🦀z\r\nlast"; + let (handle, _, text) = composer(cx, |state| state.rows(4).default_value(value)); + cx.update_window(handle.into(), |_, window, cx| { + window.click(target(&text), cx); + window.press(START, cx); + window.press("down", cx); + window.press(LINE_START, cx); + assert_eq!(text.read(cx).cursor(), 7); + window.press("right", cx); + window.press("right", cx); + let anchor = "first\r\na中".len(); + assert_eq!(text.read(cx).cursor(), anchor); + window.press(&format!("shift-{LINE_END}"), cx); + assert_eq!(text.read(cx).selected_range(), anchor..16); + window.press("secondary-c", cx); + assert_eq!( + cx.read_from_clipboard().unwrap().text().as_deref(), + Some("🦀z") + ); + window.press(&format!("shift-{LINE_START}"), cx); + assert_eq!(text.read(cx).selected_range(), 7..anchor); + assert_eq!(text.read(cx).cursor(), 7); + window.press("backspace", cx); + assert_eq!(text.read(cx).value(), "first\r\n🦀z\r\nlast"); + assert_eq!(text.read(cx).selected_range(), 7..7); + window.press(LINE_END, cx); + assert_eq!(text.read(cx).cursor(), 12); + window.press("delete", cx); + assert_eq!(text.read(cx).value(), "first\r\n🦀zlast"); + }) + .unwrap(); +} + +#[gpui_kit::test] +fn shift_click_extends_and_reverses_multiline_selection_from_the_original_anchor( + cx: &mut TestAppContext, +) { + let value = "abcd\nabcd\nabcd"; + let (handle, _, text) = composer(cx, |state| state.auto_grow(5, 5).default_value(value)); + cx.update_window(handle.into(), |_, window, cx| { + window.click(target(&text), cx); + window.press(START, cx); + window.press("right", cx); + let first = caret_point(text.read(cx)); + window.press("down", cx); + let anchor = caret_point(text.read(cx)); + window.press("down", cx); + let last = caret_point(text.read(cx)); + pointer_click(window, anchor, false, 1, cx); + assert_eq!(text.read(cx).selected_range(), 6..6); + for (position, range, cursor) in [(last, 6..11, 11), (first, 1..6, 1), (anchor, 6..6, 6)] { + pointer_click(window, position, true, 1, cx); + assert_eq!(text.read(cx).selected_range(), range); + assert_eq!(text.read(cx).cursor(), cursor); + } + pointer_click(window, last, true, 1, cx); + window.input("X", cx); + assert_eq!(text.read(cx).value(), "abcd\naXbcd"); + assert_eq!(text.read(cx).selected_range(), 7..7); + }) + .unwrap(); +} + +#[gpui_kit::test] +fn double_click_selects_a_word_and_triple_click_selects_the_whole_wrapped_paragraph( + cx: &mut TestAppContext, +) { + let paragraph = "alpha beta ".repeat(16); + let value = format!("first\n{paragraph}\nlast"); + let (handle, _, text) = composer(cx, |state| { + state.auto_grow(8, 8).default_value(value.clone()) + }); + cx.update_window(handle.into(), |_, window, cx| { + window.click(target(&text), cx); + window.press(START, cx); + for _ in 0..8 { + window.press("right", cx); + } + assert_eq!(text.read(cx).cursor(), 8); + let position = caret_point(text.read(cx)); + pointer_click(window, position, false, 2, cx); + assert_eq!(text.read(cx).selected_range(), 6..11); + window.press("secondary-c", cx); + assert_eq!( + cx.read_from_clipboard().unwrap().text().as_deref(), + Some("alpha") + ); + pointer_click(window, position, false, 3, cx); + assert_eq!(text.read(cx).selected_range(), 6..6 + paragraph.len()); + window.press("secondary-c", cx); + assert_eq!( + cx.read_from_clipboard().unwrap().text().as_deref(), + Some(paragraph.as_str()) + ); + cx.write_to_clipboard(ClipboardItem::new_string("replacement".into())); + window.press("secondary-v", cx); + assert_eq!(text.read(cx).value(), "first\nreplacement\nlast"); + window.press("secondary-z", cx); + assert_eq!(text.read(cx).value(), value); + assert_eq!(text.read(cx).selected_range(), 6..6 + paragraph.len()); + }) + .unwrap(); +} + +#[gpui_kit::test] +fn dragging_beyond_viewport_autoscrolls_selection_and_release_stops_it(cx: &mut TestAppContext) { + let value = vec!["abcdef"; 80].join("\n"); + let (handle, _, text) = composer(cx, |state| { + state.auto_grow(4, 4).default_value(value.clone()) + }); + let (outside, initial_cursor, initial_scroll) = cx + .update_window(handle.into(), |_, window, cx| { + window.click(target(&text), cx); + window.press(START, cx); + window.press("right", cx); + let from = caret_point(text.read(cx)); + let outside = point(from.x, text.read(cx).input_bounds().bottom() + px(40.)); + window.dispatch_event( + MouseMoveEvent { + position: from, + pressed_button: None, + modifiers: Default::default(), + } + .to_platform_input(), + cx, + ); + window.render_frame(cx); + window.dispatch_event( + MouseDownEvent { + position: from, + button: MouseButton::Left, + click_count: 1, + first_mouse: false, + modifiers: Default::default(), + } + .to_platform_input(), + cx, + ); + window.render_frame(cx); + assert_eq!(text.read(cx).cursor(), 1); + window.dispatch_event( + MouseMoveEvent { + position: outside, + pressed_button: Some(MouseButton::Left), + modifiers: Default::default(), + } + .to_platform_input(), + cx, + ); + window.render_frame(cx); + assert_eq!(text.read(cx).selected_range().start, 1); + assert!(text.read(cx).cursor() < value.len(), "dragging just below the viewport must not immediately select to EOF; cursor={}, document length={}", text.read(cx).cursor(), value.len()); + ( + outside, + text.read(cx).cursor(), + text.read(cx).scroll_offset().y, + ) + }) + .unwrap(); + cx.run_until_parked(); + for _ in 0..8 { + cx.background_executor + .advance_clock(std::time::Duration::from_millis(16)); + cx.run_until_parked(); + cx.update_window(handle.into(), |_, window, cx| window.render_frame(cx)) + .unwrap(); + } + let released = cx + .update_window(handle.into(), |_, window, cx| { + assert!( + text.read(cx).scroll_offset().y < initial_scroll, + "holding beyond the viewport must scroll without additional pointer moves" + ); + assert!( + text.read(cx).cursor() > initial_cursor, + "the selection must follow newly revealed rows" + ); + assert_eq!(text.read(cx).selected_range().start, 1); + assert_eq!(text.read(cx).value(), value); + window.dispatch_event( + MouseUpEvent { + position: outside, + button: MouseButton::Left, + click_count: 1, + modifiers: Default::default(), + } + .to_platform_input(), + cx, + ); + window.render_frame(cx); + ( + text.read(cx).selected_range(), + text.read(cx).scroll_offset(), + ) + }) + .unwrap(); + for _ in 0..4 { + cx.background_executor + .advance_clock(std::time::Duration::from_millis(16)); + cx.run_until_parked(); + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + assert_eq!(text.read(cx).selected_range(), released.0); + assert_eq!(text.read(cx).scroll_offset(), released.1); + }) + .unwrap(); + } +} + +#[gpui_kit::test] +fn shift_line_end_then_down_retains_wrapped_textarea_selection_and_caret(cx: &mut TestAppContext) { + let value = "x".repeat(600); + let (handle, _, text) = composer(cx, |state| { + state.auto_grow(6, 6).default_value(value.clone()) + }); + cx.update_window(handle.into(), |_, window, cx| { + window.click(target(&text), cx); + window.press(START, cx); + let first_row_top = text.read(cx).cursor_layout().unwrap().0.top(); + window.press("down", cx); + assert!(text.read(cx).cursor_layout().unwrap().0.top() > first_row_top); + window.press(LINE_END, cx); + let end = text.read(cx).cursor(); + let end_caret = text.read(cx).cursor_layout().unwrap().0; + // Textarea Home/End address logical lines even when soft-wrapped. + // The visual-row End affinity regression needs an Editor fixture. + assert_eq!(end, value.len()); + + window.press(START, cx); + for _ in 0..3 { + window.press("right", cx); + } + window.press(&format!("shift-{LINE_END}"), cx); + assert_eq!(text.read(cx).selected_range(), 3..end); + assert_eq!(text.read(cx).cursor_layout().unwrap().0, end_caret); + window.press("shift-down", cx); + assert_eq!(text.read(cx).selected_range(), 3..end); + assert_eq!(text.read(cx).cursor(), end); + assert_eq!(text.read(cx).cursor_layout().unwrap().0, end_caret); + assert_caret_visible(text.read(cx)); + }) + .unwrap(); +} diff --git a/script/test-input b/script/test-input new file mode 100755 index 0000000000..631c55a199 --- /dev/null +++ b/script/test-input @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +set -euo pipefail + +cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." + +# Input algorithms, IME protocol, inline tokens and styled InputGroup behavior. +cargo test -p gpui-base -p gpui-component \ + --features gpui-component/test-support --lib --locked input:: + +# Real component workflows, focus routing, touch selection and key-event helpers. +cargo test -p gpui-kit --features test-support,assets --locked \ + --test input --test input_focus --test touch_selection --test interactions diff --git a/website/docs/test.md b/website/docs/test.md index 875dd523b6..b4bdcd2bf4 100644 --- a/website/docs/test.md +++ b/website/docs/test.md @@ -362,6 +362,7 @@ shows how to turn a manual editing sequence into a repeatable UI test. From the repository root, run both editing and focus targets, or select one workflow: ```sh +script/test-input # Complete Input gate: Base, Component and Kit workflows (Bash). cargo test -p gpui-kit --features test-support --test input --test input_focus --locked cargo test -p gpui-kit --features test-support --test input --locked -- history::paste_is_atomic_and_separate_from_surrounding_typing --exact cargo test -p gpui-kit --features test-support --test input_focus --locked -- reverse_tab_cycles_three_inputs_with_passive_addons --exact @@ -375,6 +376,10 @@ Example workflows include typing → paste → typing → Undo/Redo, Textarea En submission versus Shift-Enter insertion, and Editor completion → acceptance → Undo. Each checks fresh snapshots plus public state or owner events where needed. Completion responses come from a deterministic provider, not a live language server. +The suite also exercises the public IME handler protocol (preedit, UTF-16 ranges, +commit/cancel and history), multi-cursor editing, folding, provider cancellation +and failure. Its operation matrix is the review checklist for ordinary input +changes; add a regression for the changed interaction and require platform CI. The separate `input_focus` target exercises focus callbacks after window updates. These cases do not establish full OS IME, accessibility action, system clipboard or pixel correctness; use the corresponding platform checks for those boundaries. diff --git a/website/zh-CN/docs/test.md b/website/zh-CN/docs/test.md index 4dce4ba65b..44ce95859e 100644 --- a/website/zh-CN/docs/test.md +++ b/website/zh-CN/docs/test.md @@ -309,6 +309,7 @@ Base motion 则可以响应公开的 `cx.set_reduce_motion(true)` 偏好,用 或选择单个流程: ```sh +script/test-input # 完整回归入口:Base、Component 和 Kit(使用 Bash) cargo test -p gpui-kit --features test-support --test input --test input_focus --locked cargo test -p gpui-kit --features test-support --test input --locked -- history::paste_is_atomic_and_separate_from_surrounding_typing --exact cargo test -p gpui-kit --features test-support --test input_focus --locked -- reverse_tab_cycles_three_inputs_with_passive_addons --exact @@ -321,6 +322,8 @@ cargo test -p gpui-kit --features test-support --test input_focus --locked -- re 以及 Editor 补全 → 接受 → Undo。每个流程读取新的快照,必要时结合公开状态或应用事件验证。 补全使用提供固定响应的 provider,不连接实际语言服务器。 独立的 `input_focus` 目标在 window update 结束后验证焦点回调。 +测试还覆盖公开输入法接口的预编辑、UTF-16 范围、提交/取消与 Undo 边界,以及多光标、折叠、异步 provider 的取消和失败。 +普通 Input 改动以操作覆盖表和各平台 CI 为验收依据,并为实际改动补充回归用例,减少重复手工检查整套编辑操作。 这些用例不证明完整的系统 IME、辅助功能动作、系统剪贴板或像素显示正确; 这些边界需要相应的平台验证。