From 085825f42b3cc5f3638500365b4fb4acf527ebed Mon Sep 17 00:00:00 2001 From: AsafMah Date: Thu, 11 Jun 2026 02:14:44 +0300 Subject: [PATCH 1/4] docs(spacing): spec for the spacing-policy epic (#14) (#85) Design spec for per-word spacing policy: free per-keystroke signals (complete, normalized prefix-richness score) drive graceMs + a two-gate Assisted tier (instant for finished words, pause for extendable stems), one deferred-space mechanism (PHANTOM), adaptive per-posture cadence, and a live tuning panel + fuller A11 insight overlay. Sequencing: #23 -> #26 -> #24 -> #25. Captures the #23 seam + the ~8 grace-test contract rewrite. --- docs/SPACING_POLICY.md | 114 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 114 insertions(+) create mode 100644 docs/SPACING_POLICY.md diff --git a/docs/SPACING_POLICY.md b/docs/SPACING_POLICY.md new file mode 100644 index 000000000..534e0f177 --- /dev/null +++ b/docs/SPACING_POLICY.md @@ -0,0 +1,114 @@ +# Spacing Policy + +**Status:** design spec. Epic [#14]; implementation tracked in [#23] (B6b), [#24] (B6a), [#25] (B6c/C1), [#26] (tuning panel). + +Goal: rethink autospace/grace as a **per-word spacing *policy*** driven by word-state signals already computed every keystroke — not a fixed timer — with an opt-in "Assisted" tier, decoupled commit-from-space, and adaptive per-posture cadence. Feel-driven: every knob is a live, on-device-tunable experimental setting; **never hardcode a feel decision**. + +--- + +## 1. The problem + +Two different mechanisms do the same job inconsistently, and grace is blind to whether a word is *finished*: + +- **Default gesture path** uses **`SpaceState.PHANTOM`**: the space is *deferred* and materialized on the next input, so it adapts to connectors / URLs / punctuation and stays backspace-reversible. +- **Combining-grace path** writes the space **eagerly** in `onCombiningGraceExpired` (`InputLogic.java` ~1139–1156: `insertAutomaticSpaceIfOptionsAndTextAllow(sv)` + `mAutospaceJustWritten`). +- Grace is a **fixed timer** (`mCombiningGraceMs`). It can't tell a finished word ("I") from an extendable stem, so finished words still need a space tap while stems get committed too eagerly. + +## 2. Principles + +- **Signals over timers.** Decisions key off per-keystroke word state, at **zero extra native cost**. +- **One deferred-space mechanism** (PHANTOM) for every commit path. +- **Opt-in / default-off**, conservative defaults. +- **Backspace-reversible**, and the policy **never fires mid multipart / live-converge fragment**. +- **Feel-driven**: knobs are live experimental settings (§7); no hardcoded feel calls; validate on-device. + +## 3. Current state (the seam) + +- `SpaceState` values: `NONE`, `DOUBLE`, `SWAP_PUNCTUATION`, `WEAK`, `PHANTOM` (`inputlogic/SpaceState.java`). PHANTOM = the deferred-space promise consumed on next input. +- `OneShotSpaceAction` (`inputlogic/OneShotSpaceAction.kt`): `JOIN_NEXT`, `FORCE_NEXT_SPACE`. +- PHANTOM consumers that materialize/suppress the space on next input: `handleNonSeparatorEvent` (~373 / ~2003), `handleSeparatorEvent` (~2207). +- The combining grace timer: scheduled in `enterCombiningMode`, cleared in `cancelCombiningMode`, fires `onCombiningGraceExpired` (~1139–1156), which currently commits **and eagerly spaces**. +- Backspace-revert of a committed word reads `mLastComposedWord.mSeparatorString` (patched ~1148–1156). + +## 4. Signals (free — from `mSuggestedWords`, computed every keystroke) + +``` +complete = mTypedWordValid && mTypedWordInfo.mSourceDict != DICTIONARY_USER_TYPED +prefixRichScore = (# of KIND_COMPLETION candidates) / (total candidates) // normalized [0..1] +graceMs = clamp(base − completeBonus·complete + prefixPenalty·prefixRichScore, min, max) +``` + +- `complete`: the typed word is a real dictionary word (not a user-typed-only string). +- **`prefixRichScore`** is a **normalized score** (completions ÷ total), not a raw count — more stable across dictionaries of different sizes. High = lots of longer words start with this stem (keep open); low = little left to extend to (safe to commit). + +## 5. The "Assisted" tier — two gates + +A confident, complete word auto-commits with the space **deferred** (PHANTOM). It fires through **either** gate, mapping onto the two signals: + +| Gate | Condition | Behaviour | +|---|---|---| +| **A — instant** | `complete` **and** `prefixRichScore ≤ lowThreshold` | Commit immediately (nothing plausible left to extend to: "I", "the", a finished gesture). Space deferred. | +| **B — pause** | `complete` **and** `prefixRichScore > lowThreshold` | Hold; commit only after an inter-word **pause** (the adaptive threshold, §6) — the stem is extendable, give the user time. Space deferred. | +| _neither_ | not `complete` | Stay open; fall back to the signal-driven `graceMs` timer. | + +So finished words feel instant; extendable-but-complete words wait until you stop. Subsumes the old "short-word auto-commit" idea, but dictionary-driven. + +**Guards (hard):** only above the confidence floor; **never** while a multipart / live-converge fragment is mid-flight; the deferred space keeps the whole thing backspace-reversible. + +## 6. Adaptive per-posture cadence ([#25]) + +The Gate-B pause threshold is **learned**, not fixed: a running percentile of the user's real inter-word pause distribution. **Separate baselines per posture** (one-handed / two-handed), keyed off the existing one-handed toggle (`KeyboardSwitcher.setOneHandedModeEnabled` ~535–547, today layout-only — timing is greenfield) so switching posture **loads the stored baseline instantly** instead of slowly re-adapting. Far-key reach stays handled by the existing layout shrink + swipe shortcuts (C2), not here. + +## 7. Tuning & insight ([#26]) + +- **For users:** a small set of **discrete tiers** — `Off` / `Conservative` / `Assisted` / `Aggressive` — that pick coherent knob bundles. +- **Behind an "experimental" expander:** the raw **sliders** — `base` grace, `completeBonus`, `prefixPenalty`, `lowThreshold`, Gate-B pause percentile — for on-device tuning. +- **Live:** changes take effect with **no restart**; defaults conservative; the whole policy **default-off**. +- **Fuller A11 typing-insight overlay** (paired): per word, surface *why* it committed or stayed open — `complete`, `prefixRichScore`, the resolved `graceMs`, and **which gate fired** (A / B / none). This is the feedback loop that makes the sliders tunable by feel. + +## 8. Sequencing + +| Phase | Issue | What | Why here | +|---|---|---|---| +| 1 | [#23] | Route the grace commit through PHANTOM (remove the eager write) | Structurally correct regardless of feel knobs — the foundation. | +| 2 | [#26] | Live tuning-panel infra + the fuller A11 insight overlay | Force-multiplier: makes Phase 3 tunable on-device and shows *why*. | +| 3 | [#24] | Signal-driven `graceMs` + the two-gate Assisted tier | The meat; tuned via Phase 2 + on-device playtest. | +| 4 | [#25] | Adaptive per-posture cadence | The learning layer on top. | + +(#23 is "first" per the epic; #26 is slotted **second** because Phase 3 is feel-critical and un-tunable without it.) + +## 9. Phase 1 (#23) implementation notes — small seam, deliberate test rewrite + +- **Seam:** in `onCombiningGraceExpired`, replace `insertAutomaticSpaceIfOptionsAndTextAllow(sv)` + `mAutospaceJustWritten` with `mSpaceState = SpaceState.PHANTOM`. Existing PHANTOM consumers then materialize/suppress on next input (already solved for the default path). +- **Move** the `mLastComposedWord.mSeparatorString` backspace-revert patch (~1148–1156) to **space-materialization time**. +- **Test-contract rewrite (not optional):** ~8 `InputLogicTest` cases lock in the *eager* space — `expireCombiningGrace(); assertEquals("hello ", textBeforeCursor)` at ~259/276/357/372/461. Each must be rewritten to assert **deferred-then-materialized** (`"hello"` after expiry, `"hello "` only after the next letter/separator). Add explicit commit-then-backspace and commit-then-punctuation coverage. +- **Risk:** this changes locked-in behaviour contracts. Build it **behind the experimental flag** and validate with **on-device playtesting** — not a blind autonomous change. + +## 10. Settings (new keys, all experimental / default-off) + +Follow the 5-file pattern (`Settings.java` / `Defaults.kt` / `SettingsValues.java` / `strings.xml` / a settings screen) + a `SettingsContainerTest` case each. + +| Key (proposed) | Type | Meaning | +|---|---|---| +| `PREF_SPACING_POLICY_TIER` | enum | Off / Conservative / Assisted / Aggressive | +| `PREF_SPACING_GRACE_BASE_MS` | int | `base` in the graceMs formula | +| `PREF_SPACING_COMPLETE_BONUS_MS` | int | `completeBonus` | +| `PREF_SPACING_PREFIX_PENALTY_MS` | int | `prefixPenalty` | +| `PREF_SPACING_LOW_THRESHOLD` | float | Gate-A `lowThreshold` on `prefixRichScore` | +| `PREF_SPACING_PAUSE_PERCENTILE` | int | Gate-B adaptive-pause percentile | + +(Tier selection writes a coherent bundle into the sliders; the experimental expander exposes the sliders directly. Reuse `MultiSliderPreference.kt`.) + +## 11. Testing strategy + +- **Unit-testable (JVM):** the `graceMs` formula and gate selection are deterministic given a `mSuggestedWords` snapshot — table-test them directly. The Phase 1 deferred-space contract is covered by the rewritten grace tests + new backspace-revert cases. +- **On-device only (feel):** the actual cadence, the tier defaults, and the adaptive percentile. These ride the #26 panel + the A11 overlay; do **not** assert specific timings in tests. +- The native gesture-replay harness ([#78]) and the trace recorder ([#20]) provide fixtures for regression-checking that the policy doesn't break recognition. + +[#14]: https://github.com/AsafMah/LeanType/issues/14 +[#20]: https://github.com/AsafMah/LeanType/issues/20 +[#23]: https://github.com/AsafMah/LeanType/issues/23 +[#24]: https://github.com/AsafMah/LeanType/issues/24 +[#25]: https://github.com/AsafMah/LeanType/issues/25 +[#26]: https://github.com/AsafMah/LeanType/issues/26 +[#78]: https://github.com/AsafMah/LeanType/issues/78 From 2d69f91128def9d5b5df8d321f06c6fa6d86e78f Mon Sep 17 00:00:00 2001 From: AsafMah Date: Thu, 11 Jun 2026 02:14:47 +0300 Subject: [PATCH 2/4] feat(spacing): defer grace-mode space via PHANTOM behind a flag (#23) (#86) Phase 1 of the spacing-policy epic (#14). Today the default gesture path defers its autospace via SpaceState.PHANTOM (materialized on next input, adapts to connectors/URLs/punctuation, backspace-reversible) but the two-thumb combining-grace path writes the space EAGERLY in onCombiningGraceExpired. This unifies them. New experimental, default-OFF pref PREF_SPACING_DEFER_GRACE_SPACE. When on, the grace commit arms PHANTOM instead of the eager insertAutomaticSpaceIfOptionsAndTextAllow + mAutospaceJustWritten + the mLastComposedWord separator patch; the existing PHANTOM consumers materialize/suppress the space on next input. The downstream cursor/undo accounting already handles autospaceInserted=false, so it's unchanged. Default-off keeps the ~8 existing eager grace tests valid (no contract rewrite needed); adds two deferred-path tests (materialize-on-next-input, backspace-reversible). Verify: InputLogicTest + SettingsContainerTest -> 115 completed, 3 failed (pre-existing baseline), 1 skipped; 0 new failures. Feel is on-device: gated behind the flag for playtesting per the roadmap's feel-driven rule. --- .../keyboard/latin/inputlogic/InputLogic.java | 78 +++++++++++-------- .../keyboard/latin/settings/Defaults.kt | 1 + .../keyboard/latin/settings/Settings.java | 3 + .../latin/settings/SettingsValues.java | 4 + .../settings/screens/TwoThumbTypingScreen.kt | 6 ++ app/src/main/res/values/strings.xml | 4 + .../keyboard/latin/InputLogicTest.kt | 31 ++++++++ .../settings/SettingsContainerTest.kt | 6 ++ 8 files changed, 101 insertions(+), 32 deletions(-) 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 b4cec9e3e..6c221323e 100644 --- a/app/src/main/java/helium314/keyboard/latin/inputlogic/InputLogic.java +++ b/app/src/main/java/helium314/keyboard/latin/inputlogic/InputLogic.java @@ -1200,40 +1200,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 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 280eade97..0dd8647b9 100644 --- a/app/src/main/java/helium314/keyboard/latin/settings/Defaults.kt +++ b/app/src/main/java/helium314/keyboard/latin/settings/Defaults.kt @@ -149,6 +149,7 @@ 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_AUTOSPACE_SUGGESTIONS = "alternatives_then_next_word" const val PREF_COMBINING_BACKSPACE_DELETES_GESTURE_WORD = true const val PREF_COMBINING_BACKSPACE_DELETES_COMPOSING_TEXT = 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 664ea8194..75edd6b6c 100644 --- a/app/src/main/java/helium314/keyboard/latin/settings/Settings.java +++ b/app/src/main/java/helium314/keyboard/latin/settings/Settings.java @@ -157,6 +157,9 @@ 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"; // 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"; 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 72a9c257c..ae1e4e17f 100644 --- a/app/src/main/java/helium314/keyboard/latin/settings/SettingsValues.java +++ b/app/src/main/java/helium314/keyboard/latin/settings/SettingsValues.java @@ -137,6 +137,7 @@ public class SettingsValues { public final boolean mCombiningAutocorrectOnAutospace; public final int mCombiningTapExtraMs; public final boolean mCombiningAutospaceOnlyAfterGesture; + public final boolean mSpacingDeferGraceSpace; // Raw string value: "keep_alternatives" | "next_word" | "alternatives_then_next_word" public final String mCombiningAutospaceSuggestions; public final boolean mCombiningBackspaceDeletesGestureWord; @@ -382,6 +383,9 @@ 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); mCombiningAutospaceSuggestions = prefs.getString(Settings.PREF_COMBINING_AUTOSPACE_SUGGESTIONS, Defaults.PREF_COMBINING_AUTOSPACE_SUGGESTIONS); final boolean nonNormalTwoThumbSpacing = mGestureManualSpacing || mCombiningGraceMs > 0; 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 160a0c62e..9469bddc4 100644 --- a/app/src/main/java/helium314/keyboard/settings/screens/TwoThumbTypingScreen.kt +++ b/app/src/main/java/helium314/keyboard/settings/screens/TwoThumbTypingScreen.kt @@ -69,6 +69,7 @@ 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) } if (nonNormalSpacing) { add(Settings.PREF_MULTIPART_FULL_WORD_SUGGESTIONS) @@ -146,6 +147,11 @@ 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_AUTOSPACE_SUGGESTIONS, R.string.combining_autospace_suggestions, R.string.combining_autospace_suggestions_summary) { def -> val items = listOf( diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 7568f00af..9948cff76 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -295,6 +295,10 @@ Add this much extra time when the last input was a tapped letter, so tap-then-swipe combinations are easier to continue. Only auto-space after swipes When enabled, tap-only words are committed without an automatic space. Words that include a swipe still auto-space. + + Defer grace space (experimental) + + 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. Backspace deletes last swipe diff --git a/app/src/test/java/helium314/keyboard/latin/InputLogicTest.kt b/app/src/test/java/helium314/keyboard/latin/InputLogicTest.kt index c5fdd54cc..d3f2accea 100644 --- a/app/src/test/java/helium314/keyboard/latin/InputLogicTest.kt +++ b/app/src/test/java/helium314/keyboard/latin/InputLogicTest.kt @@ -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 { diff --git a/app/src/test/java/helium314/keyboard/settings/SettingsContainerTest.kt b/app/src/test/java/helium314/keyboard/settings/SettingsContainerTest.kt index a51cfb673..24fb0054b 100644 --- a/app/src/test/java/helium314/keyboard/settings/SettingsContainerTest.kt +++ b/app/src/test/java/helium314/keyboard/settings/SettingsContainerTest.kt @@ -60,6 +60,12 @@ 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 twoThumbLowLevelBackspaceSettingIsHiddenFromSearchRegistry() { assertNull(container[Settings.PREF_COMBINING_BACKSPACE_DELETES_GESTURE_WORD]) From 447c046f2cbdd0294d4115c5e8fedd4e8d801e30 Mon Sep 17 00:00:00 2001 From: AsafMah Date: Thu, 11 Jun 2026 08:17:27 +0300 Subject: [PATCH 3/4] fix(two-thumb): grace-commit auto-caps refresh + opt-in tap auto-finish gate (#87) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(two-thumb): grace-commit auto-caps refresh + opt-in 'auto-finish only after swipes' Two pre-existing bugs in the combining-grace auto-commit path, found on-device (neither caused by #23 — that only defers the space behind a default-off flag). 1. Erratic capitalization after a grace auto-commit. onCombiningGraceExpired runs on the async grace timer, OFF the normal onCodeInput path that refreshes the shift state after a commit — so the next word's auto-caps was stale (dropped, or applied mid-word -> 'WORds'). Fix: call requestUpdatingShiftState(getCurrentAutoCapsState, getCurrentRecapitalizeState) at the end of onCombiningGraceExpired, mirroring the gesture-commit path. Unconditional — it was a bug for every grace commit. 2. No way to stop the grace timer auto-finishing tap-only words. enterCombiningMode armed for any composing word regardless of tap vs swipe (the fromTap param was unused); mCombiningAutospaceOnlyAfterGesture only gates the space, not the commit. Add opt-in PREF_COMBINING_GRACE_ONLY_AFTER_GESTURE (default off): when on, enterCombiningMode skips arming for a tap-only word (no gesture fragment). Tap-then-swipe still arms (the gesture trigger re-enters with the fragment). Verify: InputLogicTest + SettingsContainerTest -> 116 completed, 3 failed (pre-existing baseline), 1 skipped; 0 new failures; new pref registered. Feel (correct caps + tap-skip) validated on-device. * feat(two-thumb): default 'only auto-finish swiped words' ON + clearer labels Tapped words auto-finishing was the root of a whole cluster of confusing behavior (premature commit, Text Expander procing early, suggestion-pick appending, garbled words) — so make the swipe-only gate the default. - Default PREF_COMBINING_GRACE_ONLY_AFTER_GESTURE to true (was false). Only affects users who enabled the grace timer (advanced opt-in); for them, tap words no longer auto-finish. - Reworded the two near-identical toggles so they can't be confused: 'Only auto-space after swipes' (the SPACE) vs 'Only auto-finish swiped words' (whether the word COMMITS at all), with disambiguating summaries. - Restructure enterCombiningMode: the gate now suppresses only the TIMER ARMING, not combining-mode entry — so a tap-then-swipe word still extends and auto-finishes (fixed a regression the default flip exposed in tapThenGestureCombiningWordStillAutospaces). Verify: InputLogicTest + SettingsContainerTest -> 116 completed, 3 failed (pre-existing baseline), 1 skipped; 0 new failures. --- .../keyboard/latin/inputlogic/InputLogic.java | 21 ++++++++++++++++--- .../keyboard/latin/settings/Defaults.kt | 1 + .../keyboard/latin/settings/Settings.java | 3 +++ .../latin/settings/SettingsValues.java | 4 ++++ .../settings/screens/TwoThumbTypingScreen.kt | 6 ++++++ app/src/main/res/values/strings.xml | 6 +++++- .../settings/SettingsContainerTest.kt | 6 ++++++ 7 files changed, 43 insertions(+), 4 deletions(-) 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 6c221323e..4ac354a4d 100644 --- a/app/src/main/java/helium314/keyboard/latin/inputlogic/InputLogic.java +++ b/app/src/main/java/helium314/keyboard/latin/inputlogic/InputLogic.java @@ -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) @@ -1309,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()); } /** 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 0dd8647b9..c0e875464 100644 --- a/app/src/main/java/helium314/keyboard/latin/settings/Defaults.kt +++ b/app/src/main/java/helium314/keyboard/latin/settings/Defaults.kt @@ -150,6 +150,7 @@ object Defaults { 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 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 75edd6b6c..cf89a5379 100644 --- a/app/src/main/java/helium314/keyboard/latin/settings/Settings.java +++ b/app/src/main/java/helium314/keyboard/latin/settings/Settings.java @@ -160,6 +160,9 @@ public final class Settings implements SharedPreferences.OnSharedPreferenceChang // #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"; 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 ae1e4e17f..95efd7756 100644 --- a/app/src/main/java/helium314/keyboard/latin/settings/SettingsValues.java +++ b/app/src/main/java/helium314/keyboard/latin/settings/SettingsValues.java @@ -138,6 +138,7 @@ public class SettingsValues { 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; @@ -386,6 +387,9 @@ public SettingsValues(final Context context, final SharedPreferences prefs, fina 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; 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 9469bddc4..e73389788 100644 --- a/app/src/main/java/helium314/keyboard/settings/screens/TwoThumbTypingScreen.kt +++ b/app/src/main/java/helium314/keyboard/settings/screens/TwoThumbTypingScreen.kt @@ -70,6 +70,7 @@ fun TwoThumbTypingScreen( 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) @@ -152,6 +153,11 @@ fun createTwoThumbTypingSettings(context: Context) = listOf( 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( diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9948cff76..82e563c1c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -294,11 +294,15 @@ Extra autospace delay after taps Add this much extra time when the last input was a tapped letter, so tap-then-swipe combinations are easier to continue. Only auto-space after swipes - When enabled, tap-only words are committed without an automatic space. Words that include a swipe still auto-space. + 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. Defer grace space (experimental) 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. + + Only auto-finish swiped words + + 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). Backspace deletes last swipe diff --git a/app/src/test/java/helium314/keyboard/settings/SettingsContainerTest.kt b/app/src/test/java/helium314/keyboard/settings/SettingsContainerTest.kt index 24fb0054b..30451afb7 100644 --- a/app/src/test/java/helium314/keyboard/settings/SettingsContainerTest.kt +++ b/app/src/test/java/helium314/keyboard/settings/SettingsContainerTest.kt @@ -66,6 +66,12 @@ class SettingsContainerTest { 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]) From b9cd1828967bd348f8435d23131f8bc6b98b9166 Mon Sep 17 00:00:00 2001 From: AsafMah Date: Thu, 11 Jun 2026 08:26:50 +0300 Subject: [PATCH 4/4] release: bump to 3.9.1 (3910) + changelog (#90) Two-thumb spacing fixes since 3.9.0: grace-commit auto-caps refresh, default-on 'only auto-finish swiped words' (tapped words stay open), clearer spacing labels, and the experimental deferred-grace-space flag. --- CHANGELOG.md | 20 +++++++++++++++++++ app/build.gradle.kts | 4 ++-- .../android/en-US/changelogs/3910.txt | 4 ++++ 3 files changed, 26 insertions(+), 2 deletions(-) create mode 100644 fastlane/metadata/android/en-US/changelogs/3910.txt diff --git a/CHANGELOG.md b/CHANGELOG.md index 956fc56e3..4c551bbc4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 0ac2eef83..38fa7f669 100755 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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") diff --git a/fastlane/metadata/android/en-US/changelogs/3910.txt b/fastlane/metadata/android/en-US/changelogs/3910.txt new file mode 100644 index 000000000..4f97d6f82 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/3910.txt @@ -0,0 +1,4 @@ +- Two-thumb: fixed capitalization going wrong after the grace timer auto-finishes a word (dropped sentence capitals / stray mid-word capitals) +- Two-thumb: words you tap out no longer auto-finish on a pause by default — they stay open until you press space or pick a suggestion, so tapped shortcuts and corrections don't fire early (only swiped words auto-finish). Toggle under Two-thumb settings. +- Clearer labels for the two spacing options ("Only auto-space after swipes" vs "Only auto-finish swiped words") +- Experimental (off by default): defer the two-thumb grace space like the default swipe path