diff --git a/FLEKSY_AUTOCORRECT_HISTORY_NOTES.md b/FLEKSY_AUTOCORRECT_HISTORY_NOTES.md new file mode 100644 index 0000000000..f25b678a5e --- /dev/null +++ b/FLEKSY_AUTOCORRECT_HISTORY_NOTES.md @@ -0,0 +1,25 @@ +# Fleksy Autocorrect Revert History Notes + +Current swipe-up autocorrect revert is intentionally span/text based, not word-boundary based. This is required for corrections where one typed token becomes multiple committed words, e.g. `alot` -> `a lot`. + +Potential future improvement: keep a small history of recent revertable autocorrect commits instead of only relying on the latest `LastComposedWord`. + +Each history entry should store: + +- originally typed text, e.g. `alot` +- committed text, e.g. `a lot` +- separator string, usually a space +- active/consumed state +- optional cursor/end offset or timestamp if useful + +Important constraint: saving more words only helps if revert matching is still validated against actual editor text. Do not revert an old entry unless the text around the cursor still matches the stored committed span. Word-boundary matching is not enough for split-word autocorrections. + +Likely implementation shape: + +- Keep existing `mLastComposedWord` behavior for backspace undo to minimize risk. +- Add a separate capped deque/list of recent autocorrect commits, probably 3-5 entries. +- Push entries when `commitChosenWord(...)` creates a revertable autocorrect. +- On swipe-up, scan newest to oldest and revert the first entry whose committed text plus optional separator matches before the cursor. +- Mark entries consumed/inactive after reverting so stale history cannot repeatedly mutate text. + +Avoid broadening normal backspace undo until swipe-up history is proven safe; backspace currently assumes a single most recent commit. diff --git a/java/res-large b/java/res-large index d87d9dbdf3..17f3030148 160000 --- a/java/res-large +++ b/java/res-large @@ -1 +1 @@ -Subproject commit d87d9dbdf3966bbe18413be375dab2f6c7bbdfdd +Subproject commit 17f303014869d7a8ea1bef72489bae2141cdbdf8 diff --git a/java/res/values/strings-uix.xml b/java/res/values/strings-uix.xml index eaff240785..ca746b2eca 100644 --- a/java/res/values/strings-uix.xml +++ b/java/res/values/strings-uix.xml @@ -454,6 +454,16 @@ Misc. letters from common languages e.g. [ß] on [s] in all Latin script languages + + + Swipe input modes + Swipe Typing (alpha) + Disabled + Allow swiping from key to key to write words. + Swipe Actions (alpha) + Fleksy-style directional action swipes. + Turn off swipe typing and swipe actions. + Typing preferences Emoji Suggestions @@ -699,4 +709,4 @@ Default is %1$s Delete extra dictionary file? %1$s will be deleted - \ No newline at end of file + diff --git a/java/src/org/futo/inputmethod/engine/IMEInterface.kt b/java/src/org/futo/inputmethod/engine/IMEInterface.kt index b52598e041..174f076c7c 100644 --- a/java/src/org/futo/inputmethod/engine/IMEInterface.kt +++ b/java/src/org/futo/inputmethod/engine/IMEInterface.kt @@ -86,6 +86,7 @@ interface IMEInterface { fun onUpWithDeletePointerActive() fun onUpWithPointerActive() fun onSwipeLanguage(direction: Int) + fun onSwipeAction(direction: Int) fun onMovingCursorLockEvent(canMoveCursor: Boolean) fun clearUserHistoryDictionaries() diff --git a/java/src/org/futo/inputmethod/engine/general/ActionInputTransactionIME.kt b/java/src/org/futo/inputmethod/engine/general/ActionInputTransactionIME.kt index 5906d48f95..53cff439fc 100644 --- a/java/src/org/futo/inputmethod/engine/general/ActionInputTransactionIME.kt +++ b/java/src/org/futo/inputmethod/engine/general/ActionInputTransactionIME.kt @@ -66,6 +66,7 @@ class ActionInputTransactionIME(val helper: IMEHelper) : IMEInterface, ActionInp override fun onUpWithDeletePointerActive() {} override fun onUpWithPointerActive() {} override fun onSwipeLanguage(direction: Int) {} + override fun onSwipeAction(direction: Int) {} override fun onMovingCursorLockEvent(canMoveCursor: Boolean) {} override fun clearUserHistoryDictionaries() {} override fun requestSuggestionRefresh() {} @@ -111,4 +112,4 @@ class ActionInputTransactionIME(val helper: IMEHelper) : IMEInterface, ActionInp fun ensureFinished() { isFinished = true } -} \ No newline at end of file +} diff --git a/java/src/org/futo/inputmethod/engine/general/ChineseIME.kt b/java/src/org/futo/inputmethod/engine/general/ChineseIME.kt index fcfe2a17e5..008c9b78a5 100644 --- a/java/src/org/futo/inputmethod/engine/general/ChineseIME.kt +++ b/java/src/org/futo/inputmethod/engine/general/ChineseIME.kt @@ -902,6 +902,8 @@ class ChineseIME(val helper: IMEHelper) : IMEInterface, SuggestionStripViewAcces switchToNextLanguage(helper.context, direction) } + override fun onSwipeAction(direction: Int) {} + private var prevSuggest: SuggestedWords? = null private val blacklist = SuggestionBlacklist(Settings.getInstance(), helper.context, helper.lifecycleScope) override fun setNeutralSuggestionStrip() { @@ -948,4 +950,4 @@ class ChineseIME(val helper: IMEHelper) : IMEInterface, SuggestionStripViewAcces val debugInfo: String get() = "configuration=${prevConfiguration}\nlayoutHint=${layoutHint}\nlocale=${Settings.getInstance().current.mLocale}\nisSimplified=${isSimplifiedChinese(Settings.getInstance().current.mLocale)}\nrawInput=${rawInput.text}" -} \ No newline at end of file +} diff --git a/java/src/org/futo/inputmethod/engine/general/GeneralIME.kt b/java/src/org/futo/inputmethod/engine/general/GeneralIME.kt index 06370ccc0b..ebf049cd2b 100644 --- a/java/src/org/futo/inputmethod/engine/general/GeneralIME.kt +++ b/java/src/org/futo/inputmethod/engine/general/GeneralIME.kt @@ -26,11 +26,14 @@ import org.futo.inputmethod.engine.IMEMessage import org.futo.inputmethod.engine.NonExpandableSuggestionBar import org.futo.inputmethod.event.Event import org.futo.inputmethod.event.InputTransaction +import org.futo.inputmethod.keyboard.KeyboardActionListener import org.futo.inputmethod.keyboard.KeyboardSwitcher import org.futo.inputmethod.latin.BuildConfig +import org.futo.inputmethod.latin.Dictionary import org.futo.inputmethod.latin.DictionaryFacilitator import org.futo.inputmethod.latin.DictionaryFacilitatorImpl import org.futo.inputmethod.latin.DictionaryFacilitatorProvider +import org.futo.inputmethod.latin.LastComposedWord import org.futo.inputmethod.latin.NgramContext import org.futo.inputmethod.latin.RichInputMethodManager import org.futo.inputmethod.latin.Subtypes.switchToNextLanguage @@ -51,6 +54,7 @@ import org.futo.inputmethod.latin.uix.isDirectBootUnlocked import org.futo.inputmethod.latin.utils.AsyncResultHolder import org.futo.inputmethod.latin.xlm.LanguageModelFacilitator import org.futo.inputmethod.v2keyboard.KeyboardLayoutSetV2 +import java.util.LinkedHashSet import java.util.concurrent.atomic.AtomicInteger interface WordLearner { @@ -244,6 +248,7 @@ class GeneralIME(val helper: IMEHelper) : IMEInterface, WordLearner, SuggestionS private val expandableExpandableCfg = ExpandableSuggestionBarConfiguration(true, false) private var expandableCfg: ExpandableSuggestionBarConfiguration = NonExpandableSuggestionBar override fun onStartInput() { + resetSwipeSuggestionSession() expandableCfg = if(helper.context.getSetting(UseExpandableSuggestionsForGeneralIME)) { expandableExpandableCfg } else { @@ -287,6 +292,7 @@ class GeneralIME(val helper: IMEHelper) : IMEInterface, WordLearner, SuggestionS } override fun onFinishInput() { + resetSwipeSuggestionSession() inputLogic.finishInput() dictionaryFacilitator.onFinishInput(context) updateSuggestionJob?.cancel() @@ -301,12 +307,20 @@ class GeneralIME(val helper: IMEHelper) : IMEInterface, WordLearner, SuggestionS composingSpanStart: Int, composingSpanEnd: Int ) { - inputLogic.onUpdateSelection( + val selectionChanged = oldSelStart != newSelStart || oldSelEnd != newSelEnd + + val cursorMovedByUser = inputLogic.onUpdateSelection( oldSelStart, oldSelEnd, newSelStart, newSelEnd, composingSpanStart, composingSpanEnd, Settings.getInstance().current ) + + if (swipeSuggestionSelectionUpdatesToIgnore > 0) { + swipeSuggestionSelectionUpdatesToIgnore -= 1 + } else if (selectionChanged && cursorMovedByUser) { + resetSwipeSuggestionSession() + } } override fun isGestureHandlingAvailable(): Boolean = @@ -315,9 +329,15 @@ class GeneralIME(val helper: IMEHelper) : IMEInterface, WordLearner, SuggestionS private fun onEventInternal(event: Event, ignoreSuggestionUpdate: Boolean = false) { helper.requestCursorUpdate() + if (isSwipeActionsModeEnabled() && event.eventType != Event.EVENT_TYPE_SUGGESTION_PICKED) { + resetSwipeSuggestionSession() + } + + val swipeActionPunctuationTransaction = handleSwipeActionTrailingSpacePunctuation(event) + val cursorBefore = inputLogic.mConnection.mExpectedSelStart - val inputTransaction = when (event.eventType) { + val inputTransaction = swipeActionPunctuationTransaction ?: when (event.eventType) { Event.EVENT_TYPE_INPUT_KEYPRESS, Event.EVENT_TYPE_INPUT_KEYPRESS_RESUMED -> { inputLogic.onCodeInput( @@ -520,6 +540,425 @@ class GeneralIME(val helper: IMEHelper) : IMEInterface, WordLearner, SuggestionS private val sequenceIdCompleted = AtomicInteger(0) private val computationMutex = Mutex() private var timeTakenToUpdate = 40L + private var swipeSuggestionIndex = -1 + private var swipeSuggestionWord: String? = null + private var swipeSuggestionCandidates: List? = null + private var swipeSuggestionRestingWord: String? = null + private var swipeSuggestionRevertWord: String? = null + private var swipeReplacementSession: SwipeReplacementSession? = null + private var swipeSuggestionSelectionUpdatesToIgnore = 0 + + private data class SwipeReplacementSession( + val entries: List, + var index: Int + ) + + private fun getLastAutocorrectRevertPair(lastComposedWord: LastComposedWord): Pair? { + val typedWord = lastComposedWord.mTypedWord + val committedWord = lastComposedWord.mCommittedWord?.toString() + + if (!lastComposedWord.canRevertCommit() + || typedWord.isNullOrEmpty() + || committedWord.isNullOrEmpty() + || typedWord == committedWord) { + return null + } + + return committedWord to typedWord + } + + private fun resetSwipeSuggestionSession() { + swipeSuggestionIndex = -1 + swipeSuggestionWord = null + swipeSuggestionCandidates = null + swipeSuggestionRestingWord = null + swipeSuggestionRevertWord = null + swipeReplacementSession = null + } + + private fun moveCursorForSwipeSuggestions(steps: Int) { + swipeSuggestionSelectionUpdatesToIgnore += 1 + + if (steps < 0) { + inputLogic.cursorLeft(-steps, false, false) + } else { + inputLogic.cursorRight(steps, false, false) + } + } + + private fun replaceCommittedSwipeSuggestionIfNeeded( + currentSwipeWord: String?, + replacement: String + ): Boolean { + val resolvedSwipeWord = currentSwipeWord ?: inputLogic.mLastComposedWord.mCommittedWord?.toString() + if (resolvedSwipeWord.isNullOrEmpty() || inputLogic.mConnection.hasSelection()) { + return false + } + + if (!inputLogic.mConnection.sameAsTextBeforeCursor(resolvedSwipeWord)) { + return false + } + + helper.requestCursorUpdate() + inputLogic.mConnection.beginBatchEdit() + inputLogic.mConnection.finishComposingText() + inputLogic.mConnection.deleteTextBeforeCursor(resolvedSwipeWord.length) + inputLogic.mConnection.commitText(replacement, 1) + inputLogic.mConnection.endBatchEdit() + inputLogic.mConnection.send() + helper.keyboardSwitcher.requestUpdatingShiftState(getCurrentAutoCapsState()) + return true + } + + private fun getReplacementForSwipeSuggestion(replacement: String): String { + val restingWord = swipeSuggestionRestingWord + if (restingWord.isNullOrEmpty() || !restingWord.contains(' ') || replacement.contains(' ')) { + return replacement + } + + val prefix = restingWord.substringBeforeLast(' ', "") + return if (prefix.isNotEmpty()) "$prefix $replacement" else replacement + } + + private fun buildSwipeReplacementSession( + candidates: List, + committedWord: String, + typedWord: String + ): SwipeReplacementSession { + val entries = ArrayList() + val seen = HashSet() + + if (seen.add(typedWord)) { + entries.add(typedWord) + } + if (seen.add(committedWord)) { + entries.add(committedWord) + } + + for (candidate in candidates) { + val word = getReplacementForSwipeSuggestion(candidate.mWord) + if (seen.add(word)) { + entries.add(word) + } + } + + return SwipeReplacementSession(entries, entries.indexOf(committedWord)) + } + + private fun applySwipeReplacementSessionStep(direction: Int): Boolean { + val session = swipeReplacementSession ?: return false + if (session.index !in session.entries.indices) { + resetSwipeSuggestionSession() + return false + } + + val step = if (direction == KeyboardActionListener.SWIPE_ACTION_UP) -1 else 1 + val nextIndex = session.index + step + if (nextIndex !in session.entries.indices) { + return true + } + + val currentWord = session.entries[session.index] + val replacement = session.entries[nextIndex] + if (!replaceCommittedSwipeSuggestionIfNeeded(currentWord, replacement)) { + resetSwipeSuggestionSession() + return false + } + + session.index = nextIndex + swipeSuggestionWord = if (replacement == swipeSuggestionRestingWord) null else replacement + return true + } + + private fun revertLastAutocorrectForSwipeIfNeeded(lastComposedWord: LastComposedWord): Boolean { + val (committedWord, typedWord) = getLastAutocorrectRevertPair(lastComposedWord) + ?: return false + + if (inputLogic.mConnection.hasSelection()) { + return false + } + + val separator = lastComposedWord.mSeparatorString ?: LastComposedWord.NOT_A_SEPARATOR + val committedText = committedWord + separator + val usePhantomSpace = separator == Constants.STRING_SPACE + val textToCommit = typedWord + if (usePhantomSpace) "" else separator + val textToDelete = if (inputLogic.mConnection.sameAsTextBeforeCursor(committedText)) { + committedText + } else if (inputLogic.mConnection.sameAsTextBeforeCursor(committedWord)) { + committedWord + } else { + return false + } + + helper.requestCursorUpdate() + inputLogic.mConnection.beginBatchEdit() + inputLogic.mConnection.finishComposingText() + inputLogic.mConnection.deleteTextBeforeCursor(textToDelete.length) + inputLogic.mConnection.commitText(textToCommit, 1) + inputLogic.mConnection.endBatchEdit() + inputLogic.mConnection.send() + lastComposedWord.deactivate() + swipeSuggestionRestingWord = typedWord + swipeSuggestionRevertWord = null + swipeSuggestionCandidates = null + swipeReplacementSession = null + helper.keyboardSwitcher.requestUpdatingShiftState(getCurrentAutoCapsState()) + return true + } + + private fun shouldResetSwipeSuggestionSessionForTouchedWord(touchedWord: String?): Boolean { + if (swipeSuggestionCandidates == null && swipeSuggestionRestingWord == null && swipeSuggestionWord == null) { + return false + } + + if (touchedWord.isNullOrEmpty()) { + return false + } + + return touchedWord != swipeSuggestionWord && touchedWord != swipeSuggestionRestingWord + } + + private fun getSwipeSuggestionInfo( + candidates: List, + word: String + ): SuggestedWordInfo { + val existing = candidates.firstOrNull { it.mWord == word } + if (existing != null) { + return existing + } + + return SuggestedWordInfo( + word, + "", + SuggestedWordInfo.MAX_SCORE, + SuggestedWordInfo.KIND_TYPED, + Dictionary.DICTIONARY_USER_TYPED, + SuggestedWordInfo.NOT_AN_INDEX, + SuggestedWordInfo.NOT_A_CONFIDENCE + ) + } + + private fun isSwipeActionsModeEnabled(): Boolean { + return settings.current.mGestureActionsEnabled + } + + private fun sendDeleteKeypress() { + onEvent( + Event.createSoftwareKeypressEvent( + Event.NOT_A_CODE_POINT, + Constants.CODE_DELETE, + Constants.NOT_A_COORDINATE, + Constants.NOT_A_COORDINATE, + false + ) + ) + } + + private fun performSwipeWordDelete() { + helper.requestCursorUpdate() + + val result = inputLogic.onWordBackspace( + settings.current, + helper.keyboardShiftMode, + helper.currentKeyboardScriptId + ) + val inputTransaction = result.mInputTransaction + + inputLogic.mConnection.send() + + when (inputTransaction.requiredShiftUpdate) { + InputTransaction.SHIFT_UPDATE_LATER, + InputTransaction.SHIFT_UPDATE_NOW -> + helper.keyboardSwitcher.requestUpdatingShiftState(getCurrentAutoCapsState()) + } + + if (inputTransaction.requiresUpdateSuggestions()) { + updateSuggestions(SuggestedWords.INPUT_STYLE_TYPING) + } + + showDeletedTextUndoSuggestion(result.mDeletedText) + } + + private fun moveCursorToLastWordIfTrailingSpace(): Boolean { + val beforeCursor = inputLogic.mConnection.getTextBeforeCursor(1, 0)?.toString() + if (!beforeCursor.isNullOrEmpty() + && beforeCursor.last() == ' ' + && inputLogic.mConnection.hasCursorPosition() + && !inputLogic.mConnection.hasSelection()) { + moveCursorForSwipeSuggestions(-1) + return true + } + + return false + } + + private fun restoreCursorIfMoved(movedCursorToLastWord: Boolean) { + if (movedCursorToLastWord) { + moveCursorForSwipeSuggestions(1) + } + } + + private fun getSwipeActionTrailingSpacePunctuationCodePoint(event: Event): Int? { + if (!isSwipeActionsModeEnabled()) { + return null + } + + val codePoint = when (event.eventType) { + Event.EVENT_TYPE_INPUT_KEYPRESS, + Event.EVENT_TYPE_INPUT_KEYPRESS_RESUMED -> event.mCodePoint + Event.EVENT_TYPE_SOFTWARE_GENERATED_STRING -> { + val text = event.getTextToCommit().toString() + if (text.codePointCount(0, text.length) != 1) { + return null + } + text.codePointAt(0) + } + else -> return null + } + + if (codePoint == Event.NOT_A_CODE_POINT + || Character.isWhitespace(codePoint) + || !settings.current.isWordSeparator(codePoint) + || !settings.current.isUsuallyFollowedBySpace(codePoint) + || settings.current.isOptionallyPrecededBySpace(codePoint)) { + return null + } + + val beforeCursor = inputLogic.mConnection.getTextBeforeCursor(1, 0)?.toString() + return if (!beforeCursor.isNullOrEmpty() + && beforeCursor.last() == ' ' + && inputLogic.mConnection.hasCursorPosition() + && !inputLogic.mConnection.hasSelection()) { + codePoint + } else { + null + } + } + + private fun handleSwipeActionTrailingSpacePunctuation(event: Event): InputTransaction? { + val punctuationCodePoint = getSwipeActionTrailingSpacePunctuationCodePoint(event) + ?: return null + inputLogic.mConnection.removeTrailingSpace() + + val normalizedEvent = Event.createSoftwareKeypressEvent( + punctuationCodePoint, + punctuationCodePoint, + Constants.NOT_A_COORDINATE, + Constants.NOT_A_COORDINATE, + false + ) + + val punctuationTransaction = inputLogic.onCodeInput( + settings.current, + normalizedEvent, + helper.keyboardShiftMode, + helper.currentKeyboardScriptId + ) + + val codePointAfterCursor = inputLogic.mConnection.getCodePointAfterCursor() + if (inputLogic.mConnection.spaceFollowsCursor() + || (codePointAfterCursor != Constants.NOT_A_CODE + && Character.isWhitespace(codePointAfterCursor))) { + return punctuationTransaction + } + + return inputLogic.onCodeInput( + settings.current, + Event.createSoftwareKeypressEvent( + Constants.CODE_SPACE, + Constants.CODE_SPACE, + Constants.NOT_A_COORDINATE, + Constants.NOT_A_COORDINATE, + false + ), + helper.keyboardShiftMode, + helper.currentKeyboardScriptId + ) + } + + private fun getSwipePunctuationCycle(): List { + val cycle = LinkedHashSet() + + val suggestedPunctuation = settings.current.mSpacingAndPunctuations.suggestPuncList + for (index in 0 until suggestedPunctuation.size()) { + val punctuation = suggestedPunctuation.getWord(index) + if (!punctuation.isNullOrEmpty() && punctuation.codePointCount(0, punctuation.length) == 1) { + cycle.add(punctuation) + } + } + + if (cycle.isEmpty()) { + return emptyList() + } + + val ordered = cycle.toMutableList() + val commaIndex = ordered.indexOf(",") + if (commaIndex > 0) { + ordered.removeAt(commaIndex) + ordered.add(0, ",") + } + + return ordered + } + + private fun replacePunctuationWith(replacement: String): Boolean { + if (replacement.codePointCount(0, replacement.length) != 1) { + return false + } + + resetSwipeSuggestionSession() + sendDeleteKeypress() + + val replacementCodePoint = replacement.codePointAt(0) + onEvent( + Event.createSoftwareKeypressEvent( + replacementCodePoint, + replacementCodePoint, + Constants.NOT_A_COORDINATE, + Constants.NOT_A_COORDINATE, + false + ) + ) + + return true + } + + private fun trySwipeCyclePunctuation(direction: Int): Boolean { + val beforeCursor = inputLogic.mConnection.getTextBeforeCursor(1, 0)?.toString() + if (beforeCursor.isNullOrEmpty()) { + return false + } + + val currentPunctuation = beforeCursor.last().toString() + val cycle = getSwipePunctuationCycle() + if (cycle.size < 2) { + return false + } + + val currentIndex = cycle.indexOf(currentPunctuation) + if (currentIndex < 0) { + if (currentPunctuation == ".") { + val replacement = if (direction == KeyboardActionListener.SWIPE_ACTION_UP) { + cycle.last() + } else { + cycle.first() + } + return replacePunctuationWith(replacement) + } + + return false + } + + val step = if (direction == KeyboardActionListener.SWIPE_ACTION_UP) -1 else 1 + val nextIndex = (currentIndex + step + cycle.size) % cycle.size + val replacement = cycle[nextIndex] + if (replacement == currentPunctuation) { + return false + } + + return replacePunctuationWith(replacement) + } + fun updateSuggestions(inputStyle: Int) { updateSuggestionJob?.cancel() @@ -687,37 +1126,45 @@ class GeneralIME(val helper: IMEHelper) : IMEInterface, WordLearner, SuggestionS ignoreSuggestionUpdate = true ) - if (selection != null && settings.current.mInputAttributes.mShouldShowSuggestions) { - val info = ArrayList() - info.add( - SuggestedWordInfo( - selection.toString(), - "", - 0, - SuggestedWordInfo.KIND_UNDO, - null, - 0, - 0 - ) - ) - showSuggestionStrip( - SuggestedWords( - info, - null, - null, - false, - false, - false, - 0, - 0 - ) - ) + if (settings.current.mInputAttributes.mShouldShowSuggestions) { + showDeletedTextUndoSuggestion(selection?.toString()) } } else { onUpWithPointerActive() } } + private fun showDeletedTextUndoSuggestion(deletedText: String?) { + if (deletedText == null) { + return + } + + val info = ArrayList() + info.add( + SuggestedWordInfo( + deletedText, + "", + 0, + SuggestedWordInfo.KIND_UNDO, + null, + 0, + 0 + ) + ) + showSuggestionStrip( + SuggestedWords( + info, + null, + null, + false, + false, + false, + 0, + 0 + ) + ) + } + override fun onUpWithPointerActive() { inputLogic.restartSuggestionsOnWordTouchedByCursor( settings.current, null, @@ -730,6 +1177,219 @@ class GeneralIME(val helper: IMEHelper) : IMEInterface, WordLearner, SuggestionS switchToNextLanguage(context, direction) } + override fun onSwipeAction(direction: Int) { + if (!isSwipeActionsModeEnabled()) return + + when (direction) { + KeyboardActionListener.SWIPE_ACTION_RIGHT -> { + resetSwipeSuggestionSession() + onEvent( + Event.createSoftwareKeypressEvent( + Constants.CODE_SPACE, + Constants.CODE_SPACE, + Constants.NOT_A_COORDINATE, + Constants.NOT_A_COORDINATE, + false + ) + ) + } + + KeyboardActionListener.SWIPE_ACTION_LEFT -> { + resetSwipeSuggestionSession() + setNeutralSuggestionStrip() + performSwipeWordDelete() + } + + KeyboardActionListener.SWIPE_ACTION_UP, + KeyboardActionListener.SWIPE_ACTION_DOWN -> { + val lastComposedWordAtSwipeStart = inputLogic.mLastComposedWord + val movedCursorToLastWord = moveCursorToLastWordIfTrailingSpace() + + if (trySwipeCyclePunctuation(direction)) { + restoreCursorIfMoved(movedCursorToLastWord) + return + } + + if (applySwipeReplacementSessionStep(direction)) { + restoreCursorIfMoved(movedCursorToLastWord) + return + } + + if (direction == KeyboardActionListener.SWIPE_ACTION_UP + && revertLastAutocorrectForSwipeIfNeeded(lastComposedWordAtSwipeStart)) { + restoreCursorIfMoved(movedCursorToLastWord) + return + } + + inputLogic.restartSuggestionsOnWordTouchedByCursor( + settings.current, + null, + false, + helper.currentKeyboardScriptId + ) + + if (!ensureSuggestionsCompleted()) { + restoreCursorIfMoved(movedCursorToLastWord) + return + } + + val touchedWord = inputLogic.mWordComposer.typedWord + + if (shouldResetSwipeSuggestionSessionForTouchedWord(touchedWord)) { + resetSwipeSuggestionSession() + } + + val autocorrectRevertPair = getLastAutocorrectRevertPair(lastComposedWordAtSwipeStart) + if (swipeSuggestionRestingWord == null) { + swipeSuggestionRestingWord = autocorrectRevertPair?.first ?: touchedWord + } + + if (swipeSuggestionRevertWord == null && autocorrectRevertPair != null) { + swipeSuggestionRevertWord = autocorrectRevertPair.second + } + + val candidates = swipeSuggestionCandidates ?: run { + val suggestions = inputLogic.mSuggestedWords + val rebuiltCandidates = ArrayList() + val seen = HashSet() + for (index in 0 until suggestions.size()) { + val info = suggestions.getInfo(index) + if (!info.isKindOf(SuggestedWordInfo.KIND_UNDO) && seen.add(info.mWord)) { + rebuiltCandidates.add(info) + } + } + swipeSuggestionCandidates = rebuiltCandidates + rebuiltCandidates + } + + if (autocorrectRevertPair != null) { + swipeReplacementSession = buildSwipeReplacementSession( + candidates, + autocorrectRevertPair.first, + autocorrectRevertPair.second + ) + if (applySwipeReplacementSessionStep(direction)) { + restoreCursorIfMoved(movedCursorToLastWord) + return + } + resetSwipeSuggestionSession() + restoreCursorIfMoved(movedCursorToLastWord) + return + } + + if (direction == KeyboardActionListener.SWIPE_ACTION_UP) { + val restingWord = swipeSuggestionRestingWord + val currentWord = swipeSuggestionWord ?: restingWord ?: touchedWord + + if (!swipeSuggestionWord.isNullOrEmpty() + && swipeSuggestionIndex in candidates.indices + && candidates[swipeSuggestionIndex].mWord == swipeSuggestionWord) { + val previousIndex = (swipeSuggestionIndex - 1 + candidates.size) % candidates.size + val selected = if (!restingWord.isNullOrEmpty() + && swipeSuggestionIndex == 0) { + getSwipeSuggestionInfo(candidates, restingWord) + } else { + candidates[previousIndex] + } + val replacement = getReplacementForSwipeSuggestion(selected.mWord) + if (!replaceCommittedSwipeSuggestionIfNeeded( + swipeSuggestionWord ?: currentWord, + replacement + )) { + onEvent(Event.createSuggestionPickedEvent(getSwipeSuggestionInfo(candidates, replacement))) + } + if (!restingWord.isNullOrEmpty() && replacement == restingWord) { + swipeSuggestionIndex = -1 + swipeSuggestionWord = null + } else { + swipeSuggestionIndex = previousIndex + swipeSuggestionWord = replacement + } + restoreCursorIfMoved(movedCursorToLastWord) + return + } + + val revertWord = swipeSuggestionRevertWord + if (!revertWord.isNullOrEmpty() + && !currentWord.isNullOrEmpty() + && currentWord == restingWord + && revertWord != currentWord) { + val selected = getSwipeSuggestionInfo(candidates, revertWord) + if (!replaceCommittedSwipeSuggestionIfNeeded(currentWord, selected.mWord)) { + onEvent(Event.createSuggestionPickedEvent(selected)) + } + swipeSuggestionIndex = -1 + swipeSuggestionWord = null + swipeSuggestionRestingWord = selected.mWord + swipeSuggestionRevertWord = null + swipeSuggestionCandidates = null + restoreCursorIfMoved(movedCursorToLastWord) + return + } + + restoreCursorIfMoved(movedCursorToLastWord) + return + } + + if (candidates.size < 2) { + resetSwipeSuggestionSession() + restoreCursorIfMoved(movedCursorToLastWord) + return + } + + val typedWord = inputLogic.mWordComposer.typedWord + val currentWord = swipeSuggestionWord ?: swipeSuggestionRestingWord ?: typedWord + val typedWordIndex = if (typedWord != null) { + candidates.indexOfFirst { it.mWord == typedWord } + } else { + -1 + } + val currentWordIndex = if (currentWord != null) { + candidates.indexOfFirst { it.mWord == currentWord } + } else { + -1 + } + + val baseIndex = if (swipeSuggestionWord != null + && swipeSuggestionIndex in candidates.indices + && candidates[swipeSuggestionIndex].mWord == swipeSuggestionWord) { + swipeSuggestionIndex + } else if (currentWordIndex >= 0) { + currentWordIndex + } else if (typedWordIndex >= 0) { + typedWordIndex + } else { + 0 + } + + val step = 1 + var nextIndex = (baseIndex + step + candidates.size) % candidates.size + if (currentWord != null && candidates[nextIndex].mWord == currentWord) { + nextIndex = (nextIndex + step + candidates.size) % candidates.size + } + + val selected = candidates[nextIndex] + if (currentWord != null && selected.mWord == currentWord) { + restoreCursorIfMoved(movedCursorToLastWord) + return + } + + if (!replaceCommittedSwipeSuggestionIfNeeded( + swipeSuggestionWord ?: currentWord, + getReplacementForSwipeSuggestion(selected.mWord) + )) { + onEvent(Event.createSuggestionPickedEvent( + getSwipeSuggestionInfo(candidates, getReplacementForSwipeSuggestion(selected.mWord)) + )) + } + swipeSuggestionIndex = nextIndex + swipeSuggestionWord = getReplacementForSwipeSuggestion(selected.mWord) + + restoreCursorIfMoved(movedCursorToLastWord) + } + } + } + override fun onMovingCursorLockEvent(canMoveCursor: Boolean) { // GeneralIME does nothing } @@ -814,4 +1474,4 @@ class GeneralIME(val helper: IMEHelper) : IMEInterface, WordLearner, SuggestionS @OptIn(ExperimentalCoroutinesApi::class) val dictionaryScope = Dispatchers.Default.limitedParallelism(1) } -} \ No newline at end of file +} diff --git a/java/src/org/futo/inputmethod/engine/general/JapaneseIME.kt b/java/src/org/futo/inputmethod/engine/general/JapaneseIME.kt index 726d8daba5..6e8d4320f2 100644 --- a/java/src/org/futo/inputmethod/engine/general/JapaneseIME.kt +++ b/java/src/org/futo/inputmethod/engine/general/JapaneseIME.kt @@ -1207,6 +1207,10 @@ class JapaneseIME(val helper: IMEHelper) : IMEInterface { } + override fun onSwipeAction(direction: Int) { + + } + override fun onMovingCursorLockEvent(canMoveCursor: Boolean) { } @@ -1244,4 +1248,4 @@ class JapaneseIME(val helper: IMEHelper) : IMEInterface { prevSuggestions = words helper.showSuggestionStrip(words, expandableUiCfg) } -} \ No newline at end of file +} diff --git a/java/src/org/futo/inputmethod/keyboard/KeyboardActionListener.java b/java/src/org/futo/inputmethod/keyboard/KeyboardActionListener.java index 813426429e..e7f108f880 100644 --- a/java/src/org/futo/inputmethod/keyboard/KeyboardActionListener.java +++ b/java/src/org/futo/inputmethod/keyboard/KeyboardActionListener.java @@ -20,6 +20,11 @@ import org.futo.inputmethod.latin.common.InputPointers; public interface KeyboardActionListener { + public static final int SWIPE_ACTION_LEFT = -1; + public static final int SWIPE_ACTION_RIGHT = 1; + public static final int SWIPE_ACTION_UP = -2; + public static final int SWIPE_ACTION_DOWN = 2; + /** * Called when the user presses a key. This is sent before the {@link #onCodeInput} is called. * For keys that repeat, this is only called once. @@ -105,6 +110,7 @@ public interface KeyboardActionListener { public void onMoveDeletePointer(int steps); public void onUpWithDeletePointerActive(); public void onUpWithPointerActive(); + public void onSwipeAction(int direction); public void onMovingCursorLockEvent(boolean canMoveCursor); public static final KeyboardActionListener EMPTY_LISTENER = new Adapter(); @@ -148,6 +154,8 @@ public void onSwipeLanguageReleased() {} @Override public void onSwipeLanguageProgress(float progress) {} @Override + public void onSwipeAction(int direction) {} + @Override public void onMovingCursorLockEvent(boolean canMoveCursor) {} } } diff --git a/java/src/org/futo/inputmethod/keyboard/PointerTracker.java b/java/src/org/futo/inputmethod/keyboard/PointerTracker.java index 6469fe1ad2..a2cfd57494 100644 --- a/java/src/org/futo/inputmethod/keyboard/PointerTracker.java +++ b/java/src/org/futo/inputmethod/keyboard/PointerTracker.java @@ -97,6 +97,9 @@ public static void setStateHint(StateHint stateHint) { private static PointerTrackerParams sParams; private static final int sPointerStep = (int)(16.0 * Resources.getSystem().getDisplayMetrics().density); private static final int sPointerBigStep = (int)(32.0 * Resources.getSystem().getDisplayMetrics().density); + private static final int sPointerSwipeActionStep = (int)(18.0 * Resources.getSystem().getDisplayMetrics().density); + private static final float SWIPE_ACTION_HORIZONTAL_DOMINANCE_RATIO = 1.0f; + private static final float SWIPE_ACTION_VERTICAL_DOMINANCE_RATIO = 0.70f; private static final int sPointerHugeStep = Integer.min( (int)(64.0 * Resources.getSystem().getDisplayMetrics().density), Resources.getSystem().getDisplayMetrics().widthPixels * 3 / 2 @@ -158,6 +161,7 @@ public static void setStateHint(StateHint stateHint) { private boolean mCursorMoved = false; private boolean mProgressReported = false; private boolean mSpacebarLongPressed = false; + private boolean mSwipeActionTriggered = false; // true if keyboard layout has been changed. private boolean mKeyboardLayoutHasBeenChanged; @@ -761,8 +765,14 @@ private void onDownEventInternal(final int x, final int y, final long eventTime) mStartTime = System.currentTimeMillis(); mStartedOnFastLongPress = key.isFastLongPress(); mSpacebarLongPressed = false; + mSwipeActionTriggered = false; - mIsSlidingCursor = key.getCode() == Constants.CODE_DELETE || key.getCode() == Constants.CODE_SPACE; + final boolean swipeActionsMode = + Settings.getInstance().getCurrent().mGestureActionsEnabled; + + mIsSlidingCursor = key.getCode() == Constants.CODE_DELETE + || key.getCode() == Constants.CODE_SPACE + || swipeActionsMode; mIsFlickingKey = !mIsSlidingCursor && key.getHasFlick(); mFlickDirection = key.flickDirection(0, 0); mCurrentKey = key; @@ -970,6 +980,52 @@ private void onMoveEventInternal(final int x, final int y, final long eventTime) final SettingsValues settingsValues = Settings.getInstance().getCurrent(); + if (mIsSlidingCursor && oldKey != null + && oldKey.getCode() != Constants.CODE_SPACE + && oldKey.getCode() != Constants.CODE_DELETE + && settingsValues.mGestureActionsEnabled) { + final int pointerStep = sPointerSwipeActionStep; + final int swipeIgnoreTime = settingsValues.mKeyLongpressTimeout + / MULTIPLIER_FOR_LONG_PRESS_TIMEOUT_IN_SLIDING_INPUT; + final int dx = x - mStartX; + final int dy = y - mStartY; + final long swipeDistanceSquared = (long)dx * dx + (long)dy * dy; + final long swipeStepSquared = (long)pointerStep * pointerStep; + + if (!mSwipeActionTriggered + && mStartTime + swipeIgnoreTime < System.currentTimeMillis() + && swipeDistanceSquared >= swipeStepSquared) { + sTimerProxy.cancelKeyTimersOf(this); + mCursorMoved = true; + mSwipeActionTriggered = true; + + final int absDx = Math.abs(dx); + final int absDy = Math.abs(dy); + final float horizontalScore = absDx + - absDy * SWIPE_ACTION_HORIZONTAL_DOMINANCE_RATIO; + final float verticalScore = absDy + - absDx * SWIPE_ACTION_VERTICAL_DOMINANCE_RATIO; + final boolean isHorizontalSwipe = horizontalScore >= 0.0f; + final boolean isVerticalSwipe = verticalScore >= 0.0f; + + if ((isHorizontalSwipe && !isVerticalSwipe) + || (isHorizontalSwipe == isVerticalSwipe + && horizontalScore >= verticalScore)) { + sListener.onSwipeAction(dx > 0 + ? KeyboardActionListener.SWIPE_ACTION_RIGHT + : KeyboardActionListener.SWIPE_ACTION_LEFT); + } else { + sListener.onSwipeAction(dy < 0 + ? KeyboardActionListener.SWIPE_ACTION_UP + : KeyboardActionListener.SWIPE_ACTION_DOWN); + } + } + + mLastX = x; + mLastY = y; + return; + } + if (!sInGesture && mIsSlidingCursor && oldKey != null && oldKey.getCode() == Constants.CODE_SPACE) { boolean allowedBySettings = (mSpacebarLongPressed && settingsValues.mSpacebarHoldMode == Settings.SPACEBAR_MODE_CURSOR) || (!mSpacebarLongPressed && settingsValues.mSpacebarSwipeMode != Settings.SPACEBAR_MODE_OFF); diff --git a/java/src/org/futo/inputmethod/latin/LatinIMELegacy.java b/java/src/org/futo/inputmethod/latin/LatinIMELegacy.java index 7807c3dc84..54a63fcf66 100644 --- a/java/src/org/futo/inputmethod/latin/LatinIMELegacy.java +++ b/java/src/org/futo/inputmethod/latin/LatinIMELegacy.java @@ -685,6 +685,13 @@ public void onSwipeLanguageProgress(float progress) { mSwipeLanguageProgress = progress; } + @Override + public void onSwipeAction(int direction) { + mImeManager.getActiveIME( + mSettings.getCurrent() + ).onSwipeAction(direction); + } + @Override public void onMovingCursorLockEvent(boolean canMoveCursor) { if(canMoveCursor) { diff --git a/java/src/org/futo/inputmethod/latin/RichInputConnection.java b/java/src/org/futo/inputmethod/latin/RichInputConnection.java index e4f1428405..06511b5926 100644 --- a/java/src/org/futo/inputmethod/latin/RichInputConnection.java +++ b/java/src/org/futo/inputmethod/latin/RichInputConnection.java @@ -559,6 +559,20 @@ public void deleteTextBeforeCursor(final int beforeLength) { if (DEBUG_PREVIOUS_TEXT) checkConsistencyForDebug(); } + public void deleteTextAroundCursor(final int beforeLength, final int afterLength) { + if (DEBUG_BATCH_NESTING) checkBatchEdit(); + final int lengthToDeleteBefore = Math.min(beforeLength, mExpectedSelStart); + mExpectedSelStart -= lengthToDeleteBefore; + mExpectedSelEnd = mExpectedSelStart; + mComposingText.setLength(0); + + if (isConnected()) { + mIC.deleteSurroundingText(beforeLength, afterLength); + } + reloadTextCache(); + if (DEBUG_PREVIOUS_TEXT) checkConsistencyForDebug(); + } + public void performEditorAction(final int actionId) { updateConnection(); if (isConnected()) { diff --git a/java/src/org/futo/inputmethod/latin/inputlogic/InputLogic.java b/java/src/org/futo/inputmethod/latin/inputlogic/InputLogic.java index ec9e330ec7..fa0a881c85 100644 --- a/java/src/org/futo/inputmethod/latin/inputlogic/InputLogic.java +++ b/java/src/org/futo/inputmethod/latin/inputlogic/InputLogic.java @@ -124,6 +124,7 @@ private class RememberedSuggestedWords { // Keeps track of most recently inserted text (multi-character key) for reverting private String mEnteredText; + private String mLastBackspaceDeletedText; // TODO: This boolean is persistent state and causes large side effects at unexpected times. // Find a way to remove it for readability. @@ -629,6 +630,58 @@ public boolean onUpdateSelection(final int oldSelStart, final int oldSelEnd, public InputTransaction onCodeInput(final SettingsValues settingsValues, @Nonnull final Event event, final int keyboardShiftMode, final int currentKeyboardScriptId) { + return onCodeInputInternal(settingsValues, event, keyboardShiftMode, + currentKeyboardScriptId, false /* forceDeleteWholeWords */, + false /* deleteWordAroundCursor */); + } + + public static final class WordBackspaceResult { + public final InputTransaction mInputTransaction; + public final String mDeletedText; + + public WordBackspaceResult(final InputTransaction inputTransaction, + final String deletedText) { + mInputTransaction = inputTransaction; + mDeletedText = deletedText; + } + } + + private static final class BackspaceDeletionTarget { + public final String mDeletedText; + public final int mCharsBeforeCursor; + public final int mCharsAfterCursor; + + private BackspaceDeletionTarget(final String deletedText, final int charsBeforeCursor, + final int charsAfterCursor) { + mDeletedText = deletedText; + mCharsBeforeCursor = charsBeforeCursor; + mCharsAfterCursor = charsAfterCursor; + } + + public int getTotalCharsToDelete() { + return mCharsBeforeCursor + mCharsAfterCursor; + } + } + + public WordBackspaceResult onWordBackspace(final SettingsValues settingsValues, + final int keyboardShiftMode, final int currentKeyboardScriptId) { + final Event event = Event.createSoftwareKeypressEvent( + Event.NOT_A_CODE_POINT, + Constants.CODE_DELETE, + Constants.NOT_A_COORDINATE, + Constants.NOT_A_COORDINATE, + true /* isKeyRepeat */); + final InputTransaction inputTransaction = onCodeInputInternal(settingsValues, event, + keyboardShiftMode, currentKeyboardScriptId, + true /* forceDeleteWholeWords */, true /* deleteWordAroundCursor */); + return new WordBackspaceResult(inputTransaction, mLastBackspaceDeletedText); + } + + private InputTransaction onCodeInputInternal(final SettingsValues settingsValues, + @Nonnull final Event event, final int keyboardShiftMode, + final int currentKeyboardScriptId, final boolean forceDeleteWholeWords, + final boolean deleteWordAroundCursor) { + mLastBackspaceDeletedText = null; mWordBeingCorrectedByCursor = null; if(settingsValues.needsToLookupSuggestions()) { @@ -673,7 +726,8 @@ public InputTransaction onCodeInput(final SettingsValues settingsValues, if (currentEvent.isConsumed()) { handleConsumedEvent(currentEvent, inputTransaction); } else if (currentEvent.isFunctionalKeyEvent()) { - handleFunctionalEvent(currentEvent, inputTransaction, currentKeyboardScriptId); + handleFunctionalEvent(currentEvent, inputTransaction, currentKeyboardScriptId, + forceDeleteWholeWords, deleteWordAroundCursor); } else { handleNonFunctionalEvent(currentEvent, inputTransaction); } @@ -708,6 +762,64 @@ public InputTransaction onCodeInput(final SettingsValues settingsValues, return inputTransaction; } + private BackspaceDeletionTarget getDeletionTargetBeforeCursor(final boolean deleteWholeWords) { + final int codePointBeforeCursor = mConnection.getCodePointBeforeCursor(); + if (codePointBeforeCursor == Constants.NOT_A_CODE) { + return null; + } + + String textDeleted = new String(Character.toChars(codePointBeforeCursor)); + int lengthToDelete = Character.isSupplementaryCodePoint(codePointBeforeCursor) ? 2 : 1; + + final CharSequence textBeforeCursor = mConnection.getTextBeforeCursor( + deleteWholeWords ? 48 : 8, 0); + if (textBeforeCursor != null && textBeforeCursor.length() > 0) { + final BreakIterator breakIterator = deleteWholeWords + ? BreakIterator.getWordInstance() : BreakIterator.getCharacterInstance(); + breakIterator.setText(textBeforeCursor.toString()); + final int end = breakIterator.last(); + int start = breakIterator.previous(); + + if (deleteWholeWords && start != BreakIterator.DONE + && textBeforeCursor.subSequence(start, end).toString().equals(" ")) { + start = breakIterator.previous(); + } + + if (start != BreakIterator.DONE) { + lengthToDelete = end - start; + textDeleted = textBeforeCursor.subSequence(start, end).toString(); + } + } + + return new BackspaceDeletionTarget(textDeleted, lengthToDelete, 0); + } + + private BackspaceDeletionTarget getDeletionTargetForWordAtCursor( + final SettingsValues settingsValues, final int currentKeyboardScriptId) { + final TextRange range = mConnection.getWordRangeAtCursor( + settingsValues, currentKeyboardScriptId, true); + if (range == null || range.length() <= 0) { + return null; + } + + final int charsBeforeCursor = range.getNumberOfCharsInWordBeforeCursor(); + final int charsAfterCursor = range.getNumberOfCharsInWordAfterCursor(); + if (charsBeforeCursor <= 0 && charsAfterCursor <= 0) { + return null; + } + + return new BackspaceDeletionTarget(range.mWord.toString(), charsBeforeCursor, + charsAfterCursor); + } + + private void deleteUsingTarget(final BackspaceDeletionTarget target) { + if (target.mCharsAfterCursor > 0) { + mConnection.deleteTextAroundCursor(target.mCharsBeforeCursor, target.mCharsAfterCursor); + } else { + mConnection.deleteTextBeforeCursor(target.mCharsBeforeCursor); + } + } + /** * Updates keys whose hitboxes are boosted. This works by looking at the word being composed, * and checking for next letters that would still produce a valid word within the dictionary @@ -885,7 +997,8 @@ private void handleConsumedEvent(final Event event, final InputTransaction input * @param inputTransaction The transaction in progress. */ private void handleFunctionalEvent(final Event event, final InputTransaction inputTransaction, - final int currentKeyboardScriptId) { + final int currentKeyboardScriptId, final boolean forceDeleteWholeWords, + final boolean deleteWordAroundCursor) { if(event.getEventType() == Event.EVENT_TYPE_STOP_COMPOSING) { commitTyped(inputTransaction.mSettingsValues, ""); @@ -907,7 +1020,8 @@ private void handleFunctionalEvent(final Event event, final InputTransaction inp switch (event.mKeyCode) { case Constants.CODE_DELETE: - handleBackspaceEvent(event, inputTransaction, currentKeyboardScriptId); + handleBackspaceEvent(event, inputTransaction, currentKeyboardScriptId, + forceDeleteWholeWords, deleteWordAroundCursor); // Backspace is a functional key, but it affects the contents of the editor. inputTransaction.setDidAffectContents(); break; @@ -1398,7 +1512,8 @@ private void handleSeparatorEvent(final Event event, final InputTransaction inpu * @param inputTransaction The transaction in progress. */ private void handleBackspaceEvent(final Event event, final InputTransaction inputTransaction, - final int currentKeyboardScriptId) { + final int currentKeyboardScriptId, final boolean forceDeleteWholeWords, + final boolean deleteWordAroundCursor) { mSpaceState = SpaceState.NONE; mDeleteCount++; @@ -1423,8 +1538,14 @@ private void handleBackspaceEvent(final Event event, final InputTransaction inpu resetComposingWord(inputTransaction.mSettingsValues, false); } - final boolean deleteWholeWords = event.isKeyRepeat() - && inputTransaction.mSettingsValues.mBackspaceModeHold == Settings.BACKSPACE_MODE_WORDS; + final boolean deleteWholeWords = forceDeleteWholeWords || (event.isKeyRepeat() + && inputTransaction.mSettingsValues.mBackspaceModeHold == Settings.BACKSPACE_MODE_WORDS); + + if (deleteWholeWords && deleteWordAroundCursor && mWordComposer.isComposingWord() + && !mConnection.hasSelection()) { + mConnection.finishComposingText(); + mWordComposer.reset(true); + } if (mWordComposer.isComposingWord() && !mConnection.hasSelection()) { if (mWordComposer.isBatchMode()) { @@ -1436,9 +1557,29 @@ private void handleBackspaceEvent(final Event event, final InputTransaction inpu Constants.EVENT_REJECTION); } StatsUtils.onBackspaceWordDelete(rejectedSuggestion.length()); + } else if (deleteWholeWords && deleteWordAroundCursor) { + final BackspaceDeletionTarget target = getDeletionTargetForWordAtCursor( + inputTransaction.mSettingsValues, currentKeyboardScriptId); + if (target != null) { + mLastBackspaceDeletedText = target.mDeletedText; + unlearnWord(target.mDeletedText, inputTransaction.mSettingsValues, + Constants.EVENT_BACKSPACE); + deleteUsingTarget(target); + mWordComposer.reset(true); + StatsUtils.onBackspacePressed(target.getTotalCharsToDelete()); + } else { + final String removedWord = mWordComposer.getTypedWord(); + mWordComposer.reset(true); + mLastBackspaceDeletedText = removedWord; + if (!TextUtils.isEmpty(removedWord)) { + unlearnWord(removedWord, inputTransaction.mSettingsValues, + Constants.EVENT_BACKSPACE); + } + } } else if(deleteWholeWords) { final String removedWord = mWordComposer.getTypedWord(); mWordComposer.reset(true); + mLastBackspaceDeletedText = removedWord; if (!TextUtils.isEmpty(removedWord)) { unlearnWord(removedWord, inputTransaction.mSettingsValues, Constants.EVENT_BACKSPACE); @@ -1454,7 +1595,7 @@ private void handleBackspaceEvent(final Event event, final InputTransaction inpu } inputTransaction.setRequiresUpdateSuggestions(); } else { - if (mLastComposedWord.canRevertCommit() + if (!deleteWordAroundCursor && mLastComposedWord.canRevertCommit() && inputTransaction.mSettingsValues.mBackspaceUndoesAutocorrect) { final String lastComposedWord = mLastComposedWord.mTypedWord; revertCommit(inputTransaction, inputTransaction.mSettingsValues); @@ -1527,6 +1668,7 @@ private void handleBackspaceEvent(final Event event, final InputTransaction inpu // We also need to unlearn the selected text. final CharSequence selection = mConnection.getSelectedText(0 /* 0 for no styles */); if (!TextUtils.isEmpty(selection)) { + mLastBackspaceDeletedText = selection.toString(); unlearnWord(selection.toString(), inputTransaction.mSettingsValues, Constants.EVENT_BACKSPACE); hasUnlearnedWordBeingDeleted = true; @@ -1538,6 +1680,32 @@ private void handleBackspaceEvent(final Event event, final InputTransaction inpu mConnection.deleteTextBeforeCursor(numCharsDeleted); StatsUtils.onBackspaceSelectedText(numCharsDeleted); } else { + if (deleteWordAroundCursor) { + final BackspaceDeletionTarget target = getDeletionTargetForWordAtCursor( + inputTransaction.mSettingsValues, currentKeyboardScriptId); + if (target != null) { + mLastBackspaceDeletedText = target.mDeletedText; + unlearnWord(target.mDeletedText, inputTransaction.mSettingsValues, + Constants.EVENT_BACKSPACE); + hasUnlearnedWordBeingDeleted = true; + deleteUsingTarget(target); + StatsUtils.onBackspacePressed(target.getTotalCharsToDelete()); + + if (mConnection.hasSlowInputConnection()) { + mSuggestionStripViewAccessor.setNeutralSuggestionStrip(); + } else if (inputTransaction.mSettingsValues.isSuggestionsEnabledPerUserSettings() + && inputTransaction.mSettingsValues.mSpacingAndPunctuations + .currentLanguageHasSpaces + && !mConnection.isCursorFollowedByWordCharacter( + inputTransaction.mSettingsValues.mSpacingAndPunctuations)) { + restartSuggestionsOnWordTouchedByCursor( + inputTransaction.mSettingsValues, inputTransaction, + false /* forStartInput */, currentKeyboardScriptId); + } + return; + } + } + // There is no selection, just delete one character. if (inputTransaction.mSettingsValues.isBeforeJellyBean() || inputTransaction.mSettingsValues.mInputAttributes.isTypeNull() @@ -1585,36 +1753,12 @@ private void handleBackspaceEvent(final Event event, final InputTransaction inpu nowHasWordCharacter = true; } - String textDeleted = new String(Character.toChars(codePointBeforeCursor)); - int lengthToDelete = - Character.isSupplementaryCodePoint(codePointBeforeCursor) ? 2 : 1; - - // Handle emoji sequences (flags, etc) - CharSequence textBeforeCursor = mConnection.getTextBeforeCursor(deleteWholeWords ? 48 : 8, 0); - if (textBeforeCursor != null && textBeforeCursor.length() > 0) { - BreakIterator breakIterator; - - if(deleteWholeWords) { - breakIterator = BreakIterator.getWordInstance(); - } else { - breakIterator = BreakIterator.getCharacterInstance(); - } - breakIterator.setText(textBeforeCursor.toString()); - int end = breakIterator.last(); - int start = breakIterator.previous(); - - if(deleteWholeWords && textBeforeCursor.subSequence(start, end).toString().equals(" ")) { - start = breakIterator.previous(); - } - - if (start != BreakIterator.DONE) { - lengthToDelete = end - start; - textDeleted = textBeforeCursor.subSequence(start, end).toString(); - } - } + final BackspaceDeletionTarget target = getDeletionTargetBeforeCursor( + deleteWholeWords); + mLastBackspaceDeletedText = target.mDeletedText; - mConnection.deleteTextBeforeCursor(lengthToDelete); - int totalDeletedLength = lengthToDelete; + deleteUsingTarget(target); + int totalDeletedLength = target.getTotalCharsToDelete(); if (mDeleteCount > Constants.DELETE_ACCELERATE_AT) { // If this is an accelerated (i.e., double) deletion, then we need to // consider unlearning here because we may have already reached diff --git a/java/src/org/futo/inputmethod/latin/settings/Settings.java b/java/src/org/futo/inputmethod/latin/settings/Settings.java index 055c166a6c..e53ee0d0a5 100644 --- a/java/src/org/futo/inputmethod/latin/settings/Settings.java +++ b/java/src/org/futo/inputmethod/latin/settings/Settings.java @@ -88,7 +88,11 @@ public final class Settings implements SharedPreferences.OnSharedPreferenceChang public static final String PREF_KEY_PREVIEW_POPUP_DISMISS_DELAY = "pref_key_preview_popup_dismiss_delay"; public static final String PREF_BIGRAM_PREDICTIONS = "next_word_prediction"; - public static final String PREF_GESTURE_INPUT = "gesture_input"; + public static final String PREF_GESTURE_INPUT_MODE = "pref_gesture_input_mode"; + private static final String PREF_GESTURE_INPUT_LEGACY = "gesture_input"; + public static final int GESTURE_INPUT_MODE_TYPING = 0; + public static final int GESTURE_INPUT_MODE_ACTIONS = 1; + public static final int GESTURE_INPUT_MODE_NONE = 2; public static final String PREF_GESTURE_INPUT_SENSITIVITY = "gesture_input_sensitivity"; public static final String PREF_VIBRATION_DURATION_SETTINGS = "pref_vibration_duration_settings"; @@ -304,10 +308,24 @@ public static boolean readFromBuildConfigIfGestureInputEnabled(final Resources r return res.getBoolean(R.bool.config_gesture_input_enabled_by_build_config); } - public static boolean readGestureInputEnabled(final SharedPreferences prefs, + public static int readGestureInputMode(final SharedPreferences prefs, final Resources res) { - return readFromBuildConfigIfGestureInputEnabled(res) - && prefs.getBoolean(PREF_GESTURE_INPUT, true); + if (prefs.contains(PREF_GESTURE_INPUT_MODE)) { + final int mode = prefs.getInt(PREF_GESTURE_INPUT_MODE, GESTURE_INPUT_MODE_TYPING); + if (mode == GESTURE_INPUT_MODE_TYPING + || mode == GESTURE_INPUT_MODE_ACTIONS + || mode == GESTURE_INPUT_MODE_NONE) { + return mode; + } + } + + if (prefs.contains(PREF_GESTURE_INPUT_LEGACY)) { + return prefs.getBoolean(PREF_GESTURE_INPUT_LEGACY, true) + ? GESTURE_INPUT_MODE_TYPING + : GESTURE_INPUT_MODE_NONE; + } + + return GESTURE_INPUT_MODE_TYPING; } public static boolean readFromBuildConfigIfToShowKeyPreviewPopupOption(final Resources res) { diff --git a/java/src/org/futo/inputmethod/latin/settings/SettingsValues.java b/java/src/org/futo/inputmethod/latin/settings/SettingsValues.java index 30803dc90a..85b95aecda 100644 --- a/java/src/org/futo/inputmethod/latin/settings/SettingsValues.java +++ b/java/src/org/futo/inputmethod/latin/settings/SettingsValues.java @@ -84,7 +84,9 @@ public class SettingsValues { // Use bigrams to predict the next word when there is no input for it yet public final boolean mBigramPredictionEnabled; public final boolean mTransformerPredictionEnabled; + public final int mGestureInputMode; public final boolean mGestureInputEnabled; + public final boolean mGestureActionsEnabled; public final boolean mGestureInputSensitive; public final boolean mGestureTrailEnabled; public final boolean mGestureFloatingPreviewTextEnabled; @@ -231,7 +233,13 @@ public SettingsValues(final Context context, final SharedPreferences prefs, fina mAutoCorrectionThreshold = readAutoCorrectionThreshold(res, autoCorrectionThresholdRawValue); mPlausibilityThreshold = Settings.readPlausibilityThreshold(res); - mGestureInputEnabled = Settings.readGestureInputEnabled(prefs, res); + mGestureInputMode = Settings.readGestureInputMode(prefs, res); + final boolean gestureInputAllowedByBuild = + Settings.readFromBuildConfigIfGestureInputEnabled(res); + mGestureInputEnabled = gestureInputAllowedByBuild + && mGestureInputMode == Settings.GESTURE_INPUT_MODE_TYPING; + mGestureActionsEnabled = gestureInputAllowedByBuild + && mGestureInputMode == Settings.GESTURE_INPUT_MODE_ACTIONS; mGestureInputSensitive = prefs.getBoolean(Settings.PREF_GESTURE_INPUT_SENSITIVITY, false); mGestureTrailEnabled = prefs.getBoolean(Settings.PREF_GESTURE_PREVIEW_TRAIL, true); mCloudSyncEnabled = prefs.getBoolean(LocalSettingsConstants.PREF_ENABLE_CLOUD_SYNC, false); diff --git a/java/src/org/futo/inputmethod/latin/uix/settings/Components.kt b/java/src/org/futo/inputmethod/latin/uix/settings/Components.kt index a87b44242b..036ec55724 100644 --- a/java/src/org/futo/inputmethod/latin/uix/settings/Components.kt +++ b/java/src/org/futo/inputmethod/latin/uix/settings/Components.kt @@ -43,6 +43,8 @@ import androidx.compose.material.icons.filled.ArrowForward import androidx.compose.material.icons.filled.Send import androidx.compose.material.icons.filled.Warning import androidx.compose.material3.Icon +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme import androidx.compose.material3.RadioButton @@ -127,7 +129,7 @@ fun ScreenTitle(title: String, showBack: Boolean = false, navController: NavHost } Text(title, style = Typography.Heading.Medium, modifier = Modifier .align(CenterVertically) - .padding(0.dp, 16.dp)) + .padding(top = 16.dp, bottom = 10.dp)) } } @@ -140,7 +142,7 @@ fun ScreenTitleWithIcon(title: String, painter: Painter) { Spacer(modifier = Modifier.width(18.dp)) Text(title, style = Typography.Heading.Medium, modifier = Modifier .align(CenterVertically) - .padding(0.dp, 16.dp)) + .padding(top = 16.dp, bottom = 10.dp)) } } @@ -442,24 +444,31 @@ fun SettingToggleSharedPrefs( @Composable fun SettingRadio( - title: String, + title: String? = null, options: List, optionNames: List, setting: DataStoreItem, + optionSubtitles: List? = null, + compact: Boolean = false, modifier: Modifier = Modifier, hints: List<@Composable () -> Unit>? = null, subcontent: List<@Composable () -> Unit>? = null, ) { - ScreenTitle(title, showBack = false) + if (!title.isNullOrBlank()) { + ScreenTitle(title, showBack = false) + } Column { options.zip(optionNames).forEachIndexed { i, it -> - SettingItem(title = it.second, onClick = { setting.setValue(it.first) }, icon = { + val subtitle = optionSubtitles?.getOrNull(i) + SettingItem(title = it.second, subtitle = subtitle, onClick = { setting.setValue(it.first) }, icon = { RadioButton(selected = setting.value == it.first, onClick = null) }, modifier = modifier.clearAndSetSemantics { - this.text = AnnotatedString(it.second) + this.text = AnnotatedString( + if (subtitle.isNullOrBlank()) it.second else "${it.second}. $subtitle" + ) this.role = Role.RadioButton this.selected = setting.value == it.first - }, subcontent = subcontent?.getOrNull(i)) { + }, compact = compact, subcontent = subcontent?.getOrNull(i)) { hints?.getOrNull(i)?.invoke() } } @@ -885,7 +894,7 @@ fun DropDownPicker( role = Role.DropdownList } ) { - if(selection != null) { + if (selection != null) { Text( text = getDisplayName(selection), style = Typography.Body.Regular, @@ -941,8 +950,7 @@ fun DropDownPicker( MaterialTheme.colorScheme.onSurface } else { MaterialTheme.colorScheme.onSurfaceVariant - }, - modifier = Modifier.align(Alignment.CenterStart) + } ) } } @@ -1029,4 +1037,4 @@ fun PreviewPrimarySetting() { "Enable", dataStoreItem = DataStoreItem(false, { error("") }) ) -} \ No newline at end of file +} diff --git a/java/src/org/futo/inputmethod/latin/uix/settings/pages/Swipe.kt b/java/src/org/futo/inputmethod/latin/uix/settings/pages/Swipe.kt index 201fb0439b..98b06cca76 100644 --- a/java/src/org/futo/inputmethod/latin/uix/settings/pages/Swipe.kt +++ b/java/src/org/futo/inputmethod/latin/uix/settings/pages/Swipe.kt @@ -43,17 +43,20 @@ import org.futo.inputmethod.latin.uix.LocalNavController import org.futo.inputmethod.latin.uix.SettingsTextEdit import org.futo.inputmethod.latin.uix.SuggestionSeparator import org.futo.inputmethod.latin.uix.getSetting +import org.futo.inputmethod.latin.uix.settings.DataStoreItem import org.futo.inputmethod.latin.uix.settings.NavigationItem import org.futo.inputmethod.latin.uix.settings.NavigationItemStyle import org.futo.inputmethod.latin.uix.settings.ScreenTitle import org.futo.inputmethod.latin.uix.settings.ScrollableList import org.futo.inputmethod.latin.uix.settings.SettingRadio import org.futo.inputmethod.latin.uix.settings.SettingToggleDataStore +import org.futo.inputmethod.latin.uix.settings.SettingToggleDataStoreItem import org.futo.inputmethod.latin.uix.settings.SettingToggleRaw import org.futo.inputmethod.latin.uix.settings.UserSetting import org.futo.inputmethod.latin.uix.settings.UserSettingsMenu import org.futo.inputmethod.latin.uix.settings.useDataStore import org.futo.inputmethod.latin.uix.settings.useDataStoreValue +import org.futo.inputmethod.latin.uix.settings.useSharedPrefsInt import org.futo.inputmethod.latin.uix.settings.userSettingDecorationOnly import org.futo.inputmethod.latin.uix.settings.userSettingNavigationItem import org.futo.inputmethod.latin.uix.settings.userSettingToggleSharedPrefs @@ -207,12 +210,31 @@ val SwipeMenu = UserSettingsMenu( title = R.string.swipe_settings_title, navPath = "swipe", registerNavPath = true, settings = listOf( - userSettingToggleSharedPrefs( - title = R.string.swipe_settings_enable_swipe_typing, + UserSetting( + name = R.string.swipe_settings_enable_swipe_typing, subtitle = R.string.swipe_settings_enable_swipe_typing_subtitle, - key = Settings.PREF_GESTURE_INPUT, - default = {true}, - ), + ) { + val gestureInputMode = useSharedPrefsInt( + Settings.PREF_GESTURE_INPUT_MODE, + Settings.GESTURE_INPUT_MODE_TYPING + ) + + SettingToggleDataStoreItem( + title = stringResource(R.string.swipe_settings_enable_swipe_typing), + subtitle = stringResource(R.string.swipe_settings_enable_swipe_typing_subtitle), + dataStoreItem = DataStoreItem( + gestureInputMode.value == Settings.GESTURE_INPUT_MODE_TYPING + ) { enabled -> + gestureInputMode.setValue( + if (enabled) { + Settings.GESTURE_INPUT_MODE_TYPING + } else { + Settings.GESTURE_INPUT_MODE_NONE + } + ) + } + ) + }, userSettingToggleSharedPrefs( title = R.string.swipe_settings_sensitive_swipe, @@ -291,4 +313,4 @@ val SwipeMenu = UserSettingsMenu( } }, ) -) \ No newline at end of file +) diff --git a/java/src/org/futo/inputmethod/latin/uix/settings/pages/Typing.kt b/java/src/org/futo/inputmethod/latin/uix/settings/pages/Typing.kt index 0218653c24..5783b27c2d 100644 --- a/java/src/org/futo/inputmethod/latin/uix/settings/pages/Typing.kt +++ b/java/src/org/futo/inputmethod/latin/uix/settings/pages/Typing.kt @@ -852,6 +852,15 @@ val KeyboardSettingsMenu = UserSettingsMenu( ) ) +val SwipeInputSettingsMenu = UserSettingsMenu( + title = R.string.swipe_input_settings_title, + navPath = "swipe", registerNavPath = true, + settings = listOf( + UserSetting(name = R.string.swipe_input_settings_title) { + SwipeAlphaModesSetting() + } + ) +) val TypingSettingsMenu = UserSettingsMenu( title = R.string.typing_settings_title, navPath = "typing", registerNavPath = true, @@ -1018,6 +1027,33 @@ val TypingSettingsMenu = UserSettingsMenu( ) ) +@Composable +private fun SwipeAlphaModesSetting() { + SettingRadio( + title = stringResource(R.string.swipe_input_settings_title), + options = listOf( + Settings.GESTURE_INPUT_MODE_TYPING, + Settings.GESTURE_INPUT_MODE_ACTIONS, + Settings.GESTURE_INPUT_MODE_NONE + ), + optionNames = listOf( + stringResource(R.string.swipe_input_settings_swipe), + stringResource(R.string.swipe_input_settings_swipe_actions_mode), + stringResource(R.string.swipe_input_settings_swipe_disabled) + ), + optionSubtitles = listOf( + stringResource(R.string.swipe_input_settings_swipe_subtitle), + stringResource(R.string.swipe_input_settings_swipe_actions_mode_subtitle), + stringResource(R.string.swipe_input_settings_swipe_disabled_subtitle) + ), + compact = true, + setting = useSharedPrefsInt( + key = Settings.PREF_GESTURE_INPUT_MODE, + default = Settings.GESTURE_INPUT_MODE_TYPING + ) + ) +} + @Preview(showBackground = true) @Composable fun KeyboardAndTypingScreen(navController: NavHostController = rememberNavController()) { @@ -1043,8 +1079,9 @@ fun KeyboardAndTypingScreen(navController: NavHostController = rememberNavContro } KeyboardSettingsMenu.render(showBack = false, showTitle = false) + SwipeInputSettingsMenu.render(showBack = false, showTitle = false) TypingSettingsMenu.render(showBack = false) BottomSpacer() } -} \ No newline at end of file +}