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
11 changes: 11 additions & 0 deletions app/src/main/java/helium314/keyboard/latin/LatinIME.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
100 changes: 100 additions & 0 deletions app/src/main/java/helium314/keyboard/latin/inputlogic/InputLogic.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
) {
Expand Down
2 changes: 2 additions & 0 deletions app/src/main/res/values-de/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -408,6 +408,8 @@
<string name="subtype_dag">Dagbani</string>
<string name="subtype_st">Sesotho</string>
<string name="backspace_reverts_autocorrect">Rücktaste macht Autokorrektur rückgängig</string>
<string name="backspace_repeat_deletes_words">Gedrückte Löschtaste löscht ganze Wörter</string>
<string name="backspace_repeat_deletes_words_summary">Nach kurzem Halten der Löschtaste wortweise löschen, zunehmend schneller</string>
<string name="autospace_after_suggestion">Automatische Leerzeichen nach Auswählen eines Vorschlags</string>
<string name="prefs_emoji_key_fit">Skaliere den Emoji-Knopf mit der Schriftgröße</string>
<string name="backup_restored">Backup wiederhergestellt</string>
Expand Down
4 changes: 4 additions & 0 deletions app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,10 @@
<string name="large_screen_layout_note">Settings on this screen apply to large-screen layout</string>
<!-- Option to undo auto correction with backspace -->
<string name="backspace_reverts_autocorrect">Backspace reverts autocorrect</string>
<!-- Option to delete whole words when holding the delete key -->
<string name="backspace_repeat_deletes_words">Holding delete removes whole words</string>
<!-- Description for "backspace_repeat_deletes_words" option -->
<string name="backspace_repeat_deletes_words_summary">After holding the delete key for a moment, delete word by word with increasing speed</string>
<!-- Option to disable auto correction. -->
<string name="auto_correction_threshold_mode_off">Off</string> <!-- todo: use it or remove it -->
<!-- Option to suggest auto correction suggestions modestly. Auto-corrects only to a word which has small edit distance from typed word. -->
Expand Down
60 changes: 60 additions & 0 deletions app/src/test/java/helium314/keyboard/latin/InputLogicTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Loading