diff --git a/Agents.md b/Agents.md index a3a51df45..64efac4a1 100644 --- a/Agents.md +++ b/Agents.md @@ -14,7 +14,7 @@ | Offline AI | ONNX Runtime with T5-based grammar-correction models (Offline build only) | | Custom AI Keys | 10 toolbar keys with user-defined prompts and hashtag personas | | Floating Keyboard | Detachable draggable overlay window | -| Two-thumb Typing | Nintype-style combining mode, tap-during-swipe, manual spacing, autospace grace | +| Two-thumb Typing | Nintype-style combining mode, manual spacing, autospace grace, point hinting | | UI / UX | Squircle key backgrounds, Split toolbar, Incognito icon, Clipboard search & undo, Screenshot suggestion, Emoji search | | Build flavors | `standard` (AI + internet), `offline` (ONNX, no internet), `offlinelite` (no AI at all) | diff --git a/app/src/main/java/helium314/keyboard/keyboard/MainKeyboardView.java b/app/src/main/java/helium314/keyboard/keyboard/MainKeyboardView.java index 3a3b20fe0..3e9c1527a 100644 --- a/app/src/main/java/helium314/keyboard/keyboard/MainKeyboardView.java +++ b/app/src/main/java/helium314/keyboard/keyboard/MainKeyboardView.java @@ -106,6 +106,7 @@ public final class MainKeyboardView extends KeyboardView implements DrawingProxy // ValueAnimator drives invalidations at ~60fps so the bar shrinks smoothly; on cancel // / timer-expiry we set mCombiningModeActive=false and the bar disappears next frame. private boolean mCombiningModeActive = false; + private boolean mCombiningCompositionActiveForDebug = false; private long mCombiningStartTimeMs = 0L; private int mCombiningGraceMs = 0; @Nullable private ValueAnimator mCombiningAnimator; @@ -325,6 +326,11 @@ public void setLanguageOnSpacebarAnimAlpha(final int alpha) { * No-ops when the space key isn't on the current layout (numeric / symbol). */ public void setCombiningMode(final boolean active, final long startTimeMs, final int graceMs) { + setCombiningMode(active, startTimeMs, graceMs, active); + } + + public void setCombiningMode(final boolean active, final long startTimeMs, final int graceMs, + final boolean compositionActiveForDebug) { // Stop any in-flight animator before mutating state — guarantees we don't have an // animator updating mCombiningModeActive=false's invalidation while we set true. if (mCombiningAnimator != null) { @@ -332,6 +338,7 @@ public void setCombiningMode(final boolean active, final long startTimeMs, final mCombiningAnimator = null; } mCombiningModeActive = active && graceMs > 0; + mCombiningCompositionActiveForDebug = compositionActiveForDebug; mCombiningStartTimeMs = startTimeMs; mCombiningGraceMs = graceMs; // Always invalidate the whole view once so the global tint overlay appears or @@ -555,6 +562,16 @@ public void clearGestureDebugPoints() { mGestureDebugPointsDrawingPreview.clear(); } + @Override + public boolean isCombiningModeActiveForDebug() { + return mCombiningCompositionActiveForDebug; + } + + @Override + public boolean hasGestureDebugPoints() { + return mGestureDebugPointsDrawingPreview.hasSnapshot(); + } + @Override public void setGestureCommitPending(final boolean pending) { mGestureFloatingTextDrawingPreview.setCommitPending(pending); diff --git a/app/src/main/java/helium314/keyboard/keyboard/PointerTracker.java b/app/src/main/java/helium314/keyboard/keyboard/PointerTracker.java index 9459a9e9a..9a85256ba 100644 --- a/app/src/main/java/helium314/keyboard/keyboard/PointerTracker.java +++ b/app/src/main/java/helium314/keyboard/keyboard/PointerTracker.java @@ -129,12 +129,6 @@ public static void switchTo(DrawingProxy drawingProxy) { private boolean mIsDetectingGesture = false; // per PointerTracker. private static boolean sInGesture = false; - // True for this pointer when its down-event happened while another pointer's batch gesture - // was already in progress (sInGesture==true) and the pref PREF_GESTURE_TAP_DURING_SWIPE is on. - // Used in onUpEventInternal to suppress an accidental tap-keystroke if the parent gesture - // committed between this pointer's down and up. - private boolean mIsTapDuringSwipe = false; - // ---- Combining-mode tap seeding ------------------------------------------------------ // When the user taps a letter and within the combining-grace window starts a swipe, the // pure concat path ("s" + recognizer-of-"ilo") produces unreliable results because the @@ -365,9 +359,24 @@ private static DualThumbHinter.Result applyDualThumbHinting( private static void pushGestureDebugSnapshot( final InputPointers raw, final InputPointers syntheticOnly) { if (!Settings.getValues().mGestureDebugDrawPoints) return; + Log.d(TAG, "debug gesture fragment raw=" + raw.getPointerSize() + + " synthetic=" + syntheticOnly.getPointerSize()); sDrawingProxy.setGestureDebugPoints(raw, syntheticOnly); } + private static void pushTapDebugPoint(final int x, final int y, final int pointerId, + final long eventTime) { + final SettingsValues sv = Settings.getValues(); + if (!sv.mGestureDebugDrawPoints + || (sv.mCombiningGraceMs <= 0 && !sv.mGestureManualSpacing)) { + return; + } + final InputPointers tap = new InputPointers(1); + tap.addPointer(x, y, pointerId, 0); + Log.d(TAG, "debug tap fragment x=" + x + " y=" + y + " pointer=" + pointerId); + sDrawingProxy.setGestureDebugPoints(tap, new InputPointers(0)); + } + public static void setKeyboardActionListener(final KeyboardActionListener listener) { sListener = listener; @@ -696,9 +705,15 @@ public void onStartBatchInput() { if (DEBUG_LISTENER) { Log.d(TAG, String.format(Locale.US, "[%d] onStartBatchInput", mPointerId)); } - // Two-thumb typing (#2.1): a fresh gesture starts — wipe the previous batch's debug - // overlay so it doesn't visually mix with the in-flight gesture. - sDrawingProxy.clearGestureDebugPoints(); + // Two-thumb typing debug overlay: clear only when this is a new word. While combining + // mode is active, a fresh gesture is another fragment of the same word and should remain + // visible alongside earlier fragments. + final SettingsValues settingsValues = Settings.getValues(); + if (!settingsValues.mGestureDebugAccumulateFragments + || (!sDrawingProxy.isCombiningModeActiveForDebug() + && !settingsValues.mGestureManualSpacing)) { + sDrawingProxy.clearGestureDebugPoints(); + } sListener.onStartBatchInput(); dismissAllPopupKeysPanels(); sTimerProxy.cancelLongPressTimersOf(this); @@ -930,12 +945,6 @@ private void onDownEvent(final int x, final int y, final long eventTime, sTypingTimeRecorder.getLastLetterTypingTime(), getActivePointerTrackerCount()); mGestureStrokeDrawingPoints.onDownEvent( seedX, seedY, mBatchInputArbiter.getElapsedTimeSinceFirstDown(seedTime)); - // Two-thumb typing: remember whether this pointer started while another pointer's - // batch gesture was already in progress. Used in onUpEventInternal to suppress a - // stray tap-keystroke if the parent gesture commits before this pointer lifts. - mIsTapDuringSwipe = sInGesture - && sv.mGestureTapDuringSwipe - && key != null && Character.isLetter(key.getCode()); } } @@ -1394,8 +1403,6 @@ private void onUpEventInternal(final int x, final int y, final long eventTime) { sTimerProxy.cancelKeyTimersOf(this); final boolean isInDraggingFinger = mIsInDraggingFinger; final boolean isInSlidingKeyInput = mIsInSlidingKeyInput; - final boolean wasTapDuringSwipe = mIsTapDuringSwipe; - mIsTapDuringSwipe = false; resetKeySelectionByDraggingFinger(); mIsDetectingGesture = false; final Key currentKey = mCurrentKey; @@ -1489,15 +1496,6 @@ eventTime, getActivePointerTrackerCount(), graceMs, this, && (currentKey.getCode() == currentRepeatingKeyCode) && !isInDraggingFinger) { return; } - // Two-thumb typing (#1.3): if this pointer went down as a child of an in-progress - // batch gesture but the parent gesture committed before our up-event, suppress the - // tap-keystroke so we don't append a stray letter to the just-committed word. - // Long-press taps (longer than PREF_GESTURE_TAP_AS_SWIPE_WINDOW_MS) fall through to - // normal behaviour so the user can still type characters after a gesture commits. - if (wasTapDuringSwipe && !sInGesture - && eventTime - mDownTime <= Settings.getValues().mGestureTapAsSwipeWindowMs) { - return; - } detectAndSendKey(currentKey, mKeyX, mKeyY, eventTime); // Combining-mode seeding: remember the last letter tap so a follow-up gesture can // seed its first pointer event with this position and time. Only letter taps qualify @@ -1510,6 +1508,7 @@ eventTime, getActivePointerTrackerCount(), graceMs, this, sLastLetterTapY = mKeyY; sLastLetterTapTime = eventTime; sLastLetterTapCodepoint = code; + pushTapDebugPoint(mKeyX, mKeyY, mPointerId, eventTime); } } if (isInSlidingKeyInput) { 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 72ed64c78..6738af31f 100644 --- a/app/src/main/java/helium314/keyboard/keyboard/internal/DrawingProxy.java +++ b/app/src/main/java/helium314/keyboard/keyboard/internal/DrawingProxy.java @@ -82,6 +82,12 @@ void setGestureDebugPoints(@NonNull helium314.keyboard.latin.common.InputPointer /** Clear the debug-points overlay (e.g. on gesture start or cancel). */ void clearGestureDebugPoints(); + /** Whether debug overlay fragments should be preserved across the next gesture start. */ + boolean isCombiningModeActiveForDebug(); + + /** True when there are debug points currently visible on the overlay. */ + boolean hasGestureDebugPoints(); + /** * Toggle a "commit pending" visual indicator on the gesture floating preview text * (feature #1.2). Shown during the autospace grace window so the user has a visible cue diff --git a/app/src/main/java/helium314/keyboard/keyboard/internal/GestureDebugPointsDrawingPreview.java b/app/src/main/java/helium314/keyboard/keyboard/internal/GestureDebugPointsDrawingPreview.java index 5267bf9a1..c3fe71a16 100644 --- a/app/src/main/java/helium314/keyboard/keyboard/internal/GestureDebugPointsDrawingPreview.java +++ b/app/src/main/java/helium314/keyboard/keyboard/internal/GestureDebugPointsDrawingPreview.java @@ -12,6 +12,9 @@ import helium314.keyboard.keyboard.PointerTracker; import helium314.keyboard.latin.common.InputPointers; +import helium314.keyboard.latin.utils.Log; + +import java.util.Arrays; /** * Visual debug overlay (feature #2.1). Draws the points the gesture library actually sees, @@ -21,11 +24,14 @@ * *
The overlay snapshots the inputs at each batch-end (immediate or grace-deferred) and keeps * them visible until the next batch starts, so the trail is still on screen when the user - * compares it with the suggestion strip. Two colour bands distinguish the streams: + * compares it with the suggestion strip. The overlay distinguishes the streams and gesture + * structure: *
Everything is single-threaded on the UI / main-looper thread: {@link #updateSnapshot} is
@@ -35,30 +41,79 @@
* invariant holds.
*/
public final class GestureDebugPointsDrawingPreview extends AbstractDrawingPreview {
+ private static final String TAG = "GestureDebugOverlay";
// Snapshot of the most recent batch's points. Held as primitive int[] copies so the
// upstream BatchInputArbiter is free to reset its aggregate without affecting us.
private int[] mRawXs;
private int[] mRawYs;
+ private int[] mRawIds;
+ private int[] mRawTimes;
+ private int[] mRawFragments;
+ private int mRawSize;
private int[] mSyntheticXs;
private int[] mSyntheticYs;
+ private int[] mSyntheticIds;
+ private int[] mSyntheticTimes;
+ private int[] mSyntheticFragments;
+ private int mSyntheticSize;
+ private int mNextFragmentId;
private final Paint mRawPaint = new Paint();
+ private final Paint mLinePaint = new Paint();
private final Paint mSyntheticPaint = new Paint();
+ private final Paint mStartPaint = new Paint();
+ private final Paint mEndPaint = new Paint();
+ private final Paint mTapPaint = new Paint();
+ private final float[] mColorHsv = new float[3];
/** Radius in pixels for a "raw" sample dot. Picked so dots remain visible at typical DPIs. */
private static final float RAW_RADIUS_PX = 4f;
- /** Radius for "synthetic" sample dots — deliberately larger so the diff is obvious. */
- private static final float SYNTHETIC_RADIUS_PX = 7f;
+ private static final float SYNTHETIC_CROSS_RADIUS_PX = 8f;
+ private static final float TAP_MARKER_RADIUS_PX = 10f;
+ private static final float START_MARKER_RADIUS_PX = 9f;
+ private static final float END_MARKER_RADIUS_PX = 8f;
+ private static final int TAP_MAX_POINTS = 5;
+ private static final int TAP_MAX_DURATION_MS = 80;
+ private static final float[][] FRAGMENT_HSV = {
+ { 0f, 0.95f, 1.00f }, // red
+ { 210f, 0.90f, 1.00f }, // blue
+ { 120f, 0.85f, 0.95f }, // green
+ { 35f, 0.95f, 1.00f }, // orange
+ { 285f, 0.85f, 0.95f }, // purple
+ { 180f, 0.90f, 0.95f }, // cyan
+ };
public GestureDebugPointsDrawingPreview() {
mRawPaint.setAntiAlias(true);
- mRawPaint.setColor(Color.RED);
mRawPaint.setAlpha(0x99); // 60 % opaque — raw stream is dense, so visually a wash
mRawPaint.setStyle(Paint.Style.FILL);
+ mLinePaint.setAntiAlias(true);
+ mLinePaint.setAlpha(0x77);
+ mLinePaint.setStrokeWidth(3f);
+ mLinePaint.setStyle(Paint.Style.STROKE);
+
mSyntheticPaint.setAntiAlias(true);
mSyntheticPaint.setColor(Color.BLUE);
mSyntheticPaint.setAlpha(0xFF);
- mSyntheticPaint.setStyle(Paint.Style.FILL);
+ mSyntheticPaint.setStrokeWidth(4f);
+ mSyntheticPaint.setStyle(Paint.Style.STROKE);
+
+ mStartPaint.setAntiAlias(true);
+ mStartPaint.setColor(Color.rgb(0, 200, 83));
+ mStartPaint.setAlpha(0xFF);
+ mStartPaint.setStrokeWidth(4f);
+ mStartPaint.setStyle(Paint.Style.STROKE);
+
+ mEndPaint.setAntiAlias(true);
+ mEndPaint.setColor(Color.BLACK);
+ mEndPaint.setAlpha(0xCC);
+ mEndPaint.setStyle(Paint.Style.FILL);
+
+ mTapPaint.setAntiAlias(true);
+ mTapPaint.setColor(Color.WHITE);
+ mTapPaint.setAlpha(0xDD);
+ mTapPaint.setStrokeWidth(3f);
+ mTapPaint.setStyle(Paint.Style.STROKE);
}
/**
@@ -74,27 +129,63 @@ public GestureDebugPointsDrawingPreview() {
* {@link InputPointers} or {@code null} (treated identically — nothing blue drawn).
*/
public void updateSnapshot(@NonNull final InputPointers raw, final InputPointers synthetic) {
- mRawXs = copyOfLength(raw.getXCoordinates(), raw.getPointerSize());
- mRawYs = copyOfLength(raw.getYCoordinates(), raw.getPointerSize());
- if (synthetic != null && synthetic.getPointerSize() > 0) {
- mSyntheticXs = copyOfLength(synthetic.getXCoordinates(), synthetic.getPointerSize());
- mSyntheticYs = copyOfLength(synthetic.getYCoordinates(), synthetic.getPointerSize());
- } else {
- mSyntheticXs = null;
- mSyntheticYs = null;
+ final int fragmentId = mNextFragmentId++;
+ final int rawSize = raw.getPointerSize();
+ final int syntheticSize = synthetic == null ? 0 : synthetic.getPointerSize();
+ Log.d(TAG, "fragment=" + fragmentId
+ + " raw=" + rawSize
+ + " synthetic=" + syntheticSize
+ + " totalBefore=" + mRawSize
+ + " tapLike=" + isTapLike(raw));
+ mRawXs = append(mRawXs, mRawSize, raw.getXCoordinates(), rawSize);
+ mRawYs = append(mRawYs, mRawSize, raw.getYCoordinates(), rawSize);
+ mRawIds = append(mRawIds, mRawSize, raw.getPointerIds(), rawSize);
+ mRawTimes = append(mRawTimes, mRawSize, raw.getTimes(), rawSize);
+ mRawFragments = appendFilled(mRawFragments, mRawSize, fragmentId, rawSize);
+ mRawSize += rawSize;
+ if (syntheticSize > 0) {
+ mSyntheticXs = append(mSyntheticXs, mSyntheticSize,
+ synthetic.getXCoordinates(), syntheticSize);
+ mSyntheticYs = append(mSyntheticYs, mSyntheticSize,
+ synthetic.getYCoordinates(), syntheticSize);
+ mSyntheticIds = append(mSyntheticIds, mSyntheticSize,
+ synthetic.getPointerIds(), syntheticSize);
+ mSyntheticTimes = append(mSyntheticTimes, mSyntheticSize,
+ synthetic.getTimes(), syntheticSize);
+ mSyntheticFragments = appendFilled(mSyntheticFragments, mSyntheticSize,
+ fragmentId, syntheticSize);
+ mSyntheticSize += syntheticSize;
}
invalidateDrawingView();
}
/** Drop the overlay (e.g. on gesture cancel or view teardown). */
public void clear() {
+ if (mRawXs != null || mSyntheticXs != null) {
+ Log.d(TAG, "clear fragments=" + mNextFragmentId
+ + " rawTotal=" + mRawSize
+ + " syntheticTotal=" + mSyntheticSize);
+ }
mRawXs = null;
mRawYs = null;
+ mRawIds = null;
+ mRawTimes = null;
+ mRawFragments = null;
+ mRawSize = 0;
mSyntheticXs = null;
mSyntheticYs = null;
+ mSyntheticIds = null;
+ mSyntheticTimes = null;
+ mSyntheticFragments = null;
+ mSyntheticSize = 0;
+ mNextFragmentId = 0;
invalidateDrawingView();
}
+ public boolean hasSnapshot() {
+ return mRawSize > 0 || mSyntheticSize > 0;
+ }
+
@Override
public void onDeallocateMemory() {
clear();
@@ -103,9 +194,10 @@ public void onDeallocateMemory() {
@Override
public void drawPreview(@NonNull final Canvas canvas) {
if (!isPreviewEnabled()) return;
- // Raw first so synthetic dots draw on top.
- drawPoints(canvas, mRawXs, mRawYs, mRawPaint, RAW_RADIUS_PX);
- drawPoints(canvas, mSyntheticXs, mSyntheticYs, mSyntheticPaint, SYNTHETIC_RADIUS_PX);
+ drawPointerLines(canvas);
+ drawRawPoints(canvas);
+ drawRunMarkers(canvas);
+ drawSyntheticPoints(canvas);
}
@Override
@@ -114,19 +206,118 @@ public void setPreviewPosition(@NonNull final PointerTracker tracker) {
// a single pointer the way the trail / floating-text previews do.
}
- private static void drawPoints(final Canvas canvas, final int[] xs, final int[] ys,
- final Paint paint, final float radiusPx) {
- if (xs == null || ys == null) return;
- final int n = Math.min(xs.length, ys.length);
+ private void drawPointerLines(final Canvas canvas) {
+ if (mRawXs == null || mRawYs == null || mRawIds == null || mRawFragments == null) return;
+ final int n = Math.min(mRawSize, Math.min(Math.min(mRawXs.length, mRawYs.length),
+ Math.min(mRawIds.length, mRawFragments.length)));
+ for (int i = 1; i < n; i++) {
+ if (mRawIds[i] != mRawIds[i - 1]
+ || mRawFragments[i] != mRawFragments[i - 1]) continue;
+ mLinePaint.setColor(colorForFragment(mRawFragments[i]));
+ canvas.drawLine(mRawXs[i - 1], mRawYs[i - 1], mRawXs[i], mRawYs[i], mLinePaint);
+ }
+ }
+
+ private void drawRawPoints(final Canvas canvas) {
+ if (mRawXs == null || mRawYs == null || mRawFragments == null) return;
+ final int n = Math.min(mRawSize,
+ Math.min(Math.min(mRawXs.length, mRawYs.length), mRawFragments.length));
+ for (int i = 0; i < n; i++) {
+ mRawPaint.setColor(colorForFragment(mRawFragments[i]));
+ canvas.drawCircle(mRawXs[i], mRawYs[i], RAW_RADIUS_PX, mRawPaint);
+ }
+ }
+
+ private void drawRunMarkers(final Canvas canvas) {
+ if (mRawXs == null || mRawYs == null || mRawIds == null || mRawTimes == null
+ || mRawFragments == null) return;
+ final int n = Math.min(mRawSize, Math.min(Math.min(mRawXs.length, mRawYs.length),
+ Math.min(Math.min(mRawIds.length, mRawTimes.length), mRawFragments.length)));
+ int start = 0;
+ while (start < n) {
+ int end = start + 1;
+ while (end < n && mRawIds[end] == mRawIds[start]
+ && mRawFragments[end] == mRawFragments[start]) {
+ end++;
+ }
+ drawRunMarker(canvas, start, end);
+ start = end;
+ }
+ }
+
+ private void drawRunMarker(final Canvas canvas, final int start, final int end) {
+ canvas.drawCircle(mRawXs[start], mRawYs[start], START_MARKER_RADIUS_PX, mStartPaint);
+ canvas.drawRect(
+ mRawXs[end - 1] - END_MARKER_RADIUS_PX,
+ mRawYs[end - 1] - END_MARKER_RADIUS_PX,
+ mRawXs[end - 1] + END_MARKER_RADIUS_PX,
+ mRawYs[end - 1] + END_MARKER_RADIUS_PX,
+ mEndPaint);
+ final int pointCount = end - start;
+ final int duration = mRawTimes[end - 1] - mRawTimes[start];
+ if (pointCount <= TAP_MAX_POINTS && duration <= TAP_MAX_DURATION_MS) {
+ final int mid = start + pointCount / 2;
+ canvas.drawRect(
+ mRawXs[mid] - TAP_MARKER_RADIUS_PX,
+ mRawYs[mid] - TAP_MARKER_RADIUS_PX,
+ mRawXs[mid] + TAP_MARKER_RADIUS_PX,
+ mRawYs[mid] + TAP_MARKER_RADIUS_PX,
+ mTapPaint);
+ }
+ }
+
+ private void drawSyntheticPoints(final Canvas canvas) {
+ if (mSyntheticXs == null || mSyntheticYs == null) return;
+ final int n = Math.min(mSyntheticSize, Math.min(mSyntheticXs.length, mSyntheticYs.length));
for (int i = 0; i < n; i++) {
- canvas.drawCircle(xs[i], ys[i], radiusPx, paint);
+ final int x = mSyntheticXs[i];
+ final int y = mSyntheticYs[i];
+ canvas.drawLine(x - SYNTHETIC_CROSS_RADIUS_PX, y, x + SYNTHETIC_CROSS_RADIUS_PX,
+ y, mSyntheticPaint);
+ canvas.drawLine(x, y - SYNTHETIC_CROSS_RADIUS_PX, x,
+ y + SYNTHETIC_CROSS_RADIUS_PX, mSyntheticPaint);
+ }
+ }
+
+ private int colorForFragment(final int fragmentId) {
+ final int positiveId = fragmentId & 0x7fffffff;
+ final float[] baseHsv = FRAGMENT_HSV[positiveId % FRAGMENT_HSV.length];
+ mColorHsv[0] = baseHsv[0];
+ mColorHsv[1] = baseHsv[1];
+ mColorHsv[2] = baseHsv[2]
+ * Math.max(0.45f, 1.0f - 0.16f * (positiveId / FRAGMENT_HSV.length));
+ return Color.HSVToColor(mColorHsv);
+ }
+
+ private static int[] append(final int[] existing, final int offset,
+ final int[] src, final int length) {
+ if (length <= 0) return existing;
+ final int[] result = ensureCapacity(existing, offset + length);
+ System.arraycopy(src, 0, result, offset, length);
+ return result;
+ }
+
+ private static int[] appendFilled(final int[] existing, final int offset,
+ final int value, final int length) {
+ if (length <= 0) return existing;
+ final int[] result = ensureCapacity(existing, offset + length);
+ Arrays.fill(result, offset, offset + length, value);
+ return result;
+ }
+
+ private static int[] ensureCapacity(final int[] existing, final int neededLength) {
+ if (existing != null && existing.length >= neededLength) return existing;
+ int newLength = existing == null ? 8 : existing.length;
+ while (newLength < neededLength) {
+ newLength *= 2;
}
+ return existing == null ? new int[newLength] : Arrays.copyOf(existing, newLength);
}
- private static int[] copyOfLength(final int[] src, final int length) {
- if (length <= 0) return null;
- final int[] copy = new int[length];
- System.arraycopy(src, 0, copy, 0, length);
- return copy;
+ private static boolean isTapLike(@NonNull final InputPointers pointers) {
+ final int size = pointers.getPointerSize();
+ if (size == 0 || size > TAP_MAX_POINTS) return false;
+ final int[] times = pointers.getTimes();
+ return times[size - 1] - times[0] <= TAP_MAX_DURATION_MS;
}
}
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 c531cba49..14a93db93 100644
--- a/app/src/main/java/helium314/keyboard/latin/inputlogic/InputLogic.java
+++ b/app/src/main/java/helium314/keyboard/latin/inputlogic/InputLogic.java
@@ -153,6 +153,7 @@ public final class InputLogic {
private final Handler mCombiningHandler = new Handler(Looper.getMainLooper());
@Nullable private Runnable mPendingCombiningCommit;
private boolean mInCombiningMode;
+ private boolean mCombiningWordHasGestureFragment;
// Keeps track of most recently inserted text (multi-character key) for
// reverting
@@ -217,6 +218,17 @@ private void clearOneShotSpaceActionAndNotifyIfChanged() {
}
}
+ private void clearGestureDebugOverlayAfterSpaceIfStartingWord(final int spaceState) {
+ final MainKeyboardView kv = KeyboardSwitcher.getInstance().getMainKeyboardView();
+ if (kv == null || !kv.hasGestureDebugPoints()) return;
+ if (mAutospaceJustWritten
+ || SpaceState.PHANTOM == spaceState
+ || SpaceState.WEAK == spaceState
+ || Constants.CODE_SPACE == mConnection.getCodePointBeforeCursor()) {
+ kv.clearGestureDebugPoints();
+ }
+ }
+
private void consumeJoinNextActionAndNotifyIfChanged() {
if (OneShotSpaceAction.consumeJoinNext()) {
mLatinIME.onOneShotSpaceActionStateChanged();
@@ -625,6 +637,10 @@ public InputTransaction onCodeInput(final SettingsValues settingsValues,
final InputTransaction inputTransaction = new InputTransaction(settingsValues,
processedEvent, SystemClock.uptimeMillis(), mSpaceState,
getActualCapsMode(settingsValues, keyboardShiftMode));
+ if (!processedEvent.isFunctionalKeyEvent()
+ && settingsValues.isWordCodePoint(processedEvent.getCodePoint())) {
+ clearGestureDebugOverlayAfterSpaceIfStartingWord(inputTransaction.getSpaceState());
+ }
if (processedEvent.getKeyCode() != KeyCode.DELETE
|| inputTransaction.getTimestamp() > mLastKeyTime + Constants.LONG_PRESS_MILLISECONDS) {
mDeleteCount = 0;
@@ -678,6 +694,7 @@ public InputTransaction onCodeInput(final SettingsValues settingsValues,
public void onStartBatchInput(final SettingsValues settingsValues,
final KeyboardSwitcher keyboardSwitcher, final LatinIME.UIHandler handler) {
+ clearGestureDebugOverlayAfterSpaceIfStartingWord(mSpaceState);
markForceNextSpaceWordStarted();
// Snapshot the keyboard's shift mode BEFORE any state mutates — the shifted indicator
// typically auto-clears once the gesture starts moving, so by the time
@@ -926,8 +943,11 @@ private void enterCombiningMode(final SettingsValues settingsValues, final boole
if (kv != null) {
final boolean showAutospaceIndicator = settingsValues.shouldInsertSpacesAutomatically()
&& settingsValues.mSpacingAndPunctuations.mCurrentLanguageHasSpaces
+ && (!settingsValues.mCombiningAutospaceOnlyAfterGesture
+ || mCombiningWordHasGestureFragment)
&& !mSuppressAutospaceForForceNextSpace;
- kv.setCombiningMode(showAutospaceIndicator, startTime, graceMs);
+ kv.setCombiningMode(showAutospaceIndicator, startTime, graceMs,
+ true /* compositionActiveForDebug */);
}
}
@@ -953,7 +973,8 @@ private void enterJoinNextMode(final SettingsValues settingsValues) {
final boolean showAutospaceIndicator = settingsValues.shouldInsertSpacesAutomatically()
&& settingsValues.mSpacingAndPunctuations.mCurrentLanguageHasSpaces;
kv.setCombiningMode(showAutospaceIndicator, SystemClock.uptimeMillis(),
- Math.max(0, settingsValues.mCombiningGraceMs));
+ Math.max(0, settingsValues.mCombiningGraceMs),
+ true /* compositionActiveForDebug */);
}
}
}
@@ -999,6 +1020,7 @@ void cancelCombiningMode() {
mAutoCommitRevertLength = 0;
mLastGestureCommittedLength = 0;
mAutospaceJustWritten = false;
+ mCombiningWordHasGestureFragment = false;
if (mInCombiningMode) {
mInCombiningMode = false;
final MainKeyboardView kv = KeyboardSwitcher.getInstance().getMainKeyboardView();
@@ -1069,7 +1091,7 @@ private void onCombiningGraceExpired() {
// Capture whether the word being committed by this timer came from a gesture. We
// use this AFTER commit to decide whether to arm the "backspace deletes whole word"
// behavior. Tap-only words don't qualify — char-by-char backspace is fine for those.
- final boolean wasBatchMode = mWordComposer.isBatchMode();
+ final boolean wordHadGestureFragment = mCombiningWordHasGestureFragment || mWordComposer.isBatchMode();
// Snapshot the typed word's length BEFORE commit — the composing text is already in
// the editor (added via setComposingTextInternal during the tap/gesture path), so the
// commit itself doesn't move the cursor. We need the typed-word length plus whatever
@@ -1086,7 +1108,11 @@ private void onCombiningGraceExpired() {
}
// Track whether the helper actually wrote a space (skipped for URL / e-mail / phantom).
final int beforeSpace = mConnection.getExpectedSelectionEnd();
- insertAutomaticSpaceIfOptionsAndTextAllow(sv);
+ if (!sv.mCombiningAutospaceOnlyAfterGesture || wordHadGestureFragment) {
+ insertAutomaticSpaceIfOptionsAndTextAllow(sv);
+ } else {
+ clearOneShotSpaceActionAndNotifyIfChanged();
+ }
final boolean autospaceInserted = mConnection.getExpectedSelectionEnd() > beforeSpace;
// If we DID insert an autospace, fix up mLastComposedWord so revertCommit (backspace +
// PREF_BACKSPACE_REVERTS_AUTOCORRECT) deletes the space along with the word. Without
@@ -1139,16 +1165,16 @@ private void onCombiningGraceExpired() {
mAutospaceAlternativesPending = false;
mAutoCommitRevertLength = 0;
final String mode = sv.mCombiningAutospaceSuggestions;
- if ("next_word".equals(mode)) {
+ if (autospaceInserted && "next_word".equals(mode)) {
if (mSuggestionStripViewAccessor != null) {
setSuggestedWords(SuggestedWords.getEmptyInstance());
}
mLatinIME.mHandler.postUpdateSuggestionStrip(SuggestedWords.INPUT_STYLE_NONE);
- } else if ("alternatives_then_next_word".equals(mode)) {
+ } else if (autospaceInserted && "alternatives_then_next_word".equals(mode)) {
// Leave the strip alone; arm the swap-on-next-space behavior AND the revert window.
mAutospaceAlternativesPending = true;
mAutoCommitRevertLength = writtenChars;
- } else if ("keep_alternatives".equals(mode)) {
+ } else if ("keep_alternatives".equals(mode) || !autospaceInserted) {
// Arm the revert window so the next suggestion pick replaces the auto-committed
// word rather than appending to it.
mAutoCommitRevertLength = writtenChars;
@@ -1156,7 +1182,7 @@ private void onCombiningGraceExpired() {
// Independent of the strip behavior: if the timer just committed a GESTURE word,
// arm "first backspace deletes the whole word" — see handleBackspaceEvent. Stays
// 0 for tap-only commits, where char-by-char delete is the right behavior.
- if (wasBatchMode) {
+ if (wordHadGestureFragment) {
mLastGestureCommittedLength = writtenChars;
}
// "keep_alternatives" — fall through, do nothing.
@@ -2260,6 +2286,7 @@ private void handleBackspaceEvent(final Event event, final InputTransaction inpu
// false.
}
if (mWordComposer.isComposingWord()) {
+ boolean deletedWholeComposingWord = false;
if (mWordComposer.isBatchMode()) {
final String rejectedSuggestion = mWordComposer.getTypedWord();
mWordComposer.reset();
@@ -2269,13 +2296,27 @@ private void handleBackspaceEvent(final Event event, final InputTransaction inpu
Constants.EVENT_REJECTION);
}
StatsUtils.onBackspaceWordDelete(rejectedSuggestion.length());
+ } else if ((inputTransaction.getSettingsValues().mGestureManualSpacing
+ || inputTransaction.getSettingsValues().mCombiningGraceMs > 0)
+ && inputTransaction.getSettingsValues().mCombiningBackspaceDeletesGestureWord) {
+ final int wordLength = mWordComposer.getTypedWord().length();
+ if (inputTransaction.getSettingsValues().mCombiningBackspaceDeletesComposingText) {
+ mConnection.beginBatchEdit();
+ mConnection.deleteTextBeforeCursor(wordLength);
+ mConnection.endBatchEdit();
+ } else {
+ mConnection.finishComposingText();
+ }
+ mWordComposer.reset();
+ deletedWholeComposingWord = true;
+ StatsUtils.onBackspaceWordDelete(wordLength);
} else {
mWordComposer.applyProcessedEvent(event);
StatsUtils.onBackspacePressed(1);
}
if (mWordComposer.isComposingWord()) {
setComposingTextInternal(getTextWithUnderline(mWordComposer.getTypedWord()), 1);
- } else {
+ } else if (!deletedWholeComposingWord) {
mConnection.commitText("", 1);
}
updateInlineEmojiSearch();
@@ -3268,6 +3309,7 @@ private void resetComposingState(final boolean alsoResetLastComposedWord) {
mWordComposer.reset();
// Two-thumb typing (#1.1): composing word is wiped — drop the matching boundaries.
clearFragmentBoundaries();
+ mCombiningWordHasGestureFragment = false;
// Combining mode is keyed on a composing word existing; if we're wiping it, the
// pending timer would commit nothing useful, so cancel.
cancelCombiningMode();
@@ -3482,6 +3524,19 @@ private boolean textBeforeCursorMayBeUrlOrSimilar(final SettingsValues settingsV
public void onUpdateTailBatchInputCompleted(final SettingsValues settingsValues,
final SuggestedWords suggestedWords, final KeyboardSwitcher keyboardSwitcher) {
String batchInputText = suggestedWords.isEmpty() ? null : suggestedWords.getWord(0);
+ if (settingsValues.mGestureDebugDrawPoints) {
+ final StringBuilder candidates = new StringBuilder();
+ final int candidateCount = Math.min(suggestedWords.size(), 6);
+ for (int i = 0; i < candidateCount; i++) {
+ if (i > 0) candidates.append(" | ");
+ candidates.append(i).append(':').append(suggestedWords.getWord(i));
+ }
+ Log.d(TAG, "batch candidates size=" + suggestedWords.size()
+ + " top=" + batchInputText
+ + " composingBefore=" + mWordComposer.getTypedWord()
+ + " extendBase=" + mWordComposer.isExtendBatchInputBaseSet()
+ + " candidates=[" + candidates + "]");
+ }
// Multi-part word composition (#1.6): when the merged-trail extend-base path was
// used, the lib returns the full word but its top pick is sometimes an obscure
// hyphenated/mid-cased dictionary entry ("technon-U") that ranks just above the
@@ -3524,6 +3579,7 @@ public void onUpdateTailBatchInputCompleted(final SettingsValues settingsValues,
}
}
if (batchInputText.isEmpty()) return;
+ mCombiningWordHasGestureFragment = true;
mConnection.beginBatchEdit();
// Two-thumb typing (#1.1 + #1.4): when either manual spacing OR tap-promotion-extend
// applies, a follow-up gesture EXTENDS the existing composing word rather than
@@ -3556,6 +3612,14 @@ public void onUpdateTailBatchInputCompleted(final SettingsValues settingsValues,
mWordComposer.setExtendBatchInputBase(null);
final String prevTypedWord = (extendExistingCompose && !usedMergedTrail)
? mWordComposer.getTypedWord() : "";
+ if (settingsValues.mGestureDebugDrawPoints) {
+ Log.d(TAG, "batch merge seed=" + seedCp
+ + " extendExisting=" + extendExistingCompose
+ + " combiningExtends=" + combiningExtendsSwipe
+ + " usedMergedTrail=" + usedMergedTrail
+ + " prevTyped='" + prevTypedWord + "'"
+ + " chosen='" + batchInputText + "'");
+ }
// Auto-capitalize the first letter of a fresh-word gesture when the keyboard is in
// auto-shifted / manual-shifted / shift-locked state. The gesture-recognizer always
// returns lowercase, so without this fix swiping "Hello" at sentence-start types
@@ -3579,6 +3643,9 @@ public void onUpdateTailBatchInputCompleted(final SettingsValues settingsValues,
// commit later.
mShiftModeAtGestureStart = WordComposer.CAPS_MODE_OFF;
final String composedText = prevTypedWord + batchInputText;
+ if (settingsValues.mGestureDebugDrawPoints) {
+ Log.d(TAG, "batch composed='" + composedText + "'");
+ }
// Two-thumb typing (#1.1 + #1.4): never silently prepend an autospace when extending an
// existing composing word. {@code mSpaceState} can carry PHANTOM in from prior
// punctuation / suggestion-pick paths; clearing it here (without inserting) is the
@@ -3821,6 +3888,7 @@ private void commitChosenWord(final SettingsValues settingsValues, final String
// Two-thumb typing (#1.1): the composing word is gone — any tracked fragment
// boundaries are now stale and would point past the end of the (empty) word.
clearFragmentBoundaries();
+ mCombiningWordHasGestureFragment = false;
if (DebugFlags.DEBUG_ENABLED) {
long runTimeMillis = System.currentTimeMillis() - startTimeMillis;
Log.d(TAG, "commitChosenWord() : " + runTimeMillis + " ms to run "
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 073071296..8645d3c2e 100644
--- a/app/src/main/java/helium314/keyboard/latin/settings/Defaults.kt
+++ b/app/src/main/java/helium314/keyboard/latin/settings/Defaults.kt
@@ -129,20 +129,22 @@ object Defaults {
const val PREF_GESTURE_MANUAL_SPACING = false
const val PREF_GESTURE_FRAGMENT_BACKSPACE = true
const val PREF_GESTURE_AUTOSPACE_GRACE_MS = 0
- const val PREF_GESTURE_TAP_DURING_SWIPE = false
- const val PREF_GESTURE_TAP_AS_SWIPE_WINDOW_MS = 60
const val PREF_GESTURE_TAP_PROMOTION_MS = 0
const val PREF_GESTURE_DUAL_THUMB_HINTING = false
const val PREF_GESTURE_DUAL_THUMB_MIDLINE_PCT = 50
const val PREF_GESTURE_DEBUG_DRAW_POINTS = false
+ const val PREF_GESTURE_DEBUG_ACCUMULATE_FRAGMENTS = true
const val PREF_GESTURE_APOSTROPHE_KEY = false
const val PREF_AUTOSPACE_VISUAL_HINT = true
// Unified combining-mode model (default 0 = off; 500 ms is a comfortable starting point).
const val PREF_COMBINING_GRACE_MS = 0
+ const val PREF_COMBINING_LAST_AUTOSPACE_GRACE_MS = 500
const val PREF_COMBINING_AUTOCORRECT_ON_AUTOSPACE = true
const val PREF_COMBINING_TAP_EXTRA_MS = 250
- const val PREF_COMBINING_AUTOSPACE_SUGGESTIONS = "next_word"
+ const val PREF_COMBINING_AUTOSPACE_ONLY_AFTER_GESTURE = false
+ const val PREF_COMBINING_AUTOSPACE_SUGGESTIONS = "alternatives_then_next_word"
const val PREF_COMBINING_BACKSPACE_DELETES_GESTURE_WORD = true
+ const val PREF_COMBINING_BACKSPACE_DELETES_COMPOSING_TEXT = true
// Multi-part word composition (this branch). All default-on except the explicit join key.
const val PREF_MULTIPART_AUTO_EXTEND_IN_COMBINING = true
const val PREF_MULTIPART_FULL_WORD_SUGGESTIONS = true
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 e99d3e22e..a9a404d04 100644
--- a/app/src/main/java/helium314/keyboard/latin/settings/Settings.java
+++ b/app/src/main/java/helium314/keyboard/latin/settings/Settings.java
@@ -135,22 +135,23 @@ public final class Settings implements SharedPreferences.OnSharedPreferenceChang
public static final String PREF_GESTURE_MANUAL_SPACING = "gesture_manual_spacing";
public static final String PREF_GESTURE_FRAGMENT_BACKSPACE = "gesture_fragment_backspace";
public static final String PREF_GESTURE_AUTOSPACE_GRACE_MS = "gesture_autospace_grace_ms";
- public static final String PREF_GESTURE_TAP_DURING_SWIPE = "gesture_tap_during_swipe";
- public static final String PREF_GESTURE_TAP_AS_SWIPE_WINDOW_MS = "gesture_tap_as_swipe_window_ms";
public static final String PREF_GESTURE_TAP_PROMOTION_MS = "gesture_tap_promotion_ms";
public static final String PREF_GESTURE_DUAL_THUMB_HINTING = "gesture_dual_thumb_hinting";
public static final String PREF_GESTURE_DUAL_THUMB_MIDLINE_PCT = "gesture_dual_thumb_midline_pct";
public static final String PREF_GESTURE_DEBUG_DRAW_POINTS = "gesture_debug_draw_points";
+ public static final String PREF_GESTURE_DEBUG_ACCUMULATE_FRAGMENTS = "gesture_debug_accumulate_fragments";
public static final String PREF_GESTURE_APOSTROPHE_KEY = "gesture_apostrophe_key";
public static final String PREF_AUTOSPACE_VISUAL_HINT = "autospace_visual_hint";
// Unified "combining-mode" model: after every composing-word-extending event (tap OR
// gesture), wait this many milliseconds; any new tap/gesture within the window EXTENDS
// the same composing word, anything else (or expiry) commits + autospaces. 0 disables.
public static final String PREF_COMBINING_GRACE_MS = "combining_grace_ms";
+ public static final String PREF_COMBINING_LAST_AUTOSPACE_GRACE_MS = "combining_last_autospace_grace_ms";
public static final String PREF_COMBINING_AUTOCORRECT_ON_AUTOSPACE = "combining_autocorrect_on_autospace";
// Additional grace added on top of PREF_COMBINING_GRACE_MS when the most recent input
// was a tap (peck-typists need more headroom than swipers between letters). 0 = no extra.
public static final String PREF_COMBINING_TAP_EXTRA_MS = "combining_tap_extra_ms";
+ public static final String PREF_COMBINING_AUTOSPACE_ONLY_AFTER_GESTURE = "combining_autospace_only_after_gesture";
// What the suggestion strip shows after the combining grace timer auto-commits a word.
// Values: "keep_alternatives" (1) | "next_word" (2, default) | "alternatives_then_next_word" (3).
public static final String PREF_COMBINING_AUTOSPACE_SUGGESTIONS = "combining_autospace_suggestions";
@@ -158,6 +159,7 @@ public final class Settings implements SharedPreferences.OnSharedPreferenceChang
// in one keystroke (unless an autocorrect-revert applies — that always goes first). Tap
// typing is unaffected; this only kicks in when the just-committed word came from a swipe.
public static final String PREF_COMBINING_BACKSPACE_DELETES_GESTURE_WORD = "combining_backspace_deletes_gesture_word";
+ public static final String PREF_COMBINING_BACKSPACE_DELETES_COMPOSING_TEXT = "combining_backspace_deletes_composing_text";
// Multi-part word composition: when combining mode is armed (grace timer running), the
// next swipe/tap concatenates onto the existing composing word rather than starting a
// new one. Allows tech+nology -> technology and tech+y -> Techy without an explicit
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 53c51266c..81650ec9e 100644
--- a/app/src/main/java/helium314/keyboard/latin/settings/SettingsValues.java
+++ b/app/src/main/java/helium314/keyboard/latin/settings/SettingsValues.java
@@ -119,21 +119,22 @@ public class SettingsValues {
public final boolean mGestureManualSpacing;
public final boolean mGestureFragmentBackspace;
public final int mGestureAutospaceGraceMs;
- public final boolean mGestureTapDuringSwipe;
- public final int mGestureTapAsSwipeWindowMs;
public final int mGestureTapPromotionMs;
public final boolean mGestureDualThumbHinting;
public final int mGestureDualThumbMidlinePct;
public final boolean mGestureDebugDrawPoints;
+ public final boolean mGestureDebugAccumulateFragments;
public final boolean mGestureApostropheKey;
public final boolean mAutospaceVisualHint;
// Unified combining-mode (replaces gesture-only grace + tap-promotion). Default 0 = off.
public final int mCombiningGraceMs;
public final boolean mCombiningAutocorrectOnAutospace;
public final int mCombiningTapExtraMs;
+ public final boolean mCombiningAutospaceOnlyAfterGesture;
// Raw string value: "keep_alternatives" | "next_word" | "alternatives_then_next_word"
public final String mCombiningAutospaceSuggestions;
public final boolean mCombiningBackspaceDeletesGestureWord;
+ public final boolean mCombiningBackspaceDeletesComposingText;
// Multi-part word composition (this branch).
public final boolean mMultipartAutoExtendInCombining;
public final boolean mMultipartFullWordSuggestions;
@@ -335,10 +336,6 @@ public SettingsValues(final Context context, final SharedPreferences prefs, fina
Defaults.PREF_GESTURE_FRAGMENT_BACKSPACE);
mGestureAutospaceGraceMs = prefs.getInt(Settings.PREF_GESTURE_AUTOSPACE_GRACE_MS,
Defaults.PREF_GESTURE_AUTOSPACE_GRACE_MS);
- mGestureTapDuringSwipe = prefs.getBoolean(Settings.PREF_GESTURE_TAP_DURING_SWIPE,
- Defaults.PREF_GESTURE_TAP_DURING_SWIPE);
- mGestureTapAsSwipeWindowMs = prefs.getInt(Settings.PREF_GESTURE_TAP_AS_SWIPE_WINDOW_MS,
- Defaults.PREF_GESTURE_TAP_AS_SWIPE_WINDOW_MS);
mGestureTapPromotionMs = prefs.getInt(Settings.PREF_GESTURE_TAP_PROMOTION_MS,
Defaults.PREF_GESTURE_TAP_PROMOTION_MS);
mGestureDualThumbHinting = prefs.getBoolean(Settings.PREF_GESTURE_DUAL_THUMB_HINTING,
@@ -347,6 +344,9 @@ public SettingsValues(final Context context, final SharedPreferences prefs, fina
Defaults.PREF_GESTURE_DUAL_THUMB_MIDLINE_PCT);
mGestureDebugDrawPoints = prefs.getBoolean(Settings.PREF_GESTURE_DEBUG_DRAW_POINTS,
Defaults.PREF_GESTURE_DEBUG_DRAW_POINTS);
+ mGestureDebugAccumulateFragments = prefs.getBoolean(
+ Settings.PREF_GESTURE_DEBUG_ACCUMULATE_FRAGMENTS,
+ Defaults.PREF_GESTURE_DEBUG_ACCUMULATE_FRAGMENTS);
mGestureApostropheKey = prefs.getBoolean(Settings.PREF_GESTURE_APOSTROPHE_KEY,
Defaults.PREF_GESTURE_APOSTROPHE_KEY);
mAutospaceVisualHint = prefs.getBoolean(Settings.PREF_AUTOSPACE_VISUAL_HINT,
@@ -358,18 +358,25 @@ public SettingsValues(final Context context, final SharedPreferences prefs, fina
Defaults.PREF_COMBINING_AUTOCORRECT_ON_AUTOSPACE);
mCombiningTapExtraMs = prefs.getInt(Settings.PREF_COMBINING_TAP_EXTRA_MS,
Defaults.PREF_COMBINING_TAP_EXTRA_MS);
+ mCombiningAutospaceOnlyAfterGesture = prefs.getBoolean(
+ Settings.PREF_COMBINING_AUTOSPACE_ONLY_AFTER_GESTURE,
+ Defaults.PREF_COMBINING_AUTOSPACE_ONLY_AFTER_GESTURE);
mCombiningAutospaceSuggestions = prefs.getString(Settings.PREF_COMBINING_AUTOSPACE_SUGGESTIONS,
Defaults.PREF_COMBINING_AUTOSPACE_SUGGESTIONS);
+ final boolean nonNormalTwoThumbSpacing = mGestureManualSpacing || mCombiningGraceMs > 0;
mCombiningBackspaceDeletesGestureWord = prefs.getBoolean(
Settings.PREF_COMBINING_BACKSPACE_DELETES_GESTURE_WORD,
Defaults.PREF_COMBINING_BACKSPACE_DELETES_GESTURE_WORD);
- mMultipartAutoExtendInCombining = prefs.getBoolean(
+ mCombiningBackspaceDeletesComposingText = prefs.getBoolean(
+ Settings.PREF_COMBINING_BACKSPACE_DELETES_COMPOSING_TEXT,
+ Defaults.PREF_COMBINING_BACKSPACE_DELETES_COMPOSING_TEXT);
+ mMultipartAutoExtendInCombining = nonNormalTwoThumbSpacing || prefs.getBoolean(
Settings.PREF_MULTIPART_AUTO_EXTEND_IN_COMBINING,
Defaults.PREF_MULTIPART_AUTO_EXTEND_IN_COMBINING);
mMultipartFullWordSuggestions = prefs.getBoolean(
Settings.PREF_MULTIPART_FULL_WORD_SUGGESTIONS,
Defaults.PREF_MULTIPART_FULL_WORD_SUGGESTIONS);
- mMultipartTapSeedGesture = prefs.getBoolean(
+ mMultipartTapSeedGesture = nonNormalTwoThumbSpacing || prefs.getBoolean(
Settings.PREF_MULTIPART_TAP_SEED_GESTURE,
Defaults.PREF_MULTIPART_TAP_SEED_GESTURE);
mMultipartJoinKeyMode = prefs.getString(Settings.PREF_MULTIPART_JOIN_KEY_MODE,
diff --git a/app/src/main/java/helium314/keyboard/settings/SettingsContainer.kt b/app/src/main/java/helium314/keyboard/settings/SettingsContainer.kt
index 5339bd204..5dc52e6a5 100644
--- a/app/src/main/java/helium314/keyboard/settings/SettingsContainer.kt
+++ b/app/src/main/java/helium314/keyboard/settings/SettingsContainer.kt
@@ -94,6 +94,8 @@ object SettingsWithoutKey {
const val PERSIST_FLOATING_KEYBOARD = "persist_floating_keyboard"
const val DEBUG_SETTINGS = "screen_debug"
const val LOAD_GESTURE_LIB = "load_gesture_library"
+ const val TWO_THUMB_SPACING_MODE = "two_thumb_spacing_mode"
+ const val TWO_THUMB_BACKSPACE_BEHAVIOR = "two_thumb_backspace_behavior"
const val BACKGROUND_IMAGE = "background_image"
const val BACKGROUND_IMAGE_LANDSCAPE = "background_image_landscape"
const val CUSTOM_FONT = "custom_font"
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 f1641c1a9..94c29334c 100644
--- a/app/src/main/java/helium314/keyboard/settings/screens/TwoThumbTypingScreen.kt
+++ b/app/src/main/java/helium314/keyboard/settings/screens/TwoThumbTypingScreen.kt
@@ -2,12 +2,23 @@
package helium314.keyboard.settings.screens
import android.content.Context
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.core.content.edit
import helium314.keyboard.latin.R
import helium314.keyboard.latin.settings.Defaults
import helium314.keyboard.latin.settings.Settings
@@ -18,25 +29,16 @@ import helium314.keyboard.latin.utils.prefs
import helium314.keyboard.settings.SearchSettingsScreen
import helium314.keyboard.settings.Setting
import helium314.keyboard.settings.SettingsActivity
+import helium314.keyboard.settings.SettingsWithoutKey
import helium314.keyboard.settings.Theme
+import helium314.keyboard.settings.dialogs.ListPickerDialog
import helium314.keyboard.settings.initPreview
import helium314.keyboard.settings.preferences.ListPreference
+import helium314.keyboard.settings.preferences.Preference
import helium314.keyboard.settings.preferences.SliderPreference
import helium314.keyboard.settings.preferences.SwitchPreference
import helium314.keyboard.settings.previewDark
-/**
- * Dedicated screen for the experimental two-thumb typing features (HeliBoard issue #291).
- * Every option here defaults to off / 0 — turning the whole screen into a no-op until the
- * user opts in. Subsections group features around the user-facing behaviour (manual vs.
- * autospace-grace, tap+swipe interactions, point hinting, debug overlay) rather than around
- * the underlying code paths.
- *
- * The screen is gated on {@code JniUtils.sHaveGestureLib} + {@code PREF_GESTURE_INPUT}: with
- * either of those off, none of the toggles have any effect, so we surface a single hint row
- * and skip everything else. That keeps the experimental options out of the user's way until
- * they're actually relevant.
- */
@Composable
fun TwoThumbTypingScreen(
onClickBack: () -> Unit,
@@ -49,83 +51,80 @@ fun TwoThumbTypingScreen(
Log.v("irrelevant", "stupid way to trigger recomposition on preference change")
val hasGestureLib = JniUtils.sHaveGestureLib
- val gestureEnabled = hasGestureLib && prefs.getBoolean(Settings.PREF_GESTURE_INPUT, Defaults.PREF_GESTURE_INPUT)
+ val gesturePrefEnabled = prefs.getBoolean(Settings.PREF_GESTURE_INPUT, Defaults.PREF_GESTURE_INPUT)
+ val gestureEnabled = hasGestureLib && gesturePrefEnabled
+ val spacingMode = currentSpacingMode(prefs)
+ val autospaceMode = spacingMode == SPACING_MODE_AUTOSPACE
+ val nonNormalSpacing = spacingMode != SPACING_MODE_NORMAL
+ val backspaceBehavior = currentBackspaceBehavior(prefs)
+ val dualThumbHinting = prefs.getBoolean(Settings.PREF_GESTURE_DUAL_THUMB_HINTING, Defaults.PREF_GESTURE_DUAL_THUMB_HINTING)
+ val debugDrawPoints = prefs.getBoolean(Settings.PREF_GESTURE_DEBUG_DRAW_POINTS, Defaults.PREF_GESTURE_DEBUG_DRAW_POINTS)
val items = buildList {
- if (!gestureEnabled) {
- // Nothing useful to show — every toggle is a no-op until gesture typing is on.
- // We deliberately don't even show the section headers; the user lands on an empty
- // screen with just the search box, which is the clearest signal "you need to enable
- // gesture typing first".
- return@buildList
- }
-
- // --- Combining mode (unified replacement for autospace grace + tap promotion + flash) ---
- add(R.string.settings_category_two_thumb_typing_spacing)
- add(Settings.PREF_COMBINING_GRACE_MS)
- if (prefs.getInt(Settings.PREF_COMBINING_GRACE_MS, Defaults.PREF_COMBINING_GRACE_MS) > 0) {
+ add(R.string.settings_category_two_thumb_typing_words)
+ add(SettingsWithoutKey.TWO_THUMB_SPACING_MODE)
+ if (autospaceMode) {
+ add(Settings.PREF_COMBINING_GRACE_MS)
add(Settings.PREF_COMBINING_TAP_EXTRA_MS)
+ add(Settings.PREF_COMBINING_AUTOSPACE_ONLY_AFTER_GESTURE)
add(Settings.PREF_COMBINING_AUTOCORRECT_ON_AUTOSPACE)
add(Settings.PREF_COMBINING_AUTOSPACE_SUGGESTIONS)
- add(Settings.PREF_COMBINING_BACKSPACE_DELETES_GESTURE_WORD)
- // Multi-part word composition (only meaningful when combining grace > 0).
- add(Settings.PREF_MULTIPART_AUTO_EXTEND_IN_COMBINING)
- if (prefs.getBoolean(Settings.PREF_MULTIPART_AUTO_EXTEND_IN_COMBINING,
- Defaults.PREF_MULTIPART_AUTO_EXTEND_IN_COMBINING)) {
- add(Settings.PREF_MULTIPART_FULL_WORD_SUGGESTIONS)
- add(Settings.PREF_MULTIPART_TAP_SEED_GESTURE)
- add(Settings.PREF_MULTIPART_JOIN_KEY_MODE)
- // Show fragment-backspace alongside multi-part: backspace popping the last
- // joined fragment is the natural "undo a bad join" gesture.
- add(Settings.PREF_GESTURE_FRAGMENT_BACKSPACE)
- }
}
- add(Settings.PREF_GESTURE_MANUAL_SPACING)
- val manualSpacing = prefs.getBoolean(Settings.PREF_GESTURE_MANUAL_SPACING, Defaults.PREF_GESTURE_MANUAL_SPACING)
- if (manualSpacing) {
- // Sub-option of manual spacing too. Multi-part also shows it above; we don't
- // double-add since SettingsContainer dedupes by key.
- add(Settings.PREF_GESTURE_FRAGMENT_BACKSPACE)
- }
-
- // --- Tap / swipe interaction tweaks (#1.3) ---
- add(R.string.settings_category_two_thumb_typing_taps)
- add(Settings.PREF_GESTURE_TAP_DURING_SWIPE)
- if (prefs.getBoolean(Settings.PREF_GESTURE_TAP_DURING_SWIPE, Defaults.PREF_GESTURE_TAP_DURING_SWIPE)) {
- add(Settings.PREF_GESTURE_TAP_AS_SWIPE_WINDOW_MS)
+ if (nonNormalSpacing) {
+ add(Settings.PREF_MULTIPART_FULL_WORD_SUGGESTIONS)
+ add(SettingsWithoutKey.TWO_THUMB_BACKSPACE_BEHAVIOR)
+ if (backspaceBehavior == BACKSPACE_WORD) {
+ add(Settings.PREF_COMBINING_BACKSPACE_DELETES_COMPOSING_TEXT)
+ }
}
- // --- Layout-side (#2.3): apostrophe key for contractions. Layout edit still owed to
- // the user; the toggle is here so it's discoverable once that's in place. ---
- add(R.string.settings_category_two_thumb_typing_layout)
- add(Settings.PREF_GESTURE_APOSTROPHE_KEY)
-
- // --- Point hinting + debug overlay (#2.1). The midline only matters when hinting is
- // on, so it's nested under the toggle. ---
- add(R.string.settings_category_two_thumb_typing_hinting)
+ add(R.string.settings_category_two_thumb_typing_recognition)
add(Settings.PREF_GESTURE_DUAL_THUMB_HINTING)
- if (prefs.getBoolean(Settings.PREF_GESTURE_DUAL_THUMB_HINTING, Defaults.PREF_GESTURE_DUAL_THUMB_HINTING)) {
+ if (dualThumbHinting) {
add(Settings.PREF_GESTURE_DUAL_THUMB_MIDLINE_PCT)
}
+
+ add(R.string.settings_category_two_thumb_typing_troubleshooting)
add(Settings.PREF_GESTURE_DEBUG_DRAW_POINTS)
+ if (debugDrawPoints) {
+ add(Settings.PREF_GESTURE_DEBUG_ACCUMULATE_FRAGMENTS)
+ }
}
SearchSettingsScreen(
onClickBack = onClickBack,
title = stringResource(R.string.settings_screen_two_thumb_typing),
- settings = items,
+ settings = if (gestureEnabled) items else emptyList(),
+ content = if (!gestureEnabled) {
+ {
+ Surface(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp, vertical = 8.dp),
+ color = MaterialTheme.colorScheme.surfaceContainer,
+ shape = MaterialTheme.shapes.medium,
+ ) {
+ Text(
+ text = stringResource(if (hasGestureLib) {
+ R.string.two_thumb_typing_requires_gesture
+ } else {
+ R.string.two_thumb_typing_requires_gesture_library
+ }),
+ modifier = Modifier.padding(16.dp),
+ )
+ }
+ }
+ } else null,
)
}
-/**
- * Factory for all two-thumb typing {@link Setting} entries. Registered with
- * {@link helium314.keyboard.settings.SettingsContainer} so the entries are picked up by the
- * screen above AND remain searchable globally. Moved out of {@link #createGestureTypingSettings}
- * so the parent gesture screen stays uncluttered.
- */
fun createTwoThumbTypingSettings(context: Context) = listOf(
+ Setting(context, SettingsWithoutKey.TWO_THUMB_SPACING_MODE,
+ R.string.two_thumb_spacing_mode, R.string.two_thumb_spacing_mode_summary) {
+ TwoThumbSpacingModePreference(it)
+ },
Setting(context, Settings.PREF_COMBINING_GRACE_MS,
- R.string.combining_grace, R.string.combining_grace_summary) { def ->
+ R.string.two_thumb_autospace_duration, R.string.two_thumb_autospace_duration_summary) { def ->
SliderPreference(
name = def.title,
key = def.key,
@@ -141,6 +140,11 @@ fun createTwoThumbTypingSettings(context: Context) = listOf(
R.string.combining_autocorrect_on_autospace, R.string.combining_autocorrect_on_autospace_summary) {
SwitchPreference(it, Defaults.PREF_COMBINING_AUTOCORRECT_ON_AUTOSPACE)
},
+ Setting(context, Settings.PREF_COMBINING_AUTOSPACE_ONLY_AFTER_GESTURE,
+ R.string.combining_autospace_only_after_gesture,
+ R.string.combining_autospace_only_after_gesture_summary) {
+ SwitchPreference(it, Defaults.PREF_COMBINING_AUTOSPACE_ONLY_AFTER_GESTURE)
+ },
Setting(context, Settings.PREF_COMBINING_AUTOSPACE_SUGGESTIONS,
R.string.combining_autospace_suggestions, R.string.combining_autospace_suggestions_summary) { def ->
val items = listOf(
@@ -150,13 +154,8 @@ fun createTwoThumbTypingSettings(context: Context) = listOf(
)
ListPreference(def, items, Defaults.PREF_COMBINING_AUTOSPACE_SUGGESTIONS)
},
- Setting(context, Settings.PREF_COMBINING_BACKSPACE_DELETES_GESTURE_WORD,
- R.string.combining_backspace_deletes_gesture_word,
- R.string.combining_backspace_deletes_gesture_word_summary) {
- SwitchPreference(it, Defaults.PREF_COMBINING_BACKSPACE_DELETES_GESTURE_WORD)
- },
Setting(context, Settings.PREF_COMBINING_TAP_EXTRA_MS,
- R.string.combining_tap_extra, R.string.combining_tap_extra_summary) { def ->
+ R.string.two_thumb_tap_autospace_grace, R.string.two_thumb_tap_autospace_grace_summary) { def ->
SliderPreference(
name = def.title,
key = def.key,
@@ -168,55 +167,21 @@ fun createTwoThumbTypingSettings(context: Context) = listOf(
}
)
},
- Setting(context, Settings.PREF_GESTURE_MANUAL_SPACING,
- R.string.gesture_manual_spacing, R.string.gesture_manual_spacing_summary) {
- SwitchPreference(it, Defaults.PREF_GESTURE_MANUAL_SPACING)
+ Setting(context, SettingsWithoutKey.TWO_THUMB_BACKSPACE_BEHAVIOR,
+ R.string.two_thumb_backspace_behavior, R.string.two_thumb_backspace_behavior_summary) {
+ TwoThumbBackspaceBehaviorPreference(it)
},
- Setting(context, Settings.PREF_MULTIPART_AUTO_EXTEND_IN_COMBINING,
- R.string.multipart_auto_extend_in_combining, R.string.multipart_auto_extend_in_combining_summary) {
- SwitchPreference(it, Defaults.PREF_MULTIPART_AUTO_EXTEND_IN_COMBINING)
+ Setting(context, Settings.PREF_COMBINING_BACKSPACE_DELETES_COMPOSING_TEXT,
+ R.string.combining_backspace_deletes_composing_text,
+ R.string.combining_backspace_deletes_composing_text_summary) {
+ SwitchPreference(it, Defaults.PREF_COMBINING_BACKSPACE_DELETES_COMPOSING_TEXT)
},
Setting(context, Settings.PREF_MULTIPART_FULL_WORD_SUGGESTIONS,
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_TAP_SEED_GESTURE,
- R.string.multipart_tap_seed_gesture, R.string.multipart_tap_seed_gesture_summary) {
- SwitchPreference(it, Defaults.PREF_MULTIPART_TAP_SEED_GESTURE)
- },
- Setting(context, Settings.PREF_MULTIPART_JOIN_KEY_MODE,
- R.string.multipart_join_key_mode, R.string.multipart_join_key_mode_summary) { def ->
- val items = listOf(
- stringResource(R.string.multipart_join_key_mode_off) to "off",
- stringResource(R.string.multipart_join_key_mode_longpress_space) to "longpress_space",
- stringResource(R.string.multipart_join_key_mode_dedicated_key) to "dedicated_key",
- )
- ListPreference(def, items, Defaults.PREF_MULTIPART_JOIN_KEY_MODE)
- },
- Setting(context, Settings.PREF_GESTURE_FRAGMENT_BACKSPACE,
- R.string.gesture_fragment_backspace, R.string.gesture_fragment_backspace_summary) {
- SwitchPreference(it, Defaults.PREF_GESTURE_FRAGMENT_BACKSPACE)
- },
- Setting(context, Settings.PREF_GESTURE_TAP_DURING_SWIPE,
- R.string.gesture_tap_during_swipe, R.string.gesture_tap_during_swipe_summary) {
- SwitchPreference(it, Defaults.PREF_GESTURE_TAP_DURING_SWIPE)
- },
- Setting(context, Settings.PREF_GESTURE_TAP_AS_SWIPE_WINDOW_MS,
- R.string.gesture_tap_as_swipe_window, R.string.gesture_tap_as_swipe_window_summary) { def ->
- SliderPreference(
- name = def.title,
- key = def.key,
- default = Defaults.PREF_GESTURE_TAP_AS_SWIPE_WINDOW_MS,
- range = 0f..200f,
- description = { stringResource(R.string.abbreviation_unit_milliseconds, it.toString()) }
- )
- },
- Setting(context, Settings.PREF_GESTURE_APOSTROPHE_KEY,
- R.string.gesture_apostrophe_key, R.string.gesture_apostrophe_key_summary) {
- SwitchPreference(it, Defaults.PREF_GESTURE_APOSTROPHE_KEY)
- },
Setting(context, Settings.PREF_GESTURE_DUAL_THUMB_HINTING,
- R.string.gesture_dual_thumb_hinting, R.string.gesture_dual_thumb_hinting_summary) {
+ R.string.two_thumb_point_hinting, R.string.two_thumb_point_hinting_summary) {
SwitchPreference(it, Defaults.PREF_GESTURE_DUAL_THUMB_HINTING)
},
Setting(context, Settings.PREF_GESTURE_DUAL_THUMB_MIDLINE_PCT, R.string.gesture_dual_thumb_midline) { def ->
@@ -232,8 +197,124 @@ fun createTwoThumbTypingSettings(context: Context) = listOf(
R.string.gesture_debug_draw_points, R.string.gesture_debug_draw_points_summary) {
SwitchPreference(it, Defaults.PREF_GESTURE_DEBUG_DRAW_POINTS)
},
+ Setting(context, Settings.PREF_GESTURE_DEBUG_ACCUMULATE_FRAGMENTS,
+ R.string.gesture_debug_accumulate_fragments, R.string.gesture_debug_accumulate_fragments_summary) {
+ SwitchPreference(it, Defaults.PREF_GESTURE_DEBUG_ACCUMULATE_FRAGMENTS)
+ },
)
+private const val SPACING_MODE_NORMAL = "normal"
+private const val SPACING_MODE_MANUAL = "manual"
+private const val SPACING_MODE_AUTOSPACE = "autospace"
+
+private const val BACKSPACE_NORMAL = "normal"
+private const val BACKSPACE_FRAGMENT = "fragment"
+private const val BACKSPACE_WORD = "word"
+
+private fun currentSpacingMode(prefs: android.content.SharedPreferences): String = when {
+ prefs.getBoolean(Settings.PREF_GESTURE_MANUAL_SPACING, Defaults.PREF_GESTURE_MANUAL_SPACING) -> SPACING_MODE_MANUAL
+ prefs.getInt(Settings.PREF_COMBINING_GRACE_MS, Defaults.PREF_COMBINING_GRACE_MS) > 0 -> SPACING_MODE_AUTOSPACE
+ else -> SPACING_MODE_NORMAL
+}
+
+@Composable
+private fun TwoThumbSpacingModePreference(setting: Setting) {
+ val prefs = LocalContext.current.prefs()
+ var showDialog by rememberSaveable { mutableStateOf(false) }
+ val items = listOf(
+ stringResource(R.string.two_thumb_spacing_mode_normal) to SPACING_MODE_NORMAL,
+ stringResource(R.string.two_thumb_spacing_mode_manual) to SPACING_MODE_MANUAL,
+ stringResource(R.string.two_thumb_spacing_mode_autospace) to SPACING_MODE_AUTOSPACE,
+ )
+ val selected = currentSpacingMode(prefs)
+ Preference(
+ name = setting.title,
+ description = items.first { it.second == selected }.first,
+ onClick = { showDialog = true },
+ )
+ if (showDialog) {
+ ListPickerDialog(
+ onDismissRequest = { showDialog = false },
+ items = items,
+ onItemSelected = { item ->
+ val mode = item.second
+ prefs.edit {
+ putBoolean(Settings.PREF_GESTURE_MANUAL_SPACING, mode == SPACING_MODE_MANUAL)
+ when (mode) {
+ SPACING_MODE_NORMAL, SPACING_MODE_MANUAL -> {
+ val currentGraceMs = prefs.getInt(Settings.PREF_COMBINING_GRACE_MS, 0)
+ if (currentGraceMs > 0) {
+ putInt(Settings.PREF_COMBINING_LAST_AUTOSPACE_GRACE_MS, currentGraceMs)
+ }
+ putInt(Settings.PREF_COMBINING_GRACE_MS, 0)
+ }
+ SPACING_MODE_AUTOSPACE -> if (prefs.getInt(Settings.PREF_COMBINING_GRACE_MS, 0) <= 0) {
+ val restoredGraceMs = prefs.getInt(
+ Settings.PREF_COMBINING_LAST_AUTOSPACE_GRACE_MS,
+ Defaults.PREF_COMBINING_LAST_AUTOSPACE_GRACE_MS,
+ )
+ putInt(Settings.PREF_COMBINING_GRACE_MS, restoredGraceMs.coerceAtLeast(1))
+ }
+ }
+ }
+ },
+ selectedItem = items.first { it.second == selected },
+ title = { Text(setting.title) },
+ getItemName = { it.first },
+ )
+ }
+}
+
+private fun currentBackspaceBehavior(prefs: android.content.SharedPreferences): String = when {
+ prefs.getBoolean(Settings.PREF_COMBINING_BACKSPACE_DELETES_GESTURE_WORD,
+ Defaults.PREF_COMBINING_BACKSPACE_DELETES_GESTURE_WORD) -> BACKSPACE_WORD
+ prefs.getBoolean(Settings.PREF_GESTURE_FRAGMENT_BACKSPACE,
+ Defaults.PREF_GESTURE_FRAGMENT_BACKSPACE) -> BACKSPACE_FRAGMENT
+ else -> BACKSPACE_NORMAL
+}
+
+@Composable
+private fun TwoThumbBackspaceBehaviorPreference(setting: Setting) {
+ val prefs = LocalContext.current.prefs()
+ var showDialog by rememberSaveable { mutableStateOf(false) }
+ val items = listOf(
+ stringResource(R.string.two_thumb_backspace_normal) to BACKSPACE_NORMAL,
+ stringResource(R.string.two_thumb_backspace_fragment) to BACKSPACE_FRAGMENT,
+ stringResource(R.string.two_thumb_backspace_word) to BACKSPACE_WORD,
+ )
+ val selected = currentBackspaceBehavior(prefs)
+ Preference(
+ name = setting.title,
+ description = items.first { it.second == selected }.first,
+ onClick = { showDialog = true },
+ )
+ if (showDialog) {
+ ListPickerDialog(
+ onDismissRequest = { showDialog = false },
+ items = items,
+ onItemSelected = { item ->
+ when (item.second) {
+ BACKSPACE_NORMAL -> prefs.edit {
+ putBoolean(Settings.PREF_GESTURE_FRAGMENT_BACKSPACE, false)
+ putBoolean(Settings.PREF_COMBINING_BACKSPACE_DELETES_GESTURE_WORD, false)
+ }
+ BACKSPACE_FRAGMENT -> prefs.edit {
+ putBoolean(Settings.PREF_GESTURE_FRAGMENT_BACKSPACE, true)
+ putBoolean(Settings.PREF_COMBINING_BACKSPACE_DELETES_GESTURE_WORD, false)
+ }
+ BACKSPACE_WORD -> prefs.edit {
+ putBoolean(Settings.PREF_GESTURE_FRAGMENT_BACKSPACE, false)
+ putBoolean(Settings.PREF_COMBINING_BACKSPACE_DELETES_GESTURE_WORD, true)
+ }
+ }
+ },
+ selectedItem = items.first { it.second == selected },
+ title = { Text(setting.title) },
+ getItemName = { it.first },
+ )
+ }
+}
+
@Preview
@Composable
private fun Preview() {
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 46e63d2d2..98816d057 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -36,7 +36,7 @@