+ * The overlay's own commands are activated as handlers once, rather than + * imperatively activated and deactivated on every focus change. They are scoped + * by {@link FindReplaceOverlayContextSupport#overlayFocusedExpression()}, which + * both limits them to the time an input field has focus and tells them apart + * from the handlers of the overlays of other editors, since all of those are + * activated at the workbench for the same commands. *
- * The overlay's own commands are activated as handlers once, scoped by
- * {@link #overlayFocusedExpression}, rather than imperatively
- * activated/deactivated on every focus change. That expression relies on
- * {@link IFocusService} tracking the search/replace bar controls so that
- * {@code ACTIVE_FOCUS_CONTROL} reflects them. Context activation (which
- * drives key binding resolution and has no expression-based equivalent)
- * remains imperative and is updated directly from the overlay's focus
- * listeners.
+ * Everything context related, both the overlay's key binding scopes and keeping
+ * the editor's own commands from consuming keys meant for the input fields, is
+ * owned by {@link FindReplaceOverlayContextSupport}. This class only forwards
+ * the overlay's focus changes to it, because the shortcut hints have to be
+ * refreshed whenever the active scopes change.
*/
class FindReplaceOverlayCommandSupport {
@@ -81,70 +62,20 @@ class FindReplaceOverlayCommandSupport {
static final String CMD_REPLACE_ALL =
"org.eclipse.ui.workbench.texteditor.findReplaceOverlay.replaceAll"; //$NON-NLS-1$
- private static final String OVERLAY_CONTEXT_ID =
- "org.eclipse.ui.workbench.texteditor.findReplaceOverlay"; //$NON-NLS-1$
- private static final String OVERLAY_SEARCH_CONTEXT_ID =
- "org.eclipse.ui.workbench.texteditor.findReplaceOverlay.searchFocused"; //$NON-NLS-1$
- private static final String OVERLAY_REPLACE_CONTEXT_ID =
- "org.eclipse.ui.workbench.texteditor.findReplaceOverlay.replaceFocused"; //$NON-NLS-1$
-
- private Composite containerControl;
- private final IWorkbenchPart targetPart;
- private DeactivateGlobalActionHandlers globalActionHandlerDeaction;
-
- private final List
+ * Two conditions decide whether one of the editor's handlers wins, and the
+ * editor's commands are spread over both. Its key binding scopes and part-level
+ * handlers are only reachable through the editor's part context, which
+ * activating a sibling of that context takes off the chain. Its retargetable
+ * actions live in the window context instead, guarded by an expression
+ * over {@link ISources#ACTIVE_PART_ID_NAME}, which publishing this context's own
+ * id as the active part id makes false. Together they leave no editor handler in
+ * the resolution path, so no command has to be suppressed individually. The
+ * context sits below the window context rather than the application context,
+ * which would detach window-scoped commands and services as well.
+ *
+ * The {@link MPart} looks superfluous, since nothing reads it, but removing it
+ * changes behaviour: it is what lets {@code ActivePartLookupFunction} resolve an
+ * active part here. Without it that lookup yields {@code null}, and
+ * {@code PartServiceImpl} answers a null active part by firing part deactivation
+ * and clearing the active selection. With it, the same code path finds a part
+ * outside the application model and returns early. That early return is also why
+ * the part is neither added to the model nor rendered nor activated through
+ * {@code EPartService}.
+ *
+ * The alternatives considered, and what was measured about them, are recorded in
+ * {@code docs/adr/0001-find-replace-overlay-key-handling.md}.
+ */
+class FindReplaceOverlayContextSupport {
+
+ static final String OVERLAY_PART_ID_PREFIX = "org.eclipse.ui.workbench.texteditor.findReplaceOverlay.part."; //$NON-NLS-1$
+
+ /**
+ * Tells the overlays of different editors apart: their command handlers are all
+ * activated at the workbench, so the id must not be shared.
+ */
+ private static final AtomicInteger PART_ID_SEQUENCE = new AtomicInteger();
+
+ private static final String OVERLAY_CONTEXT_ID = "org.eclipse.ui.workbench.texteditor.findReplaceOverlay"; //$NON-NLS-1$
+
+ private static final String OVERLAY_SEARCH_CONTEXT_ID = "org.eclipse.ui.workbench.texteditor.findReplaceOverlay.searchFocused"; //$NON-NLS-1$
+
+ private static final String OVERLAY_REPLACE_CONTEXT_ID = "org.eclipse.ui.workbench.texteditor.findReplaceOverlay.replaceFocused"; //$NON-NLS-1$
+
+ private final IWorkbenchPart targetPart;
+
+ private final String overlayPartId = OVERLAY_PART_ID_PREFIX + PART_ID_SEQUENCE.incrementAndGet();
+
+ private IEclipseContext overlayContext;
+
+ /**
+ * The leaf that was active before the overlay took over, restored when it gives
+ * focus back. Remembered rather than derived from the target part, which also
+ * works without a part and does not need the part's site to still be there. Held
+ * only while the overlay owns the active leaf, so that handing it back does not
+ * keep the context of an editor closed in the meantime reachable.
+ */
+ private IEclipseContext contextToRestore;
+
+ private boolean overlayContextActive;
+
+ private final Expression overlayFocusedExpression = createOverlayFocusedExpression();
+
+ /**
+ * The per-field scope currently activated in {@link #overlayContext}. Only ever
+ * switched: while that context is off the active chain its scopes are inert.
+ */
+ private String activeFieldContextId;
+
+ FindReplaceOverlayContextSupport(IWorkbenchPart targetPart) {
+ this.targetPart = targetPart;
+ this.overlayContext = createOverlayContext();
+ }
+
+ private IEclipseContext createOverlayContext() {
+ IEclipseContext windowContext = getWindowContext();
+ if (windowContext == null) {
+ return null;
+ }
+ MPart overlayPart = MBasicFactory.INSTANCE.createPart();
+ overlayPart.setElementId(overlayPartId);
+
+ IEclipseContext context = windowContext.createChild(overlayPartId);
+ context.set(MPart.class, overlayPart);
+ // Only the id is overridden, not ACTIVE_PART_NAME: the overlay operates on the
+ // editor, and so does anything invoked while the overlay has focus, so the
+ // active part itself must keep pointing at the editor.
+ context.set(ISources.ACTIVE_PART_ID_NAME, overlayPartId);
+ overlayPart.setContext(context);
+
+ context.get(EContextService.class).activateContext(OVERLAY_CONTEXT_ID);
+ return context;
+ }
+
+ /**
+ * Holds exactly while one of this overlay's input fields has focus, for scoping
+ * activations to this overlay alone. It tests the active part id this context
+ * publishes, which is unique per overlay. Declaring that variable in
+ * {@code collectExpressionInfo} is what gives such activations their source
+ * priority, so the expression belongs next to the code setting the variable.
+ */
+ Expression overlayFocusedExpression() {
+ return overlayFocusedExpression;
+ }
+
+ private Expression createOverlayFocusedExpression() {
+ return new Expression() {
+ @Override
+ public EvaluationResult evaluate(IEvaluationContext context) {
+ return EvaluationResult
+ .valueOf(overlayPartId.equals(context.getVariable(ISources.ACTIVE_PART_ID_NAME)));
+ }
+
+ @Override
+ public void collectExpressionInfo(ExpressionInfo info) {
+ info.addVariableNameAccess(ISources.ACTIVE_PART_ID_NAME);
+ }
+ };
+ }
+
+ void searchBarFocused() {
+ fieldFocused(OVERLAY_SEARCH_CONTEXT_ID);
+ }
+
+ void replaceBarFocused() {
+ fieldFocused(OVERLAY_REPLACE_CONTEXT_ID);
+ }
+
+ private void fieldFocused(String fieldContextId) {
+ if (overlayContext == null) {
+ return;
+ }
+ if (!fieldContextId.equals(activeFieldContextId)) {
+ EContextService contextService = overlayContext.get(EContextService.class);
+ if (activeFieldContextId != null) {
+ contextService.deactivateContext(activeFieldContextId);
+ }
+ contextService.activateContext(fieldContextId);
+ activeFieldContextId = fieldContextId;
+ }
+ if (!overlayContextActive) {
+ contextToRestore = overlayContext.getParent().getActiveLeaf();
+ overlayContextActive = true;
+ }
+ overlayContext.activate();
+ }
+
+ /**
+ * Hands the active leaf back to whatever held it before the overlay took over.
+ * Activating the overlay's context replaced the window context's active child,
+ * so the whole chain down to that context has to be re-established, not just its
+ * last link.
+ */
+ void fieldsLostFocus() {
+ if (!overlayContextActive) {
+ return;
+ }
+ overlayContextActive = false;
+ if (contextToRestore != null) {
+ contextToRestore.activateBranch();
+ contextToRestore = null;
+ }
+ }
+
+ void dispose() {
+ if (overlayContext != null) {
+ fieldsLostFocus();
+ overlayContext.dispose();
+ overlayContext = null;
+ }
+ }
+
+ /**
+ * The window the overlay belongs to. Only when there is no part at all, which
+ * the find/replace UI tests exercise, does the overlay fall back to the active
+ * window rather than guessing one for a part whose site is unavailable.
+ */
+ private IEclipseContext getWindowContext() {
+ IWorkbenchWindow window;
+ if (targetPart == null) {
+ window = PlatformUI.getWorkbench().getActiveWorkbenchWindow();
+ } else {
+ IWorkbenchPartSite site = targetPart.getSite();
+ window = site == null ? null : site.getWorkbenchWindow();
+ }
+ return window == null ? null : window.getService(IEclipseContext.class);
+ }
+
+}
diff --git a/docs/adr/0001-find-replace-overlay-key-handling.md b/docs/adr/0001-find-replace-overlay-key-handling.md
new file mode 100644
index 00000000000..d46cddc9f9b
--- /dev/null
+++ b/docs/adr/0001-find-replace-overlay-key-handling.md
@@ -0,0 +1,281 @@
+# 1. Find/Replace overlay: key event and editor command handling
+
+How the Find/Replace overlay makes sure that the right commands are in effect while one of its
+input fields has focus: its own, and those of the surrounding workbench, but not those of the
+editor it is drawn on, whose key bindings and handlers would otherwise carry out edits on the
+document rather than in the field the user is typing into. The overlay is not a workbench part,
+so the framework does not separate the two on its own. This decision covers how that separation
+is achieved, and how it is achieved without tying the overlay to a particular kind of editor.
+
+## Status
+
+Accepted.
+
+## Context
+
+The Find/Replace overlay shows text input fields on top of a text editor. Its `containerControl`
+is a plain SWT `Composite` parented into the editor's widget tree.
+
+Being embedded rather than living in its own `Shell` is a hard requirement: a shell has to
+manually follow the target widget's move, resize and hide/show operations, always lags behind by
+some milliseconds, and cannot be positioned at all under Wayland (see commit `78c9a1c60a`, which
+introduced the embedded composite for these reasons). **A separate shell is therefore out of
+scope**, which constrains the whole design space below.
+
+The consequence of being embedded is the root of the problem: focus in the overlay's fields does
+not change the active workbench part. The editor stays active, so its key-binding scopes and its
+command handlers stay in effect and compete with the overlay for every keystroke.
+
+### Goals
+
+1. Standard text editing keys (Ctrl+C/V/X/Z/A, Ctrl+Backspace, word navigation, Home/End,
+ arrows, Delete) must operate on the overlay's input fields, not on the editor's document.
+2. The retargetable global actions (Edit > Cut/Copy/Paste/Select All) must be bound to the
+ overlay's fields while they have focus, so the Edit menu and toolbar act on them.
+3. Commands in the "In Windows" scope (Save, Close, Next Editor, Preferences, ...) must stay
+ executable while the overlay has focus.
+4. The overlay must not depend on `AbstractTextEditor` or `StatusTextEditor`, so it can later
+ serve non-text editors.
+5. No reflective access to private platform API.
+
+### How Eclipse decides which handler runs
+
+Two independent gates decide whether a handler activation wins.
+
+**Gate 1, reachability.** `HandlerServiceImpl#lookUpHandler` resolves
+`context.getActiveLeaf().get("handler::" + commandId)`, and the `HandlerSelectionFunction`
+installed there walks from that leaf up to the root. An activation in a context that is not an
+ancestor of the active leaf is never considered. Key-binding scopes work the same way:
+`ActiveContextsFunction` unions the `localContexts` of every context from the active leaf
+upwards, and that union is what `ContextManager` and the E4 `BindingService` use.
+
+**Gate 2, expression.** Among reachable activations, only those whose `activeWhen` expression
+evaluates to true participate. The winner is chosen by `HandlerActivation#compareTo` on source
+priorities computed by `SourcePriorityNameMapping#computeSourcePriority` from the variables the
+expression accesses. There is no part/window layering in this comparison; `getDepth()` is
+always `0`. Note that `activeFocusControl` maps to `ISources.ACTIVE_MENU` (`1 << 31`), which
+`compareTo` normalises into `1 << 30`, higher than any other source. That, and not any notion
+of part-level versus window-level, is why an `ACTIVE_FOCUS_CONTROL`-scoped activation outranks
+the editor's.
+
+Legacy `ISources` variables are resolved by `ExpressionContext#getVariable` through
+`IEclipseContext#getActive(name)`, that is, **from the active leaf upwards**, so a context
+nearer the leaf can shadow them.
+
+A key reaches the widget when the winning handler reports `isHandled() == false`:
+`KeyBindingDispatcher#executeCommand` computes `commandHandled` from the handler, `press()`
+returns false, `processKeyEvent` leaves `event.doit` untouched, and SWT delivers the key
+natively. A command with no handler at all behaves the same way.
+
+### Where the editor registers what
+
+The editor's commands do not all live in one place, which is what makes the problem non-obvious:
+
+| Registered by | Into which context | Guarded by |
+|---|---|---|
+| Editor's `KeyBindingService` (scopes such as `org.eclipse.ui.textEditorScope`) | the **part** context | n/a |
+| Editor's own `IHandlerService.activateHandler` calls | the **part** context | `ActivePartExpression` |
+| Retargetable actions (cut, copy, paste, delete, select all, undo, redo) via `IActionBars.setGlobalActionHandler` | the **window** context, because `EditorReference` constructs `EditorActionBars` with `page.getWorkbenchWindow()` as service locator | `LegacyEditorActionBarExpression`, comparing `activePartId` against the editor id |
+
+Any approach that only detaches the part context addresses the first two rows and leaves the
+third untouched. The third row holds exactly the commands users notice most.
+
+### Context inheritance of the overlay's own scope
+
+`ContextSet` resolves parent contexts when building the set a binding lookup runs against. If
+the overlay's own key-binding context declares `parentId="org.eclipse.ui.textEditorScope"`, the
+editor scope is pulled back in **even when the editor's part context has been taken off the
+active chain**, and Ctrl+Delete still resolves to `deleteNextWord`. Giving that context a parent
+outside the editor scope is therefore a precondition for the chosen alternative, not cosmetic
+tidying towards goal 4.
+
+## Decision
+
+While one of the overlay's input fields has focus, the overlay activates an `IEclipseContext` of
+its own below the window context and publishes its own id as the active part id. That takes the
+editor's key binding scopes and part-level handlers off the active context chain, and makes the
+expression guarding its retargetable actions evaluate to false, so no editor handler is left in
+the command resolution path and no individual command has to be suppressed. The overlay's own key
+binding scopes are activated in that same context, which also gives them their lifetime, and its
+shared scope is given a parent outside `org.eclipse.ui.textEditorScope`.
+
+### Alternatives
+
+**Suppress the editor's commands individually.** The editor stays the active part, and for every
+command whose key an input field needs, its handler is neutralised or replaced while the field
+has focus, so that the key falls through to the widget. This leaves the workbench's view of the
+world untouched, since the editor remains the active part throughout. It needs a set of affected
+command ids, which is either maintained by hand, and then misses commands contributed by other
+plugins, or derived from the current key bindings, and then unbounded. Suppression works per
+command id, so a command bound to both a native and a non-native key loses both. It also only
+takes the keys away from the editor: nothing binds them to the input field, so cut, copy and
+paste stay unbound and the menu entries appear enabled while doing nothing, leaving goal 2 unmet.
+
+**Make the overlay a workbench element of its own.** Give the overlay its own shell, or model it
+as a real part and activate it, and the framework's own activation takes the editor's contexts
+and handlers off the active chain. This needs no per-command work at all, and is how content
+assist, Quick Access and dialogs already avoid the problem. A shell is excluded by the embedding
+requirement stated in the context. A real part changes the part service's notion of the active
+part, so part listeners, the selection service, Outline, link-with-editor and the editor's tab
+all react, and it needs a home in the application model that a floating overlay does not
+naturally have.
+
+**Give the overlay a context of its own (adopted).** Create an `IEclipseContext` for the overlay
+and activate it while an input field has focus, without the overlay becoming a part as far as the
+part service is concerned. This addresses both gates at once and needs no command set: the
+editor's part-level registrations become unreachable, and its window-level ones evaluate to
+false. The platform's own default handlers for cut, copy, paste and select all then act on the
+focused field, which meets goal 2 without registering anything, while window-scoped commands stay
+reachable. Its cost is that it relies on a divergence between the active-leaf chain and
+`EPartService`'s notion of the active part which E4 does not promise to preserve, that the
+`MPart` it carries exists only in a context and not in the application model, and that shadowing
+`activePartId` also switches off contributions keyed on that variable against the host editor.
+
+### Observed behaviour
+
+With the search field focused in a text editor. The columns are variants described in the
+appendix.
+
+| | Non-handling overrides | Own context, window | Own context, application | **Adopted** |
+|---|---|---|---|---|
+| `textEditorScope` active | yes | no | no | **no** |
+| Ctrl+Delete resolves | yes | no | no | **no** |
+| `edit.undo` / `edit.delete` handler | non-handling override | editor | none | **none** |
+| `edit.copy` / `edit.selectAll` handler | non-handling override | editor | platform default | **platform default** |
+| `deleteNextWord` handler | non-handling override | none | none | **none** |
+| Save / Close / Next Editor / Preferences | ok | ok | ok | **ok** |
+| `IWorkbenchWindow` from leaf | ok | ok | **null** | **ok** |
+| `ISources.activePart` / `activeEditor` | editor | editor | **undefined** / editor | **editor** |
+| Overlay's own bindings and handlers | ok | ok | ok | **ok** |
+
+## Consequences
+
+- The overlay does not reference `AbstractTextEditor`, and nothing in the key handling depends on
+ the editor type. Goal 4 is met for the command infrastructure, though `FindReplaceOverlay`
+ still has two `instanceof StatusTextEditor` checks (target control, colours) and
+ `FindReplaceAction#shouldUseOverlay()` still gates the overlay on `StatusTextEditor`, so the
+ overlay remains text-editor-only until those are addressed separately.
+- Cut, copy, paste and select all work on the overlay's fields through the platform's default
+ handlers, including from the Edit menu.
+- Undo, delete and the word-wise editing and navigation commands resolve to no handler while the
+ overlay has focus, so the native `Text` widget handles those keys.
+- `org.eclipse.ui.workbench.texteditor` gains a dependency on the E4 context and model bundles.
+- Per-focus imperative state is limited to activating and deactivating that one context plus
+ switching which of the two per-field scopes is activated inside it. The overlay's shared scope
+ is activated once, because scopes activated in the overlay's context are active exactly while
+ that context is the active leaf.
+
+## Appendix: variants and dead ends
+
+### Suppressing the editor's commands
+
+**Reflection into `AbstractTextEditor#setActionActivation`, plus nulling the action bars.** On
+focus gained, call the private `setActionActivation(false)` reflectively, and for multi-page
+editors additionally null out the `IActionBars` global action handlers for
+cut/copy/paste/delete/select-all/find. Reverse both on focus lost. Addresses both registration
+sites, but uses reflective private API against goal 5, is hard-coded to `AbstractTextEditor` and
+therefore a no-op for any other editor against goal 4, and covers only the six global action ids,
+not word navigation or undo.
+
+**Non-handling handler overrides.** Collect the commands bound to the ~40 key sequences that SWT
+`Text` handles natively and, for each, activate a handler at the target part's `IHandlerService`
+that returns `isHandled() == false`, scoped by an expression on `ACTIVE_FOCUS_CONTROL`, whose
+rogue-bit priority outranks the editor's activations. Needs no reflection and is not tied to an
+editor class, but each `activateHandler` also sets `handler::
+ * The overlay's input fields sit inside the editor's widget tree, so the editor
+ * remains the active part while a search term is typed, and its key bindings and
+ * handlers would otherwise carry out keys meant for an input field. How that is
+ * prevented is an implementation concern and deliberately not observed here; only
+ * where a command ends up taking effect is.
+ *
+ * Key strokes are delivered by notifying the widget rather than by posting native
+ * events, which still runs the display filter the key binding dispatcher installs
+ * but does not depend on the operating system, so the test also works headless.
+ * The native editing inside the SWT text widget does not happen that way, so the
+ * assertions say what must not reach the editor, plus one control
+ * proving that keys are dispatched at all.
+ *
+ * Unlike {@link FindReplaceOverlayTest}, which uses a bare viewer, this needs a
+ * real editor part: without one there is nothing to arbitrate.
+ */
+public class FindReplaceOverlayInEditorTest {
+
+ private static final String CONTENT = "word one word two"; //$NON-NLS-1$
+
+ /** Tagged onto the widgets under {@link FindReplaceOverlay#ID_DATA_KEY}. */
+ private static final String SEARCH_FIELD = "searchInput"; //$NON-NLS-1$
+
+ private static final String REPLACE_FIELD = "replaceInput"; //$NON-NLS-1$
+
+ private StatusTextEditor editor;
+
+ private Text searchField;
+
+ @BeforeEach
+ void openEditorWithOverlay() throws PartInitException {
+ PlatformUI.getWorkbench().getWorkbenchWindows()[0].getShell().forceActive();
+ IWorkbenchPage page = PlatformUI.getWorkbench().getActiveWorkbenchWindow().getActivePage();
+ editor = (StatusTextEditor) page.openEditor(new TestTextEditorInput(CONTENT), TestTextEditor.ID);
+ processPendingEvents();
+
+ // Opening through the action rather than through its internals also asserts
+ // that an editor of this kind gets the overlay rather than the dialog.
+ new FindReplaceAction(ResourceBundle.getBundle("org.eclipse.ui.texteditor.ConstructedEditorMessages"), //$NON-NLS-1$
+ "Editor.FindReplace.", editor).run(); //$NON-NLS-1$
+ processPendingEvents();
+
+ searchField = focusedInputField("opening the overlay is expected to focus its search field", //$NON-NLS-1$
+ SEARCH_FIELD);
+ }
+
+ @AfterEach
+ void closeEditor() {
+ searchField = null;
+ if (editor != null) {
+ editor.getSite().getPage().closeEditor(editor, false);
+ editor = null;
+ }
+ }
+
+ /**
+ * A key typed into an input field belongs to that field: it must never be
+ * carried out on the editor's document instead.
+ */
+ @Test
+ public void testKeysTypedIntoTheOverlayDoNotReachTheEditor() {
+ focusSearchField();
+ int selectionLengthBefore = editorSelectionLength();
+
+ type(searchField, SWT.MOD1, 'a');
+
+ assertEquals(selectionLengthBefore, editorSelectionLength(),
+ "Select All's key binding must not select the editor's document"); //$NON-NLS-1$
+ assertEquals(CONTENT, documentText(), "the document must not change"); //$NON-NLS-1$
+
+ type(searchField, SWT.MOD1, SWT.DEL);
+
+ assertEquals(CONTENT, documentText(),
+ "delete-next-word's key binding must not delete from the document"); //$NON-NLS-1$
+
+ // Control: a key the overlay does bind must take effect, otherwise the
+ // assertions above would hold simply because no key was dispatched at all.
+ assertTrue(searchField.isVisible(), "precondition: the overlay is open"); //$NON-NLS-1$
+ type(searchField, SWT.NONE, SWT.ESC);
+ assertFalse(searchField.isVisible(), "Escape in the search field must close the overlay"); //$NON-NLS-1$
+ }
+
+ /**
+ * The retargetable global actions must act on the focused input field, so that
+ * Edit > Select All does what the user expects while typing a search term.
+ */
+ @Test
+ public void testSelectAllActsOnTheFocusedInputField() throws Exception {
+ focusSearchField();
+ searchField.setText("abc"); //$NON-NLS-1$
+ searchField.setSelection(0, 0);
+ int editorSelectionBefore = editorSelectionLength();
+
+ executeCommand("org.eclipse.ui.edit.selectAll"); //$NON-NLS-1$
+
+ assertEquals(3, searchField.getSelectionCount(),
+ "Select All must select the text of the focused input field"); //$NON-NLS-1$
+ assertEquals(editorSelectionBefore, editorSelectionLength(),
+ "Select All must not select the editor's document"); //$NON-NLS-1$
+ }
+
+ /**
+ * Enter finds the next match in the search field and replaces in the replace
+ * field, so the two must be told apart although the key is the same.
+ */
+ @Test
+ public void testEnterMeansSomethingElseInEachInputField() throws Exception {
+ focusSearchField();
+ searchField.setText("word"); //$NON-NLS-1$
+ processPendingEvents();
+ int firstMatch = editorSelectionOffset();
+
+ type(searchField, SWT.NONE, SWT.CR);
+
+ assertNotEquals(firstMatch, editorSelectionOffset(),
+ "Enter in the search field must move on to the next match"); //$NON-NLS-1$
+ assertEquals(CONTENT, documentText(), "finding must not change the document"); //$NON-NLS-1$
+
+ Text replaceField = showReplaceField();
+ replaceField.setText("X"); //$NON-NLS-1$
+ processPendingEvents();
+
+ type(replaceField, SWT.NONE, SWT.CR);
+
+ assertNotEquals(CONTENT, documentText(),
+ "Enter in the replace field must replace the current match in the document"); //$NON-NLS-1$
+ }
+
+ /**
+ * Commands of the surrounding window, Save among them, must stay executable
+ * while an input field has focus: only the editor's own are out of place there.
+ */
+ @Test
+ public void testWindowCommandsStillWorkWhileTheOverlayHasFocus() throws Exception {
+ document().set("edited"); //$NON-NLS-1$
+ processPendingEvents();
+ assertTrue(editor.isDirty(), "precondition: the editor has unsaved changes"); //$NON-NLS-1$
+ focusSearchField();
+
+ executeCommand("org.eclipse.ui.file.save"); //$NON-NLS-1$
+
+ assertFalse(editor.isDirty(), "Save must still reach the editor while the overlay has focus"); //$NON-NLS-1$
+ }
+
+ /**
+ * The overlay is not a part of its own as far as the workbench is concerned, so
+ * views tracking the active part or the selection must see nothing change.
+ */
+ @Test
+ public void testTheEditorStaysTheActivePartWhileTheOverlayHasFocus() {
+ focusSearchField();
+ IWorkbenchPage page = PlatformUI.getWorkbench().getActiveWorkbenchWindow().getActivePage();
+
+ assertEquals(editor, page.getActiveEditor(), "active editor"); //$NON-NLS-1$
+ assertEquals(editor, page.getActivePart(), "active part"); //$NON-NLS-1$
+ assertNotNull(PlatformUI.getWorkbench().getActiveWorkbenchWindow().getSelectionService().getSelection(),
+ "the selection service must keep reporting the editor's selection"); //$NON-NLS-1$
+ }
+
+ /**
+ * The editor acts on its keys again once the overlay no longer has focus. Only
+ * asserting the other direction would be satisfied by an overlay that disabled
+ * the editor's keys for good.
+ */
+ @Test
+ public void testTheEditorActsOnKeysAgainOnceTheOverlayLostFocus() {
+ focusSearchField();
+ editor.setFocus();
+ processPendingEvents();
+
+ type(editorWidget(), SWT.MOD1, 'a');
+
+ assertEquals(CONTENT.length(), editorSelectionLength(),
+ "Select All must apply to the document again once the editor has focus"); //$NON-NLS-1$
+ }
+
+ private void focusSearchField() {
+ // Focus the editor first, so that focusing the search field is a real
+ // transition rather than a no-op on an already focused control.
+ editor.setFocus();
+ processPendingEvents();
+ searchField.forceFocus();
+ processPendingEvents();
+ assertTrue(searchField.isFocusControl(), "the search field is expected to have focus"); //$NON-NLS-1$
+ }
+
+ /** Reveals the replace field through the overlay's own command, which focuses it. */
+ private Text showReplaceField() throws Exception {
+ executeCommand(FindReplaceOverlayCommandSupport.CMD_TOGGLE_REPLACE);
+ return focusedInputField("showing the replace field is expected to focus it", //$NON-NLS-1$
+ REPLACE_FIELD);
+ }
+
+ private static Text focusedInputField(String message, String expectedId) {
+ Text field = assertInstanceOf(Text.class, Display.getCurrent().getFocusControl(), message);
+ assertEquals(expectedId, field.getParent().getData(FindReplaceOverlay.ID_DATA_KEY), message);
+ return field;
+ }
+
+ private static void executeCommand(String commandId) throws Exception {
+ PlatformUI.getWorkbench().getService(IHandlerService.class).executeCommand(commandId, null);
+ processPendingEvents();
+ }
+
+ /**
+ * Delivers a key stroke the way the workbench sees it, through the display
+ * filter the key binding dispatcher installs.
+ */
+ private static void type(org.eclipse.swt.widgets.Control target, int stateMask, int keyCode) {
+ Event keyEvent = new Event();
+ keyEvent.widget = target;
+ keyEvent.type = SWT.KeyDown;
+ keyEvent.stateMask = stateMask;
+ keyEvent.keyCode = keyCode;
+ // Control, and only Control, turns a letter into a control character. On macOS
+ // SWT.MOD1 is Command, which does not.
+ keyEvent.character = (char) ((stateMask & SWT.CTRL) != 0 && Character.isLetter(keyCode)
+ ? Character.toUpperCase(keyCode) - 64
+ : keyCode);
+ target.notifyListeners(SWT.KeyDown, keyEvent);
+ processPendingEvents();
+ }
+
+ private org.eclipse.swt.widgets.Control editorWidget() {
+ return editor.getAdapter(org.eclipse.jface.text.ITextViewer.class).getTextWidget();
+ }
+
+ private IDocument document() {
+ return editor.getDocumentProvider().getDocument(editor.getEditorInput());
+ }
+
+ private String documentText() {
+ return document().get();
+ }
+
+ private ITextSelection editorSelection() {
+ return (ITextSelection) editor.getSelectionProvider().getSelection();
+ }
+
+ private int editorSelectionLength() {
+ return editorSelection().getLength();
+ }
+
+ private int editorSelectionOffset() {
+ return editorSelection().getOffset();
+ }
+
+ private static void processPendingEvents() {
+ Display display = Display.getCurrent();
+ while (display != null && !display.isDisposed() && display.readAndDispatch()) {
+ // keep dispatching
+ }
+ }
+
+}
diff --git a/tests/org.eclipse.ui.workbench.texteditor.tests/src/org/eclipse/ui/internal/findandreplace/overlay/TestTextEditor.java b/tests/org.eclipse.ui.workbench.texteditor.tests/src/org/eclipse/ui/internal/findandreplace/overlay/TestTextEditor.java
new file mode 100644
index 00000000000..a7fd00c5767
--- /dev/null
+++ b/tests/org.eclipse.ui.workbench.texteditor.tests/src/org/eclipse/ui/internal/findandreplace/overlay/TestTextEditor.java
@@ -0,0 +1,79 @@
+/*******************************************************************************
+ * Copyright (c) 2026 Vector Informatik GmbH and others.
+ *
+ * This program and the accompanying materials
+ * are made available under the terms of the Eclipse Public License 2.0
+ * which accompanies this distribution, and is available at
+ * https://www.eclipse.org/legal/epl-2.0/
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ *******************************************************************************/
+package org.eclipse.ui.internal.findandreplace.overlay;
+
+import org.eclipse.core.runtime.IProgressMonitor;
+
+import org.eclipse.jface.operation.IRunnableContext;
+
+import org.eclipse.jface.text.Document;
+import org.eclipse.jface.text.IDocument;
+import org.eclipse.jface.text.source.IAnnotationModel;
+
+import org.eclipse.ui.texteditor.AbstractDocumentProvider;
+import org.eclipse.ui.texteditor.StatusTextEditor;
+
+/**
+ * A minimal text editor for tests that need a real editor part rather than a bare
+ * viewer, without pulling in the workspace or the IDE: its input is held in memory
+ * and nothing is ever saved. Contributed by this bundle's {@code plugin.xml} and
+ * opened by {@link #ID}, with no file name or content type association, so it is
+ * never offered for a real file.
+ */
+public class TestTextEditor extends StatusTextEditor {
+
+ public static final String ID = "org.eclipse.ui.workbench.texteditor.tests.testTextEditor"; //$NON-NLS-1$
+
+ public TestTextEditor() {
+ setDocumentProvider(new InMemoryDocumentProvider());
+ // Normally established by AbstractDecoratedTextEditor, which belongs to a higher
+ // layer. This scope is what makes an editor a text editor for key bindings.
+ setKeyBindingScopes(new String[] { "org.eclipse.ui.textEditorScope" }); //$NON-NLS-1$
+ }
+
+ private static final class InMemoryDocumentProvider extends AbstractDocumentProvider {
+
+ @Override
+ protected IDocument createDocument(Object element) {
+ return new Document(element instanceof TestTextEditorInput input ? input.getContent() : ""); //$NON-NLS-1$
+ }
+
+ @Override
+ protected IAnnotationModel createAnnotationModel(Object element) {
+ return null;
+ }
+
+ @Override
+ protected void doSaveDocument(IProgressMonitor monitor, Object element, IDocument document,
+ boolean overwrite) {
+ // nothing to save, the document only lives for the duration of a test
+ }
+
+ @Override
+ protected IRunnableContext getOperationRunner(IProgressMonitor monitor) {
+ return null;
+ }
+
+ // AbstractDocumentProvider defaults to read-only, for which the overlay hides
+ // its replace field, unlike the editors it is actually used with.
+
+ @Override
+ public boolean isReadOnly(Object element) {
+ return false;
+ }
+
+ @Override
+ public boolean isModifiable(Object element) {
+ return true;
+ }
+ }
+
+}
diff --git a/tests/org.eclipse.ui.workbench.texteditor.tests/src/org/eclipse/ui/internal/findandreplace/overlay/TestTextEditorInput.java b/tests/org.eclipse.ui.workbench.texteditor.tests/src/org/eclipse/ui/internal/findandreplace/overlay/TestTextEditorInput.java
new file mode 100644
index 00000000000..0e094e9d7ff
--- /dev/null
+++ b/tests/org.eclipse.ui.workbench.texteditor.tests/src/org/eclipse/ui/internal/findandreplace/overlay/TestTextEditorInput.java
@@ -0,0 +1,63 @@
+/*******************************************************************************
+ * Copyright (c) 2026 Vector Informatik GmbH and others.
+ *
+ * This program and the accompanying materials
+ * are made available under the terms of the Eclipse Public License 2.0
+ * which accompanies this distribution, and is available at
+ * https://www.eclipse.org/legal/epl-2.0/
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ *******************************************************************************/
+package org.eclipse.ui.internal.findandreplace.overlay;
+
+import org.eclipse.jface.resource.ImageDescriptor;
+
+import org.eclipse.ui.IEditorInput;
+import org.eclipse.ui.IPersistableElement;
+
+/**
+ * Input for {@link TestTextEditor}, holding its content in memory.
+ */
+public class TestTextEditorInput implements IEditorInput {
+
+ private final String content;
+
+ public TestTextEditorInput(String content) {
+ this.content = content;
+ }
+
+ public String getContent() {
+ return content;
+ }
+
+ @Override
+ public boolean exists() {
+ return true;
+ }
+
+ @Override
+ public ImageDescriptor getImageDescriptor() {
+ return ImageDescriptor.getMissingImageDescriptor();
+ }
+
+ @Override
+ public String getName() {
+ return "Test"; //$NON-NLS-1$
+ }
+
+ @Override
+ public IPersistableElement getPersistable() {
+ return null;
+ }
+
+ @Override
+ public String getToolTipText() {
+ return getName();
+ }
+
+ @Override
+ public