Skip to content
Draft
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
96 changes: 61 additions & 35 deletions app/src/main/java/helium314/keyboard/latin/inputlogic/InputLogic.kt
Original file line number Diff line number Diff line change
Expand Up @@ -1192,9 +1192,48 @@ 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
val firstStep = handleBackspaceStep(event, inputTransaction, resumeSuggestions = !accelerated)
if (firstStep == BackspaceStepResult.SINGLE_ACTION
|| !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 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 -> {
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)
}
extraEvent = extraEvent.nextEvent
}
}

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,
alreadyUnlearnedWord: Boolean = false
): BackspaceStepResult {
val currentKeyboardScript = inputTransaction.settingsValues.mCurrentKeyboardScript

val selection = mConnection.getSelectedText(0)
val hasSelection = !selection.isNullOrEmpty() || mConnection.hasSelection()
Expand All @@ -1212,7 +1251,7 @@ class InputLogic(
restartSuggestionsOnWordTouchedByCursor(inputTransaction.settingsValues)
}
inputTransaction.requireShiftUpdate(InputTransaction.SHIFT_UPDATE_LATER)
return
return BackspaceStepResult.SINGLE_ACTION
}

val lastExpandedText = mLastExpandedText
Expand All @@ -1238,7 +1277,7 @@ class InputLogic(
mLastExpandedCursorPosition = -1
mLastExpandedCursorOffset = -1
mLastComposedWord = LastComposedWord.NOT_A_COMPOSED_WORD
return
return BackspaceStepResult.SINGLE_ACTION
}
}
}
Expand All @@ -1256,6 +1295,7 @@ class InputLogic(
}
if (mWordComposer.isComposingWord()) {
val wasBatchMode = mWordComposer.isBatchMode()
var expandedShortcut = false
if (mWordComposer.isBatchMode()) {
val rejectedSuggestion = mWordComposer.getTypedWord()
mWordComposer.reset()
Expand Down Expand Up @@ -1285,6 +1325,7 @@ class InputLogic(
}
commitExpandedText(result.matchedString, result.expandedText)
resetComposingState(true)
expandedShortcut = true
}
}
}
Expand All @@ -1299,6 +1340,8 @@ class InputLogic(
}
updateInlineEmojiSearch()
inputTransaction.setRequiresUpdateSuggestions()
return if (wasBatchMode || expandedShortcut) BackspaceStepResult.SINGLE_ACTION
else BackspaceStepResult.DELETED
} else {
if (mJustRevertedExpandedShortcut != null) {
mLastComposedWord = LastComposedWord.NOT_A_COMPOSED_WORD
Expand All @@ -1317,40 +1360,34 @@ class InputLogic(
) {
restartSuggestionsOnWordTouchedByCursor(inputTransaction.settingsValues)
}
return
return BackspaceStepResult.SINGLE_ACTION
}
if (SpaceState.DOUBLE == inputTransaction.spaceState) {
cancelDoubleSpacePeriodCountdown()
if (mConnection.revertDoubleSpacePeriod(inputTransaction.settingsValues.mSpacingAndPunctuations)) {
inputTransaction.setRequiresUpdateSuggestions()
mWordComposer.setCapitalizedModeAtStartComposingTime(WordComposer.CAPS_MODE_OFF)
StatsUtils.onRevertDoubleSpacePeriod()
return
return BackspaceStepResult.SINGLE_ACTION
}
} else if (SpaceState.SWAP_PUNCTUATION == inputTransaction.spaceState) {
if (mConnection.revertSwapPunctuation()) {
StatsUtils.onRevertSwapPunctuation()
return
return BackspaceStepResult.SINGLE_ACTION
}
}

var hasUnlearnedWordBeingDeleted = false
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)
} 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) {
Expand All @@ -1359,41 +1396,30 @@ class InputLogic(
} else {
mConnection.deleteTextBeforeCursor(1)
}
return
return BackspaceStepResult.SINGLE_ACTION
}
val lengthToDelete = if (codePointBeforeCursor > 0xFE00 || StringUtils.mightBeEmoji(codePointBeforeCursor)) {
mConnection.charCountToDeleteBeforeCursor
} else {
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)
}
val hasUnlearnedWord = alreadyUnlearnedWord || unlearnWordBeingDeleted(inputTransaction.settingsValues)
if (mConnection.hasSlowInputConnection()) {
mSuggestionStripViewAccessor.setNeutralSuggestionStrip()
} else if (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
}
}
}

Expand Down
161 changes: 160 additions & 1 deletion app/src/test/java/helium314/keyboard/latin/InputLogicTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,154 @@ 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)
}

@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
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")
Expand Down Expand Up @@ -1236,15 +1384,19 @@ 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
override fun getTextBeforeCursor(p0: Int, p1: Int): CharSequence = textBeforeCursor.take(p0)
// 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
Expand Down Expand Up @@ -1446,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,
Expand All @@ -1458,6 +1616,7 @@ class ShadowFacilitator2 {
companion object {
var lastAddedWord = ""
var lastNgramContext = ""
val unlearnedWords = mutableListOf<String>()
val addedWords = mutableListOf<String>()
val ngramContexts = mutableListOf<String>()
}
Expand Down