From 0a008fe4efedbdb09b456ace40098b4b0aad886e Mon Sep 17 00:00:00 2001 From: Tryanks Date: Mon, 21 Sep 2026 09:32:36 +0800 Subject: [PATCH] Handle IME edit-panel actions (select all, copy, cut, paste) on Android BaseInputConnection.performContextMenuAction is a no-op that returns false, so the select-all / paste / copy / cut buttons on Android keyboards' edit panels never reached the draft. Implement it on GpuiInputConnection against the shared Editable and publish the resulting state like any other IME edit. Clipboard access goes through a small seam so the instrumented test covers paste without touching the system clipboard. --- .../tcode/GpuiInputConnectionTest.java | 36 +++++++++++++++++-- .../java/com/tryanks/tcode/GpuiActivity.java | 6 +++- .../tryanks/tcode/GpuiInputConnection.java | 30 +++++++++++++++- 3 files changed, 67 insertions(+), 5 deletions(-) diff --git a/crates/android/host/app/src/androidTest/java/com/tryanks/tcode/GpuiInputConnectionTest.java b/crates/android/host/app/src/androidTest/java/com/tryanks/tcode/GpuiInputConnectionTest.java index 7c28d1ca..6d4833c1 100644 --- a/crates/android/host/app/src/androidTest/java/com/tryanks/tcode/GpuiInputConnectionTest.java +++ b/crates/android/host/app/src/androidTest/java/com/tryanks/tcode/GpuiInputConnectionTest.java @@ -9,12 +9,42 @@ /** Exercises the production connection against Android's real Editable/InputConnection code. */ @SuppressWarnings("deprecation") public final class GpuiInputConnectionTest extends AndroidTestCase { + private static final class FakeClipboard implements GpuiInputConnection.Clipboard { + String text; + @Override public String read() { return text; } + @Override public void write(String value) { text = value; } + } + + public void testImeEditPanelActionsEditTheDraftAndClipboard() { + SpannableStringBuilder text = new SpannableStringBuilder("hello 世界"); + Selection.setSelection(text, 0); + ArrayList sent = new ArrayList<>(); + FakeClipboard clipboard = new FakeClipboard(); + GpuiInputConnection input = + new GpuiInputConnection(new View(getContext()), text, clipboard, sent::add); + assertFalse(input.performContextMenuAction(android.R.id.paste)); + assertFalse(input.performContextMenuAction(android.R.id.copy)); + assertTrue(sent.isEmpty()); + assertTrue(input.performContextMenuAction(android.R.id.selectAll)); + assertEquals(new GpuiInputConnection.State("hello 世界", 0, 8, -1, -1), sent.get(0)); + assertTrue(input.performContextMenuAction(android.R.id.copy)); + assertEquals("hello 世界", clipboard.text); + assertEquals(1, sent.size()); + input.setSelection(6, 8); + assertTrue(input.performContextMenuAction(android.R.id.cut)); + assertEquals("世界", clipboard.text); + assertEquals(new GpuiInputConnection.State("hello ", 6, 6, -1, -1), sent.get(sent.size() - 1)); + clipboard.text = "again"; + assertTrue(input.performContextMenuAction(android.R.id.paste)); + assertEquals(new GpuiInputConnection.State("hello again", 11, 11, -1, -1), sent.get(sent.size() - 1)); + } + public void testAutocompletePublishesTheCompleteReplacementOnce() { for (boolean composing : new boolean[] {false, true}) { SpannableStringBuilder text = new SpannableStringBuilder("an exmple here"); Selection.setSelection(text, 9); ArrayList sent = new ArrayList<>(); - GpuiInputConnection input = new GpuiInputConnection(new View(getContext()), text, sent::add); + GpuiInputConnection input = new GpuiInputConnection(new View(getContext()), text, new FakeClipboard(), sent::add); input.beginBatchEdit(); if (composing) { input.setComposingRegion(3, 9); @@ -33,7 +63,7 @@ public void testSelectionAndUnicodeDeletionReachTheComposer() { SpannableStringBuilder text = new SpannableStringBuilder("😀word中"); Selection.setSelection(text, text.length()); ArrayList sent = new ArrayList<>(); - GpuiInputConnection input = new GpuiInputConnection(new View(getContext()), text, sent::add); + GpuiInputConnection input = new GpuiInputConnection(new View(getContext()), text, new FakeClipboard(), sent::add); input.setSelection(2, 6); assertEquals(new GpuiInputConnection.State("😀word中", 2, 6, -1, -1), sent.get(0)); input.commitText("example", 0); @@ -48,7 +78,7 @@ public void testRetiredConnectionCannotChangeTheNextDraft() { SpannableStringBuilder text = new SpannableStringBuilder("old"); Selection.setSelection(text, text.length()); ArrayList sent = new ArrayList<>(); - GpuiInputConnection input = new GpuiInputConnection(new View(getContext()), text, sent::add); + GpuiInputConnection input = new GpuiInputConnection(new View(getContext()), text, new FakeClipboard(), sent::add); input.closeConnection(); text.replace(0, text.length(), "new"); input.commitText("stale suggestion", 1); diff --git a/crates/android/host/app/src/main/java/com/tryanks/tcode/GpuiActivity.java b/crates/android/host/app/src/main/java/com/tryanks/tcode/GpuiActivity.java index e8016a22..0d7df641 100644 --- a/crates/android/host/app/src/main/java/com/tryanks/tcode/GpuiActivity.java +++ b/crates/android/host/app/src/main/java/com/tryanks/tcode/GpuiActivity.java @@ -441,7 +441,11 @@ public InputConnection onCreateInputConnection(EditorInfo outAttrs) { outAttrs.initialSelStart = Selection.getSelectionStart(editable); outAttrs.initialSelEnd = Selection.getSelectionEnd(editable); if (connection != null) connection.closeConnection(); - connection = new GpuiInputConnection(this, editable, this::publishInput) { + GpuiInputConnection.Clipboard clipboard = new GpuiInputConnection.Clipboard() { + @Override public String read() { return gpuiReadClipboard(); } + @Override public void write(String text) { gpuiWriteClipboard(text); } + }; + connection = new GpuiInputConnection(this, editable, clipboard, this::publishInput) { @Override public boolean commitText(CharSequence text, int cursor) { if (getEditable() == null) return false; if (!textEditable) nativeCommitText(text.toString()); diff --git a/crates/android/host/app/src/main/java/com/tryanks/tcode/GpuiInputConnection.java b/crates/android/host/app/src/main/java/com/tryanks/tcode/GpuiInputConnection.java index 57d90be8..012ffea2 100644 --- a/crates/android/host/app/src/main/java/com/tryanks/tcode/GpuiInputConnection.java +++ b/crates/android/host/app/src/main/java/com/tryanks/tcode/GpuiInputConnection.java @@ -9,15 +9,21 @@ /** Publish Android's completed edits, including selection and composing-span changes. */ class GpuiInputConnection extends BaseInputConnection { record State(String text, int selectionStart, int selectionEnd, int composingStart, int composingEnd) {} + interface Clipboard { + String read(); + void write(String text); + } private final Editable editable; + private final Clipboard clipboard; private final Consumer changed; private int batchDepth; private boolean closed; private State lastState; - GpuiInputConnection(View view, Editable editable, Consumer changed) { + GpuiInputConnection(View view, Editable editable, Clipboard clipboard, Consumer changed) { super(view, true); this.editable = editable; + this.clipboard = clipboard; this.changed = changed; rememberState(); } @@ -48,6 +54,28 @@ record State(String text, int selectionStart, int selectionEnd, int composingSta return result; } + // IME edit panels (select all, copy, cut, paste) arrive here; BaseInputConnection ignores them. + @Override public boolean performContextMenuAction(int id) { + if (closed) return false; + if (id == android.R.id.selectAll) return setSelection(0, editable.length()); + if (id == android.R.id.paste) { + String text = clipboard.read(); + return text != null && commitText(text, 1); + } + int start = Math.min(Selection.getSelectionStart(editable), Selection.getSelectionEnd(editable)); + int end = Math.max(Selection.getSelectionStart(editable), Selection.getSelectionEnd(editable)); + if ((id != android.R.id.copy && id != android.R.id.cut) || start < 0 || start == end) return false; + clipboard.write(editable.subSequence(start, end).toString()); + if (id == android.R.id.cut) { + beginBatchEdit(); + removeComposingSpans(editable); + editable.delete(start, end); + Selection.setSelection(editable, start); + endBatchEdit(); + } + return true; + } + void publishChanges() { if (closed || batchDepth != 0) return; State state = readState();