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
138 changes: 109 additions & 29 deletions app/src/main/java/helium314/keyboard/latin/inputlogic/InputLogic.java
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,11 @@ public final class InputLogic {
// character at a time. The list is kept in sync with {@code mWordComposer.getTypedWord()}:
// entries past the current length are filtered at read time, and the list is cleared
// outright whenever the composing word is committed or reset.
private final java.util.ArrayList<Integer> mGestureFragmentBoundaries = new java.util.ArrayList<>();
private final ArrayList<Integer> mGestureFragmentBoundaries = new ArrayList<>();
/** Fragment lengths within the most recent gesture-driven commit. The last entry includes
* the trailing autospace if one was inserted. Used by "Delete last fragment" after the
* composing word has already been auto-committed. */
private final ArrayList<Integer> mLastGestureCommittedFragmentLengths = new ArrayList<>();

// ---- Unified combining-mode state machine ----------------------------------------------
// After every composing-word-extending event (tap of a letter OR gesture completion),
Expand Down Expand Up @@ -882,8 +886,7 @@ public void onCancelBatchInput(final LatinIME.UIHandler handler) {
// composing word is committed or reset; stale entries beyond the current length are
// filtered in {@link #tryFragmentBackspace} as a defensive measure.

/** Record a fragment boundary at the current composing-word length. No-op when not in manual+fragment mode. */
private void recordFragmentBoundaryIfTracking(final SettingsValues sv) {
private boolean shouldTrackFragmentBoundaries(final SettingsValues sv) {
// Two paths into fragment tracking:
// * Legacy manual-spacing mode (#1.1) — gated on both prefs.
// * Multi-part word composition (#1.6) — combining mode is the trigger; fragment
Expand All @@ -893,9 +896,20 @@ private void recordFragmentBoundaryIfTracking(final SettingsValues sv) {
final boolean multipartTracking = sv.mMultipartAutoExtendInCombining
&& sv.mCombiningGraceMs > 0
&& sv.mGestureFragmentBackspace;
if (!legacyTracking && !multipartTracking) return;
return legacyTracking || multipartTracking;
}

/** Record a fragment boundary at the current composing-word length. No-op when not in fragment mode. */
private void recordFragmentBoundaryIfTracking(final SettingsValues sv) {
if (!shouldTrackFragmentBoundaries(sv)) return;
if (!mWordComposer.isComposingWord()) return;
final int len = mWordComposer.getTypedWord().length();
recordFragmentBoundaryIfTracking(sv, mWordComposer.getTypedWord().length());
}

/** Record a fragment boundary at a known composing-word length. */
private void recordFragmentBoundaryIfTracking(final SettingsValues sv, final int len) {
if (!shouldTrackFragmentBoundaries(sv)) return;
if (len <= 0) return;
// Don't record duplicates (e.g. the same fragment appended twice in quick succession).
if (!mGestureFragmentBoundaries.isEmpty()
&& mGestureFragmentBoundaries.get(mGestureFragmentBoundaries.size() - 1) == len) {
Expand All @@ -909,6 +923,13 @@ private void clearFragmentBoundaries() {
if (!mGestureFragmentBoundaries.isEmpty()) mGestureFragmentBoundaries.clear();
}

private void clearCommittedGestureBackspaceState() {
mLastGestureCommittedLength = 0;
if (!mLastGestureCommittedFragmentLengths.isEmpty()) {
mLastGestureCommittedFragmentLengths.clear();
}
}

// ---- Unified combining-mode helpers --------------------------------------------------

/**
Expand All @@ -925,7 +946,7 @@ private void enterCombiningMode(final SettingsValues settingsValues, final boole
// next-word predictions" one-shot — the user moved on to typing the next word.
mAutospaceAlternativesPending = false;
mAutoCommitRevertLength = 0;
mLastGestureCommittedLength = 0;
clearCommittedGestureBackspaceState();
mAutospaceJustWritten = false;
final int baseGraceMs = settingsValues.mCombiningGraceMs;
if (baseGraceMs <= 0) return;
Expand Down Expand Up @@ -955,7 +976,7 @@ private void enterJoinNextMode(final SettingsValues settingsValues) {
OneShotSpaceAction.armJoinNext();
mAutospaceAlternativesPending = false;
mAutoCommitRevertLength = 0;
mLastGestureCommittedLength = 0;
clearCommittedGestureBackspaceState();
mAutospaceJustWritten = false;
if (Constants.CODE_SPACE == mConnection.getCodePointBeforeCursor()
&& !mWordComposer.isComposingWord()) {
Expand Down Expand Up @@ -1018,7 +1039,7 @@ void cancelCombiningMode() {
// represent the user explicitly leaving the just-auto-committed context.
mAutospaceAlternativesPending = false;
mAutoCommitRevertLength = 0;
mLastGestureCommittedLength = 0;
clearCommittedGestureBackspaceState();
mAutospaceJustWritten = false;
mCombiningWordHasGestureFragment = false;
if (mInCombiningMode) {
Expand Down Expand Up @@ -1097,6 +1118,8 @@ private void onCombiningGraceExpired() {
// commit itself doesn't move the cursor. We need the typed-word length plus whatever
// chars the autospace path writes to know how much to undo on a suggestion-pick.
final String typedWordAtCommit = mWordComposer.getTypedWord();
final ArrayList<Integer> fragmentLengthsAtCommit =
getFragmentLengthsForCommit(typedWordAtCommit.length());
final int cursorBefore = mConnection.getExpectedSelectionEnd();
mConnection.beginBatchEdit();
if (sv.mCombiningAutocorrectOnAutospace) {
Expand Down Expand Up @@ -1184,6 +1207,21 @@ private void onCombiningGraceExpired() {
// 0 for tap-only commits, where char-by-char delete is the right behavior.
if (wordHadGestureFragment) {
mLastGestureCommittedLength = writtenChars;
mLastGestureCommittedFragmentLengths.clear();
if (!fragmentLengthsAtCommit.isEmpty()) {
final int committedDelta = committedLen - typedWordAtCommit.length();
final int lastIndex = fragmentLengthsAtCommit.size() - 1;
final int adjustedLastFragmentLen =
fragmentLengthsAtCommit.get(lastIndex) + committedDelta + autospaceChars;
if (adjustedLastFragmentLen > 0) {
fragmentLengthsAtCommit.set(lastIndex, adjustedLastFragmentLen);
mLastGestureCommittedFragmentLengths.addAll(fragmentLengthsAtCommit);
} else if (writtenChars > 0) {
mLastGestureCommittedFragmentLengths.add(writtenChars);
}
} else if (writtenChars > 0) {
mLastGestureCommittedFragmentLengths.add(writtenChars);
}
}
// "keep_alternatives" — fall through, do nothing.
}
Expand All @@ -1204,6 +1242,7 @@ private boolean tryFragmentBackspace(final SettingsValues sv) {
&& sv.mCombiningGraceMs > 0
&& sv.mGestureFragmentBackspace;
if (!legacyTracking && !multipartTracking) return false;
if (sv.mCombiningBackspaceDeletesGestureWord) return false;
if (mGestureFragmentBoundaries.isEmpty()) return false;
if (!mWordComposer.isComposingWord()) {
clearFragmentBoundaries();
Expand All @@ -1214,16 +1253,24 @@ private boolean tryFragmentBackspace(final SettingsValues sv) {
final int currentLen = mWordComposer.getTypedWord().length();
// Filter out stale boundaries past the current length.
while (!mGestureFragmentBoundaries.isEmpty()
&& mGestureFragmentBoundaries.get(mGestureFragmentBoundaries.size() - 1) >= currentLen) {
&& mGestureFragmentBoundaries.get(mGestureFragmentBoundaries.size() - 1) > currentLen) {
mGestureFragmentBoundaries.remove(mGestureFragmentBoundaries.size() - 1);
}
if (mGestureFragmentBoundaries.isEmpty()) return false;

// Pop one boundary and shrink to the previous one (or 0 if no more).
mGestureFragmentBoundaries.remove(mGestureFragmentBoundaries.size() - 1);
final int newLen = mGestureFragmentBoundaries.isEmpty()
? 0
: mGestureFragmentBoundaries.get(mGestureFragmentBoundaries.size() - 1);
final int lastBoundary = mGestureFragmentBoundaries.get(mGestureFragmentBoundaries.size() - 1);
final int newLen;
if (lastBoundary == currentLen) {
// The last marker is the end of the current fragment. Pop it and shrink to the
// previous marker, or to 0 for a single-fragment word.
mGestureFragmentBoundaries.remove(mGestureFragmentBoundaries.size() - 1);
newLen = mGestureFragmentBoundaries.isEmpty()
? 0
: mGestureFragmentBoundaries.get(mGestureFragmentBoundaries.size() - 1);
} else {
// Defensive fallback for words whose current fragment end was not recorded.
newLen = lastBoundary;
}

final String oldWord = mWordComposer.getTypedWord();
final String newWord = newLen <= 0
Expand All @@ -1250,6 +1297,22 @@ private boolean tryFragmentBackspace(final SettingsValues sv) {
return true;
}

private ArrayList<Integer> getFragmentLengthsForCommit(final int currentLen) {
final ArrayList<Integer> fragmentLengths = new ArrayList<>();
if (currentLen <= 0) return fragmentLengths;
int previousBoundary = 0;
for (int i = 0; i < mGestureFragmentBoundaries.size(); ++i) {
final int boundary = mGestureFragmentBoundaries.get(i);
if (boundary <= previousBoundary || boundary > currentLen) continue;
fragmentLengths.add(boundary - previousBoundary);
previousBoundary = boundary;
}
if (previousBoundary < currentLen) {
fragmentLengths.add(currentLen - previousBoundary);
}
return fragmentLengths;
}

// TODO: on the long term, this method should become private, but it will be
// difficult.
// Especially, how do we deal with InputMethodService.onDisplayCompletions?
Expand Down Expand Up @@ -2236,6 +2299,8 @@ private void handleBackspaceEvent(final Event event, final InputTransaction inpu
// clears it. If non-zero (the previous commit was a gesture), this backspace MIGHT
// delete the whole word — see further down, after the autocorrect-revert branch.
final int gestureCommittedLen = mLastGestureCommittedLength;
final ArrayList<Integer> gestureCommittedFragmentLengths =
new ArrayList<>(mLastGestureCommittedFragmentLengths);
// Combining mode: a backspace always cancels the pending commit. The user is
// explicitly retracting input; we don't want the timer to fire mid-correction.
cancelCombiningMode();
Expand Down Expand Up @@ -2298,15 +2363,13 @@ private void handleBackspaceEvent(final Event event, final InputTransaction inpu
StatsUtils.onBackspaceWordDelete(rejectedSuggestion.length());
} else if ((inputTransaction.getSettingsValues().mGestureManualSpacing
|| inputTransaction.getSettingsValues().mCombiningGraceMs > 0)
&& inputTransaction.getSettingsValues().mCombiningBackspaceDeletesGestureWord) {
&& inputTransaction.getSettingsValues().mCombiningBackspaceDeletesGestureWord
&& inputTransaction.getSettingsValues().mCombiningBackspaceDeletesComposingText) {
final int wordLength = mWordComposer.getTypedWord().length();
if (inputTransaction.getSettingsValues().mCombiningBackspaceDeletesComposingText) {
mConnection.beginBatchEdit();
mConnection.deleteTextBeforeCursor(wordLength);
mConnection.endBatchEdit();
} else {
mConnection.finishComposingText();
}
mConnection.beginBatchEdit();
mConnection.deleteTextBeforeCursor(wordLength);
mConnection.finishComposingText();
mConnection.endBatchEdit();
mWordComposer.reset();
deletedWholeComposingWord = true;
StatsUtils.onBackspaceWordDelete(wordLength);
Expand Down Expand Up @@ -2348,6 +2411,21 @@ private void handleBackspaceEvent(final Event event, final InputTransaction inpu
// words don't qualify — gestureCommittedLen is 0 for those. We delete via
// deleteTextBeforeCursor instead of the usual single-char path so the user
// doesn't have to mash backspace to undo a swiped word.
final int gestureCommittedFragmentLen = gestureCommittedFragmentLengths.isEmpty()
? 0
: gestureCommittedFragmentLengths.remove(gestureCommittedFragmentLengths.size() - 1);
if (gestureCommittedFragmentLen > 0
&& inputTransaction.getSettingsValues().mGestureFragmentBackspace
&& !inputTransaction.getSettingsValues().mCombiningBackspaceDeletesGestureWord) {
mConnection.beginBatchEdit();
mConnection.deleteTextBeforeCursor(gestureCommittedFragmentLen);
mConnection.endBatchEdit();
mLastGestureCommittedFragmentLengths.clear();
mLastGestureCommittedFragmentLengths.addAll(gestureCommittedFragmentLengths);
StatsUtils.onBackspaceWordDelete(gestureCommittedFragmentLen);
inputTransaction.setRequiresUpdateSuggestions();
return;
}
if (gestureCommittedLen > 0
&& inputTransaction.getSettingsValues().mCombiningBackspaceDeletesGestureWord) {
mConnection.beginBatchEdit();
Expand Down Expand Up @@ -3678,17 +3756,19 @@ public void onUpdateTailBatchInputCompleted(final SettingsValues settingsValues,
} else {
setSuggestedWords(SuggestedWords.getEmptyInstance());
}
// PREF_GESTURE_FRAGMENT_BACKSPACE: record this gesture as a fragment boundary
// so backspace can pop the whole word at once. When extending an existing
// composing word, both the prior fragments AND this new one are already in the
// boundaries list (prior fragments were recorded at their own append time);
// recordFragmentBoundaryIfTracking adds the NEW boundary at the end. No-op if
// PREF_GESTURE_FRAGMENT_BACKSPACE isn't on.
// PREF_GESTURE_FRAGMENT_BACKSPACE: make sure the previous word length is present
// (fresh gesture words did not have an earlier extension point), then record this
// gesture's end so backspace can pop exactly one fragment.
if (!usedMergedTrail) {
recordFragmentBoundaryIfTracking(settingsValues, prevTypedWord.length());
}
recordFragmentBoundaryIfTracking(settingsValues);
} else {
// Non-extend path replaces the whole composing word each gesture; any prior
// fragment boundaries become meaningless.
// fragment boundaries become meaningless, then the new gesture becomes the only
// fragment currently available to pop.
clearFragmentBoundaries();
recordFragmentBoundaryIfTracking(settingsValues);
}
// Tap-promotion-extend is a one-shot decision per gesture; clear the flag so the
// next gesture re-evaluates the timing.
Expand Down
2 changes: 1 addition & 1 deletion app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -294,7 +294,7 @@
<string name="two_thumb_backspace_behavior">Backspace behavior</string>
<string name="two_thumb_backspace_behavior_summary">Choose what backspace removes while using manual spacing or delayed autospace.</string>
<string name="two_thumb_backspace_normal">Delete one character</string>
<string name="two_thumb_backspace_fragment">Delete last word part</string>
<string name="two_thumb_backspace_fragment">Delete last fragment</string>
<string name="two_thumb_backspace_word">Delete whole word</string>
<string name="multipart_join_key_mode">Explicit "join next" modifier</string>
<string name="multipart_join_key_mode_summary">Force the next input to extend the current word regardless of timing.</string>
Expand Down
85 changes: 83 additions & 2 deletions app/src/test/java/helium314/keyboard/latin/InputLogicTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -390,7 +390,7 @@ class InputLogicTest {
assertEquals("", composingText)
}

@Test fun wholeWordBackspaceCanKeepManualSpacingComposingTextInEditor() {
@Test fun wholeWordBackspaceWithLiveComposingDeleteOffFallsBackToOneCharacter() {
reset()
latinIME.prefs().edit {
putBoolean(Settings.PREF_GESTURE_MANUAL_SPACING, true)
Expand All @@ -405,7 +405,88 @@ class InputLogicTest {

functionalKeyPress(KeyCode.DELETE)

assertEquals("hello", textBeforeCursor)
assertEquals("hell", textBeforeCursor)
assertEquals("hell", composingText)
}

@Test fun fragmentBackspaceDeletesOnlySwipeFragment() {
reset()
latinIME.prefs().edit {
putBoolean(Settings.PREF_GESTURE_MANUAL_SPACING, true)
putBoolean(Settings.PREF_GESTURE_FRAGMENT_BACKSPACE, true)
putBoolean(Settings.PREF_COMBINING_BACKSPACE_DELETES_GESTURE_WORD, false)
}

gestureInput("fire")
assertEquals("fire", textBeforeCursor)
assertEquals("fire", composingText)

functionalKeyPress(KeyCode.DELETE)

assertEquals("", textBeforeCursor)
assertEquals("", composingText)
}

@Test fun fragmentBackspaceDeletesLastSwipeFragmentInMultipartWord() {
reset()
latinIME.prefs().edit {
putBoolean(Settings.PREF_GESTURE_MANUAL_SPACING, true)
putBoolean(Settings.PREF_GESTURE_FRAGMENT_BACKSPACE, true)
putBoolean(Settings.PREF_COMBINING_BACKSPACE_DELETES_GESTURE_WORD, false)
}

gestureInput("fire")
gestureInput("truck")
assertEquals("firetruck", textBeforeCursor)
assertEquals("firetruck", composingText)

functionalKeyPress(KeyCode.DELETE)

assertEquals("fire", textBeforeCursor)
assertEquals("fire", composingText)
}

@Test fun fragmentBackspaceDeletesLastSwipeFragmentAfterAutospaceCommit() {
reset()
latinIME.prefs().edit {
putInt(Settings.PREF_COMBINING_GRACE_MS, 1000)
putBoolean(Settings.PREF_GESTURE_MANUAL_SPACING, false)
putBoolean(Settings.PREF_GESTURE_FRAGMENT_BACKSPACE, true)
putBoolean(Settings.PREF_COMBINING_BACKSPACE_DELETES_GESTURE_WORD, false)
}

gestureInput("fire")
gestureInput("truck")
expireCombiningGrace()
assertEquals("firetruck ", textBeforeCursor)
assertEquals("", composingText)

functionalKeyPress(KeyCode.DELETE)

assertEquals("fire", textBeforeCursor)
assertEquals("", composingText)

functionalKeyPress(KeyCode.DELETE)

assertEquals("", textBeforeCursor)
assertEquals("", composingText)
}

@Test fun wholeWordBackspaceWithLiveComposingDeleteOnClearsComposingSpan() {
reset()
latinIME.prefs().edit {
putBoolean(Settings.PREF_GESTURE_MANUAL_SPACING, true)
putBoolean(Settings.PREF_GESTURE_FRAGMENT_BACKSPACE, false)
putBoolean(Settings.PREF_COMBINING_BACKSPACE_DELETES_GESTURE_WORD, true)
putBoolean(Settings.PREF_COMBINING_BACKSPACE_DELETES_COMPOSING_TEXT, true)
}

chainInput("hello")
functionalKeyPress(KeyCode.DELETE)
chainInput("hi")
functionalKeyPress(KeyCode.DELETE)

assertEquals("", textBeforeCursor)
assertEquals("", composingText)
}

Expand Down
Loading
Loading