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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,26 @@ and this project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html)

## [Unreleased]

## [3.9.1] - 2026-06-11

### Fixed
- **Erratic capitalization in two-thumb grace mode.** After the grace timer auto-committed a
word, the shift/auto-caps state wasn't refreshed, so the next word's capitalization came out
wrong (dropped sentence caps, or mid-word capitals). (#14)

### Changed
- **Tapped words no longer auto-finish by default** in two-thumb grace mode — the new
"Only auto-finish swiped words" option defaults on, so a word you tap out stays open until you
press space or pick a suggestion (fixes tapped shortcuts/corrections firing early). Only swiped
words auto-commit on a pause. (#14)
- Reworded the two easily-confused spacing toggles: **"Only auto-space after swipes"** (the
trailing space) vs **"Only auto-finish swiped words"** (whether the word commits at all). (#14)

### Added
- **Experimental: defer grace-mode space** (`PREF_SPACING_DEFER_GRACE_SPACE`, default off) — routes
the two-thumb grace auto-commit space through the same deferred mechanism as the default swipe
path. (#23)

## [3.9.0] - 2026-06-10

### Added
Expand Down
4 changes: 2 additions & 2 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,8 @@ android {
applicationId = "com.asafmah.leantypedual"
minSdk = 21
targetSdk = 35
versionCode = 3900
versionName = "3.9.0"
versionCode = 3910
versionName = "3.9.1"

proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -988,12 +988,21 @@ private void enterCombiningMode(final SettingsValues settingsValues, final boole
final int graceMs = baseGraceMs + Math.max(0, settingsValues.mCombiningTapExtraMs);
cancelCombiningTimerOnly();
mInCombiningMode = true;
// #14 "only auto-finish swiped words": still ENTER combining mode (so a following swipe
// can extend this word), but DON'T arm the auto-commit timer for a pure tap word — it
// stays open until the user commits. A tap-then-swipe still arms: the gesture re-enters
// here with fromTap=false and the fragment present, so it arms then.
final boolean armTimer = !(fromTap && settingsValues.mCombiningGraceOnlyAfterGesture
&& !mCombiningWordHasGestureFragment && !mWordComposer.isBatchMode());
final long startTime = SystemClock.uptimeMillis();
mPendingCombiningCommit = () -> onCombiningGraceExpired();
mCombiningHandler.postDelayed(mPendingCombiningCommit, graceMs);
if (armTimer) {
mPendingCombiningCommit = () -> onCombiningGraceExpired();
mCombiningHandler.postDelayed(mPendingCombiningCommit, graceMs);
}
final MainKeyboardView kv = KeyboardSwitcher.getInstance().getMainKeyboardView();
if (kv != null) {
final boolean showAutospaceIndicator = settingsValues.shouldInsertSpacesAutomatically()
final boolean showAutospaceIndicator = armTimer
&& settingsValues.shouldInsertSpacesAutomatically()
&& settingsValues.mSpacingAndPunctuations.mCurrentLanguageHasSpaces
&& (!settingsValues.mCombiningAutospaceOnlyAfterGesture
|| mCombiningWordHasGestureFragment)
Expand Down Expand Up @@ -1200,40 +1209,54 @@ private void onCombiningGraceExpired() {
} else {
commitTyped(sv, LastComposedWord.NOT_A_SEPARATOR);
}
// Track whether the helper actually wrote a space (skipped for URL / e-mail / phantom).
final int beforeSpace = mConnection.getExpectedSelectionEnd();
if (!sv.mCombiningAutospaceOnlyAfterGesture || wordHadGestureFragment) {
insertAutomaticSpaceIfOptionsAndTextAllow(sv);
// #23 (PREF_SPACING_DEFER_GRACE_SPACE): defer the grace-mode space through PHANTOM
// instead of writing it eagerly, so it materializes on the NEXT input via the same path
// as the default gesture word — URL/e-mail/punctuation gates + backspace-reversibility
// are applied at materialization time, with no eager space to patch.
final boolean autospaceInserted;
if (sv.mSpacingDeferGraceSpace) {
if (!sv.mCombiningAutospaceOnlyAfterGesture || wordHadGestureFragment) {
// Arm the deferred space; the PHANTOM consumer (handleNonSeparatorEvent /
// handleSeparatorEvent) writes or suppresses it on the next input.
mSpaceState = SpaceState.PHANTOM;
} else {
clearOneShotSpaceActionAndNotifyIfChanged();
mSpaceState = SpaceState.NONE;
}
// No eager write: the cursor-delta accounting below treats this as "no space".
autospaceInserted = false;
mAutospaceJustWritten = false;
} else {
clearOneShotSpaceActionAndNotifyIfChanged();
// Eager path (default). Track whether the helper actually wrote a space (skipped for
// URL / e-mail / phantom).
final int beforeSpace = mConnection.getExpectedSelectionEnd();
if (!sv.mCombiningAutospaceOnlyAfterGesture || wordHadGestureFragment) {
insertAutomaticSpaceIfOptionsAndTextAllow(sv);
} else {
clearOneShotSpaceActionAndNotifyIfChanged();
}
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
// this the revert's `deleteLength = cancelLength + separatorLength` only deletes the
// word, and the DEBUG assertion (last cancelLength chars equals committedWord) throws.
if (autospaceInserted && mLastComposedWord != null
&& mLastComposedWord != LastComposedWord.NOT_A_COMPOSED_WORD
&& Constants.STRING_SPACE.equals(mLastComposedWord.mSeparatorString) == false) {
mLastComposedWord = new LastComposedWord(
mLastComposedWord.mEvents,
mLastComposedWord.mInputPointers,
mLastComposedWord.mTypedWord,
mLastComposedWord.mCommittedWord,
Constants.STRING_SPACE,
mLastComposedWord.mNgramContext,
mLastComposedWord.mCapitalizedMode);
}
// Don't set PHANTOM here — we already wrote the space; PHANTOM would make the next
// letter insert a second one.
mAutospaceJustWritten = autospaceInserted;
mSpaceState = SpaceState.NONE;
}
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
// this the existing revert code's `deleteLength = cancelLength + separatorLength`
// would only delete the word, and in DEBUG builds the bundled assertion against
// `getTextBeforeCursor(...).subSequence(0, cancelLength) equals committedWord` throws
// because the last cancelLength chars now include the trailing space, not the word.
if (autospaceInserted && mLastComposedWord != null
&& mLastComposedWord != LastComposedWord.NOT_A_COMPOSED_WORD
&& Constants.STRING_SPACE.equals(mLastComposedWord.mSeparatorString) == false) {
mLastComposedWord = new LastComposedWord(
mLastComposedWord.mEvents,
mLastComposedWord.mInputPointers,
mLastComposedWord.mTypedWord,
mLastComposedWord.mCommittedWord,
Constants.STRING_SPACE,
mLastComposedWord.mNgramContext,
mLastComposedWord.mCapitalizedMode);
}
// Don't set PHANTOM here — we already wrote the space to the editor. PHANTOM would
// make the next letter call insertAutomaticSpaceIfOptionsAndTextAllow AGAIN (see
// handleNonSeparatorEvent line ~1760), giving a double space. Instead, set a
// dedicated one-shot flag that handleSeparatorEvent uses to strip the autospace if
// a punctuation character follows. The flag is cleared by enterCombiningMode (next
// input took over), cancelCombiningMode (backspace etc), or once consumed.
mAutospaceJustWritten = autospaceInserted;
mSpaceState = SpaceState.NONE;
mConnection.endBatchEdit();
final int cursorAfter = mConnection.getExpectedSelectionEnd();
// The commit doesn't move the cursor for the composing text itself (it was already
Expand Down Expand Up @@ -1295,6 +1318,12 @@ private void onCombiningGraceExpired() {
mBackspaceUnits.setCommitted(writtenChars, committedFragments);
}
// "keep_alternatives" — fall through, do nothing.
// #14 bug fix: this commit ran on the async grace timer, OFF the normal onCodeInput path
// that refreshes the shift state after a commit. Without this, the next word's auto-caps
// is stale — auto-caps gets dropped after a grace auto-commit and capitalization comes out
// erratic. Mirror the gesture-commit path's requestUpdatingShiftState.
KeyboardSwitcher.getInstance().requestUpdatingShiftState(
getCurrentAutoCapsState(sv), getCurrentRecapitalizeState());
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,8 @@ object Defaults {
const val PREF_COMBINING_AUTOCORRECT_ON_AUTOSPACE = true
const val PREF_COMBINING_TAP_EXTRA_MS = 250
const val PREF_COMBINING_AUTOSPACE_ONLY_AFTER_GESTURE = false
const val PREF_SPACING_DEFER_GRACE_SPACE = false
const val PREF_COMBINING_GRACE_ONLY_AFTER_GESTURE = true // default on: tapped words shouldn't auto-finish
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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,12 @@ public final class Settings implements SharedPreferences.OnSharedPreferenceChang
// 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";
// #23: route the two-thumb grace-mode auto-commit space through the deferred PHANTOM
// mechanism (like the default gesture path) instead of writing it eagerly. Experimental.
public static final String PREF_SPACING_DEFER_GRACE_SPACE = "spacing_defer_grace_space";
// #14: when on, the combining grace timer only auto-commits words that include a swipe —
// pure tap-typed words are never auto-finished by the timer. Experimental, default off.
public static final String PREF_COMBINING_GRACE_ONLY_AFTER_GESTURE = "combining_grace_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";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,8 @@ public class SettingsValues {
public final boolean mCombiningAutocorrectOnAutospace;
public final int mCombiningTapExtraMs;
public final boolean mCombiningAutospaceOnlyAfterGesture;
public final boolean mSpacingDeferGraceSpace;
public final boolean mCombiningGraceOnlyAfterGesture;
// Raw string value: "keep_alternatives" | "next_word" | "alternatives_then_next_word"
public final String mCombiningAutospaceSuggestions;
public final boolean mCombiningBackspaceDeletesGestureWord;
Expand Down Expand Up @@ -382,6 +384,12 @@ public SettingsValues(final Context context, final SharedPreferences prefs, fina
mCombiningAutospaceOnlyAfterGesture = prefs.getBoolean(
Settings.PREF_COMBINING_AUTOSPACE_ONLY_AFTER_GESTURE,
Defaults.PREF_COMBINING_AUTOSPACE_ONLY_AFTER_GESTURE);
mSpacingDeferGraceSpace = prefs.getBoolean(
Settings.PREF_SPACING_DEFER_GRACE_SPACE,
Defaults.PREF_SPACING_DEFER_GRACE_SPACE);
mCombiningGraceOnlyAfterGesture = prefs.getBoolean(
Settings.PREF_COMBINING_GRACE_ONLY_AFTER_GESTURE,
Defaults.PREF_COMBINING_GRACE_ONLY_AFTER_GESTURE);
mCombiningAutospaceSuggestions = prefs.getString(Settings.PREF_COMBINING_AUTOSPACE_SUGGESTIONS,
Defaults.PREF_COMBINING_AUTOSPACE_SUGGESTIONS);
final boolean nonNormalTwoThumbSpacing = mGestureManualSpacing || mCombiningGraceMs > 0;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,8 @@ fun TwoThumbTypingScreen(
add(Settings.PREF_COMBINING_AUTOSPACE_ONLY_AFTER_GESTURE)
add(Settings.PREF_COMBINING_AUTOCORRECT_ON_AUTOSPACE)
add(Settings.PREF_COMBINING_AUTOSPACE_SUGGESTIONS)
add(Settings.PREF_SPACING_DEFER_GRACE_SPACE)
add(Settings.PREF_COMBINING_GRACE_ONLY_AFTER_GESTURE)
}
if (nonNormalSpacing) {
add(Settings.PREF_MULTIPART_FULL_WORD_SUGGESTIONS)
Expand Down Expand Up @@ -146,6 +148,16 @@ fun createTwoThumbTypingSettings(context: Context) = listOf(
R.string.combining_autospace_only_after_gesture_summary) {
SwitchPreference(it, Defaults.PREF_COMBINING_AUTOSPACE_ONLY_AFTER_GESTURE)
},
Setting(context, Settings.PREF_SPACING_DEFER_GRACE_SPACE,
R.string.spacing_defer_grace_space,
R.string.spacing_defer_grace_space_summary) {
SwitchPreference(it, Defaults.PREF_SPACING_DEFER_GRACE_SPACE)
},
Setting(context, Settings.PREF_COMBINING_GRACE_ONLY_AFTER_GESTURE,
R.string.combining_grace_only_after_gesture,
R.string.combining_grace_only_after_gesture_summary) {
SwitchPreference(it, Defaults.PREF_COMBINING_GRACE_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(
Expand Down
10 changes: 9 additions & 1 deletion app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -294,7 +294,15 @@
<string name="two_thumb_tap_autospace_grace">Extra autospace delay after taps</string>
<string name="two_thumb_tap_autospace_grace_summary">Add this much extra time when the last input was a tapped letter, so tap-then-swipe combinations are easier to continue.</string>
<string name="combining_autospace_only_after_gesture">Only auto-space after swipes</string>
<string name="combining_autospace_only_after_gesture_summary">When enabled, tap-only words are committed without an automatic space. Words that include a swipe still auto-space.</string>
<string name="combining_autospace_only_after_gesture_summary">Controls the automatic space only \u2014 not whether a word commits. When on, a tap-only word still commits but without an auto-space; swiped words still get one.</string>
<!-- Title of the experimental toggle that defers the two-thumb grace auto-commit space. -->
<string name="spacing_defer_grace_space">Defer grace space (experimental)</string>
<!-- Description for spacing_defer_grace_space. -->
<string name="spacing_defer_grace_space_summary">Route the two-thumb grace auto-commit space through the deferred mechanism (like the default swipe path) instead of writing it immediately. The space appears on your next input and stays backspace-reversible.</string>
<!-- Title of the toggle that limits the grace auto-commit timer to swiped words. -->
<string name="combining_grace_only_after_gesture">Only auto-finish swiped words</string>
<!-- Description for combining_grace_only_after_gesture. -->
<string name="combining_grace_only_after_gesture_summary">The pause timer auto-commits a word only when it includes a swipe. Words you tap out stay open until you press space or pick a suggestion, so tapped shortcuts and corrections won\'t fire early. On by default \u2014 this controls whether the word commits (the auto-space option above only controls the trailing space).</string>
<!-- Title of the toggle that makes the first backspace after a gesture delete the whole word. -->
<string name="combining_backspace_deletes_gesture_word">Backspace deletes last swipe</string>
<!-- Description for combining_backspace_deletes_gesture_word. -->
Expand Down
31 changes: 31 additions & 0 deletions app/src/test/java/helium314/keyboard/latin/InputLogicTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -362,6 +362,37 @@ class InputLogicTest {
assertEquals("hello ", textBeforeCursor)
}

@Test fun deferredGraceSpaceMaterializesOnNextInput() {
// #23: with PREF_SPACING_DEFER_GRACE_SPACE on, the grace commit does NOT write the space
// eagerly (the default path gives "hello "); it arms PHANTOM so the space appears on the
// next input instead.
reset()
latinIME.prefs().edit {
putInt(Settings.PREF_COMBINING_GRACE_MS, 1000)
putBoolean(Settings.PREF_SPACING_DEFER_GRACE_SPACE, true)
}
gestureInput("hello")
expireCombiningGrace()
assertEquals("hello", textBeforeCursor) // deferred: no trailing space yet
chainInput("world")
assertEquals("hello world", textBeforeCursor) // materialized on the next letter
}

@Test fun deferredGraceCommitIsBackspaceReversible() {
// The deferred commit leaves no eager space to orphan; the first backspace deletes the
// gesture word cleanly (PREF_COMBINING_BACKSPACE_DELETES_GESTURE_WORD default on).
reset()
latinIME.prefs().edit {
putInt(Settings.PREF_COMBINING_GRACE_MS, 1000)
putBoolean(Settings.PREF_SPACING_DEFER_GRACE_SPACE, true)
}
gestureInput("hello")
expireCombiningGrace()
assertEquals("hello", textBeforeCursor)
functionalKeyPress(KeyCode.DELETE)
assertEquals("", textBeforeCursor)
}

@Test fun tapThenGestureCombiningWordStillAutospacesWhenGestureGateEnabled() {
reset()
latinIME.prefs().edit {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,18 @@ class SettingsContainerTest {
container[Settings.PREF_COMBINING_AUTOSPACE_ONLY_AFTER_GESTURE]?.key)
}

@Test
fun spacingDeferGraceSpaceSettingIsRegistered() {
assertEquals(Settings.PREF_SPACING_DEFER_GRACE_SPACE,
container[Settings.PREF_SPACING_DEFER_GRACE_SPACE]?.key)
}

@Test
fun combiningGraceOnlyAfterGestureSettingIsRegistered() {
assertEquals(Settings.PREF_COMBINING_GRACE_ONLY_AFTER_GESTURE,
container[Settings.PREF_COMBINING_GRACE_ONLY_AFTER_GESTURE]?.key)
}

@Test
fun twoThumbLowLevelBackspaceSettingIsHiddenFromSearchRegistry() {
assertNull(container[Settings.PREF_COMBINING_BACKSPACE_DELETES_GESTURE_WORD])
Expand Down
Loading
Loading