From 6862fbedfff5b58807916467b84ce28cb928e1df Mon Sep 17 00:00:00 2001 From: Dvredin <32620766+Dvredin@users.noreply.github.com> Date: Wed, 9 Sep 2026 19:49:29 +0200 Subject: [PATCH 1/2] fix: keep backspace acceleration consistent across composing states --- .../keyboard/latin/inputlogic/InputLogic.kt | 79 ++++++------ .../keyboard/latin/InputLogicTest.kt | 112 ++++++++++++++++++ 2 files changed, 157 insertions(+), 34 deletions(-) diff --git a/app/src/main/java/helium314/keyboard/latin/inputlogic/InputLogic.kt b/app/src/main/java/helium314/keyboard/latin/inputlogic/InputLogic.kt index a7d2dd09e..9f55d81db 100644 --- a/app/src/main/java/helium314/keyboard/latin/inputlogic/InputLogic.kt +++ b/app/src/main/java/helium314/keyboard/latin/inputlogic/InputLogic.kt @@ -1192,9 +1192,38 @@ class InputLogic( } private fun handleBackspaceEvent(event: Event, inputTransaction: InputTransaction) { - val currentKeyboardScript = inputTransaction.settingsValues.mCurrentKeyboardScript mSpaceState = SpaceState.NONE mDeleteCount++ + // Decide acceleration independently of whether the cursor is in a composing word. + // Selection deletion, undo and gesture-word rejection remain single actions. + val accelerated = mDeleteCount > Constants.DELETE_ACCELERATE_AT + if (!handleBackspaceStep(event, inputTransaction, resumeSuggestions = !accelerated) + || !accelerated + || mConnection.expectedSelectionStart == 0 + ) return + + // This is a new logical deletion, not a second application of the processed event. + // The first step may have emptied/resumed a word or changed a combiner's state. + val extraBackspace = Event.createSoftwareKeypressEvent( + KeyCode.DELETE, event.metaState, event.x, event.y, event.isKeyRepeat + ) + var extraEvent: Event? = mWordComposer.processEvent(extraBackspace) + while (extraEvent != null) { + when { + extraEvent.isConsumed -> handleConsumedEvent(extraEvent, inputTransaction) + extraEvent.keyCode == KeyCode.DELETE -> handleBackspaceStep(extraEvent, inputTransaction) + extraEvent.isFunctionalKeyEvent -> handleFunctionalEvent(extraEvent, inputTransaction, mLatinIME.mHandler) + else -> handleNonFunctionalEvent(extraEvent, inputTransaction, mLatinIME.mHandler) + } + extraEvent = extraEvent.nextEvent + } + } + + // Returns true only for ordinary deletion, which may receive an accelerated second step. + private fun handleBackspaceStep( + event: Event, inputTransaction: InputTransaction, resumeSuggestions: Boolean = true + ): Boolean { + val currentKeyboardScript = inputTransaction.settingsValues.mCurrentKeyboardScript val selection = mConnection.getSelectedText(0) val hasSelection = !selection.isNullOrEmpty() || mConnection.hasSelection() @@ -1212,7 +1241,7 @@ class InputLogic( restartSuggestionsOnWordTouchedByCursor(inputTransaction.settingsValues) } inputTransaction.requireShiftUpdate(InputTransaction.SHIFT_UPDATE_LATER) - return + return false } val lastExpandedText = mLastExpandedText @@ -1238,7 +1267,7 @@ class InputLogic( mLastExpandedCursorPosition = -1 mLastExpandedCursorOffset = -1 mLastComposedWord = LastComposedWord.NOT_A_COMPOSED_WORD - return + return false } } } @@ -1256,6 +1285,7 @@ class InputLogic( } if (mWordComposer.isComposingWord()) { val wasBatchMode = mWordComposer.isBatchMode() + var expandedShortcut = false if (mWordComposer.isBatchMode()) { val rejectedSuggestion = mWordComposer.getTypedWord() mWordComposer.reset() @@ -1285,6 +1315,7 @@ class InputLogic( } commitExpandedText(result.matchedString, result.expandedText) resetComposingState(true) + expandedShortcut = true } } } @@ -1299,6 +1330,7 @@ class InputLogic( } updateInlineEmojiSearch() inputTransaction.setRequiresUpdateSuggestions() + return !wasBatchMode && !expandedShortcut } else { if (mJustRevertedExpandedShortcut != null) { mLastComposedWord = LastComposedWord.NOT_A_COMPOSED_WORD @@ -1317,7 +1349,7 @@ class InputLogic( ) { restartSuggestionsOnWordTouchedByCursor(inputTransaction.settingsValues) } - return + return false } if (SpaceState.DOUBLE == inputTransaction.spaceState) { cancelDoubleSpacePeriodCountdown() @@ -1325,32 +1357,26 @@ class InputLogic( inputTransaction.setRequiresUpdateSuggestions() mWordComposer.setCapitalizedModeAtStartComposingTime(WordComposer.CAPS_MODE_OFF) StatsUtils.onRevertDoubleSpacePeriod() - return + return false } } else if (SpaceState.SWAP_PUNCTUATION == inputTransaction.spaceState) { if (mConnection.revertSwapPunctuation()) { StatsUtils.onRevertSwapPunctuation() - return + return false } } - var hasUnlearnedWordBeingDeleted = false val fallbackSel = mConnection.getSelectedText(0) if (!TextUtils.isEmpty(fallbackSel) || mConnection.hasSelection()) { mWordComposer.reset() sendDownUpKeyEvent(KeyEvent.KEYCODE_DEL) + return false } else { if (inputTransaction.settingsValues.mInputAttributes.isTypeNull || Constants.NOT_A_CURSOR_POSITION == mConnection.expectedSelectionEnd ) { sendDownUpKeyEvent(KeyEvent.KEYCODE_DEL) - var totalDeletedLength = 1 - if (mDeleteCount > Constants.DELETE_ACCELERATE_AT) { - hasUnlearnedWordBeingDeleted = hasUnlearnedWordBeingDeleted or unlearnWordBeingDeleted(inputTransaction.settingsValues) - sendDownUpKeyEvent(KeyEvent.KEYCODE_DEL) - totalDeletedLength++ - } - StatsUtils.onBackspacePressed(totalDeletedLength) + StatsUtils.onBackspacePressed(1) } else { val codePointBeforeCursor = mConnection.codePointBeforeCursor if (codePointBeforeCursor == Constants.NOT_A_CODE) { @@ -1359,7 +1385,7 @@ class InputLogic( } else { mConnection.deleteTextBeforeCursor(1) } - return + return false } val lengthToDelete = if (codePointBeforeCursor > 0xFE00 || StringUtils.mightBeEmoji(codePointBeforeCursor)) { mConnection.charCountToDeleteBeforeCursor @@ -1367,34 +1393,19 @@ class InputLogic( 1 } mConnection.deleteTextBeforeCursor(lengthToDelete) - var totalDeletedLength = lengthToDelete - if (mDeleteCount > Constants.DELETE_ACCELERATE_AT) { - hasUnlearnedWordBeingDeleted = hasUnlearnedWordBeingDeleted or unlearnWordBeingDeleted(inputTransaction.settingsValues) - val codePointBeforeCursorToDeleteAgain = mConnection.codePointBeforeCursor - if (codePointBeforeCursorToDeleteAgain != Constants.NOT_A_CODE) { - val lengthToDeleteAgain = if (codePointBeforeCursorToDeleteAgain > 0xFE00 || StringUtils.mightBeEmoji(codePointBeforeCursorToDeleteAgain)) { - mConnection.charCountToDeleteBeforeCursor - } else { - 1 - } - mConnection.deleteTextBeforeCursor(lengthToDeleteAgain) - totalDeletedLength += lengthToDeleteAgain - } - } - StatsUtils.onBackspacePressed(totalDeletedLength) + StatsUtils.onBackspacePressed(lengthToDelete) } } - if (!hasUnlearnedWordBeingDeleted) { - unlearnWordBeingDeleted(inputTransaction.settingsValues) - } + unlearnWordBeingDeleted(inputTransaction.settingsValues) if (mConnection.hasSlowInputConnection()) { mSuggestionStripViewAccessor.setNeutralSuggestionStrip() - } else if (inputTransaction.settingsValues.needsToLookupSuggestions() + } else if (resumeSuggestions && inputTransaction.settingsValues.needsToLookupSuggestions() && inputTransaction.settingsValues.mSpacingAndPunctuations.mCurrentLanguageHasSpaces ) { restartSuggestionsOnWordTouchedByCursor(inputTransaction.settingsValues) } } + return true } internal fun getWordAtCursor(settingsValues: SettingsValues): String { diff --git a/app/src/test/java/helium314/keyboard/latin/InputLogicTest.kt b/app/src/test/java/helium314/keyboard/latin/InputLogicTest.kt index f6ea5fe19..764e0b322 100644 --- a/app/src/test/java/helium314/keyboard/latin/InputLogicTest.kt +++ b/app/src/test/java/helium314/keyboard/latin/InputLogicTest.kt @@ -105,6 +105,118 @@ class InputLogicTest { assertEquals("there", composingText) } + @Test fun `held backspace accelerates inside a composing word`() { + reset() + val original = "abcdefghijklmnopqrstuvwxyzabcdefghij" + chainInput(original) + assertEquals(original, composingText) + repeat(Constants.DELETE_ACCELERATE_AT) { repeatBackspace() } + assertEquals(original.dropLast(Constants.DELETE_ACCELERATE_AT), text) + repeatBackspace() + assertEquals(original.dropLast(Constants.DELETE_ACCELERATE_AT + 2), text) + assertEquals(text, composingText) + } + + @Test fun `held backspace keeps accelerating after resuming a committed word`() { + reset() + val original = "abcdefghijklmnopqrstuvwxyzabcdefghij" + setText("$original ") + repeatBackspace() + assertEquals(original, composingText) + repeat(Constants.DELETE_ACCELERATE_AT - 1) { repeatBackspace() } + repeatBackspace() + assertEquals(original.dropLast(Constants.DELETE_ACCELERATE_AT + 1), text) + assertEquals(text, composingText) + } + + @Test fun `accelerated backspace crosses from composition into committed text`() { + reset() + setText("prefix ") + chainInput("a".repeat(Constants.DELETE_ACCELERATE_AT + 1)) + repeat(Constants.DELETE_ACCELERATE_AT) { repeatBackspace() } + assertEquals("prefix a", text) + assertEquals("a", composingText) + repeatBackspace() + assertEquals("prefix", text) + checkConnectionConsistency() + repeatBackspace() + assertEquals("pref", text) + } + + @Test fun `held backspace preserves acceleration for committed digits`() { + reset() + val original = "1234567890".repeat(4) + setText(original) + repeat(Constants.DELETE_ACCELERATE_AT) { repeatBackspace() } + repeatBackspace() + assertEquals(original.dropLast(Constants.DELETE_ACCELERATE_AT + 2), text) + } + + @Test fun `accelerated committed deletion keeps an emoji intact`() { + reset() + setText("x🕵🏼" + "1".repeat(Constants.DELETE_ACCELERATE_AT + 1)) + repeat(Constants.DELETE_ACCELERATE_AT) { repeatBackspace() } + assertEquals("x🕵🏼1", text) + repeatBackspace() + assertEquals("x", text) + } + + @Test fun `accelerated backspace stops after deleting a selection`() { + reset() + setText("prefix selected suffix") + setCursorPosition(7, 15) + armBackspaceAcceleration() + repeatBackspace() + assertEquals("prefix suffix", text) + } + + @Test fun `accelerated backspace stops after reverting autocorrection`() { + reset() + setInputType(InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_FLAG_AUTO_CORRECT) + chainInput("hullo") + getAutocorrectedWithSpaceAfter("hello", "hullo") + armBackspaceAcceleration() + repeatBackspace() + assertEquals("hullo", text) + } + + @Test fun `accelerated backspace stops after rejecting a gesture word`() { + reset() + setText("prefix ") + glideTypingInput("hello") + armBackspaceAcceleration() + repeatBackspace() + assertEquals("prefix ", text) + } + + @Test fun `accelerated backspace does not overrun the last composing character`() { + reset() + chainInput("a") + armBackspaceAcceleration() + repeatBackspace() + assertEquals("", text) + } + + private fun armBackspaceAcceleration() { + InputLogic::class.java.getDeclaredField("mDeleteCount").apply { + isAccessible = true + setInt(inputLogic, Constants.DELETE_ACCELERATE_AT) + } + InputLogic::class.java.getDeclaredField("mLastKeyTime").apply { + isAccessible = true + setLong(inputLogic, android.os.SystemClock.uptimeMillis()) + } + } + + private fun repeatBackspace() { + org.robolectric.shadows.ShadowSystemClock.advanceBy(java.time.Duration.ofMillis(50)) + latinIME.onEvent(Event.createSoftwareKeypressEvent( + KeyCode.DELETE, 0, Constants.NOT_A_COORDINATE, Constants.NOT_A_COORDINATE, true + )) + handleMessages() + checkConnectionConsistency() + } + @Test fun deleteInsideWord() { reset() setText("hello you there") From ca5bd73ce113f459db8c515bdc874dce43dd7f67 Mon Sep 17 00:00:00 2001 From: Dvredin <32620766+Dvredin@users.noreply.github.com> Date: Wed, 9 Sep 2026 20:06:24 +0200 Subject: [PATCH 2/2] fix: preserve backspace unlearning and fallback suggestion handling --- .../keyboard/latin/inputlogic/InputLogic.kt | 49 ++++++++++++------- .../keyboard/latin/InputLogicTest.kt | 49 ++++++++++++++++++- 2 files changed, 80 insertions(+), 18 deletions(-) diff --git a/app/src/main/java/helium314/keyboard/latin/inputlogic/InputLogic.kt b/app/src/main/java/helium314/keyboard/latin/inputlogic/InputLogic.kt index 9f55d81db..125d5acc5 100644 --- a/app/src/main/java/helium314/keyboard/latin/inputlogic/InputLogic.kt +++ b/app/src/main/java/helium314/keyboard/latin/inputlogic/InputLogic.kt @@ -1197,7 +1197,8 @@ class InputLogic( // Decide acceleration independently of whether the cursor is in a composing word. // Selection deletion, undo and gesture-word rejection remain single actions. val accelerated = mDeleteCount > Constants.DELETE_ACCELERATE_AT - if (!handleBackspaceStep(event, inputTransaction, resumeSuggestions = !accelerated) + val firstStep = handleBackspaceStep(event, inputTransaction, resumeSuggestions = !accelerated) + if (firstStep == BackspaceStepResult.SINGLE_ACTION || !accelerated || mConnection.expectedSelectionStart == 0 ) return @@ -1207,11 +1208,16 @@ class InputLogic( val extraBackspace = Event.createSoftwareKeypressEvent( KeyCode.DELETE, event.metaState, event.x, event.y, event.isKeyRepeat ) + var hasUnlearnedWord = firstStep == BackspaceStepResult.DELETED_AND_UNLEARNED var extraEvent: Event? = mWordComposer.processEvent(extraBackspace) while (extraEvent != null) { when { extraEvent.isConsumed -> handleConsumedEvent(extraEvent, inputTransaction) - extraEvent.keyCode == KeyCode.DELETE -> handleBackspaceStep(extraEvent, inputTransaction) + extraEvent.keyCode == KeyCode.DELETE -> { + val result = handleBackspaceStep(extraEvent, inputTransaction, + alreadyUnlearnedWord = hasUnlearnedWord) + hasUnlearnedWord = hasUnlearnedWord || result == BackspaceStepResult.DELETED_AND_UNLEARNED + } extraEvent.isFunctionalKeyEvent -> handleFunctionalEvent(extraEvent, inputTransaction, mLatinIME.mHandler) else -> handleNonFunctionalEvent(extraEvent, inputTransaction, mLatinIME.mHandler) } @@ -1219,10 +1225,14 @@ class InputLogic( } } - // Returns true only for ordinary deletion, which may receive an accelerated second step. + private enum class BackspaceStepResult { SINGLE_ACTION, DELETED, DELETED_AND_UNLEARNED } + + // Only ordinary deletion may receive an extra step. Carry successful unlearning across + // steps so one key event does not remove multiple intermediate prefixes from history. private fun handleBackspaceStep( - event: Event, inputTransaction: InputTransaction, resumeSuggestions: Boolean = true - ): Boolean { + event: Event, inputTransaction: InputTransaction, resumeSuggestions: Boolean = true, + alreadyUnlearnedWord: Boolean = false + ): BackspaceStepResult { val currentKeyboardScript = inputTransaction.settingsValues.mCurrentKeyboardScript val selection = mConnection.getSelectedText(0) @@ -1241,7 +1251,7 @@ class InputLogic( restartSuggestionsOnWordTouchedByCursor(inputTransaction.settingsValues) } inputTransaction.requireShiftUpdate(InputTransaction.SHIFT_UPDATE_LATER) - return false + return BackspaceStepResult.SINGLE_ACTION } val lastExpandedText = mLastExpandedText @@ -1267,7 +1277,7 @@ class InputLogic( mLastExpandedCursorPosition = -1 mLastExpandedCursorOffset = -1 mLastComposedWord = LastComposedWord.NOT_A_COMPOSED_WORD - return false + return BackspaceStepResult.SINGLE_ACTION } } } @@ -1330,7 +1340,8 @@ class InputLogic( } updateInlineEmojiSearch() inputTransaction.setRequiresUpdateSuggestions() - return !wasBatchMode && !expandedShortcut + return if (wasBatchMode || expandedShortcut) BackspaceStepResult.SINGLE_ACTION + else BackspaceStepResult.DELETED } else { if (mJustRevertedExpandedShortcut != null) { mLastComposedWord = LastComposedWord.NOT_A_COMPOSED_WORD @@ -1349,7 +1360,7 @@ class InputLogic( ) { restartSuggestionsOnWordTouchedByCursor(inputTransaction.settingsValues) } - return false + return BackspaceStepResult.SINGLE_ACTION } if (SpaceState.DOUBLE == inputTransaction.spaceState) { cancelDoubleSpacePeriodCountdown() @@ -1357,20 +1368,20 @@ class InputLogic( inputTransaction.setRequiresUpdateSuggestions() mWordComposer.setCapitalizedModeAtStartComposingTime(WordComposer.CAPS_MODE_OFF) StatsUtils.onRevertDoubleSpacePeriod() - return false + return BackspaceStepResult.SINGLE_ACTION } } else if (SpaceState.SWAP_PUNCTUATION == inputTransaction.spaceState) { if (mConnection.revertSwapPunctuation()) { StatsUtils.onRevertSwapPunctuation() - return false + return BackspaceStepResult.SINGLE_ACTION } } val fallbackSel = mConnection.getSelectedText(0) - if (!TextUtils.isEmpty(fallbackSel) || mConnection.hasSelection()) { + val deletedSelection = !TextUtils.isEmpty(fallbackSel) || mConnection.hasSelection() + if (deletedSelection) { mWordComposer.reset() sendDownUpKeyEvent(KeyEvent.KEYCODE_DEL) - return false } else { if (inputTransaction.settingsValues.mInputAttributes.isTypeNull || Constants.NOT_A_CURSOR_POSITION == mConnection.expectedSelectionEnd @@ -1385,7 +1396,7 @@ class InputLogic( } else { mConnection.deleteTextBeforeCursor(1) } - return false + return BackspaceStepResult.SINGLE_ACTION } val lengthToDelete = if (codePointBeforeCursor > 0xFE00 || StringUtils.mightBeEmoji(codePointBeforeCursor)) { mConnection.charCountToDeleteBeforeCursor @@ -1396,16 +1407,20 @@ class InputLogic( StatsUtils.onBackspacePressed(lengthToDelete) } } - unlearnWordBeingDeleted(inputTransaction.settingsValues) + val hasUnlearnedWord = alreadyUnlearnedWord || unlearnWordBeingDeleted(inputTransaction.settingsValues) if (mConnection.hasSlowInputConnection()) { mSuggestionStripViewAccessor.setNeutralSuggestionStrip() - } else if (resumeSuggestions && inputTransaction.settingsValues.needsToLookupSuggestions() + } else if ((resumeSuggestions || deletedSelection) && inputTransaction.settingsValues.needsToLookupSuggestions() && inputTransaction.settingsValues.mSpacingAndPunctuations.mCurrentLanguageHasSpaces ) { restartSuggestionsOnWordTouchedByCursor(inputTransaction.settingsValues) } + return when { + deletedSelection -> BackspaceStepResult.SINGLE_ACTION + hasUnlearnedWord -> BackspaceStepResult.DELETED_AND_UNLEARNED + else -> BackspaceStepResult.DELETED + } } - return true } internal fun getWordAtCursor(settingsValues: SettingsValues): String { diff --git a/app/src/test/java/helium314/keyboard/latin/InputLogicTest.kt b/app/src/test/java/helium314/keyboard/latin/InputLogicTest.kt index 764e0b322..336204af6 100644 --- a/app/src/test/java/helium314/keyboard/latin/InputLogicTest.kt +++ b/app/src/test/java/helium314/keyboard/latin/InputLogicTest.kt @@ -197,6 +197,42 @@ class InputLogicTest { assertEquals("", text) } + @Test fun `accelerated committed deletion unlearns only the first matched prefix`() { + reset() + setText("cats") + inputLogic.finishInput() + assertEquals("", composingText) + ShadowFacilitator2.unlearnedWords.clear() + armBackspaceAcceleration() + repeatBackspace() + assertEquals("ca", text) + assertEquals(listOf("cat"), ShadowFacilitator2.unlearnedWords) + } + + @Test fun `fallback selection deletion resumes suggestions without extra deletion`() { + reset() + setText("catsmore") + inputLogic.finishInput() + assertEquals("", composingText) + armBackspaceAcceleration() + var queries = 0 + selectedTextQueryHook = { + if (++queries == 2) { + // The editor reveals a selection only on the defensive second query. + selectionStart = 4 + selectionEnd = 8 + selectedTextQueryHook = null + } + } + try { + repeatBackspace() + assertEquals("cats", text) + assertEquals("cats", composingText) + } finally { + selectedTextQueryHook = null + } + } + private fun armBackspaceAcceleration() { InputLogic::class.java.getDeclaredField("mDeleteCount").apply { isAccessible = true @@ -1348,6 +1384,7 @@ private val composingText get() = if (composingStart == -1 || composingEnd == -1 else text.substring(composingStart, composingEnd) // essentially this is the text field we're editing in +private var selectedTextQueryHook: (() -> Unit)? = null private val ic = object : InputConnection { // pretty clear (though this may be slow depending on the editor) // bad return value here is likely the cause for that weird bug improved/fixed by fixIncorrectLength @@ -1355,8 +1392,11 @@ private val ic = object : InputConnection { // pretty clear (though this may be slow depending on the editor) override fun getTextAfterCursor(p0: Int, p1: Int): CharSequence = textAfterCursor.take(p0) // pretty clear - override fun getSelectedText(p0: Int): CharSequence? = if (selectionStart == selectionEnd) null + override fun getSelectedText(p0: Int): CharSequence? { + selectedTextQueryHook?.invoke() + return if (selectionStart == selectionEnd) null else text.substring(selectionStart, selectionEnd) + } // inserts text at cursor (right?), and sets it as composing text // this REPLACES currently composing text (even if at a different position) // moves the cursor: positive means relative to composing text start, negative means relative to start @@ -1558,6 +1598,12 @@ class ShadowKeyboardSwitcher { @Implements(DictionaryFacilitatorImpl::class) class ShadowFacilitator2 { + @Implementation + fun unlearnFromUserHistory(word: String, ngramContext: NgramContext, + timeStampInSeconds: Long, eventType: Int) { + unlearnedWords.add(word) + } + @Implementation fun addToUserHistory(suggestion: String, wasAutoCapitalized: Boolean, ngramContext: NgramContext, timeStampInSeconds: Long, @@ -1570,6 +1616,7 @@ class ShadowFacilitator2 { companion object { var lastAddedWord = "" var lastNgramContext = "" + val unlearnedWords = mutableListOf() val addedWords = mutableListOf() val ngramContexts = mutableListOf() }