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.