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")