diff --git a/app/src/main/java/helium314/keyboard/latin/inputlogic/InputLogic.java b/app/src/main/java/helium314/keyboard/latin/inputlogic/InputLogic.java index d925595e5..af91356e7 100644 --- a/app/src/main/java/helium314/keyboard/latin/inputlogic/InputLogic.java +++ b/app/src/main/java/helium314/keyboard/latin/inputlogic/InputLogic.java @@ -159,6 +159,14 @@ public final class InputLogic { private boolean mInCombiningMode; private boolean mCombiningWordHasGestureFragment; + // Live-converge (multi-part, opt-in PREF_MULTIPART_RERECOGNIZE_TAPS): the accumulated raw + // pointer trail of the CURRENT word — the latest gesture fragment's points plus any tap + // key-centers appended by {@link #tryLiveConvergeTap}. This must persist across fragments + // because {@link WordComposer#setBatchInputWord} resets the composer's own mInputPointers + // on every commit, so without a separate copy a tap arriving after a swipe would have no + // stroke to extend. Captured in {@link #onUpdateTailBatchInputCompleted}; cleared whenever + // the composing word ends ({@link #resetComposingState}, {@link #commitChosenWord}). + private final InputPointers mLiveStroke = new InputPointers(48); // Keeps track of most recently inserted text (multi-character key) for // reverting @@ -1995,6 +2003,14 @@ private void handleNonSeparatorEvent(final Event event, final SettingsValues set // typing the next word — the strip-on-next-punctuation one-shot no longer applies. mAutospaceJustWritten = false; final int codePoint = event.getCodePoint(); + // Live-converge (#1.7): if we're building a word that already contains a swipe and the + // opt-in is on, route this tap through the recognizer together with the accumulated + // stroke instead of appending it literally. Returns false (and we fall through to the + // normal literal path) when it doesn't apply or recognition is unavailable. + if (tryLiveConvergeTap(event, settingsValues)) { + inputTransaction.setRequiresUpdateSuggestions(); + return; + } // TODO: refactor this method to stop flipping isComposingWord around all the // time, and // make it shorter (possibly cut into several pieces). Also factor @@ -2336,6 +2352,14 @@ private void handleBackspaceEvent(final Event event, final InputTransaction inpu // explicitly retracting input; we don't want the timer to fire mid-correction. cancelCombiningMode(); clearOneShotSpaceActionAndNotifyIfChanged(); + // Live-converge (#1.7): a backspace invalidates the accumulated gesture stroke — we + // can't reliably trim the raw trail to match a partially-deleted word, and leaving it + // intact would make the NEXT swipe/tap merge with stale geometry (e.g. delete "ing" + // from "thing", swipe it again, and the recognizer keeps resolving ever-growing words + // that still start with "th"). Dropping it means a re-done word starts from a clean + // stroke. The whole-word and batch delete branches below call mWordComposer.reset() + // directly rather than resetComposingState(), so clear here to cover every path. + mLiveStroke.reset(); // Typing-insight overlay: a backspace edits/clears the gesture word, so its trail is now // stale. Drop it so it doesn't linger. final MainKeyboardView backspaceKv = KeyboardSwitcher.getInstance().getMainKeyboardView(); @@ -3006,6 +3030,74 @@ && isInlineEmojiSearchAction()) { } } + /** + * Live-converge (#1.7): run gesture recognition on the CURRENT {@link #mWordComposer} batch + * state synchronously. Uses the same handler + {@link AsyncResultHolder} mechanism as + * {@link #performUpdateSuggestionStripSync} so the native recognizer runs on the suggestions + * thread and the call is bounded by {@link Constants#GET_SUGGESTED_WORDS_TIMEOUT}. Returns + * {@code null} on timeout / error (caller falls back to a literal tap). + */ + @Nullable + private SuggestedWords getBatchSuggestionsSync() { + final AsyncResultHolder holder = new AsyncResultHolder<>("LiveConverge"); + mInputLogicHandler.getSuggestedWords(() -> getSuggestedWords( + SuggestedWords.INPUT_STYLE_TAIL_BATCH, SuggestedWords.NOT_A_SEQUENCE_NUMBER, + holder::set)); + return holder.get(null, Constants.GET_SUGGESTED_WORDS_TIMEOUT); + } + + /** + * Live-converge (#1.7, opt-in {@link Settings#PREF_MULTIPART_RERECOGNIZE_TAPS}): when the + * user is building a word that already contains a gesture fragment, treat a tapped letter as + * a one-point continuation of the accumulated stroke and re-recognize the WHOLE word, rather + * than literally appending it to a (possibly mis-resolved) fragment. This makes a slow + * tap-after-swipe behave like a fast one — swipe "th" then tap "e" yields "the". + * + *

Taps are recognizer hints here, not exact letters; pure-tap words are excluded so normal + * typing stays exact. Returns {@code true} if it handled the tap (composing word replaced); + * {@code false} to fall through to the normal literal-append path — feature off, guards unmet, + * no key coordinates, or recognition unavailable (the tap must never be lost). + */ + private boolean tryLiveConvergeTap(final Event event, final SettingsValues settingsValues) { + if (!settingsValues.mMultipartRerecognizeTaps) return false; + if (!settingsValues.isMultipartComposeActive()) return false; + if (!mWordComposer.isComposingWord() + || mWordComposer.isCursorFrontOrMiddleOfComposingWord()) return false; + // Only for words that already contain a swipe — pure-tap words stay exact literal typing. + if (!mCombiningWordHasGestureFragment && !mWordComposer.isBatchMode()) return false; + final int codePoint = event.getCodePoint(); + if (!settingsValues.isWordCodePoint(codePoint)) return false; + final int x = event.getX(); + final int y = event.getY(); + if (x < 0 || y < 0) return false; // no real key coordinates (hardware / programmatic) + if (mLiveStroke.getPointerSize() == 0) return false; // nothing to extend + // Feed (accumulated stroke + this tap's key center) to the recognizer as one stroke; the + // extend-base re-timing in WordComposer makes them look like a single continuous glide. + final InputPointers tap = new InputPointers(1); + // Time anchor for the tap: a fixed positive value, not SystemClock (which, cast to int, + // can wrap negative after long uptime). WordComposer#setBatchInputPointers re-times the + // base RELATIVE to this anchor, so only its positivity and the synthetic inter-point + // gaps matter to the recognizer — the absolute value is irrelevant. Large enough that + // the re-timed base (anchor - gap - n*interval) stays positive for any real stem. + tap.addPointer(x, y, 0, 1_000_000); + mWordComposer.setExtendBatchInputBase(mLiveStroke); + mWordComposer.setBatchInputPointers(tap); + final SuggestedWords suggestedWords = getBatchSuggestionsSync(); + if (suggestedWords == null || suggestedWords.isEmpty()) { + // Recognition unavailable — clear the merge and let the caller append the tap as a + // literal letter so it is never lost. The typed-word cache is untouched above, so the + // composing word is unchanged. + mWordComposer.setExtendBatchInputBase(null); + return false; + } + // Reuse the gesture consumer. The extend-base is still set, so + // onUpdateTailBatchInputCompleted treats the recognizer output as the WHOLE word + // (usedMergedTrail == true) and replaces the composing word without re-concatenating the + // previous text. It also re-snapshots mLiveStroke (incl. this tap) for the next fragment. + onUpdateTailBatchInputCompleted(settingsValues, suggestedWords, KeyboardSwitcher.getInstance()); + return true; + } + /** * Check if the cursor is touching a word. If so, restart suggestions on this * word, else @@ -3453,6 +3545,8 @@ private void resetComposingState(final boolean alsoResetLastComposedWord) { // Two-thumb typing (#1.1): composing word is wiped — drop the matching boundaries. clearFragmentBoundaries(); mCombiningWordHasGestureFragment = false; + // Live-converge (#1.7): the word is gone, so its accumulated stroke is stale. + mLiveStroke.reset(); // Combining mode is keyed on a composing word existing; if we're wiping it, the // pending timer would commit nothing useful, so cancel. cancelCombiningMode(); @@ -3808,6 +3902,13 @@ public void onUpdateTailBatchInputCompleted(final SettingsValues settingsValues, } } enterInlineEmojiSearchIfNeeded(batchInputText.codePointAt(0), settingsValues); + // Live-converge (#1.7): snapshot the stroke geometry NOW, before setBatchInputWord + // resets mInputPointers, so a tap arriving later (tryLiveConvergeTap) can re-feed the + // accumulated stroke to the recognizer. getInputPointers() here holds this gesture's + // (merged) trail. + if (settingsValues.mMultipartRerecognizeTaps) { + mLiveStroke.set(mWordComposer.getInputPointers()); + } mWordComposer.setBatchInputWord(composedText); setComposingTextInternal(composedText, 1); if (extendExistingCompose) { @@ -4074,6 +4175,8 @@ private void commitChosenWord(final SettingsValues settingsValues, final String // boundaries are now stale and would point past the end of the (empty) word. clearFragmentBoundaries(); mCombiningWordHasGestureFragment = false; + // Live-converge (#1.7): word committed — its accumulated stroke no longer applies. + mLiveStroke.reset(); if (DebugFlags.DEBUG_ENABLED) { long runTimeMillis = System.currentTimeMillis() - startTimeMillis; Log.d(TAG, "commitChosenWord() : " + runTimeMillis + " ms to run " @@ -4233,6 +4336,9 @@ private void setComposingTextInternalWithBackgroundColor(final CharSequence newC // inconsistency in set and found composing text, better cancel composing // (should be restarted automatically) mWordComposer.reset(); + // Live-converge (#1.7): composing was force-cancelled due to a desync — the stored + // stroke no longer matches anything on screen, so drop it. + mLiveStroke.reset(); } } diff --git a/app/src/main/java/helium314/keyboard/latin/settings/Defaults.kt b/app/src/main/java/helium314/keyboard/latin/settings/Defaults.kt index 96b40bd5e..b948de0b1 100644 --- a/app/src/main/java/helium314/keyboard/latin/settings/Defaults.kt +++ b/app/src/main/java/helium314/keyboard/latin/settings/Defaults.kt @@ -156,6 +156,7 @@ object Defaults { const val PREF_MULTIPART_AUTO_EXTEND_IN_COMBINING = true const val PREF_MULTIPART_FULL_WORD_SUGGESTIONS = true const val PREF_MULTIPART_TAP_SEED_GESTURE = true + const val PREF_MULTIPART_RERECOGNIZE_TAPS = false const val PREF_SHOW_SETUP_WIZARD_ICON = true const val PREF_USE_CONTACTS = false const val PREF_USE_APPS = false diff --git a/app/src/main/java/helium314/keyboard/latin/settings/Settings.java b/app/src/main/java/helium314/keyboard/latin/settings/Settings.java index 7a0d51439..1b07fb3e7 100644 --- a/app/src/main/java/helium314/keyboard/latin/settings/Settings.java +++ b/app/src/main/java/helium314/keyboard/latin/settings/Settings.java @@ -178,6 +178,11 @@ public final class Settings implements SharedPreferences.OnSharedPreferenceChang // prepend a synthetic input point at the tail of the composing word so the gesture // recognizer treats it as a continuation. Helps tap-then-swipe joins land sensibly. public static final String PREF_MULTIPART_TAP_SEED_GESTURE = "multipart_tap_seed_gesture"; + // Live-converge (opt-in): while building a word that already contains a swipe, route a + // tapped letter through the gesture recognizer together with the accumulated stroke and + // re-recognize the whole word, instead of literally appending it to a (possibly + // mis-resolved) fragment. Makes a slow tap-after-swipe behave like a fast one. Default off. + public static final String PREF_MULTIPART_RERECOGNIZE_TAPS = "multipart_rerecognize_taps"; public static final String PREF_SHOW_SETUP_WIZARD_ICON = "show_setup_wizard_icon"; public static final String PREF_USE_CONTACTS = "use_contacts"; public static final String PREF_USE_APPS = "use_apps"; diff --git a/app/src/main/java/helium314/keyboard/latin/settings/SettingsValues.java b/app/src/main/java/helium314/keyboard/latin/settings/SettingsValues.java index f823045d8..e0081fac6 100644 --- a/app/src/main/java/helium314/keyboard/latin/settings/SettingsValues.java +++ b/app/src/main/java/helium314/keyboard/latin/settings/SettingsValues.java @@ -144,6 +144,7 @@ public class SettingsValues { public final boolean mMultipartAutoExtendInCombining; public final boolean mMultipartFullWordSuggestions; public final boolean mMultipartTapSeedGesture; + public final boolean mMultipartRerecognizeTaps; public final boolean mSlidingKeyInputPreviewEnabled; public final int mKeyLongpressTimeout; public final boolean mEnableEmojiAltPhysicalKey; @@ -391,6 +392,9 @@ public SettingsValues(final Context context, final SharedPreferences prefs, fina mMultipartTapSeedGesture = nonNormalTwoThumbSpacing || prefs.getBoolean( Settings.PREF_MULTIPART_TAP_SEED_GESTURE, Defaults.PREF_MULTIPART_TAP_SEED_GESTURE); + mMultipartRerecognizeTaps = prefs.getBoolean( + Settings.PREF_MULTIPART_RERECOGNIZE_TAPS, + Defaults.PREF_MULTIPART_RERECOGNIZE_TAPS); mSuggestionStripHiddenPerUserSettings = mToolbarMode == ToolbarMode.HIDDEN || mToolbarMode == ToolbarMode.TOOLBAR_KEYS; mOverrideShowingSuggestions = mInputAttributes.mMayOverrideShowingSuggestions diff --git a/app/src/main/java/helium314/keyboard/settings/screens/TwoThumbTypingScreen.kt b/app/src/main/java/helium314/keyboard/settings/screens/TwoThumbTypingScreen.kt index 57cc572ec..160a0c62e 100644 --- a/app/src/main/java/helium314/keyboard/settings/screens/TwoThumbTypingScreen.kt +++ b/app/src/main/java/helium314/keyboard/settings/screens/TwoThumbTypingScreen.kt @@ -72,6 +72,7 @@ fun TwoThumbTypingScreen( } if (nonNormalSpacing) { add(Settings.PREF_MULTIPART_FULL_WORD_SUGGESTIONS) + add(Settings.PREF_MULTIPART_RERECOGNIZE_TAPS) add(SettingsWithoutKey.TWO_THUMB_BACKSPACE_BEHAVIOR) if (backspaceBehavior == BACKSPACE_WORD) { add(Settings.PREF_COMBINING_BACKSPACE_DELETES_COMPOSING_TEXT) @@ -180,6 +181,10 @@ fun createTwoThumbTypingSettings(context: Context) = listOf( R.string.multipart_full_word_suggestions, R.string.multipart_full_word_suggestions_summary) { SwitchPreference(it, Defaults.PREF_MULTIPART_FULL_WORD_SUGGESTIONS) }, + Setting(context, Settings.PREF_MULTIPART_RERECOGNIZE_TAPS, + R.string.multipart_rerecognize_taps, R.string.multipart_rerecognize_taps_summary) { + SwitchPreference(it, Defaults.PREF_MULTIPART_RERECOGNIZE_TAPS) + }, Setting(context, Settings.PREF_GESTURE_DUAL_THUMB_HINTING, R.string.two_thumb_point_hinting, R.string.two_thumb_point_hinting_summary) { SwitchPreference(it, Defaults.PREF_GESTURE_DUAL_THUMB_HINTING) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 858d5c5c5..da85fb934 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -293,6 +293,10 @@ During multi-part composition, the suggestion strip shows alternatives for the entire word so far, not just the most recent fragment. Seed swipe from typed prefix When you type some letters and then swipe while combining mode is armed, treat the swipe as a continuation of the typed prefix. + + Re-recognize taps within swiped words + + When a word already contains a swipe, a tapped letter joins the gesture and the whole word is recognized again, so a slow tap after a swipe behaves like a fast one (swipe \"th\" then tap \"e\" → \"the\"). Taps act as recognizer hints, not exact letters. Experimental. Swipe continues typed prefix If you type a few letters and then swipe during the countdown, treat the swipe as the rest of that word. Backspace behavior diff --git a/app/src/test/java/helium314/keyboard/latin/InputLogicTest.kt b/app/src/test/java/helium314/keyboard/latin/InputLogicTest.kt index 33f73c301..3ef7bb4fc 100644 --- a/app/src/test/java/helium314/keyboard/latin/InputLogicTest.kt +++ b/app/src/test/java/helium314/keyboard/latin/InputLogicTest.kt @@ -433,6 +433,24 @@ class InputLogicTest { assertEquals("RJe", textBeforeCursor) } + // Live-converge ON: in the JVM harness the native recognizer isn't loaded and tap events + // carry no key coordinates, so the feature must degrade gracefully — fall back to a literal + // append, never lose the tap, never crash. (The real re-recognition path is validated + // on-device; it can't be exercised here without the gesture library.) + @Test fun liveConvergeOnDegradesGracefullyWithoutRecognizer() { + reset() + latinIME.prefs().edit { + putBoolean(Settings.PREF_GESTURE_MANUAL_SPACING, true) + putBoolean(Settings.PREF_MULTIPART_RERECOGNIZE_TAPS, true) + } + + gestureInput("RJ") + input('e') + // Same literal fallback as OFF — the tap is preserved and the word stays open. + assertEquals("RJe", composingText) + assertEquals("RJe", textBeforeCursor) + } + @Test fun manualSpacingActivatesMultipartCompose() { reset() latinIME.prefs().edit { putBoolean(Settings.PREF_GESTURE_MANUAL_SPACING, true) } diff --git a/docs/TWO_THUMB_TYPING_INTERNALS.md b/docs/TWO_THUMB_TYPING_INTERNALS.md index 97373a20f..588b14f88 100644 --- a/docs/TWO_THUMB_TYPING_INTERNALS.md +++ b/docs/TWO_THUMB_TYPING_INTERNALS.md @@ -622,24 +622,42 @@ is tracked in #34. armed" vs "grace window open") is still deferred — comes with the join-key UX work (#34). -### 4.12 Live-converge: re-recognize taps within swiped words (#1.7) — REMOVED - -This was an opt-in (`PREF_MULTIPART_RERECOGNIZE_TAPS`, default off) that, when a -word already contained a swipe, routed a following tap through the recognizer -together with the accumulated stroke (`mLiveStroke`) and re-recognized the whole -word — so a slow tap-after-swipe behaved like a fast one. - -It was **removed** (2026-06) after on-device dogfooding. The re-recognition is -*destructive* (you stop seeing the letter you tapped), it blocks typing words the -recognizer doesn't know, and it isn't unified with starting a word by typing. The -only case it improved — swiping a *partial* word then finishing it with taps — is -rare and not worth those costs. With it gone, a tap after a swipe simply appends -literally (you see exactly what you typed). - -The multi-part composition users rely on (swipe+swipe, tap+swipe, swipe+tap → one -word, via the seed/concat and `setExtendBatchInputBase` merged-trail path) is -**unchanged** — it never depended on this flag. A non-destructive "offer, don't -replace" successor is tracked in issue #27 (B2). +### 4.12 Live-converge: re-recognize taps within swiped words (#1.7, 2026-06, opt-in) + +Problem: the native glide recognizer resolves every completed stroke to a whole +word immediately, so a short swipe fragment (`t→h`) resolves to a nearby short +word in isolation. A *fast* follow-up tap folds into the same stroke and +`t→h→e` recognizes as `the`; a *slow* tap arrives after the stroke has ended +and `handleNonSeparatorEvent` appends it literally to the mis-resolved word. + +Fix (`PREF_MULTIPART_RERECOGNIZE_TAPS`, default off): when the current word +already contains a gesture fragment, route a tap as a one-point continuation of +the accumulated stroke and re-recognize the whole word — making slow taps behave +like fast ones. Pieces in `InputLogic`: + +- `mLiveStroke` (InputPointers): the word's accumulated raw trail. Captured in + `onUpdateTailBatchInputCompleted` *before* `setBatchInputWord` resets the + composer's `mInputPointers`; cleared in `resetComposingState` / + `commitChosenWord`. +- `getBatchSuggestionsSync()`: synchronous batch recognition via the same + handler + `AsyncResultHolder` + timeout pattern as + `performUpdateSuggestionStripSync`. +- `tryLiveConvergeTap(event, sv)`: hooked at the top of + `handleNonSeparatorEvent`. Guards on the pref, `isMultipartComposeActive()`, + a composing gesture-word with cursor at end, a word codepoint, real key + coordinates, and a non-empty accumulator. Builds a 1-point `InputPointers` at + the tap's key center, merges via `setExtendBatchInputBase` + + `setBatchInputPointers`, recognizes, then reuses + `onUpdateTailBatchInputCompleted` (extend-base set → whole-word replace, no + prevTypedWord concat). Returns `false` → literal-append fallback when + recognition is unavailable, so a tap is never lost. + +Constraints: pure-tap words are excluded (so ordinary typing stays exact); +taps are recognizer *hints*, not exact anchors. v1 handles one swipe stem plus +following taps; multi-swipe-then-tap resets the accumulator to the latest swipe. +The positive recognition path is **not JVM-unit-testable** (no native lib in +tests; harness tap events carry no coordinates), so unit coverage is limited to +the safe-fallback/no-regression property and the change is validated on-device. ---