diff --git a/SPEC.md b/SPEC.md new file mode 100644 index 000000000..27e1d61bc --- /dev/null +++ b/SPEC.md @@ -0,0 +1,75 @@ +# Step-One Tap + Gesture Combining + +## Goal + +Add the smallest upstreamable foundation for two-thumb typing: allow tap input from one pointer and a simultaneous/overlapping gesture from another pointer to coexist during the current word, and optionally draw a visual trail of taps and gestures for debugging. + +This is intentionally not the full fork two-thumb system. It is a first slice that makes the input pipeline tolerate overlapping multi-pointer mixed tap/gesture word construction while preserving upstream's current word-boundary behavior. A single-finger tap followed later by a single-finger swipe after all fingers have left the keyboard is out of scope for this step. + +## Non-goals + +- No spacing changes. +- No phantom-space behavior. +- No grace timers. +- No auto-finish/auto-commit delay after all fingers leave the keyboard. +- No re-recognize experiments. +- No toolbar, clipboard, dictionary, or shortcut-row changes. +- No local test identity changes (`applicationId`, package name, app label, version). +- No Java/Kotlin package or namespace refactor. + +## User-visible behavior + +Add exactly two toggles under Gesture Typing, in a new category: + +1. `Combine simultaneous tapping and additional gestures` + - Allows mixed tap and gesture input only when the subsequent gesture starts while at least one other pointer is still active (overlapping multi-pointer input). + - Does not keep the word open after all fingers leave the keyboard; a later single-finger swipe starts a new word and keeps the normal fast-typing cooldown. +2. `Draw taps and gestures` + - Draws visual debug points/trails for tap and gesture input when the first toggle is enabled. + - Has no effect on committed text. + +Both default off for upstream safety. + +## Design + +### Settings + +Follow the mandatory settings five-file pattern: + +1. `Settings.java` — add two `PREF_*` constants. +2. `Defaults.kt` — add default values. +3. `SettingsValues.java` — cache the two booleans. +4. `res/values/strings.xml` — add category/title/summary strings. +5. Gesture settings screen — add the new category and toggles. + +Update `SettingsContainerTest` if the settings screens are validated there. + +### Input behavior + +The first toggle should relax the input pipeline only enough to avoid rejecting a gesture when another pointer is still active and tap input has just contributed to the composing word. The cooldown bypass must require overlapping multi-pointer input, so a rapid tap-space-gesture sequence across a no-fingers word boundary remains a new word and keeps upstream fast-typing suppression. This branch must not add timers, deferred spacing, or no-finger continuation semantics. + +### Visual trail + +Reuse existing drawing surfaces/preview plumbing where possible. The debug trail should be cheap when disabled and must not allocate avoidably in hot paths. + +The debug view should show enough information to verify mixed input ordering: tap points and gesture path points. It is diagnostic only and may be visually simple. + +### Tests + +Add focused tests for: + +- settings defaults/wiring; +- any unit-testable gate or state object introduced for mixed tap/gesture behavior. + +## Verification + +Run targeted tests only: + +- `:app:testOfflineDebugUnitTest --tests "*SettingsContainerTest*"` +- any new focused tests for the mixed input gate/state + +## Commit plan + +1. Commit this `SPEC.md` design. +2. Commit the upstreamable implementation. +3. Add a separate local-only test identity commit after the upstreamable implementation is complete and verified. diff --git a/app/src/main/java/helium314/keyboard/keyboard/MainKeyboardView.java b/app/src/main/java/helium314/keyboard/keyboard/MainKeyboardView.java index 9d8cab874..a945dda48 100644 --- a/app/src/main/java/helium314/keyboard/keyboard/MainKeyboardView.java +++ b/app/src/main/java/helium314/keyboard/keyboard/MainKeyboardView.java @@ -45,6 +45,7 @@ import helium314.keyboard.keyboard.internal.PopupKeySpec; import helium314.keyboard.keyboard.internal.NonDistinctMultitouchHelper; import helium314.keyboard.keyboard.internal.SlidingKeyInputDrawingPreview; +import helium314.keyboard.keyboard.internal.TapGestureDrawingPreview; import helium314.keyboard.keyboard.internal.TimerHandler; import helium314.keyboard.keyboard.internal.KeyboardIconsSet; import helium314.keyboard.keyboard.internal.keyboard_parser.floris.KeyCode; @@ -124,6 +125,7 @@ public final class MainKeyboardView extends KeyboardView implements DrawingProxy // Gesture floating preview text private final int mGestureFloatingPreviewTextLingerTimeout; + private final TapGestureDrawingPreview mTapGestureDrawingPreview; private final KeyDetector mKeyDetector; private final NonDistinctMultitouchHelper mNonDistinctMultitouchHelper; @@ -208,6 +210,9 @@ public MainKeyboardView(final Context context, final AttributeSet attrs, final i mSlidingKeyInputDrawingPreview = new SlidingKeyInputDrawingPreview(mainKeyboardViewAttr); mSlidingKeyInputDrawingPreview.setDrawingView(drawingPreviewPlacerView); + + mTapGestureDrawingPreview = new TapGestureDrawingPreview(); + mTapGestureDrawingPreview.setDrawingView(drawingPreviewPlacerView); mainKeyboardViewAttr.recycle(); mDrawingPreviewPlacerView = drawingPreviewPlacerView; @@ -449,6 +454,9 @@ private void setGesturePreviewMode(final boolean isGestureTrailEnabled, final boolean isGestureFloatingPreviewTextEnabled) { mGestureFloatingTextDrawingPreview.setPreviewEnabled(isGestureFloatingPreviewTextEnabled); mGestureTrailsDrawingPreview.setPreviewEnabled(isGestureTrailEnabled); + mTapGestureDrawingPreview.setPreviewEnabled( + Settings.getValues().mGestureCombineTapsAndGestures + && Settings.getValues().mGestureDrawTapsAndGestures); } public void showGestureFloatingPreviewText(@NonNull final SuggestedWords suggestedWords, @@ -468,6 +476,25 @@ public void dismissGestureFloatingPreviewTextWithoutDelay() { mGestureFloatingTextDrawingPreview.dismissGestureFloatingPreviewText(); } + @Override + public void setTapGesturePreviewEnabled(final boolean enabled) { + mTapGestureDrawingPreview.setPreviewEnabled(enabled); + if (!enabled) { + mTapGestureDrawingPreview.clear(); + } + } + + @Override + public void recordTapGesturePoint(final int x, final int y, final boolean gesture) { + locatePreviewPlacerView(); + mTapGestureDrawingPreview.addPoint(x, y, gesture); + } + + @Override + public void clearTapGesturePreview() { + mTapGestureDrawingPreview.clear(); + } + @Override public void showGestureTrail(@NonNull final PointerTracker tracker, final boolean showsFloatingPreviewText) { diff --git a/app/src/main/java/helium314/keyboard/keyboard/PointerTracker.java b/app/src/main/java/helium314/keyboard/keyboard/PointerTracker.java index b4fe8ce76..e240bbfc4 100644 --- a/app/src/main/java/helium314/keyboard/keyboard/PointerTracker.java +++ b/app/src/main/java/helium314/keyboard/keyboard/PointerTracker.java @@ -648,6 +648,7 @@ private void cancelBatchInput() { if (DEBUG_LISTENER) { Log.d(TAG, String.format(Locale.US, "[%d] onCancelBatchInput", mPointerId)); } + sDrawingProxy.clearTapGesturePreview(); sListener.onCancelBatchInput(); } @@ -715,6 +716,11 @@ private void onDownEvent(final int x, final int y, final long eventTime, } } sPointerTrackerQueue.add(this); + if (Settings.getValues().mGestureCombineTapsAndGestures + && Settings.getValues().mGestureDrawTapsAndGestures + && getActivePointerTrackerCount() == 1) { + sDrawingProxy.clearTapGesturePreview(); + } onDownEventInternal(x, y, eventTime); if (!sGestureEnabler.shouldHandleGesture()) { return; @@ -725,10 +731,19 @@ private void onDownEvent(final int x, final int y, final long eventTime, mIsDetectingGesture = (mKeyboard != null) && mKeyboard.mId.isAlphabetKeyboard() && key != null && !key.isModifier() && !mKeySwipeAllowed && !sInKeySwipe; if (mIsDetectingGesture) { + final SettingsValues settingsValues = Settings.getValues(); + final boolean combineTapsAndGestures = settingsValues.mGestureCombineTapsAndGestures + && getActivePointerTrackerCount() >= 2 + && sTypingTimeRecorder.wasLastCodeInputLetter(); mBatchInputArbiter.addDownEventPoint(x, y, eventTime, - sTypingTimeRecorder.getLastLetterTypingTime(), getActivePointerTrackerCount()); + sTypingTimeRecorder.getLastLetterTypingTime(), getActivePointerTrackerCount(), + combineTapsAndGestures); mGestureStrokeDrawingPoints.onDownEvent( x, y, mBatchInputArbiter.getElapsedTimeSinceFirstDown(eventTime)); + if (settingsValues.mGestureCombineTapsAndGestures + && settingsValues.mGestureDrawTapsAndGestures) { + sDrawingProxy.recordTapGesturePoint(x, y, true); + } } } @@ -1253,6 +1268,11 @@ eventTime, getActivePointerTrackerCount(), this)) { && (currentKey.getCode() == currentRepeatingKeyCode) && !isInDraggingFinger) { return; } + if (Settings.getValues().mGestureCombineTapsAndGestures + && Settings.getValues().mGestureDrawTapsAndGestures && currentKey != null + && !currentKey.isModifier()) { + sDrawingProxy.recordTapGesturePoint(x, y, false); + } detectAndSendKey(currentKey, mKeyX, mKeyY, eventTime); if (isInSlidingKeyInput) { callListenerOnFinishSlidingInput(); diff --git a/app/src/main/java/helium314/keyboard/keyboard/internal/BatchInputArbiter.java b/app/src/main/java/helium314/keyboard/keyboard/internal/BatchInputArbiter.java index 2b4ff2cb1..f347eabf4 100644 --- a/app/src/main/java/helium314/keyboard/keyboard/internal/BatchInputArbiter.java +++ b/app/src/main/java/helium314/keyboard/keyboard/internal/BatchInputArbiter.java @@ -61,11 +61,18 @@ public int getElapsedTimeSinceFirstDown(final long eventTime) { */ public void addDownEventPoint(final int x, final int y, final long downEventTime, final long lastLetterTypingTime, final int activePointerCount) { + addDownEventPoint(x, y, downEventTime, lastLetterTypingTime, activePointerCount, false); + } + + public void addDownEventPoint(final int x, final int y, final long downEventTime, + final long lastLetterTypingTime, final int activePointerCount, + final boolean ignoreFastTypingCooldown) { if (activePointerCount == 1) { sGestureFirstDownTime = downEventTime; } final int elapsedTimeSinceFirstDown = getElapsedTimeSinceFirstDown(downEventTime); - final int elapsedTimeSinceLastTyping = (int)(downEventTime - lastLetterTypingTime); + final int elapsedTimeSinceLastTyping = ignoreFastTypingCooldown + ? Integer.MAX_VALUE : (int)(downEventTime - lastLetterTypingTime); mRecognitionPoints.addDownEventPoint( x, y, elapsedTimeSinceFirstDown, elapsedTimeSinceLastTyping); } diff --git a/app/src/main/java/helium314/keyboard/keyboard/internal/DrawingProxy.java b/app/src/main/java/helium314/keyboard/keyboard/internal/DrawingProxy.java index 986bf9197..c995da611 100644 --- a/app/src/main/java/helium314/keyboard/keyboard/internal/DrawingProxy.java +++ b/app/src/main/java/helium314/keyboard/keyboard/internal/DrawingProxy.java @@ -65,4 +65,10 @@ public interface DrawingProxy { * Dismiss a gesture floating preview text without delay. */ void dismissGestureFloatingPreviewTextWithoutDelay(); + + void setTapGesturePreviewEnabled(boolean enabled); + + void recordTapGesturePoint(int x, int y, boolean gesture); + + void clearTapGesturePreview(); } diff --git a/app/src/main/java/helium314/keyboard/keyboard/internal/TapGestureDrawingPreview.java b/app/src/main/java/helium314/keyboard/keyboard/internal/TapGestureDrawingPreview.java new file mode 100644 index 000000000..d25c3decf --- /dev/null +++ b/app/src/main/java/helium314/keyboard/keyboard/internal/TapGestureDrawingPreview.java @@ -0,0 +1,103 @@ +/* + * SPDX-License-Identifier: GPL-3.0-only + */ +package helium314.keyboard.keyboard.internal; + +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.Paint; + +import androidx.annotation.NonNull; + +import helium314.keyboard.keyboard.PointerTracker; + +/** Draws simple diagnostic tap and gesture points for mixed tap/gesture input. */ +public final class TapGestureDrawingPreview extends AbstractDrawingPreview { + private static final int MAX_POINTS = 192; + private static final float TAP_RADIUS = 9.0f; + private static final float GESTURE_RADIUS = 5.0f; + + private final int[] mXs = new int[MAX_POINTS]; + private final int[] mYs = new int[MAX_POINTS]; + private final boolean[] mGesture = new boolean[MAX_POINTS]; + private int mSize; + private int mKeyboardHeight; + + private final Paint mTapPaint = new Paint(); + private final Paint mGesturePaint = new Paint(); + private final Paint mLinePaint = new Paint(); + + public TapGestureDrawingPreview() { + mTapPaint.setAntiAlias(true); + mTapPaint.setColor(0xCCFF9800); + mTapPaint.setStyle(Paint.Style.FILL); + + mGesturePaint.setAntiAlias(true); + mGesturePaint.setColor(0xCC03A9F4); + mGesturePaint.setStyle(Paint.Style.FILL); + + mLinePaint.setAntiAlias(true); + mLinePaint.setColor(0x9903A9F4); + mLinePaint.setStrokeWidth(3.0f); + mLinePaint.setStyle(Paint.Style.STROKE); + } + + @Override + public void setKeyboardViewGeometry(@NonNull final int[] originCoords, final int width, + final int height) { + super.setKeyboardViewGeometry(originCoords, width, height); + mKeyboardHeight = height; + } + + public void clear() { + mSize = 0; + invalidateDrawingView(); + } + + public void addPoint(final int x, final int y, final boolean gesture) { + if (!isPreviewEnabled()) { + return; + } + if (mSize == MAX_POINTS) { + System.arraycopy(mXs, 1, mXs, 0, MAX_POINTS - 1); + System.arraycopy(mYs, 1, mYs, 0, MAX_POINTS - 1); + System.arraycopy(mGesture, 1, mGesture, 0, MAX_POINTS - 1); + mSize = MAX_POINTS - 1; + } + mXs[mSize] = x; + mYs[mSize] = y; + mGesture[mSize] = gesture; + mSize++; + invalidateDrawingView(); + } + + @Override + public void onDeallocateMemory() { + clear(); + } + + @Override + public void drawPreview(@NonNull final Canvas canvas) { + if (!isPreviewEnabled() || mSize == 0) { + return; + } + int lastGesture = -1; + for (int i = 0; i < mSize; i++) { + if (mGesture[i]) { + if (lastGesture >= 0) { + canvas.drawLine(mXs[lastGesture], mYs[lastGesture], mXs[i], mYs[i], mLinePaint); + } + canvas.drawCircle(mXs[i], mYs[i], GESTURE_RADIUS, mGesturePaint); + lastGesture = i; + } else { + canvas.drawCircle(mXs[i], mYs[i], TAP_RADIUS, mTapPaint); + lastGesture = -1; + } + } + } + + @Override + public void setPreviewPosition(@NonNull final PointerTracker tracker) { + // Points are pushed explicitly by PointerTracker. + } +} diff --git a/app/src/main/java/helium314/keyboard/keyboard/internal/TypingTimeRecorder.java b/app/src/main/java/helium314/keyboard/keyboard/internal/TypingTimeRecorder.java index f5b1e54e8..765dcf3f0 100644 --- a/app/src/main/java/helium314/keyboard/keyboard/internal/TypingTimeRecorder.java +++ b/app/src/main/java/helium314/keyboard/keyboard/internal/TypingTimeRecorder.java @@ -12,6 +12,7 @@ public final class TypingTimeRecorder { private long mLastTypingTime; private long mLastLetterTypingTime; private long mLastBatchInputTime; + private boolean mLastCodeInputWasLetter; public TypingTimeRecorder(final int staticTimeThresholdAfterFastTyping, final int suppressKeyPreviewAfterBatchInputDuration) { @@ -29,6 +30,7 @@ private boolean wasLastInputTyping() { } public void onCodeInput(final int code, final long eventTime) { + mLastCodeInputWasLetter = Character.isLetter(code); // Record the letter typing time when // 1. Letter keys are typed successively without any batch input in between. // 2. A letter key is typed within the threshold time since the last any key typing. @@ -55,6 +57,10 @@ public long getLastLetterTypingTime() { return mLastLetterTypingTime; } + public boolean wasLastCodeInputLetter() { + return mLastCodeInputWasLetter; + } + public boolean needsToSuppressKeyPreviewPopup(final long eventTime) { return !wasLastInputTyping() && eventTime - mLastBatchInputTime < mSuppressKeyPreviewAfterBatchInputDuration; 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 f0e6034c7..d98e9ece5 100644 --- a/app/src/main/java/helium314/keyboard/latin/settings/Defaults.kt +++ b/app/src/main/java/helium314/keyboard/latin/settings/Defaults.kt @@ -136,6 +136,8 @@ object Defaults { const val PREF_GESTURE_SPACE_AWARE = false const val PREF_GESTURE_FAST_TYPING_COOLDOWN = 500 const val PREF_GESTURE_TRAIL_FADEOUT_DURATION = 800 + const val PREF_GESTURE_COMBINE_TAPS_AND_GESTURES = false + const val PREF_GESTURE_DRAW_TAPS_AND_GESTURES = 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 b676fb54f..636764eb9 100644 --- a/app/src/main/java/helium314/keyboard/latin/settings/Settings.java +++ b/app/src/main/java/helium314/keyboard/latin/settings/Settings.java @@ -133,6 +133,8 @@ public final class Settings implements SharedPreferences.OnSharedPreferenceChang public static final String PREF_GESTURE_SPACE_AWARE = "gesture_space_aware"; public static final String PREF_GESTURE_FAST_TYPING_COOLDOWN = "gesture_fast_typing_cooldown"; public static final String PREF_GESTURE_TRAIL_FADEOUT_DURATION = "gesture_trail_fadeout_duration"; + public static final String PREF_GESTURE_COMBINE_TAPS_AND_GESTURES = "gesture_combine_taps_and_gestures"; + public static final String PREF_GESTURE_DRAW_TAPS_AND_GESTURES = "gesture_draw_taps_and_gestures"; 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 f54150a9f..ee6934a80 100644 --- a/app/src/main/java/helium314/keyboard/latin/settings/SettingsValues.java +++ b/app/src/main/java/helium314/keyboard/latin/settings/SettingsValues.java @@ -117,6 +117,8 @@ public class SettingsValues { public final boolean mGestureFloatingPreviewDynamicEnabled; public final int mGestureFastTypingCooldown; public final int mGestureTrailFadeoutDuration; + public final boolean mGestureCombineTapsAndGestures; + public final boolean mGestureDrawTapsAndGestures; public final boolean mSlidingKeyInputPreviewEnabled; public final int mKeyLongpressTimeout; public final boolean mEnableEmojiAltPhysicalKey; @@ -328,6 +330,12 @@ public SettingsValues(final Context context, final SharedPreferences prefs, fina Defaults.PREF_GESTURE_FAST_TYPING_COOLDOWN); mGestureTrailFadeoutDuration = prefs.getInt(Settings.PREF_GESTURE_TRAIL_FADEOUT_DURATION, Defaults.PREF_GESTURE_TRAIL_FADEOUT_DURATION); + mGestureCombineTapsAndGestures = prefs.getBoolean( + Settings.PREF_GESTURE_COMBINE_TAPS_AND_GESTURES, + Defaults.PREF_GESTURE_COMBINE_TAPS_AND_GESTURES); + mGestureDrawTapsAndGestures = prefs.getBoolean( + Settings.PREF_GESTURE_DRAW_TAPS_AND_GESTURES, + Defaults.PREF_GESTURE_DRAW_TAPS_AND_GESTURES); mSuggestionStripHiddenPerUserSettings = mToolbarMode == ToolbarMode.HIDDEN || mToolbarMode == ToolbarMode.TOOLBAR_KEYS; mOverrideShowingSuggestions = mInputAttributes.mMayOverrideShowingSuggestions diff --git a/app/src/main/java/helium314/keyboard/settings/screens/GestureTypingScreen.kt b/app/src/main/java/helium314/keyboard/settings/screens/GestureTypingScreen.kt index a206f09ee..29e234e1a 100644 --- a/app/src/main/java/helium314/keyboard/settings/screens/GestureTypingScreen.kt +++ b/app/src/main/java/helium314/keyboard/settings/screens/GestureTypingScreen.kt @@ -65,6 +65,11 @@ fun GestureTypingScreen( add(R.string.settings_category_behavior) add(Settings.PREF_GESTURE_SPACE_AWARE) add(Settings.PREF_GESTURE_FAST_TYPING_COOLDOWN) + + add(R.string.settings_category_tap_gesture_combining) + add(Settings.PREF_GESTURE_COMBINE_TAPS_AND_GESTURES) + if (prefs.getBoolean(Settings.PREF_GESTURE_COMBINE_TAPS_AND_GESTURES, Defaults.PREF_GESTURE_COMBINE_TAPS_AND_GESTURES)) + add(Settings.PREF_GESTURE_DRAW_TAPS_AND_GESTURES) } add(R.string.settings_category_gestures_advanced) @@ -141,6 +146,16 @@ fun createGestureTypingSettings(context: Context) = listOf( stepSize = 10, ) { KeyboardSwitcher.getInstance().setThemeNeedsReload() } }, + Setting(context, Settings.PREF_GESTURE_COMBINE_TAPS_AND_GESTURES, + R.string.gesture_combine_taps_and_gestures, R.string.gesture_combine_taps_and_gestures_summary) + { + SwitchPreference(it, Defaults.PREF_GESTURE_COMBINE_TAPS_AND_GESTURES) + }, + Setting(context, Settings.PREF_GESTURE_DRAW_TAPS_AND_GESTURES, + R.string.gesture_draw_taps_and_gestures, R.string.gesture_draw_taps_and_gestures_summary) + { + SwitchPreference(it, Defaults.PREF_GESTURE_DRAW_TAPS_AND_GESTURES) + }, Setting(context, SettingsWithoutKey.LOAD_GESTURE_LIB, R.string.load_gesture_library, R.string.load_gesture_library_summary) { LoadGestureLibPreference(it.title) }, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c15b1ede9..25cfa8090 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -65,6 +65,7 @@ Behavior + Tap + gesture combining Advanced Gestures Touchpad @@ -221,6 +222,10 @@ Always start instantly Gesture trail lifespan + Combine simultaneous tapping and additional gestures + Allow gesture input to start immediately after tap typing within the current word + Draw taps and gestures + Show diagnostic tap points and gesture paths while combining tap and gesture input Enable clipboard history diff --git a/app/src/test/java/helium314/keyboard/keyboard/internal/BatchInputArbiterTest.kt b/app/src/test/java/helium314/keyboard/keyboard/internal/BatchInputArbiterTest.kt new file mode 100644 index 000000000..557cc261d --- /dev/null +++ b/app/src/test/java/helium314/keyboard/keyboard/internal/BatchInputArbiterTest.kt @@ -0,0 +1,68 @@ +package helium314.keyboard.keyboard.internal + +import helium314.keyboard.latin.LatinIME +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.shadows.ShadowLog + +@RunWith(RobolectricTestRunner::class) +class BatchInputArbiterTest { + @Before + fun setUp() { + Robolectric.setupService(LatinIME::class.java) + ShadowLog.setupLogging() + } + + @Test + fun ignoreFastTypingCooldownBypassesAfterFastTypingGate() { + val normal = BatchInputArbiter(0, GestureStrokeRecognitionParams.DEFAULT) + normal.addDownEventPoint( + 10, + 10, + 1_000L, + 990L, + 1, + false, + ) + assertTrue(normal.afterFastTyping()) + + val bypassed = BatchInputArbiter(0, GestureStrokeRecognitionParams.DEFAULT) + bypassed.addDownEventPoint( + 10, + 10, + 1_000L, + 990L, + 1, + true, + ) + assertFalse(bypassed.afterFastTyping()) + } + + @Test + fun lastCodeInputLetterGuardTreatsSpaceAsWordBoundary() { + val recorder = TypingTimeRecorder(500, 0) + recorder.onCodeInput('h'.code, 1_000L) + assertTrue(recorder.wasLastCodeInputLetter()) + + recorder.onCodeInput(' '.code, 1_050L) + assertFalse(recorder.wasLastCodeInputLetter()) + assertTrue( + "space remains part of upstream fast-typing suppression but must not keep same-word tap/gesture combining alive", + recorder.isInFastTyping(1_060L), + ) + } + + private fun BatchInputArbiter.afterFastTyping(): Boolean { + val recognitionPointsField = BatchInputArbiter::class.java.getDeclaredField("mRecognitionPoints") + recognitionPointsField.isAccessible = true + val recognitionPoints = recognitionPointsField.get(this) + val afterFastTypingField = GestureStrokeRecognitionPoints::class.java.getDeclaredField("mAfterFastTyping") + afterFastTypingField.isAccessible = true + return afterFastTypingField.getBoolean(recognitionPoints) + } +} diff --git a/app/src/test/java/helium314/keyboard/settings/SettingsContainerTest.kt b/app/src/test/java/helium314/keyboard/settings/SettingsContainerTest.kt index 99f275c1e..26a0aa6e7 100644 --- a/app/src/test/java/helium314/keyboard/settings/SettingsContainerTest.kt +++ b/app/src/test/java/helium314/keyboard/settings/SettingsContainerTest.kt @@ -30,6 +30,12 @@ class SettingsContainerTest { assertTrue(res.isNotEmpty()) } + @Test + fun tapGestureCombiningSettingsAreSearchable() { + assertTrue(container.filter("combine").any { it.title.contains("Combine simultaneous tapping") }) + assertTrue(container.filter("draw").any { it.title.contains("Draw taps") }) + } + @Test fun testFilterPerformance() { val searches = listOf("a", "b", "c", "theme", "color", "sound", "vib", "dictionary", "key", "layout") diff --git a/docs/two-thumb-step-one.md b/docs/two-thumb-step-one.md new file mode 100644 index 000000000..cd6fa029d --- /dev/null +++ b/docs/two-thumb-step-one.md @@ -0,0 +1,71 @@ +# Tap + Gesture Combining (Step One) + +LeanType can optionally accept a tap from one finger and a simultaneous gesture from another finger as part of the same current word. This is a small first step toward two-thumb typing support. + +The feature is intentionally conservative: it does not keep a word open after all fingers leave the keyboard, and it does not add spacing, grace timers, or delayed commit behavior. + +## Enable it + +Open **Settings → Gesture Typing** and enable: + +1. **Combine simultaneous tapping and additional gestures** +2. Optional: **Draw taps and gestures** + +Both settings default to off. + +## How it works + +With the combine setting enabled: + +- A gesture may bypass the rapid-typing suppression only when another pointer is still active. +- The previous input must have been a letter. +- The normal upstream word boundary remains unchanged: once all fingers are lifted, the next gesture starts a new word. + +This means overlapping multi-pointer input is allowed, but a later single-finger tap → swipe sequence is not treated as part of the same word. + +## Example + +A supported sequence is: + +1. Press or tap a letter with one thumb. +2. While at least one finger is still active, start a gesture with another finger. +3. Lift normally to finish the gesture. + +An out-of-scope sequence is: + +1. Tap a word. +2. Lift all fingers. +3. Tap space. +4. Start a new single-finger gesture. + +That remains normal gesture typing for the next word and keeps the usual rapid-typing guard. + +## Debug drawing + +The optional **Draw taps and gestures** setting draws a lightweight diagnostic overlay: + +- tap points are shown as points +- gesture samples are drawn as a simple path + +The overlay is for manual testing and debugging only. It does not affect committed text or suggestions. + +The overlay clears when a new pointer sequence starts, when gesture input is cancelled, and when debug drawing is disabled. + +## Scope limits + +This step deliberately avoids the larger two-thumb model from downstream forks: + +- no phantom spaces +- no grace timers +- no delayed commit after all fingers lift +- no re-recognition of prior taps +- no custom spacing behavior +- no synthetic point insertion + +Those can be evaluated separately after the minimal mixed-input gate has proven useful. + +## Implementation notes + +The feature adds two cached settings in `SettingsValues` and a small `TapGestureDrawingPreview`. `PointerTracker` passes a new `ignoreFastTypingCooldown` flag to `BatchInputArbiter` only when overlapping multi-pointer tap+gesture input is active. + +`BatchInputArbiter` keeps the original five-argument `addDownEventPoint` as the default path, so existing callers preserve upstream behavior.