From 424b75d80c3e2ddfb21506e4dc8bf381161136cd Mon Sep 17 00:00:00 2001 From: SHAWNERZZ Date: Mon, 8 Jun 2026 22:44:18 -0700 Subject: [PATCH] fix(two-thumb): clear stale stroke buffer so re-typed words don't accumulate geometry (#7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a multi-part word (tap a stem, then swipe the rest — e.g. "do" + swipe -> "doesn't") mis-recognizes and the user backspaces to retry, the previous stroke geometry stuck around and each retry built an ever-longer garbage word. Root cause: the merged stroke lives in WordComposer.mInputPointers, which is intentionally NOT cleared by reset()/backspace (so a gesture commit can re-feed it via setBatchInputWord), and ResizableIntArray.addAt(index) overwrites in place WITHOUT shrinking the length. So after a word is abandoned via backspace, the raw buffer keeps its length; re-typing the stem overwrites only the leading indices and the stale tail survives. The next swipe then snapshots the whole stale buffer as the merged-trail extend base, producing an ever-longer word that grows with each retry. PR #61 cleared the extend-base flag and mLiveStroke, but not the mInputPointers buffer the base is re-armed from, so the leak persisted through that fix. Fix: reset mInputPointers when the FIRST tap of a fresh word lands (newIndex == 0 in WordComposer.applyProcessedEvent). This covers every abandonment path (whole-word / batch / per-char-to-empty / cursor move), never touches the gesture-commit flow (batch mode is guarded above), preserves multi-part composition (a tap EXTENDING a gesture has newIndex > 0), and can't disturb mid-word backspace coordinates for normal typing. Space remains the word-submission point. Test: WordComposerTest.testFreshWordResetsStaleInputPointers asserts a stale 5-point buffer surviving reset() is dropped to 1 point on the first fresh tap. Full :app:testOfflineDebugUnitTest shows zero new failures vs the main baseline (same 12 pre-existing/environment-dependent failures). Fixes #7. Co-Authored-By: Claude Opus 4.8 --- .../keyboard/latin/WordComposer.java | 12 +++++++ .../keyboard/latin/WordComposerTest.java | 34 +++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/app/src/main/java/helium314/keyboard/latin/WordComposer.java b/app/src/main/java/helium314/keyboard/latin/WordComposer.java index a7e1b8034..097b74747 100644 --- a/app/src/main/java/helium314/keyboard/latin/WordComposer.java +++ b/app/src/main/java/helium314/keyboard/latin/WordComposer.java @@ -208,6 +208,18 @@ public void applyProcessedEvent(final Event event, final boolean keepCursorPosit // shouldn't be overridden by the "typed key" coordinates // (See {@link #setBatchInputWord}). if (!mIsBatchMode) { + // Two-thumb / multi-part: the FIRST tap of a fresh word resets the raw stroke + // buffer. mInputPointers is intentionally NOT cleared by reset() or backspace + // (it must survive setBatchInputWord so a gesture commit can re-feed it), and + // addPointerAt overwrites in place WITHOUT shrinking the length — so a word + // abandoned via backspace leaves a stale tail. Without this, re-typing the word + // and swiping would snapshot that stale buffer as the merged-trail extend base + // and build an ever-longer garbage word. Clearing only at index 0 (a brand-new + // word) drops the stale geometry while leaving mid-word taps (newIndex > 0, incl. + // a tap extending a gesture) and the batch-mode gesture trail untouched. + if (newIndex == 0) { + mInputPointers.reset(); + } // TODO: Set correct pointer id and time mInputPointers.addPointerAt(newIndex, keyX, keyY, 0, 0); } diff --git a/app/src/test/java/helium314/keyboard/latin/WordComposerTest.java b/app/src/test/java/helium314/keyboard/latin/WordComposerTest.java index e9c867895..3f2f25e7f 100644 --- a/app/src/test/java/helium314/keyboard/latin/WordComposerTest.java +++ b/app/src/test/java/helium314/keyboard/latin/WordComposerTest.java @@ -130,6 +130,40 @@ public void testExtendBatchInputBaseMergesAndRetimes() { assertTrue("base must end before the new gesture begins", times[1] < times[2]); } + // #7: the raw stroke buffer (mInputPointers) must not bleed into a fresh word. It is + // intentionally NOT cleared by reset() (so a gesture commit can re-feed it via + // setBatchInputWord), and addPointerAt overwrites in place WITHOUT shrinking the length — so + // after a word is abandoned via backspace, a stale tail survives. The next swipe would then + // snapshot that stale buffer as the merged-trail extend base and build an ever-longer garbage + // word (the "swipes stick around and grow" bug). The first tap of a new word must start clean. + @Test + public void testFreshWordResetsStaleInputPointers() { + final WordComposer wordComposer = new WordComposer(); + + // Geometry left by a prior (gesture) word: a 5-point stroke. + final InputPointers stale = new InputPointers(16); + stale.addPointer(1, 1, 0, 0); + stale.addPointer(2, 2, 0, 10); + stale.addPointer(3, 3, 0, 20); + stale.addPointer(4, 4, 0, 30); + stale.addPointer(5, 5, 0, 40); + wordComposer.setBatchInputPointers(stale); + assertEquals(5, wordComposer.getInputPointers().getPointerSize()); + + // Word abandoned (e.g. backspaced). reset() deliberately does NOT clear mInputPointers, + // so the stale 5-point buffer survives — this is the premise of the bug. + wordComposer.reset(); + assertEquals(5, wordComposer.getInputPointers().getPointerSize()); + + // Start a fresh word with a single tapped letter (index 0). The stale buffer must be + // dropped, leaving exactly one point — not 5 (pre-fix: addAt(0) overwrites in place and + // the length stays 5, which is what the following swipe then merged with). + final int[] codePoints = new int[] { 'a' }; + final int[] coordinates = CoordinateUtils.newCoordinateArray(1, 50, 60); + wordComposer.setComposingWord(codePoints, coordinates); + assertEquals(1, wordComposer.getInputPointers().getPointerSize()); + } + @Test public void testExtendBatchInputBaseEmptyIsNoOp() { final WordComposer wordComposer = new WordComposer();