From a664380ec4c9e8f5dc823f459de9ed7119702f3d Mon Sep 17 00:00:00 2001 From: Asaf Mahlev Date: Tue, 29 Sep 2026 18:06:04 +0300 Subject: [PATCH] feat(input): add temporary per-field literal typing mode Keep composition and explicit editing while suppressing automatic assistance, expansion, learning and stale suggestion delivery. Share optional toolbar, pinned and JSON controls without changing stored preferences. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../keyboard/KeyboardActionListenerImpl.kt | 1 + .../keyboard/internal/KeyboardIconsSet.kt | 3 + .../keyboard_parser/floris/KeyCode.kt | 3 +- .../java/helium314/keyboard/latin/LatinIME.kt | 108 ++++++- .../keyboard/latin/inputlogic/InputLogic.kt | 51 ++- .../latin/inputlogic/InputLogicHandler.kt | 31 +- .../keyboard/latin/settings/SettingsValues.kt | 38 ++- .../keyboard/latin/utils/ToolbarUtils.kt | 7 +- app/src/main/res/drawable/ic_literal.xml | 9 + app/src/main/res/values/strings.xml | 1 + .../helium314/keyboard/KeyboardParserTest.kt | 17 + .../keyboard/latin/InputLogicTest.kt | 296 +++++++++++++++++- docs/FEATURES.md | 12 +- 13 files changed, 519 insertions(+), 58 deletions(-) create mode 100644 app/src/main/res/drawable/ic_literal.xml diff --git a/app/src/main/java/helium314/keyboard/keyboard/KeyboardActionListenerImpl.kt b/app/src/main/java/helium314/keyboard/keyboard/KeyboardActionListenerImpl.kt index 7d676e860..7c7f51245 100644 --- a/app/src/main/java/helium314/keyboard/keyboard/KeyboardActionListenerImpl.kt +++ b/app/src/main/java/helium314/keyboard/keyboard/KeyboardActionListenerImpl.kt @@ -198,6 +198,7 @@ class KeyboardActionListenerImpl(private val latinIME: LatinIME, private val inp } } when (primaryCode) { + KeyCode.TOGGLE_LITERAL_MODE -> return latinIME.toggleLiteralMode() KeyCode.TOGGLE_SELECTION_MODE -> { sPersistentSelectionModeActive = !sPersistentSelectionModeActive keyboardSwitcher.mainKeyboardView?.invalidateAllKeys() diff --git a/app/src/main/java/helium314/keyboard/keyboard/internal/KeyboardIconsSet.kt b/app/src/main/java/helium314/keyboard/keyboard/internal/KeyboardIconsSet.kt index 9613135af..0fc749100 100644 --- a/app/src/main/java/helium314/keyboard/keyboard/internal/KeyboardIconsSet.kt +++ b/app/src/main/java/helium314/keyboard/keyboard/internal/KeyboardIconsSet.kt @@ -165,6 +165,7 @@ class KeyboardIconsSet private constructor() { ToolbarKey.TOUCHPAD -> R.drawable.ic_touchpad ToolbarKey.TEXT_EDIT -> R.drawable.ic_text_edit ToolbarKey.AUTOCORRECT -> R.drawable.ic_autocorrect + ToolbarKey.LITERAL -> R.drawable.ic_literal ToolbarKey.CLEAR_CLIPBOARD -> R.drawable.ic_bin ToolbarKey.CLOSE_HISTORY -> R.drawable.ic_close ToolbarKey.HANDWRITING -> R.drawable.ic_edit @@ -245,6 +246,7 @@ class KeyboardIconsSet private constructor() { ToolbarKey.TOUCHPAD -> R.drawable.ic_touchpad ToolbarKey.TEXT_EDIT -> R.drawable.ic_text_edit ToolbarKey.AUTOCORRECT -> R.drawable.ic_autocorrect + ToolbarKey.LITERAL -> R.drawable.ic_literal ToolbarKey.CLEAR_CLIPBOARD -> R.drawable.ic_bin ToolbarKey.CLOSE_HISTORY -> R.drawable.ic_close ToolbarKey.HANDWRITING -> R.drawable.ic_edit @@ -325,6 +327,7 @@ class KeyboardIconsSet private constructor() { ToolbarKey.TOUCHPAD -> R.drawable.ic_touchpad_rounded ToolbarKey.TEXT_EDIT -> R.drawable.ic_text_edit ToolbarKey.AUTOCORRECT -> R.drawable.ic_autocorrect_rounded + ToolbarKey.LITERAL -> R.drawable.ic_literal ToolbarKey.CLEAR_CLIPBOARD -> R.drawable.ic_bin ToolbarKey.CLOSE_HISTORY -> R.drawable.ic_close_rounded ToolbarKey.HANDWRITING -> R.drawable.ic_edit diff --git a/app/src/main/java/helium314/keyboard/keyboard/internal/keyboard_parser/floris/KeyCode.kt b/app/src/main/java/helium314/keyboard/keyboard/internal/keyboard_parser/floris/KeyCode.kt index 056c083a3..8d09604b5 100644 --- a/app/src/main/java/helium314/keyboard/keyboard/internal/keyboard_parser/floris/KeyCode.kt +++ b/app/src/main/java/helium314/keyboard/keyboard/internal/keyboard_parser/floris/KeyCode.kt @@ -205,6 +205,7 @@ object KeyCode { const val CLEAR_HANDWRITING = -10075 const val SWITCH_TO_USER_IME = -10076 const val OCR = -10077 + const val TOGGLE_LITERAL_MODE = -10078 // Intents @@ -230,7 +231,7 @@ object KeyCode { TIMESTAMP, CTRL_LEFT, CTRL_RIGHT, ALT_LEFT, ALT_RIGHT, META_LEFT, META_RIGHT, SEND_INTENT_ONE, SEND_INTENT_TWO, SEND_INTENT_THREE, INLINE_EMOJI_SEARCH_DONE, META_LOCK, PROOFREAD, TRANSLATE, SHOW_TRANSLATE_LANGUAGES, CUSTOM_AI_1, CUSTOM_AI_2, CUSTOM_AI_3, CUSTOM_AI_4, CUSTOM_AI_5, - CUSTOM_AI_6, CUSTOM_AI_7, CUSTOM_AI_8, CUSTOM_AI_9, CUSTOM_AI_10, CLIPBOARD_SEARCH, TOGGLE_FLOATING_KEYBOARD, TOGGLE_TOUCHPAD_MODE, TOGGLE_TEXT_EDIT_MODE, TOGGLE_SELECTION_MODE, HANDWRITING, CLEAR_HANDWRITING, OCR, + CUSTOM_AI_6, CUSTOM_AI_7, CUSTOM_AI_8, CUSTOM_AI_9, CUSTOM_AI_10, CLIPBOARD_SEARCH, TOGGLE_FLOATING_KEYBOARD, TOGGLE_TOUCHPAD_MODE, TOGGLE_TEXT_EDIT_MODE, TOGGLE_SELECTION_MODE, HANDWRITING, CLEAR_HANDWRITING, OCR, TOGGLE_LITERAL_MODE, CUSTOM1, CUSTOM2, CUSTOM3, CUSTOM4, CUSTOM5, SWITCH_TO_USER_IME -> this diff --git a/app/src/main/java/helium314/keyboard/latin/LatinIME.kt b/app/src/main/java/helium314/keyboard/latin/LatinIME.kt index c59c55c28..e9f66efd8 100644 --- a/app/src/main/java/helium314/keyboard/latin/LatinIME.kt +++ b/app/src/main/java/helium314/keyboard/latin/LatinIME.kt @@ -184,6 +184,46 @@ class LatinIME : InputMethodService(), private val mTempRect = Rect() private val mFloatingTouchableRect = Rect() @Volatile var isCursorGestureActive = false + @Volatile var isLiteralMode = false + private set + private var literalEditor: EditorInfo? = null + private var literalEditorIdentity: LiteralEditorIdentity? = null + private var literalEditorConnection: android.view.inputmethod.InputConnection? = null + + private data class LiteralEditorIdentity( + val packageName: String?, val fieldId: Int, val fieldName: String?, + val inputType: Int, val imeOptions: Int + ) + + fun toggleLiteralMode() { + if (currentInputEditorInfo == null) return + // Finish the visible composition as typed, without correction, expansion, or learning. + inputLogic.finishInput() + isLiteralMode = !isLiteralMode + handler.cancelPendingSuggestions() + sSettingsDirty = true + loadSettings() + setNeutralSuggestionStrip() + keyboardSwitcher.mainKeyboardView?.apply { + setGestureHandlingEnabledByUser( + settings.current.mGestureInputEnabled, settings.current.mGestureTrailEnabled, + settings.current.mGestureFloatingPreviewTextEnabled + ) + showGestureFloatingPreviewText(SuggestedWords.getEmptyInstance(), true) + } + keyboardSwitcher.requestUpdatingShiftState(currentAutoCapsState, currentRecapitalizeState) + suggestionStripView?.updateToolbarButtonsActivatedState() + } + + private fun resetLiteralMode() { + isLiteralMode = false + literalEditor = null + literalEditorIdentity = null + literalEditorConnection = null + inputLogic.invalidateSuggestions() + handler.cancelPendingSuggestions() + sSettingsDirty = true + } private var voicePluginManager: VoicePluginManager? = null private var voiceInputManager: VoiceInputManager? = null @@ -370,6 +410,7 @@ class LatinIME : InputMethodService(), val localeAndConfidenceInfo: String? get() = dictionaryFacilitator.localesAndConfidences() override fun onDestroy() { + resetLiteralMode() if (sInstance === this) sInstance = null if (isShowingOptionDialog()) { optionsDialog?.dismiss() @@ -641,6 +682,16 @@ class LatinIME : InputMethodService(), fun onStartInputInternal(editorInfo: EditorInfo?, restarting: Boolean) { super.onStartInput(editorInfo, restarting) + val identity = editorInfo?.let { + LiteralEditorIdentity(it.packageName, it.fieldId, it.fieldName, it.inputType, it.imeOptions) + } + val sameEditor = restarting && identity != null && identity == literalEditorIdentity && + (identity.fieldId != 0 || editorInfo === literalEditor || + (currentInputConnection != null && currentInputConnection === literalEditorConnection)) + if (!sameEditor) resetLiteralMode() + literalEditor = editorInfo + literalEditorIdentity = identity + literalEditorConnection = currentInputConnection floatingKeyboardManager?.resetDragAndResizeState() inputLogic.connection.onStartInput() @@ -700,7 +751,7 @@ class LatinIME : InputMethodService(), updateFullscreenMode() - if (isDifferentTextField || !currentSettingsValues.hasSameOrientation(resources.configuration)) { + if (sSettingsDirty || isDifferentTextField || !currentSettingsValues.hasSameOrientation(resources.configuration)) { loadSettings() currentSettingsValues = settings.current suggestionStripView?.updateVoiceKey() @@ -816,6 +867,7 @@ class LatinIME : InputMethodService(), } fun onFinishInputInternal() { + resetLiteralMode() super.onFinishInput() Log.i(TAG, "onFinishInput") floatingKeyboardManager?.resetDragAndResizeState() @@ -903,6 +955,7 @@ class LatinIME : InputMethodService(), } override fun onDisplayCompletions(applicationSpecifiedCompletions: Array?) { + if (isLiteralMode) return if (DebugFlags.DEBUG_ENABLED) { Log.i(TAG, "Received completions:") applicationSpecifiedCompletions?.forEachIndexed { i, info -> Log.i(TAG, " #$i: $info") } @@ -1350,6 +1403,7 @@ class LatinIME : InputMethodService(), private fun hasSuggestionStripView(): Boolean = suggestionStripView != null private fun setSuggestedWords(suggestedWords: SuggestedWords) { + if (isLiteralMode && !suggestedWords.isEmpty) return val currentSettingsValues = settings.current inputLogic.setSuggestedWords(suggestedWords) if (!hasSuggestionStripView()) return @@ -1374,6 +1428,10 @@ class LatinIME : InputMethodService(), } override fun setSuggestions(suggestedWords: SuggestedWords) { + if (isLiteralMode) { + setSuggestedWords(SuggestedWords.getEmptyInstance()) + return + } if (tryShowMathSuggestion()) return if (suggestedWords.isEmpty) { if (keyboardSwitcher.isHandwritingShowing) { @@ -1392,7 +1450,7 @@ class LatinIME : InputMethodService(), } override fun pickSuggestionManually(suggestionInfo: SuggestedWordInfo?) { - if (suggestionInfo == null) return + if (suggestionInfo == null || isLiteralMode) return val completeInputTransaction = inputLogic.onPickSuggestionManually(settings.current, suggestionInfo, keyboardSwitcher.keyboardShiftMode, handler) updateStateAfterInputTransaction(completeInputTransaction) if (keyboardSwitcher.isHandwritingShowing) keyboardSwitcher.clearHandwritingCanvas() @@ -1403,6 +1461,7 @@ class LatinIME : InputMethodService(), } fun tryShowOtpSuggestion(): Boolean { + if (isLiteralMode) return false val strip = suggestionStripView ?: return false val otpView = otpSuggestionManager.getOtpSuggestionView(strip) if (otpView != null) { @@ -1413,6 +1472,7 @@ class LatinIME : InputMethodService(), } fun tryShowMathSuggestion(): Boolean { + if (isLiteralMode) return false val strip = suggestionStripView ?: return false val mathView = mathSuggestionManager.getMathSuggestionView(strip) if (mathView != null) { @@ -1423,6 +1483,7 @@ class LatinIME : InputMethodService(), } fun tryShowClipboardSuggestion(): Boolean { + if (isLiteralMode) return false val strip = suggestionStripView ?: return false val clipboardView = clipboardHistoryManager.getClipboardSuggestionView(currentInputEditorInfo, strip) if (clipboardView != null) { @@ -1440,6 +1501,11 @@ class LatinIME : InputMethodService(), get() = suggestionStripView?.isExternalSuggestionVisible == true override fun setNeutralSuggestionStrip() { + if (isLiteralMode) { + suggestionStripView?.setExternalSuggestionView(null, false) + setSuggestedWords(SuggestedWords.getEmptyInstance()) + return + } if (keyboardSwitcher.isHandwritingShowing) return val currentSettings = settings.current if (tryShowExternalSuggestion()) { @@ -1741,6 +1807,9 @@ class LatinIME : InputMethodService(), override fun handleMessage(msg: Message) { val latinIme = ownerInstance ?: return + if (msg.what in listOf(MSG_UPDATE_SUGGESTION_STRIP, MSG_RESUME_SUGGESTIONS, + MSG_SHOW_GESTURE_PREVIEW_AND_SET_SUGGESTIONS, MSG_UPDATE_TAIL_BATCH_INPUT_COMPLETED) && + !latinIme.inputLogic.isSuggestionRequestCurrent(msg.arg2)) return when (msg.what) { MSG_UPDATE_SUGGESTION_STRIP -> { cancelUpdateSuggestionStrip() @@ -1776,9 +1845,9 @@ class LatinIME : InputMethodService(), } fun postUpdateSuggestionStrip(inputStyle: Int) { - val latinIme = ownerInstance - if (latinIme != null && latinIme.keyboardSwitcher.isHandwritingShowing) return - sendMessageDelayed(obtainMessage(MSG_UPDATE_SUGGESTION_STRIP, inputStyle, 0), delayInMillisecondsToUpdateSuggestions.toLong()) + val latinIme = ownerInstance ?: return + if (latinIme.isLiteralMode || latinIme.keyboardSwitcher.isHandwritingShowing) return + sendMessageDelayed(obtainMessage(MSG_UPDATE_SUGGESTION_STRIP, inputStyle, latinIme.inputLogic.suggestionGeneration), delayInMillisecondsToUpdateSuggestions.toLong()) } fun postReopenDictionaries() { sendMessage(obtainMessage(MSG_REOPEN_DICTIONARIES)) } @@ -1789,8 +1858,9 @@ class LatinIME : InputMethodService(), if (latinIme.keyboardSwitcher.isHandwritingShowing) return if (!latinIme.settings.current.needsToLookupSuggestions()) return removeMessages(MSG_RESUME_SUGGESTIONS) - if (shouldDelay) sendMessageDelayed(obtainMessage(MSG_RESUME_SUGGESTIONS), delayInMillisecondsToUpdateSuggestions.toLong()) - else sendMessage(obtainMessage(MSG_RESUME_SUGGESTIONS)) + val message = obtainMessage(MSG_RESUME_SUGGESTIONS, 0, latinIme.inputLogic.suggestionGeneration) + if (shouldDelay) sendMessageDelayed(message, delayInMillisecondsToUpdateSuggestions.toLong()) + else sendMessage(message) } fun postResetCaches(tryResumeSuggestions: Boolean, remainingTries: Int) { @@ -1803,6 +1873,12 @@ class LatinIME : InputMethodService(), fun hasPendingWaitForDictionaryLoad(): Boolean = hasMessages(MSG_WAIT_FOR_DICTIONARY_LOAD) fun cancelUpdateSuggestionStrip() { removeMessages(MSG_UPDATE_SUGGESTION_STRIP) } fun cancelResumeSuggestions() { removeMessages(MSG_RESUME_SUGGESTIONS) } + fun cancelPendingSuggestions() { + cancelUpdateSuggestionStrip() + cancelResumeSuggestions() + removeMessages(MSG_SHOW_GESTURE_PREVIEW_AND_SET_SUGGESTIONS) + removeMessages(MSG_UPDATE_TAIL_BATCH_INPUT_COMPLETED) + } fun hasPendingUpdateSuggestions(): Boolean = hasMessages(MSG_UPDATE_SUGGESTION_STRIP) fun hasPendingResumeSuggestions(): Boolean = hasMessages(MSG_RESUME_SUGGESTIONS) fun hasPendingReopenDictionaries(): Boolean = hasMessages(MSG_REOPEN_DICTIONARIES) @@ -1820,19 +1896,25 @@ class LatinIME : InputMethodService(), for (i in 0..MSG_LAST) removeMessages(i) } - fun showGesturePreviewAndSetSuggestions(suggestedWords: SuggestedWords, dismissGestureFloatingPreviewText: Boolean) { + fun showGesturePreviewAndSetSuggestions( + suggestedWords: SuggestedWords, dismissGestureFloatingPreviewText: Boolean, + generation: Int = ownerInstance?.inputLogic?.suggestionGeneration ?: 0 + ) { + if (ownerInstance?.inputLogic?.isSuggestionRequestCurrent(generation) != true) return removeMessages(MSG_SHOW_GESTURE_PREVIEW_AND_SET_SUGGESTIONS) val arg1 = if (dismissGestureFloatingPreviewText) ARG1_DISMISS_GESTURE_FLOATING_PREVIEW_TEXT else ARG1_SHOW_GESTURE_FLOATING_PREVIEW_TEXT - obtainMessage(MSG_SHOW_GESTURE_PREVIEW_AND_SET_SUGGESTIONS, arg1, ARG2_UNUSED, suggestedWords).sendToTarget() + obtainMessage(MSG_SHOW_GESTURE_PREVIEW_AND_SET_SUGGESTIONS, arg1, generation, suggestedWords).sendToTarget() } - fun setSuggestions(suggestedWords: SuggestedWords) { + fun setSuggestions(suggestedWords: SuggestedWords, generation: Int = ownerInstance?.inputLogic?.suggestionGeneration ?: 0) { + if (ownerInstance?.inputLogic?.isSuggestionRequestCurrent(generation) != true) return removeMessages(MSG_SHOW_GESTURE_PREVIEW_AND_SET_SUGGESTIONS) - obtainMessage(MSG_SHOW_GESTURE_PREVIEW_AND_SET_SUGGESTIONS, ARG1_NOT_GESTURE_INPUT, ARG2_UNUSED, suggestedWords).sendToTarget() + obtainMessage(MSG_SHOW_GESTURE_PREVIEW_AND_SET_SUGGESTIONS, ARG1_NOT_GESTURE_INPUT, generation, suggestedWords).sendToTarget() } - fun showTailBatchInputResult(suggestedWords: SuggestedWords) { - obtainMessage(MSG_UPDATE_TAIL_BATCH_INPUT_COMPLETED, suggestedWords).sendToTarget() + fun showTailBatchInputResult(suggestedWords: SuggestedWords, generation: Int) { + if (ownerInstance?.inputLogic?.isSuggestionRequestCurrent(generation) != true) return + obtainMessage(MSG_UPDATE_TAIL_BATCH_INPUT_COMPLETED, 0, generation, suggestedWords).sendToTarget() } fun postSwitchLanguage(subtype: InputMethodSubtype) { 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 cbaabc8d7..fb5045326 100644 --- a/app/src/main/java/helium314/keyboard/latin/inputlogic/InputLogic.kt +++ b/app/src/main/java/helium314/keyboard/latin/inputlogic/InputLogic.kt @@ -115,6 +115,13 @@ class InputLogic( val suggestedWords: SuggestedWords get() = mSuggestedWords + val suggestionGeneration: Int get() = mInputLogicHandler.generation + + fun isSuggestionRequestCurrent(generation: Int): Boolean = + generation == suggestionGeneration && !mLatinIME.isLiteralMode + + fun invalidateSuggestions() = mInputLogicHandler.reset() + fun startInput(combiningSpec: String?, settingsValues: SettingsValues) { mEnteredText = null mWordBeingCorrectedByCursor = null @@ -150,13 +157,14 @@ class InputLogic( } fun finishInput() { + invalidateSuggestions() if (mWordComposer.isComposingWord()) { mConnection.finishComposingText() StatsUtils.onWordCommitUserTyped(mWordComposer.getTypedWord(), mWordComposer.isBatchMode()) } resetComposingState(true) - mInputLogicHandler.reset() mSpaceState = SpaceState.NONE + cancelDoubleSpacePeriodCountdown() mConnection.ensureBatchEditClosed() } @@ -251,7 +259,7 @@ class InputLogic( resetComposingState(true /* alsoResetLastComposedWord */) } handler.postUpdateSuggestionStrip(SuggestedWords.INPUT_STYLE_TYPING) - val text = performSpecificTldProcessingOnTextInput(rawText) + val text = if (settingsValues.mLiteralMode) rawText else performSpecificTldProcessingOnTextInput(rawText) if (SpaceState.PHANTOM == mSpaceState) { insertAutomaticSpaceIfOptionsAndTextAllow(settingsValues) } @@ -429,6 +437,7 @@ class InputLogic( keyboardSwitcher: KeyboardSwitcher, handler: LatinIME.UIHandler ) { + if (mLatinIME.isLiteralMode) return mWordBeingCorrectedByCursor = null mInputLogicHandler.onStartBatchInput() handler.showGesturePreviewAndSetSuggestions(SuggestedWords.getEmptyBatchInstance(), false) @@ -488,6 +497,7 @@ class InputLogic( } fun setSuggestedWords(suggestedWords: SuggestedWords) { + if (mLatinIME.isLiteralMode && !suggestedWords.isEmpty) return if (!suggestedWords.isEmpty) { val suggestedWordInfo = if (suggestedWords.mWillAutoCorrect) { suggestedWords.getInfo(SuggestedWords.INDEX_OF_AUTO_CORRECTION) @@ -1037,7 +1047,7 @@ class InputLogic( if (!isComposingWord && settingsValues.isWordCodePoint(codePoint) - && settingsValues.needsToLookupSuggestions() + && settingsValues.shouldComposeInput() && !settingsValues.isDirectCommitApp && (!settingsValues.mSpacingAndPunctuations.mCurrentLanguageHasSpaces || !mConnection.isCursorTouchingWord(settingsValues.mSpacingAndPunctuations, !mConnection.hasSlowInputConnection()) @@ -1057,7 +1067,7 @@ class InputLogic( var didSetComposingText = false var didExpand = false var shouldDeferSegmentation = false - if (TextExpanderUtils.isEnabled(mLatinIME) + if (!mLatinIME.isLiteralMode && TextExpanderUtils.isEnabled(mLatinIME) && TextExpanderUtils.isImmediateEnabled(mLatinIME) ) { val typedWord = mWordComposer.getTypedWord() @@ -1106,7 +1116,7 @@ class InputLogic( inputTransaction.setRequiresUpdateSuggestions() } } - if (TextExpanderUtils.isEnabled(mLatinIME) + if (!mLatinIME.isLiteralMode && TextExpanderUtils.isEnabled(mLatinIME) && TextExpanderUtils.isImmediateEnabled(mLatinIME) ) { val textBefore = mConnection.getTextBeforeCursor(50, 0) @@ -1225,7 +1235,7 @@ class InputLogic( } else { commitTyped(settingsValues, StringUtils.newSingleCodePointString(codePoint)) } - } else if (TextExpanderUtils.isEnabled(mLatinIME)) { + } else if (!mLatinIME.isLiteralMode && TextExpanderUtils.isEnabled(mLatinIME)) { val textBefore = mConnection.getTextBeforeCursor(50, 0) if (textBefore != null) { val result = TextExpanderUtils.getExpandedWordForTyped(null, textBefore.toString(), mLatinIME) @@ -1409,7 +1419,7 @@ class InputLogic( if (mWordComposer.isComposingWord()) { val typedWord = mWordComposer.getTypedWord() setComposingTextInternal(getTextWithUnderline(typedWord), 1) - if (TextExpanderUtils.isEnabled(mLatinIME) + if (!mLatinIME.isLiteralMode && TextExpanderUtils.isEnabled(mLatinIME) && TextExpanderUtils.isImmediateEnabled(mLatinIME) ) { val textBefore = mConnection.getTextBeforeCursor(50, 0) @@ -1603,6 +1613,7 @@ class InputLogic( } internal fun unlearnWord(word: String, settingsValues: SettingsValues, eventType: Int) { + if (mLatinIME.isLiteralMode) return val ngramContext = mConnection.getNgramContextFromNthPreviousWord(settingsValues.mSpacingAndPunctuations, 2) val timeStampInSeconds = TimeUnit.MILLISECONDS.toSeconds(System.currentTimeMillis()) mDictionaryFacilitator.unlearnFromUserHistory(word, ngramContext, timeStampInSeconds, eventType) @@ -1631,6 +1642,7 @@ class InputLogic( event: Event, inputTransaction: InputTransaction ): Boolean { + if (mLatinIME.isLiteralMode) return false val codePoint = event.codePoint val isFromSuggestionStrip = event.isSuggestionStripPress if (Constants.CODE_ENTER == codePoint @@ -1745,6 +1757,7 @@ class InputLogic( suggestion: String, ngramContext: NgramContext ) { + if (mLatinIME.isLiteralMode) return if (!settingsValues.isSuggestionsEnabledPerUserSettings() || TextUtils.isEmpty(suggestion)) { return } @@ -1935,12 +1948,13 @@ class InputLogic( ) } if (suggestions.size <= 1) { + val generation = suggestionGeneration mInputLogicHandler.getSuggestedWords { getSuggestedWords( SuggestedWords.INPUT_STYLE_TYPING, SuggestedWords.NOT_A_SEQUENCE_NUMBER ) { words -> - if (words != null) doShowSuggestionsAndClearAutoCorrectionIndicator(words) + if (words != null) doShowSuggestionsAndClearAutoCorrectionIndicator(words, generation) } } } else { @@ -1952,9 +1966,12 @@ class InputLogic( } } - private fun doShowSuggestionsAndClearAutoCorrectionIndicator(suggestedWords: SuggestedWords) { + private fun doShowSuggestionsAndClearAutoCorrectionIndicator( + suggestedWords: SuggestedWords, generation: Int = suggestionGeneration + ) { + if (!isSuggestionRequestCurrent(generation)) return mIsAutoCorrectionIndicatorOn = false - mLatinIME.mHandler.setSuggestions(suggestedWords) + mLatinIME.mHandler.setSuggestions(suggestedWords, generation) } private fun revertCommit(inputTransaction: InputTransaction) { @@ -2039,7 +2056,7 @@ class InputLogic( get() = mWordComposer.isComposingWord() fun getCurrentAutoCapsState(settingsValues: SettingsValues): Int { - if (!settingsValues.mAutoCap) return Constants.TextUtils.CAP_MODE_OFF + if (mLatinIME.isLiteralMode || !settingsValues.mAutoCap) return Constants.TextUtils.CAP_MODE_OFF val ei = getCurrentInputEditorInfo() ?: return Constants.TextUtils.CAP_MODE_OFF var inputType = ei.inputType @@ -2217,6 +2234,7 @@ class InputLogic( suggestedWords: SuggestedWords, keyboardSwitcher: KeyboardSwitcher ) { + if (mLatinIME.isLiteralMode) return val batchInputText = if (suggestedWords.isEmpty) null else suggestedWords.getWord(0) if (batchInputText.isNullOrEmpty()) { return @@ -2260,6 +2278,10 @@ class InputLogic( separator: String, handler: LatinIME.UIHandler ) { + if (settingsValues.mLiteralMode) { + commitTyped(settingsValues, separator) + return + } if (handler.hasPendingUpdateSuggestions()) { handler.cancelUpdateSuggestionStrip() performUpdateSuggestionStripSync(settingsValues, SuggestedWords.INPUT_STYLE_TYPING) @@ -2300,7 +2322,7 @@ class InputLogic( startTimeMillis = System.currentTimeMillis() Log.d(TAG, "commitChosenWord() : [$chosenWord]") } - val isEnabled = TextExpanderUtils.isEnabled(mLatinIME) + val isEnabled = !mLatinIME.isLiteralMode && TextExpanderUtils.isEnabled(mLatinIME) if (isEnabled) { val textBefore = mConnection.getTextBeforeCursor(50, 0) if (textBefore != null) { @@ -2399,6 +2421,8 @@ class InputLogic( sequenceNumber: Int, callback: Suggest.OnGetSuggestedWordsCallback ) { + val generation = suggestionGeneration + if (!isSuggestionRequestCurrent(generation)) return val keyboard = KeyboardSwitcher.getInstance().keyboard if (keyboard == null) { callback.onGetSuggestedWords(SuggestedWords.getEmptyInstance()) @@ -2427,9 +2451,10 @@ class InputLogic( settingsValues.mAutoCorrectEnabled, inputStyle, sequenceNumber ) - callback.onGetSuggestedWords(suggestedWords) + if (isSuggestionRequestCurrent(generation)) callback.onGetSuggestedWords(suggestedWords) } catch (e: Exception) { Log.e(TAG, "Error fetching suggested words, using empty words instead", e) + if (!isSuggestionRequestCurrent(generation)) return callback.onGetSuggestedWords(SuggestedWords.getEmptyInstance()) KeyboardSwitcher.getInstance().showToast("Error getting suggestions", true) } diff --git a/app/src/main/java/helium314/keyboard/latin/inputlogic/InputLogicHandler.kt b/app/src/main/java/helium314/keyboard/latin/inputlogic/InputLogicHandler.kt index 56bb81ab0..d046735e5 100644 --- a/app/src/main/java/helium314/keyboard/latin/inputlogic/InputLogicHandler.kt +++ b/app/src/main/java/helium314/keyboard/latin/inputlogic/InputLogicHandler.kt @@ -24,6 +24,8 @@ class InputLogicHandler( private val nonUIThreadHandler: Handler private val lock = Any() private var inBatchInput = false // synchronized using lock. + @Volatile var generation = 0 + private set init { val handlerThread = HandlerThread(InputLogicHandler::class.java.simpleName) @@ -32,7 +34,11 @@ class InputLogicHandler( } fun reset() { - nonUIThreadHandler.removeCallbacksAndMessages(null) + synchronized(lock) { + generation++ + inBatchInput = false + nonUIThreadHandler.removeCallbacksAndMessages(null) + } } /** @@ -83,14 +89,15 @@ class InputLogicHandler( return } inputLogic.wordComposer.setBatchInputPointers(batchPointers) + val requestGeneration = generation getSuggestedWords { inputLogic.getSuggestedWords( if (isTailBatchInput) SuggestedWords.INPUT_STYLE_TAIL_BATCH else SuggestedWords.INPUT_STYLE_UPDATE_BATCH, sequenceNumber, object : Suggest.OnGetSuggestedWordsCallback { override fun onGetSuggestedWords(suggestedWords: SuggestedWords?) { - if (suggestedWords != null) { - showGestureSuggestionsWithPreviewVisuals(suggestedWords, isTailBatchInput) + if (suggestedWords != null && inputLogic.isSuggestionRequestCurrent(requestGeneration)) { + showGestureSuggestionsWithPreviewVisuals(suggestedWords, isTailBatchInput, requestGeneration) } } } @@ -101,7 +108,8 @@ class InputLogicHandler( private fun showGestureSuggestionsWithPreviewVisuals( suggestedWordsForBatchInput: SuggestedWords, - isTailBatchInput: Boolean + isTailBatchInput: Boolean, + requestGeneration: Int ) { // We're now inside the callback. This always runs on the Non-UI thread, // no matter what thread updateBatchInput was originally called on. @@ -114,12 +122,15 @@ class InputLogicHandler( } else { suggestedWordsForBatchInput } - latinIMEHandler.showGesturePreviewAndSetSuggestions(suggestedWordsToShowSuggestions, isTailBatchInput) + latinIMEHandler.showGesturePreviewAndSetSuggestions(suggestedWordsToShowSuggestions, isTailBatchInput, requestGeneration) if (isTailBatchInput) { - inBatchInput = false + synchronized(lock) { + if (!inputLogic.isSuggestionRequestCurrent(requestGeneration)) return + inBatchInput = false + } // The following call schedules onEndBatchInputInternal // to be called on the UI thread. - latinIMEHandler.showTailBatchInputResult(suggestedWordsToShowSuggestions) + latinIMEHandler.showTailBatchInputResult(suggestedWordsToShowSuggestions, requestGeneration) } } @@ -173,7 +184,11 @@ class InputLogicHandler( } fun getSuggestedWords(callback: Runnable) { - nonUIThreadHandler.obtainMessage(MSG_GET_SUGGESTED_WORDS, callback).sendToTarget() + val requestGeneration = generation + val guardedCallback = Runnable { + if (inputLogic.isSuggestionRequestCurrent(requestGeneration)) callback.run() + } + nonUIThreadHandler.obtainMessage(MSG_GET_SUGGESTED_WORDS, guardedCallback).sendToTarget() } companion object { 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 25ef8accb..ab7f82bbc 100644 --- a/app/src/main/java/helium314/keyboard/latin/settings/SettingsValues.kt +++ b/app/src/main/java/helium314/keyboard/latin/settings/SettingsValues.kt @@ -21,6 +21,7 @@ import helium314.keyboard.keyboard.KeyboardActionListener import helium314.keyboard.keyboard.KeyboardTheme import helium314.keyboard.keyboard.internal.keyboard_parser.POPUP_KEYS_NORMAL import helium314.keyboard.latin.InputAttributes +import helium314.keyboard.latin.LatinIME import helium314.keyboard.latin.R import helium314.keyboard.latin.RichInputMethodManager import helium314.keyboard.latin.common.Colors @@ -48,6 +49,7 @@ open class SettingsValues( val mInputAttributes: InputAttributes, currentKeyboardScript: String ) { + val mLiteralMode = LatinIME.getInstance()?.isLiteralMode == true // From resources: val mSpacingAndPunctuations: SpacingAndPunctuations val mDoubleSpacePeriodTimeout: Long @@ -248,7 +250,7 @@ open class SettingsValues( mToolbarKeysAlignment = prefs.getString(Settings.PREF_TOOLBAR_KEYS_ALIGNMENT, fallbackAlign) ?: fallbackAlign mClipboardKeysAlignment = mToolbarKeysAlignment mShowDownloadButtonInToolbar = prefs.getBoolean(Settings.PREF_SHOW_DOWNLOAD_BUTTON_IN_TOOLBAR, Defaults.PREF_SHOW_DOWNLOAD_BUTTON_IN_TOOLBAR) - mAutoCap = prefs.getBoolean(Settings.PREF_AUTO_CAP, Defaults.PREF_AUTO_CAP) && ScriptUtils.scriptSupportsUppercase(mLocale) + mAutoCap = !mLiteralMode && prefs.getBoolean(Settings.PREF_AUTO_CAP, Defaults.PREF_AUTO_CAP) && ScriptUtils.scriptSupportsUppercase(mLocale) mVibrateOn = Settings.readVibrationEnabled(prefs) mVibrateInDndMode = prefs.getBoolean(Settings.PREF_VIBRATE_IN_DND_MODE, Defaults.PREF_VIBRATE_IN_DND_MODE) mSoundOn = prefs.getBoolean(Settings.PREF_SOUND_ON, Defaults.PREF_SOUND_ON) @@ -295,12 +297,12 @@ open class SettingsValues( mEnableClipboardListener = prefs.getBoolean(Settings.PREF_ENABLE_CLIPBOARD_LISTENER, Defaults.PREF_ENABLE_CLIPBOARD_LISTENER) mEnableSmsOtpReceiver = prefs.getBoolean(Settings.PREF_ENABLE_SMS_OTP_RECEIVER, Defaults.PREF_ENABLE_SMS_OTP_RECEIVER) mEnableAppSyncListener = prefs.getBoolean(Settings.PREF_ENABLE_APP_SYNC_LISTENER, Defaults.PREF_ENABLE_APP_SYNC_LISTENER) - mUseDoubleSpacePeriod = prefs.getBoolean(Settings.PREF_KEY_USE_DOUBLE_SPACE_PERIOD, Defaults.PREF_KEY_USE_DOUBLE_SPACE_PERIOD) && mInputAttributes.mIsGeneralTextInput + mUseDoubleSpacePeriod = !mLiteralMode && prefs.getBoolean(Settings.PREF_KEY_USE_DOUBLE_SPACE_PERIOD, Defaults.PREF_KEY_USE_DOUBLE_SPACE_PERIOD) && mInputAttributes.mIsGeneralTextInput mBlockPotentiallyOffensive = prefs.getBoolean(Settings.PREF_BLOCK_POTENTIALLY_OFFENSIVE, Defaults.PREF_BLOCK_POTENTIALLY_OFFENSIVE) mUrlDetectionEnabled = prefs.getBoolean(Settings.PREF_URL_DETECTION, Defaults.PREF_URL_DETECTION) val globalAutoCorrect = prefs.getBoolean(Settings.PREF_AUTO_CORRECTION, Defaults.PREF_AUTO_CORRECTION) val appAutoCorrection = AppQuirksManager.getAutoCorrectionOverride(mInputAttributes.mTargetApplicationPackageName) - mAutoCorrectionEnabledPerUserSettings = when (appAutoCorrection) { + mAutoCorrectionEnabledPerUserSettings = !mLiteralMode && when (appAutoCorrection) { AppQuirksManager.AUTOCORRECT_FORCE_ENABLE -> true AppQuirksManager.AUTOCORRECT_FORCE_DISABLE -> false else -> globalAutoCorrect @@ -323,7 +325,7 @@ open class SettingsValues( 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) 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) + mBigramPredictionEnabled = !mLiteralMode && prefs.getBoolean(Settings.PREF_BIGRAM_PREDICTIONS, Defaults.PREF_BIGRAM_PREDICTIONS) mPrioritizePersonalSuggestions = prefs.getBoolean(Settings.PREF_PRIORITIZE_PERSONAL_SUGGESTIONS, Defaults.PREF_PRIORITIZE_PERSONAL_SUGGESTIONS) mSuggestionBalance = prefs.getInt(Settings.PREF_SUGGESTION_BALANCE, Defaults.PREF_SUGGESTION_BALANCE) @@ -335,7 +337,7 @@ open class SettingsValues( } mNextWordBoostLevel = boostLevel mNextWordStrictNgram = prefs.getBoolean(Settings.PREF_NEXT_WORD_STRICT_NGRAM, Defaults.PREF_NEXT_WORD_STRICT_NGRAM) - mFirstWordPredictionEnabled = prefs.getBoolean(Settings.PREF_FIRST_WORD_PREDICTIONS, Defaults.PREF_FIRST_WORD_PREDICTIONS) + mFirstWordPredictionEnabled = !mLiteralMode && prefs.getBoolean(Settings.PREF_FIRST_WORD_PREDICTIONS, Defaults.PREF_FIRST_WORD_PREDICTIONS) mSuggestPunctuation = prefs.getBoolean(Settings.PREF_SUGGEST_PUNCTUATION, Defaults.PREF_SUGGEST_PUNCTUATION) mSuggestClipboardContent = prefs.getBoolean(Settings.PREF_SUGGEST_CLIPBOARD_CONTENT, Defaults.PREF_SUGGEST_CLIPBOARD_CONTENT) mSuggestScreenshots = prefs.getBoolean(Settings.PREF_SUGGEST_SCREENSHOTS, Defaults.PREF_SUGGEST_SCREENSHOTS) @@ -373,7 +375,7 @@ open class SettingsValues( mSoundVolModifiers = prefs.getFloat(Settings.PREF_SOUND_VOL_MODIFIERS, Defaults.PREF_SOUND_VOL_MODIFIERS) mEnableEmojiAltPhysicalKey = prefs.getBoolean(Settings.PREF_ENABLE_EMOJI_ALT_PHYSICAL_KEY, Defaults.PREF_ENABLE_EMOJI_ALT_PHYSICAL_KEY) mGestureMethod = prefs.getString(Settings.PREF_GESTURE_METHOD, "fallback") ?: "fallback" - mGestureInputEnabled = JniUtils.sHaveGestureLib && prefs.getBoolean(Settings.PREF_GESTURE_INPUT, Defaults.PREF_GESTURE_INPUT) + mGestureInputEnabled = !mLiteralMode && JniUtils.sHaveGestureLib && prefs.getBoolean(Settings.PREF_GESTURE_INPUT, Defaults.PREF_GESTURE_INPUT) mGestureTrailEnabled = prefs.getBoolean(Settings.PREF_GESTURE_PREVIEW_TRAIL, Defaults.PREF_GESTURE_PREVIEW_TRAIL) mGestureFloatingPreviewTextEnabled = !mInputAttributes.mDisableGestureFloatingPreviewText && prefs.getBoolean(Settings.PREF_GESTURE_FLOATING_PREVIEW_TEXT, Defaults.PREF_GESTURE_FLOATING_PREVIEW_TEXT) mGestureFloatingPreviewDynamicEnabled = Settings.readGestureDynamicPreviewEnabled(prefs) @@ -386,7 +388,7 @@ open class SettingsValues( mOverrideShowingSuggestions = mInputAttributes.mMayOverrideShowingSuggestions && (prefs.getBoolean(Settings.PREF_ALWAYS_SHOW_SUGGESTIONS, Defaults.PREF_ALWAYS_SHOW_SUGGESTIONS) || (moreAutoCorrection && !isUriOrEmail)) && ((mInputAttributes.mInputType and InputType.TYPE_MASK_VARIATION) != InputType.TYPE_TEXT_VARIATION_WEB_EDIT_TEXT || !prefs.getBoolean(Settings.PREF_ALWAYS_SHOW_SUGGESTIONS_EXCEPT_WEB_TEXT, Defaults.PREF_ALWAYS_SHOW_SUGGESTIONS_EXCEPT_WEB_TEXT)) val suggestionsEnabled = prefs.getBoolean(Settings.PREF_SHOW_SUGGESTIONS, Defaults.PREF_SHOW_SUGGESTIONS) - mSuggestionsEnabledPerUserSettings = suggestionsEnabled && (mInputAttributes.mShouldShowSuggestions || mOverrideShowingSuggestions) && !mSuggestionStripHiddenPerUserSettings + mSuggestionsEnabledPerUserSettings = !mLiteralMode && suggestionsEnabled && (mInputAttributes.mShouldShowSuggestions || mOverrideShowingSuggestions) && !mSuggestionStripHiddenPerUserSettings mSecondaryStripVisible = mToolbarMode != ToolbarMode.HIDDEN || !mToolbarHidingGlobal mIncognitoModeEnabled = if (AppQuirksManager.isNonIncognitoApp(mInputAttributes.mTargetApplicationPackageName)) { mInputAttributes.mIsPasswordField @@ -400,14 +402,14 @@ open class SettingsValues( mLanguageSwipeDistance = prefs.getInt(Settings.PREF_LANGUAGE_SWIPE_DISTANCE, Defaults.PREF_LANGUAGE_SWIPE_DISTANCE) mTouchpadSensitivity = prefs.getInt(Settings.PREF_TOUCHPAD_SENSITIVITY, Defaults.PREF_TOUCHPAD_SENSITIVITY) mTouchpadFullscreen = prefs.getBoolean(Settings.PREF_TOUCHPAD_FULLSCREEN, Defaults.PREF_TOUCHPAD_FULLSCREEN) - mForceAutoCaps = prefs.getBoolean(Settings.PREF_FORCE_AUTO_CAPS, Defaults.PREF_FORCE_AUTO_CAPS) + mForceAutoCaps = !mLiteralMode && prefs.getBoolean(Settings.PREF_FORCE_AUTO_CAPS, Defaults.PREF_FORCE_AUTO_CAPS) mDeleteSwipeEnabled = prefs.getBoolean(Settings.PREF_DELETE_SWIPE, Defaults.PREF_DELETE_SWIPE) - mAutospaceAfterPunctuation = prefs.getBoolean(Settings.PREF_AUTOSPACE_AFTER_PUNCTUATION, Defaults.PREF_AUTOSPACE_AFTER_PUNCTUATION) - mAutospaceAfterEmoji = prefs.getBoolean(Settings.PREF_AUTOSPACE_AFTER_EMOJI, Defaults.PREF_AUTOSPACE_AFTER_EMOJI) - mAutospaceAfterSuggestion = prefs.getBoolean(Settings.PREF_AUTOSPACE_AFTER_SUGGESTION, Defaults.PREF_AUTOSPACE_AFTER_SUGGESTION) - mImmediateAutoSpace = prefs.getBoolean(Settings.PREF_IMMEDIATE_AUTO_SPACE, Defaults.PREF_IMMEDIATE_AUTO_SPACE) - mAutospaceAfterGestureTyping = prefs.getBoolean(Settings.PREF_AUTOSPACE_AFTER_GESTURE_TYPING, Defaults.PREF_AUTOSPACE_AFTER_GESTURE_TYPING) - mAutospaceBeforeGestureTyping = prefs.getBoolean(Settings.PREF_AUTOSPACE_BEFORE_GESTURE_TYPING, Defaults.PREF_AUTOSPACE_BEFORE_GESTURE_TYPING) + mAutospaceAfterPunctuation = !mLiteralMode && prefs.getBoolean(Settings.PREF_AUTOSPACE_AFTER_PUNCTUATION, Defaults.PREF_AUTOSPACE_AFTER_PUNCTUATION) + mAutospaceAfterEmoji = !mLiteralMode && prefs.getBoolean(Settings.PREF_AUTOSPACE_AFTER_EMOJI, Defaults.PREF_AUTOSPACE_AFTER_EMOJI) + mAutospaceAfterSuggestion = !mLiteralMode && prefs.getBoolean(Settings.PREF_AUTOSPACE_AFTER_SUGGESTION, Defaults.PREF_AUTOSPACE_AFTER_SUGGESTION) + mImmediateAutoSpace = !mLiteralMode && prefs.getBoolean(Settings.PREF_IMMEDIATE_AUTO_SPACE, Defaults.PREF_IMMEDIATE_AUTO_SPACE) + mAutospaceAfterGestureTyping = !mLiteralMode && prefs.getBoolean(Settings.PREF_AUTOSPACE_AFTER_GESTURE_TYPING, Defaults.PREF_AUTOSPACE_AFTER_GESTURE_TYPING) + mAutospaceBeforeGestureTyping = !mLiteralMode && prefs.getBoolean(Settings.PREF_AUTOSPACE_BEFORE_GESTURE_TYPING, Defaults.PREF_AUTOSPACE_BEFORE_GESTURE_TYPING) mShiftRemovesAutospace = prefs.getBoolean(Settings.PREF_SHIFT_REMOVES_AUTOSPACE, Defaults.PREF_SHIFT_REMOVES_AUTOSPACE) mPreserveSpaceBeforePunctuation = prefs.getBoolean(Settings.PREF_PRESERVE_SPACE_BEFORE_PUNCTUATION, Defaults.PREF_PRESERVE_SPACE_BEFORE_PUNCTUATION) mClipboardHistoryEnabled = prefs.getBoolean(Settings.PREF_ENABLE_CLIPBOARD_HISTORY, Defaults.PREF_ENABLE_CLIPBOARD_HISTORY) @@ -468,13 +470,15 @@ open class SettingsValues( } fun isApplicationSpecifiedCompletionsOn(): Boolean { - return mInputAttributes.mApplicationSpecifiedCompletionOn + return !mLiteralMode && mInputAttributes.mApplicationSpecifiedCompletionOn } fun needsToLookupSuggestions(): Boolean { - return (mInputAttributes.mShouldShowSuggestions || mOverrideShowingSuggestions || isSymbolComposingApp) && (mAutoCorrectEnabled || mSuggestionsEnabledPerUserSettings || isSymbolComposingApp) + return !mLiteralMode && (mInputAttributes.mShouldShowSuggestions || mOverrideShowingSuggestions || isSymbolComposingApp) && (mAutoCorrectEnabled || mSuggestionsEnabledPerUserSettings || isSymbolComposingApp) } + fun shouldComposeInput(): Boolean = mLiteralMode || needsToLookupSuggestions() + fun isSuggestionsEnabledPerUserSettings(): Boolean { return mSuggestionsEnabledPerUserSettings } @@ -503,7 +507,7 @@ open class SettingsValues( val isAutoSpaceDisabled: Boolean = AppQuirksManager.isAutoSpaceDisabled(mInputAttributes.mTargetApplicationPackageName) fun shouldInsertSpacesAutomatically(): Boolean { - return mInputAttributes.mShouldInsertSpacesAutomatically && !isAutoSpaceDisabled + return !mLiteralMode && mInputAttributes.mShouldInsertSpacesAutomatically && !isAutoSpaceDisabled } fun isLanguageSwitchKeyEnabled(): Boolean { diff --git a/app/src/main/java/helium314/keyboard/latin/utils/ToolbarUtils.kt b/app/src/main/java/helium314/keyboard/latin/utils/ToolbarUtils.kt index 451e78caf..14c0606d5 100644 --- a/app/src/main/java/helium314/keyboard/latin/utils/ToolbarUtils.kt +++ b/app/src/main/java/helium314/keyboard/latin/utils/ToolbarUtils.kt @@ -18,6 +18,7 @@ import helium314.keyboard.keyboard.internal.KeyboardIconsSet import helium314.keyboard.keyboard.internal.keyboard_parser.floris.KeyCode import helium314.keyboard.latin.BuildConfig import helium314.keyboard.latin.R +import helium314.keyboard.latin.LatinIME import helium314.keyboard.latin.common.Constants.Separators import helium314.keyboard.latin.common.ColorType import helium314.keyboard.latin.settings.Defaults @@ -202,12 +203,13 @@ fun setToolbarButtonActivatedState(button: ImageButton) { ONE_HANDED -> Settings.getValues().mOneHandedModeEnabled SPLIT -> Settings.getValues().mIsSplitKeyboardEnabled AUTOCORRECT -> Settings.getValues().mAutoCorrectionEnabledPerUserSettings + LITERAL -> LatinIME.getInstance()?.isLiteralMode == true SELECT_MODE -> helium314.keyboard.keyboard.KeyboardActionListenerImpl.sPersistentSelectionModeActive else -> true } button.isActivated = activated val colors = Settings.getValues().mColors - if (activated && button.tag in listOf(INCOGNITO, ONE_HANDED, SPLIT, AUTOCORRECT, SELECT_MODE)) { + if (activated && button.tag in listOf(INCOGNITO, ONE_HANDED, SPLIT, AUTOCORRECT, LITERAL, SELECT_MODE)) { colors.setColor(button.background, ColorType.TOOL_BAR_KEY_ENABLED_BACKGROUND) if (button.drawable != null) { button.clearColorFilter() @@ -243,6 +245,7 @@ fun getCodeForToolbarKey(key: ToolbarKey) = Settings.getInstance().getCustomTool TOUCHPAD -> KeyCode.TOGGLE_TOUCHPAD_MODE TEXT_EDIT -> KeyCode.TOGGLE_TEXT_EDIT_MODE AUTOCORRECT -> KeyCode.TOGGLE_AUTOCORRECT + LITERAL -> KeyCode.TOGGLE_LITERAL_MODE CLEAR_CLIPBOARD -> KeyCode.CLIPBOARD_CLEAR_HISTORY CLOSE_HISTORY -> KeyCode.ALPHA EMOJI -> KeyCode.EMOJI @@ -299,7 +302,7 @@ fun getCodeForToolbarKeyLongClick(key: ToolbarKey) = Settings.getInstance().getC // names need to be aligned with resources strings (using lowercase of key.name) enum class ToolbarKey { VOICE, CLIPBOARD, CLIPBOARD_SEARCH, NUMPAD, HANDWRITING, UNDO, REDO, SETTINGS, SELECT_ALL, SELECT_WORD, COPY, CUT, PASTE, ONE_HANDED, SPLIT, FLOATING, - INCOGNITO, TOUCHPAD, TEXT_EDIT, AUTOCORRECT, CLEAR_CLIPBOARD, CLOSE_HISTORY, EMOJI, LEFT, RIGHT, UP, DOWN, WORD_LEFT, WORD_RIGHT, + INCOGNITO, TOUCHPAD, TEXT_EDIT, AUTOCORRECT, LITERAL, CLEAR_CLIPBOARD, CLOSE_HISTORY, EMOJI, LEFT, RIGHT, UP, DOWN, WORD_LEFT, WORD_RIGHT, PAGE_UP, PAGE_DOWN, FULL_LEFT, FULL_RIGHT, PAGE_START, PAGE_END, PROOFREAD, TRANSLATE, OCR, SELECT_MODE, CUSTOM_AI_1, CUSTOM_AI_2, CUSTOM_AI_3, CUSTOM_AI_4, CUSTOM_AI_5, CUSTOM_AI_6, CUSTOM_AI_7, CUSTOM_AI_8, CUSTOM_AI_9, CUSTOM_AI_10 diff --git a/app/src/main/res/drawable/ic_literal.xml b/app/src/main/res/drawable/ic_literal.xml new file mode 100644 index 000000000..36af1d827 --- /dev/null +++ b/app/src/main/res/drawable/ic_literal.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f6071907c..f2575dcc5 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -149,6 +149,7 @@ Do not suggest potentially offensive words Auto-correction + Literal typing Spacebar and punctuation automatically correct mistyped words Auto-correct on diff --git a/app/src/test/java/helium314/keyboard/KeyboardParserTest.kt b/app/src/test/java/helium314/keyboard/KeyboardParserTest.kt index 5af7ad7a4..c77682a99 100644 --- a/app/src/test/java/helium314/keyboard/KeyboardParserTest.kt +++ b/app/src/test/java/helium314/keyboard/KeyboardParserTest.kt @@ -407,6 +407,23 @@ f""", // no newline at the end assertEquals(KeyCode.UNDO, key.toKeyParams(params).mPopupKeys?.first()?.mCode) } + @Test fun literalToolbarKey() { + assertIsExpected("""[[{ "label": "literal" }]]""", + Expected(KeyCode.TOGGLE_LITERAL_MODE, icon = "literal")) + assertIsExpected("""[[{ "code": -10078, "label": "Raw" }]]""", + Expected(KeyCode.TOGGLE_LITERAL_MODE, label = "Raw")) + } + + @Test fun literalPopupToolbarKey() { + val key = LayoutParser.parseJsonString("""[[{ "label": "x", "popup": { + "main": { "label": "literal" } + } }]]""").map { row -> row.mapNotNull { it.compute(params) } }.flatten().single() + val popup = key.toKeyParams(params).mPopupKeys?.first() + assertEquals(null, popup?.mLabel) + assertEquals("literal", popup?.mIconName) + assertEquals(KeyCode.TOGGLE_LITERAL_MODE, popup?.mCode) + } + @Test fun popupKeyWithIconAndImplicitText() { val key = LayoutParser.parseJsonString("""[[{ "label": "a", "popup": { "relevant": [ { "label": "!icon/go_key|aa" } diff --git a/app/src/test/java/helium314/keyboard/latin/InputLogicTest.kt b/app/src/test/java/helium314/keyboard/latin/InputLogicTest.kt index d9b5b2324..b91e5cdf1 100644 --- a/app/src/test/java/helium314/keyboard/latin/InputLogicTest.kt +++ b/app/src/test/java/helium314/keyboard/latin/InputLogicTest.kt @@ -13,6 +13,9 @@ import org.junit.Ignore import helium314.keyboard.ShadowInputMethodManager2 import helium314.keyboard.ShadowLocaleManagerCompat import helium314.keyboard.event.Event +import helium314.keyboard.compat.AppQuirk +import helium314.keyboard.compat.AppQuirksManager +import helium314.keyboard.keyboard.Keyboard import helium314.keyboard.keyboard.KeyboardSwitcher import helium314.keyboard.keyboard.MainKeyboardView import helium314.keyboard.keyboard.internal.keyboard_parser.floris.KeyCode @@ -26,10 +29,15 @@ import helium314.keyboard.latin.common.LocaleUtils.constructLocale import helium314.keyboard.latin.common.StringUtils import helium314.keyboard.latin.dictionary.Dictionary import helium314.keyboard.latin.inputlogic.InputLogic +import helium314.keyboard.latin.inputlogic.InputLogicHandler import helium314.keyboard.latin.inputlogic.SpaceState import helium314.keyboard.latin.settings.Settings import helium314.keyboard.latin.utils.ScriptUtils import helium314.keyboard.latin.utils.SubtypeSettings +import helium314.keyboard.latin.utils.TextExpanderUtils +import helium314.keyboard.latin.utils.ToolbarKey +import helium314.keyboard.latin.utils.createToolbarKey +import helium314.keyboard.latin.suggestions.SuggestionStripView import helium314.keyboard.latin.utils.getTimestampFormatter import helium314.keyboard.latin.utils.prefs import org.junit.runner.RunWith @@ -89,6 +97,283 @@ class InputLogicTest { assertEquals("", composingText) } + @Test fun literalActionPreservesTypedCompositionAndRestoresPreferences() { + reset() + setInputType(InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_FLAG_AUTO_CORRECT) + typeNoAssert("cps") + composer.setAutoCorrection( + SuggestedWordInfo("CPP", "", 0, 0, Mockito.mock(Dictionary::class.java), 0, 0) + ) + val prefsBefore = latinIME.prefs().all.toMap() + val cursorBefore = cursor + toggleLiteral() + assertEquals("cps", text) + assertEquals(cursorBefore, cursor) + assertEquals("", composingText) + assertEquals(false, settingsValues.mAutoCorrectEnabled) + assertEquals(false, settingsValues.mAutoCap) + assertEquals(false, settingsValues.isSuggestionsEnabledPerUserSettings()) + assertEquals(false, settingsValues.mGestureInputEnabled) + assertEquals(prefsBefore, latinIME.prefs().all) + toggleLiteral() + assertEquals("cps", text) + assertEquals(cursorBefore, cursor) + assertEquals(true, settingsValues.mAutoCorrectEnabled) + assertEquals(prefsBefore, latinIME.prefs().all) + } + + @Test fun literalTypingKeepsCaseSpacesPunctuationAndDoesNotExpandOrLearn() { + configureExpansion("cpp", true) + setInputType(InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_FLAG_MULTI_LINE) + editorInfoOverride = EditorInfo().apply { + inputType = currentInputType + imeOptions = EditorInfo.IME_ACTION_NONE + } + latinIME.prefs().edit { + putBoolean(Settings.PREF_AUTO_CAP, true) + putBoolean(Settings.PREF_FORCE_AUTO_CAPS, true) + putBoolean(Settings.PREF_KEY_USE_DOUBLE_SPACE_PERIOD, true) + putBoolean(Settings.PREF_AUTOSPACE_AFTER_PUNCTUATION, true) + } + toggleLiteral() + val literal = "cpp https://Host/x_Y?ID=ABC C:\\Src\\a.py .\n" + typeNoAssert(literal) + assertEquals(literal, text) + assertEquals(emptyList(), addedWords) + checkConnectionConsistency() + } + + private fun toggleLiteral() { + latinIME.keyboardActionListener.onCodeInput(KeyCode.TOGGLE_LITERAL_MODE, Constants.NOT_A_COORDINATE, Constants.NOT_A_COORDINATE, false) + handleMessages() + checkConnectionConsistency() + } + + private fun configureExpansion(shortcut: String, immediate: Boolean) { + reset() + latinIME.prefs().edit { + putBoolean(TextExpanderUtils.PREF_ENABLED, true) + putBoolean(TextExpanderUtils.PREF_IMMEDIATE, immediate) + putBoolean(TextExpanderUtils.PREF_BACKSPACE_REVERTS, true) + } + TextExpanderUtils.saveShortcuts( + latinIME, mapOf(shortcut to TextExpanderUtils.ShortcutEntry("EXPANDED")) + ) + } + + @Test fun literalModePreservesHangulCompositionAndExplicitEditing() { + reset() + currentScript = ScriptUtils.SCRIPT_HANGUL + latinIME.switchToSubtype(SubtypeSettings.getResourceSubtypesForLocale("ko".constructLocale()).first()) + toggleLiteral() + typeNoAssert("ㅎㅏㄴ") + assertEquals("한", java.text.Normalizer.normalize(text, java.text.Normalizer.Form.NFC)) + assertEquals(true, composer.isComposingWord()) + functionalKeyPress(KeyCode.DELETE) + assertEquals("하", java.text.Normalizer.normalize(text, java.text.Normalizer.Form.NFC)) + latinIME.onTextInput("PASTED") + handleMessages() + assertEquals("하PASTED", java.text.Normalizer.normalize(text, java.text.Normalizer.Form.NFC)) + assertEquals(emptyList(), addedWords) + } + + @Test fun literalModeRejectsCompletionsManualStalePicksAndGlideResults() { + reset() + typeNoAssert("raw") + toggleLiteral() + val word = SuggestedWordInfo("REPLACED", "", 0, 0, Mockito.mock(Dictionary::class.java), 0, 0) + val words = SuggestedWords(arrayListOf(word), null, word, true, true, false, 0, 0) + latinIME.onDisplayCompletions(arrayOf(CompletionInfo(1, 0, "REPLACED"))) + latinIME.setSuggestions(words) + latinIME.pickSuggestionManually(word) + inputLogic.setSuggestedWords(words) + inputLogic.onStartBatchInput(settingsValues, latinIME.keyboardSwitcher, latinIME.handler) + inputLogic.onUpdateTailBatchInputCompleted(settingsValues, words, latinIME.keyboardSwitcher) + assertEquals("raw", text) + assertEquals(true, inputLogic.suggestedWords.isEmpty) + assertEquals(SpaceState.NONE, spaceState) + } + + @Test fun literalModeSurvivesHideAndSameFieldRestartButNotNewEditorOrFinish() { + reset() + fun editor(id: Int = 42, input: Int = currentInputType) = EditorInfo().apply { + packageName = "test.literal.editor" + fieldId = id + inputType = input + } + fun start(info: EditorInfo, restarting: Boolean) { + editorInfoOverride = info + latinIME.onStartInputInternal(info, restarting) + latinIME.onStartInputViewInternal(info, restarting) + } + start(editor(), false) + toggleLiteral() + latinIME.onFinishInputViewInternal(false) + latinIME.onStartInputViewInternal(requireNotNull(editorInfoOverride), true) + assertEquals(true, latinIME.isLiteralMode) + start(editor(), true) + assertEquals(true, latinIME.isLiteralMode) + start(editor(43), true) + assertEquals(false, latinIME.isLiteralMode) + toggleLiteral() + start(editor(43, InputType.TYPE_CLASS_NUMBER), true) + assertEquals(false, latinIME.isLiteralMode) + start(editor(0), false) + toggleLiteral() + start(editor(0), true) // Same connection is positive evidence for a zero-ID restart. + assertEquals(true, latinIME.isLiteralMode) + inputConnectionOverride = InputConnectionWrapper(ic, false) + start(editor(0), true) + assertEquals(false, latinIME.isLiteralMode) + toggleLiteral() + start(editor(0), false) + assertEquals(false, latinIME.isLiteralMode) + toggleLiteral() + latinIME.onFinishInputInternal() + assertEquals(false, latinIME.isLiteralMode) + start(editor(), false) + toggleLiteral() + latinIME.onDestroy() + assertEquals(false, latinIME.isLiteralMode) + } + + @Test fun literalModeOverridesForcedAppCorrectionAcrossSettingsReloadWithoutChangingPrefs() { + reset() + val packageName = "test.literal.forced" + editorInfoOverride = EditorInfo().apply { + inputType = currentInputType + this.packageName = packageName + } + AppQuirksManager.saveQuirk(AppQuirk(packageName, autoCorrectionMode = AppQuirksManager.AUTOCORRECT_FORCE_ENABLE)) + try { + latinIME.onStartInputInternal(editorInfoOverride, false) + latinIME.onStartInputViewInternal(requireNotNull(editorInfoOverride), false) + val personalized = settingsValues.mUsePersonalizedDicts + val prefsBefore = latinIME.prefs().all.toMap() + assertEquals(true, settingsValues.mAutoCorrectEnabled) + toggleLiteral() + latinIME.settings.loadSettings(latinIME, Locale.US, settingsValues.mInputAttributes, currentScript) + assertEquals(true, latinIME.isLiteralMode) + assertEquals(false, settingsValues.mAutoCorrectEnabled) + assertEquals(personalized, settingsValues.mUsePersonalizedDicts) + assertEquals(prefsBefore, latinIME.prefs().all) + toggleLiteral() + assertEquals(true, settingsValues.mAutoCorrectEnabled) + assertEquals(prefsBefore, latinIME.prefs().all) + } finally { + AppQuirksManager.removeQuirk(packageName) + } + } + + @Test fun literalModeInvalidatesQueuedWorkerAndUiTailAcrossOnOff() { + reset() + val worker = InputLogic::class.java.getDeclaredField("mInputLogicHandler").apply { isAccessible = true } + .get(inputLogic) as InputLogicHandler + var workerCalls = 0 + worker.getSuggestedWords { workerCalls++ } + val queued = messages.last().obj as Runnable + val word = SuggestedWordInfo("STALE", "", 0, 0, Mockito.mock(Dictionary::class.java), 0, 0) + val words = SuggestedWords(arrayListOf(word), null, word, true, true, false, 0, 0) + latinIME.handler.setSuggestions(words) + val queuedSuggestions = Message.obtain(messages.last()) + latinIME.handler.showTailBatchInputResult(words, inputLogic.suggestionGeneration) + val queuedTail = Message.obtain(messages.last()) + toggleLiteral() + toggleLiteral() + queued.run() + latinIME.handler.handleMessage(queuedSuggestions) + latinIME.handler.handleMessage(queuedTail) + assertEquals(0, workerCalls) + assertEquals("", text) + assertEquals(true, inputLogic.suggestedWords.isEmpty) + queuedSuggestions.recycle() + queuedTail.recycle() + } + + @Test fun literalModeDiscardsRunningSuggestionResultEvenAfterTurningOff() { + reset() + latinIME.prefs().edit { + putBoolean(Settings.PREF_FIRST_WORD_PREDICTIONS, false) + putBoolean(Settings.PREF_BIGRAM_PREDICTIONS, false) + } + val view = Mockito.mock(MainKeyboardView::class.java) + Mockito.`when`(view.keyboard).thenReturn(Mockito.mock(Keyboard::class.java, Mockito.RETURNS_DEEP_STUBS)) + val viewField = KeyboardSwitcher::class.java.getDeclaredField("mKeyboardView").apply { isAccessible = true } + viewField.set(latinIME.keyboardSwitcher, view) + var computations = 0 + val suggest = Mockito.mock(Suggest::class.java) { invocation -> + if (invocation.method.name == "getSuggestedWords") { + computations++ + if (computations == 1) { + toggleLiteral() + toggleLiteral() + } + SuggestedWords.getEmptyInstance() + } else Mockito.RETURNS_DEFAULTS.answer(invocation) + } + InputLogic::class.java.getDeclaredField("mSuggest").apply { isAccessible = true }.set(inputLogic, suggest) + var delivered = 0 + try { + inputLogic.getSuggestedWords(SuggestedWords.INPUT_STYLE_TYPING, 1) { delivered++ } + assertEquals(1, computations) + assertEquals(0, delivered) + assertEquals(false, latinIME.isLiteralMode) + } finally { + viewField.set(latinIME.keyboardSwitcher, null) + } + } + + @Test fun literalModeKeepsExplicitCaseEmojiAndTextKeysWithoutAutomaticSpacingOrTldRewriting() { + reset() + toggleLiteral() + assertEquals(Constants.TextUtils.CAP_MODE_OFF, latinIME.inputLogic.getCurrentAutoCapsState(Settings.getInstance().current)) + latinIME.keyboardActionListener.onCodeInput('A'.code, 0, 0, false) + latinIME.keyboardActionListener.onCodeInput('b'.code, 0, 0, false) + latinIME.onTextInput(".") + latinIME.onTextInput(".com") + latinIME.onTextInput("🙂") + latinIME.keyboardActionListener.onCodeInput('C'.code, 0, 0, false) + handleMessages() + assertEquals("Ab..com🙂C", text) + assertEquals(SpaceState.NONE, spaceState) + assertEquals(emptyList(), addedWords) + checkConnectionConsistency() + } + + @Test fun literalToolbarAndPinnedButtonsShareDispatchAndActivation() { + reset() + val strip = SuggestionStripView(latinIME, null) + val keyboardView = MainKeyboardView(latinIME, null).apply { id = R.id.keyboard_view } + strip.setListener(latinIME, keyboardView) + LatinIME::class.java.getDeclaredField("suggestionStripView").apply { isAccessible = true }.set(latinIME, strip) + val toolbar = strip.findViewById(R.id.toolbar) + val pinned = strip.findViewById(R.id.pinned_keys) + toolbar.removeAllViews() + pinned.removeAllViews() + val mainButton = createToolbarKey(latinIME, ToolbarKey.LITERAL) + val pinnedButton = createToolbarKey(latinIME, ToolbarKey.LITERAL) + val correctionButton = createToolbarKey(latinIME, ToolbarKey.AUTOCORRECT) + toolbar.addView(mainButton) + toolbar.addView(correctionButton) + pinned.addView(pinnedButton) + mainButton.setOnClickListener(strip) + pinnedButton.setOnClickListener(strip) + val prefsBefore = latinIME.prefs().all.toMap() + assertEquals("Literal typing", mainButton.contentDescription) + assertEquals(false, mainButton.isActivated) + mainButton.performClick() + assertEquals(true, latinIME.isLiteralMode) + assertEquals(true, mainButton.isActivated) + assertEquals(true, pinnedButton.isActivated) + assertEquals(false, correctionButton.isActivated) + assertEquals(true, createToolbarKey(latinIME, ToolbarKey.LITERAL).isActivated) + pinnedButton.performClick() + assertEquals(false, mainButton.isActivated) + assertEquals(false, pinnedButton.isActivated) + assertEquals(true, correctionButton.isActivated) + assertEquals(prefsBefore, latinIME.prefs().all) + } + @Test fun `english space-separated typing keeps composing word`() { reset() chainInput("hello") @@ -1260,6 +1545,8 @@ class InputLogicTest { // should be called before every test, so the same state is guaranteed private fun reset() { + editorInfoOverride = null + inputConnectionOverride = null // reset input connection & facilitator currentScript = ScriptUtils.SCRIPT_LATIN text = "" @@ -1703,18 +1990,21 @@ private val ic = object : InputConnection { // could also extend LatinIME, it's not final anyway @Implements(InputMethodService::class) -class ShadowInputMethodService { +class ShadowInputMethodService : org.robolectric.shadows.ShadowService() { @Implementation - fun getCurrentInputEditorInfo() = EditorInfo().apply { + fun getCurrentInputEditorInfo() = editorInfoOverride ?: EditorInfo().apply { inputType = currentInputType // anything else? } @Implementation - fun getCurrentInputConnection() = ic + fun getCurrentInputConnection(): InputConnection = inputConnectionOverride ?: ic @Implementation fun isInputViewShown() = true // otherwise selection updates will do nothing } +private var editorInfoOverride: EditorInfo? = null +private var inputConnectionOverride: InputConnection? = null + @Implements(Handler::class) class ShadowHandler { @Implementation diff --git a/docs/FEATURES.md b/docs/FEATURES.md index 797ce075f..6bbceaa9d 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -347,6 +347,16 @@ LeanType features an advanced **Native IME Floating Window** architecture that r Split your toolbar and suggestion strip into two independent rows for fast, unhindered access to both word predictions and quick actions. - Configure via **Settings → Appearance → Split toolbar & suggestions**. +### Optional Literal typing + +For URLs, code, or other text that should stay as typed, add **Literal typing** in **Settings → Toolbar → Toolbar keys** (or **Pinned toolbar keys**, where available). It is off by default and is not automatically added to your enabled toolbar actions. + +Tap the action to turn it on for the current field; tap again to restore normal typing. The highlighted toolbar and pinned buttons share the same state. A toggle finishes any pending composition as typed, without applying a correction or moving the cursor. The mode resets when input finishes or a new field starts; hiding the keyboard and identifiable same-field restarts preserve it. An ambiguous restart without a stable field identity resets it rather than carrying it to another field. + +While active, Literal typing suppresses automatic capitalization (including Force auto-caps), auto-correction (including per-app force-enable), word predictions, application completions, automatic spacing, double-space periods, text expansion, word-glide typing, and dictionary learning. Manual Shift/caps, script composition such as Hangul, explicit editing, paste, emoji insertion, and the editor's Enter action remain available. Normal typing preferences and learned data are not changed or deleted. + +Custom JSON layouts can use `{"label":"literal"}` or `{"code":-10078,"label":"Raw"}`. This is an explicit per-field mode, not automatic URL/code detection or a fix for all capitalization or contraction behavior. + --- ## 12. Versatile Text Expander & Modifiers @@ -716,6 +726,7 @@ LeanType provides an extensible keycode architecture that allows customizing key | **`-10075`** | `CLEAR_HANDWRITING` | Clears all current drawing strokes from the handwriting canvas. | | **`-10077`** | `OCR` | Opens the live camera viewfinder and screenshot OCR scanner. | | **`-10076`** | `SWITCH_TO_USER_IME` | Instantly switches to the user's configured target IME keyboard. | +| **`-10078`** | `TOGGLE_LITERAL_MODE` | Toggles optional Literal typing for the current field; JSON keyword `literal`. | --- @@ -829,4 +840,3 @@ LeanType provides an extensible keycode architecture that allows customizing key | **`-10008`** | `NOT_SPECIFIED` | Structural dummy spacer key (disables key interaction, renders blank). | -