Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 106 additions & 0 deletions app/src/main/java/helium314/keyboard/latin/inputlogic/InputLogic.java
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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<SuggestedWords> 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".
*
* <p>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
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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 "
Expand Down Expand Up @@ -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();
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
4 changes: 4 additions & 0 deletions app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,10 @@
<string name="multipart_full_word_suggestions_summary">During multi-part composition, the suggestion strip shows alternatives for the entire word so far, not just the most recent fragment.</string>
<string name="multipart_tap_seed_gesture">Seed swipe from typed prefix</string>
<string name="multipart_tap_seed_gesture_summary">When you type some letters and then swipe while combining mode is armed, treat the swipe as a continuation of the typed prefix.</string>
<!-- Title of the experimental setting that re-recognizes a whole swiped+tapped word when a tap follows a swipe. -->
<string name="multipart_rerecognize_taps">Re-recognize taps within swiped words</string>
<!-- Description for "multipart_rerecognize_taps". -->
<string name="multipart_rerecognize_taps_summary">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.</string>
<string name="two_thumb_typed_prefix_swipe">Swipe continues typed prefix</string>
<string name="two_thumb_typed_prefix_swipe_summary">If you type a few letters and then swipe during the countdown, treat the swipe as the rest of that word.</string>
<string name="two_thumb_backspace_behavior">Backspace behavior</string>
Expand Down
18 changes: 18 additions & 0 deletions app/src/test/java/helium314/keyboard/latin/InputLogicTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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) }
Expand Down
Loading
Loading