diff --git a/app/src/main/java/helium314/keyboard/latin/LatinIME.kt b/app/src/main/java/helium314/keyboard/latin/LatinIME.kt index ee35b1f67..b095635ec 100644 --- a/app/src/main/java/helium314/keyboard/latin/LatinIME.kt +++ b/app/src/main/java/helium314/keyboard/latin/LatinIME.kt @@ -1689,10 +1689,21 @@ class LatinIME : InputMethodService(), } } + /** Haptic and audio feedback when holding delete removed a word */ + fun feedbackForDeletedWord() { + try { + hapticAndAudioFeedback(KeyCode.DELETE, 0, HapticEvent.KEY_PRESS) + } catch (e: Exception) { + Log.w(TAG, "no feedback for deleted word", e) + } + } + fun hapticAndAudioFeedback(code: Int, repeatCount: Int, hapticEvent: HapticEvent) { val keyboardView = keyboardSwitcher.mainKeyboardView if (keyboardView != null && keyboardView.isInDraggingFinger()) return + // holding delete removes whole words: feedback comes per word, see feedbackForDeletedWord + if (repeatCount > 0 && code == KeyCode.DELETE && inputLogic.isBackspaceWordModeActive(mSettings.current)) return if (repeatCount > 0) { when (code) { KeyCode.DELETE, KeyCode.ARROW_LEFT, KeyCode.ARROW_UP, KeyCode.WORD_LEFT, KeyCode.PAGE_UP -> if (!inputLogic.connection.canDeleteCharacters()) return 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 2c3efc08c..ca212935a 100644 --- a/app/src/main/java/helium314/keyboard/latin/inputlogic/InputLogic.kt +++ b/app/src/main/java/helium314/keyboard/latin/inputlogic/InputLogic.kt @@ -89,6 +89,13 @@ class InputLogic( private var mTypedWordOnShiftPress: String? = null private var mDeleteCount = 0 + // for "backspace repeat deletes words": repeat ticks in the current hold, and words deleted so far + private var mBackspaceRepeatTicks = 0 + private var mBackspaceWordsDeleted = 0 + /** time (ms after the first key repeat) when the next word is deleted */ + private var mBackspaceNextWordTime = 0f + /** word deletion not possible in the current hold (no text available), normal deletion is used */ + private var mBackspaceWordModeFailed = false private var mLastKeyTime = 0L private var mEnteredText: String? = null private var mIsAutoCorrectionIndicatorOn = false @@ -1350,6 +1357,65 @@ class InputLogic( } } + /** + * Whether holding delete currently removes whole words. Not for languages without spaces, where + * a "word" may be a whole sentence. Also used to give feedback per word instead of per key repeat. + */ + fun isBackspaceWordModeActive(settingsValues: SettingsValues): Boolean = + settingsValues.mBackspaceRepeatDeletesWords + && settingsValues.mSpacingAndPunctuations.mCurrentLanguageHasSpaces + && !mBackspaceWordModeFailed + + /** + * When the option is enabled and the delete key is held down, switch from deleting characters + * to deleting whole words after a short while. Words are deleted with increasing speed. + * Returns true if the event was consumed (a word was deleted, or this repeat tick is skipped). + */ + private fun handleBackspaceRepeatWordDeletion(event: Event, settingsValues: SettingsValues): Boolean { + if (!event.isKeyRepeat || mDeleteCount <= 1) { + // new press of the delete key + mBackspaceRepeatTicks = 0 + mBackspaceWordsDeleted = 0 + mBackspaceNextWordTime = 0f + mBackspaceWordModeFailed = false + } + if (!event.isKeyRepeat) return false + mBackspaceRepeatTicks++ + if (!isBackspaceWordModeActive(settingsValues)) return false + // key repeats come in fixed intervals, so we can count time in repeats + val time = (mBackspaceRepeatTicks - 1) * KEY_REPEAT_INTERVAL_MS + if (time < mBackspaceNextWordTime) return true // wait between words + + if (mWordComposer.isComposingWord()) { + mConnection.finishComposingText() + resetComposingState(true) + } + // with very short intervals, more than one word may be due per key repeat + var deletedWords = 0 + while (time >= mBackspaceNextWordTime && deletedWords < 2) { + val textBefore = mConnection.getTextBeforeCursor(BACKSPACE_WORD_LOOKBACK, 0)?.toString() + if (textBefore == null) { // fall back to normal deletion + mBackspaceWordModeFailed = true + return deletedWords > 0 + } + if (textBefore.isEmpty()) return true + val length = textBefore.length - getStartOfPreviousWord(textBefore) + if (InputTypeUtils.isWebEditor(getCurrentInputEditorInfo())) { + repeat(length) { sendDownUpKeyEvent(KeyEvent.KEYCODE_DEL) } + } else { + mConnection.deleteTextBeforeCursor(length) + } + StatsUtils.onBackspaceWordDelete(length) + mBackspaceNextWordTime += getBackspaceWordInterval(mBackspaceWordsDeleted) + mBackspaceWordsDeleted++ + deletedWords++ + } + mSuggestionStripViewAccessor.setNeutralSuggestionStrip() + // one feedback per deleted word, key repeat feedback is suppressed in word mode + mLatinIME.feedbackForDeletedWord() + return true + } + private fun handleBackspaceEvent(event: Event, inputTransaction: InputTransaction) { val currentKeyboardScript = inputTransaction.settingsValues.mCurrentKeyboardScript mSpaceState = SpaceState.NONE @@ -1410,6 +1476,8 @@ class InputLogic( } inputTransaction.requireShiftUpdate(shiftUpdateKind) + if (handleBackspaceRepeatWordDeletion(event, inputTransaction.settingsValues)) return + if (mWordComposer.isCursorFrontOrMiddleOfComposingWord()) { unlearnWord(mWordComposer.getTypedWord(), inputTransaction.settingsValues, Constants.EVENT_BACKSPACE) resetEntireInputState(mConnection.expectedSelectionStart, mConnection.expectedSelectionEnd, true) @@ -2947,10 +3015,42 @@ class InputLogic( companion object { private const val TAG = "InputLogic" private const val INLINE_EMOJI_SEARCH_MARKER = ':' + private const val KEY_REPEAT_INTERVAL_MS = 50 + /** interval before the second word, it gets shorter with each word until the minimum */ + private const val BACKSPACE_WORD_START_INTERVAL = 350f + private const val BACKSPACE_WORD_ACCELERATION = 0.8f + private const val BACKSPACE_WORD_MIN_INTERVAL = 80f + + /** Time until the next word is deleted, after [wordsDeleted] words were deleted already */ + fun getBackspaceWordInterval(wordsDeleted: Int): Float { + var interval = BACKSPACE_WORD_START_INTERVAL + repeat(wordsDeleted.coerceAtMost(30)) { interval *= BACKSPACE_WORD_ACCELERATION } + return interval.coerceAtLeast(BACKSPACE_WORD_MIN_INTERVAL) + } + private const val BACKSPACE_WORD_LOOKBACK = 200 private val THAI_LOCALE = Locale.forLanguageTag("th") private val THAI_WORD_BREAK_ITERATOR = ThreadLocal.withInitial { BreakIterator.getWordInstance(THAI_LOCALE) } private val CURSOR_PATTERN = java.util.regex.Pattern.compile("%cursor(\\d+)%") + /** + * Index where the word before the end of [text] starts, including whitespace and punctuation + * after it, but keeping the whitespace before it (so "one two " -> "one "). + */ + internal fun getStartOfPreviousWord(text: String): Int { + var i = text.length + fun skipWhile(predicate: (Int) -> Boolean) { + while (i > 0) { + val cp = Character.codePointBefore(text, i) + if (!predicate(cp)) break + i -= Character.charCount(cp) + } + } + skipWhile { Character.isWhitespace(it) } + skipWhile { !Character.isLetterOrDigit(it) && !Character.isWhitespace(it) } + skipWhile { Character.isLetterOrDigit(it) } + return i + } + fun isSpaceStrippingPunctuation(codePoint: Int): Boolean { return codePoint == '.'.code || codePoint == ','.code 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 e21cd68ac..8462553b9 100644 --- a/app/src/main/java/helium314/keyboard/latin/settings/Defaults.kt +++ b/app/src/main/java/helium314/keyboard/latin/settings/Defaults.kt @@ -94,6 +94,7 @@ object Defaults { const val PREF_AUTO_CORRECT_THRESHOLD = 0.185f const val PREF_AUTOCORRECT_SHORTCUTS = true const val PREF_BACKSPACE_REVERTS_AUTOCORRECT = true + const val PREF_BACKSPACE_REPEAT_DELETES_WORDS = false const val PREF_CENTER_SUGGESTION_TEXT_TO_ENTER = false const val PREF_CENTER_TYPED_WORD = false const val PREF_SHOW_SUGGESTIONS = true diff --git a/app/src/main/java/helium314/keyboard/latin/settings/Settings.kt b/app/src/main/java/helium314/keyboard/latin/settings/Settings.kt index b28526dcb..00bca0c8f 100644 --- a/app/src/main/java/helium314/keyboard/latin/settings/Settings.kt +++ b/app/src/main/java/helium314/keyboard/latin/settings/Settings.kt @@ -343,6 +343,7 @@ class Settings private constructor() : SharedPreferences.OnSharedPreferenceChang const val PREF_AUTO_CORRECT_THRESHOLD = "auto_correct_threshold" const val PREF_AUTOCORRECT_SHORTCUTS = "autocorrect_shortcuts" const val PREF_BACKSPACE_REVERTS_AUTOCORRECT = "backspace_reverts_autocorrect" + const val PREF_BACKSPACE_REPEAT_DELETES_WORDS = "backspace_repeat_deletes_words" const val PREF_CENTER_SUGGESTION_TEXT_TO_ENTER = "center_suggestion_text_to_enter" const val PREF_CENTER_TYPED_WORD = "center_typed_word" const val PREF_SHOW_SUGGESTIONS = "show_suggestions" diff --git a/app/src/main/java/helium314/keyboard/latin/settings/SettingsValues.kt b/app/src/main/java/helium314/keyboard/latin/settings/SettingsValues.kt index decdbbdb0..494bd26c7 100644 --- a/app/src/main/java/helium314/keyboard/latin/settings/SettingsValues.kt +++ b/app/src/main/java/helium314/keyboard/latin/settings/SettingsValues.kt @@ -219,6 +219,7 @@ open class SettingsValues( val mRememberFloatingKeyboard: Boolean val mPersistTextEditMode: Boolean val mBackspaceRevertsAutocorrect: Boolean + val mBackspaceRepeatDeletesWords: Boolean val mDisableMultiWordSuggestions: Boolean val mPrioritizePersonalSuggestions: Boolean val mSuggestionBalance: Int @@ -360,6 +361,7 @@ open class SettingsValues( mRememberFloatingKeyboard = prefs.getBoolean(Settings.PREF_REMEMBER_FLOATING_KEYBOARD, Defaults.PREF_REMEMBER_FLOATING_KEYBOARD) mPersistTextEditMode = prefs.getBoolean(Settings.PREF_PERSIST_TEXT_EDIT_MODE, Defaults.PREF_PERSIST_TEXT_EDIT_MODE) mBackspaceRevertsAutocorrect = prefs.getBoolean(Settings.PREF_BACKSPACE_REVERTS_AUTOCORRECT, Defaults.PREF_BACKSPACE_REVERTS_AUTOCORRECT) + mBackspaceRepeatDeletesWords = prefs.getBoolean(Settings.PREF_BACKSPACE_REPEAT_DELETES_WORDS, Defaults.PREF_BACKSPACE_REPEAT_DELETES_WORDS) mDisableMultiWordSuggestions = prefs.getBoolean(Settings.PREF_DISABLE_MULTI_WORD_SUGGESTIONS, Defaults.PREF_DISABLE_MULTI_WORD_SUGGESTIONS) mBigramPredictionEnabled = prefs.getBoolean(Settings.PREF_BIGRAM_PREDICTIONS, Defaults.PREF_BIGRAM_PREDICTIONS) mPrioritizePersonalSuggestions = prefs.getBoolean(Settings.PREF_PRIORITIZE_PERSONAL_SUGGESTIONS, Defaults.PREF_PRIORITIZE_PERSONAL_SUGGESTIONS) diff --git a/app/src/main/java/helium314/keyboard/settings/screens/TextCorrectionScreen.kt b/app/src/main/java/helium314/keyboard/settings/screens/TextCorrectionScreen.kt index 877a7a385..f804aed89 100644 --- a/app/src/main/java/helium314/keyboard/settings/screens/TextCorrectionScreen.kt +++ b/app/src/main/java/helium314/keyboard/settings/screens/TextCorrectionScreen.kt @@ -59,6 +59,7 @@ fun TextCorrectionScreen( if (autocorrectEnabled) Settings.PREF_AUTO_CORRECT_AGGRESSIVENESS else null, if (autocorrectEnabled) Settings.PREF_AUTO_CORRECT_TRIGGER else null, if (autocorrectEnabled) Settings.PREF_BACKSPACE_REVERTS_AUTOCORRECT else null, + Settings.PREF_BACKSPACE_REPEAT_DELETES_WORDS, if (autocorrectEnabled) Settings.PREF_EXPAND_FINE_TUNE_AUTOCORRECT else null, if (autocorrectEnabled && fineTuneAutocorrectExpanded) Settings.PREF_AUTO_CORRECT_THRESHOLD else null, if (autocorrectEnabled && fineTuneAutocorrectExpanded) Settings.PREF_MORE_AUTO_CORRECTION else null, @@ -215,6 +216,11 @@ fun createCorrectionSettings(context: Context) = listOf( Setting(context, Settings.PREF_BACKSPACE_REVERTS_AUTOCORRECT, R.string.backspace_reverts_autocorrect) { SwitchPreference(it, Defaults.PREF_BACKSPACE_REVERTS_AUTOCORRECT) }, + Setting(context, Settings.PREF_BACKSPACE_REPEAT_DELETES_WORDS, + R.string.backspace_repeat_deletes_words, R.string.backspace_repeat_deletes_words_summary + ) { + SwitchPreference(it, Defaults.PREF_BACKSPACE_REPEAT_DELETES_WORDS) + }, Setting(context, Settings.PREF_AUTO_CAP, R.string.auto_cap, R.string.auto_cap_summary ) { diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index aedc47bcc..05a5b6123 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -408,6 +408,8 @@ Dagbani Sesotho Rücktaste macht Autokorrektur rückgängig + Gedrückte Löschtaste löscht ganze Wörter + Nach kurzem Halten der Löschtaste wortweise löschen, zunehmend schneller Automatische Leerzeichen nach Auswählen eines Vorschlags Skaliere den Emoji-Knopf mit der Schriftgröße Backup wiederhergestellt diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 7921e25d2..329aecd03 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -184,6 +184,10 @@ Settings on this screen apply to large-screen layout Backspace reverts autocorrect + + Holding delete removes whole words + + After holding the delete key for a moment, delete word by word with increasing speed Off diff --git a/app/src/test/java/helium314/keyboard/latin/InputLogicTest.kt b/app/src/test/java/helium314/keyboard/latin/InputLogicTest.kt index 23f26c2fe..7998c1527 100644 --- a/app/src/test/java/helium314/keyboard/latin/InputLogicTest.kt +++ b/app/src/test/java/helium314/keyboard/latin/InputLogicTest.kt @@ -1576,6 +1576,66 @@ class InputLogicTest { assertEquals("", composingText) } + @Test fun `held backspace deletes whole words when enabled`() { + reset() + latinIME.prefs().edit { putBoolean(Settings.PREF_BACKSPACE_REPEAT_DELETES_WORDS, true) } + setText("eins zwei drei vier fuenf sechs sieben") + functionalKeyPress(KeyCode.DELETE) // a single press deletes one character + assertEquals("eins zwei drei vier fuenf sechs siebe", text) + // the first key repeat deletes the rest of the word + functionalKeyPress(KeyCode.DELETE, isKeyRepeat = true) + assertEquals("eins zwei drei vier fuenf sechs ", text) + // some repeats pass without deleting before the next word + for (i in 1..6) { + functionalKeyPress(KeyCode.DELETE, isKeyRepeat = true) + } + assertEquals("eins zwei drei vier fuenf sechs ", text) + functionalKeyPress(KeyCode.DELETE, isKeyRepeat = true) + assertEquals("eins zwei drei vier fuenf ", text) + // keep holding: everything gets deleted word by word + for (i in 1..30) { + functionalKeyPress(KeyCode.DELETE, isKeyRepeat = true) + } + assertEquals("", text) + } + + @Test fun `held backspace word deletion accelerates`() { + reset() + latinIME.prefs().edit { putBoolean(Settings.PREF_BACKSPACE_REPEAT_DELETES_WORDS, true) } + setText("a b c d e f g h") + functionalKeyPress(KeyCode.DELETE) // deletes "h" + // key repeats every 50 ms: repeat 1 at 0 ms ... repeat 22 at 1050 ms + for (i in 1..22) functionalKeyPress(KeyCode.DELETE, isKeyRepeat = true) + // recommended: words at 0, 350, 630, 854, 1033 ms + assertEquals("a b ", text) + } + + @Test fun `backspace word intervals`() { + assertEquals(350f, InputLogic.getBackspaceWordInterval(0), 0.01f) + assertEquals(280f, InputLogic.getBackspaceWordInterval(1), 0.01f) + assertEquals(80f, InputLogic.getBackspaceWordInterval(20), 0.01f) // minimum + } + + @Test fun `start of previous word keeps whitespace before the word`() { + fun remaining(text: String) = text.substring(0, InputLogic.getStartOfPreviousWord(text)) + assertEquals("Hallo, ", remaining("Hallo, Welt! ")) + assertEquals("", remaining("Hallo, ")) + assertEquals("", remaining("Hallo")) + assertEquals("eins ", remaining("eins zwei")) + assertEquals("", remaining(" ")) + } + + @Test fun `held backspace deletes characters only when word deletion is disabled`() { + reset() + latinIME.prefs().edit { putBoolean(Settings.PREF_BACKSPACE_REPEAT_DELETES_WORDS, false) } + setText("eins zwei drei vier") + functionalKeyPress(KeyCode.DELETE) + for (i in 1..15) { + functionalKeyPress(KeyCode.DELETE, isKeyRepeat = true) + } + assertEquals("ein", text) // with word deletion, "eins" would be gone completely + } + @Test fun timestamp() { reset() chainInput("hello")