diff --git a/CHANGELOG.md b/CHANGELOG.md index c40eff41..6128d3bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -94,3 +94,5 @@ Emojis for the following are chosen based on [gitmoji](https://gitmoji.dev/). ### ♻️ Code Refactoring - Code quality improvements were continuously done to assure that the application is easy to maintain and meets Kotlin standards ([#426](https://github.com/scribe-org/Scribe-Android/issues/426)). +- `CommandHandler` was extracted from `GeneralKeyboardIME` to encapsulate command execution, Enter key dispatching, and lookup state machine logic ([#426](https://github.com/scribe-org/Scribe-Android/issues/426)). + diff --git a/app/src/keyboards/java/be/scri/helpers/CommandHandler.kt b/app/src/keyboards/java/be/scri/helpers/CommandHandler.kt new file mode 100644 index 00000000..9c09f0a5 --- /dev/null +++ b/app/src/keyboards/java/be/scri/helpers/CommandHandler.kt @@ -0,0 +1,180 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package be.scri.helpers + +import android.view.KeyEvent +import android.view.inputmethod.EditorInfo.IME_ACTION_NONE +import android.view.inputmethod.InputConnection +import be.scri.helpers.LanguageMappingConstants.getLanguageAlias +import be.scri.helpers.english.ENInterfaceVariables.ALREADY_PLURAL_MSG +import be.scri.models.ScribeState +import be.scri.services.GeneralKeyboardIME +import be.scri.services.GeneralKeyboardIME.Companion.COMMIT_TEXT_CURSOR_POSITION + +/** + * Encapsulates command execution logic, Enter key dispatching, + * lookup state machine transitions, and command output formatting. + */ +class CommandHandler( + private val ime: GeneralKeyboardIME, +) { + /** + * Handles the logic for the Enter key press. This can either perform an editor action, + * commit a newline, or execute a Scribe command depending on the current state. + */ + fun handleKeycodeEnter() { + val inputConnection = ime.currentInputConnection ?: return + + if (ime.currentState == ScribeState.INVALID || ime.currentState == ScribeState.ALREADY_PLURAL) { + ime.moveToIdleState() + return + } + + if (ime.currentState == ScribeState.IDLE || ime.currentState == ScribeState.SELECT_COMMAND) { + handleDefaultEnter(inputConnection) + return + } + + val rawInput = + ime.uiManager + .getCommandBarTextWithoutCursor() + .trim() + .takeIf { it.isNotEmpty() } + + if (rawInput == null) { + ime.moveToIdleState() + } else { + when (ime.currentState) { + ScribeState.PLURAL, ScribeState.TRANSLATE -> handlePluralOrTranslateState(rawInput, inputConnection) + ScribeState.CONJUGATE -> handleConjugateState(rawInput) + else -> handleDefaultEnter(inputConnection) + } + } + } + + /** + * Handles the Enter key press when in the plural or translate state. + * + * @param rawInput The text from the command bar. + * @param inputConnection The current input connection. + */ + fun handlePluralOrTranslateState( + rawInput: String, + inputConnection: InputConnection, + ) { + val isAllCaps = rawInput.isNotEmpty() && rawInput.all { !it.isLetter() || it.isUpperCase() } + + val commandModeOutput = + when (ime.currentState) { + ScribeState.PLURAL -> { + when (val pluralResult = ime.getPluralRepresentation(rawInput)) { + ALREADY_PLURAL_MSG -> { + ime.currentState = ScribeState.ALREADY_PLURAL + ime.refreshUI() + return + } + + null -> "" + else -> if (isAllCaps) pluralResult.uppercase() else pluralResult + } + } + + ScribeState.TRANSLATE -> { + val translation = ime.getTranslation(ime.language, rawInput) + if (isAllCaps) translation.uppercase() else translation + } + + else -> "" + } + + if (commandModeOutput.isEmpty()) { + ime.stateManager.setInvalidState(ime.currentState) + ime.refreshUI() + } else { + applyCommandOutput(commandModeOutput, inputConnection) + } + } + + /** + * Handles the Enter key press when in the `CONJUGATE` state. It fetches the + * conjugation data for the entered verb and transitions to the selection view. + * + * @param rawInput The verb entered in the command bar. + */ + fun handleConjugateState(rawInput: String) { + val searchInput = rawInput.lowercase() + ime.currentVerbForConjugation = rawInput + val languageAlias = getLanguageAlias(ime.language) + + val tempOutput = ime.dbManagers.conjugateDataManager.getTheConjugateLabels(languageAlias, ime.dataContract, searchInput) + + val isAllCaps = rawInput.isNotEmpty() && rawInput.all { !it.isLetter() || it.isUpperCase() } + val isCapitalized = !isAllCaps && rawInput.firstOrNull()?.isUpperCase() == true + + ime.conjugateOutput = + if (tempOutput?.isEmpty() == true || tempOutput?.values?.all { it.isEmpty() } == true) { + null + } else if ((isAllCaps || isCapitalized) && tempOutput != null) { + ime.applyCapitalizationToConjugations(tempOutput, isAllCaps) + } else { + tempOutput + } + + ime.conjugateLabels = ime.dbManagers.conjugateDataManager.extractConjugateHeadings(ime.dataContract, searchInput) + + if (ime.conjugateOutput == null) { + ime.stateManager.setInvalidState(ScribeState.CONJUGATE) + } else { + ime.saveConjugateModeType(ime.language) + ime.stateManager.moveToState(ScribeState.SELECT_VERB_CONJUNCTION) + } + ime.refreshUI() + } + + /** + * Handles the default behavior of the Enter key when not in a special Scribe command mode. + * + * It performs the editor action or sends a standard Enter key event. + * + * @param inputConnection The current input connection. + */ + fun handleDefaultEnter(inputConnection: InputConnection) { + val wordBeforeEnter = ime.getLastWordBeforeCursor() + val imeOptionsActionId = ime.getImeOptionsActionId() + if (imeOptionsActionId != IME_ACTION_NONE) { + inputConnection.performEditorAction(imeOptionsActionId) + } else { + inputConnection.sendKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_ENTER)) + inputConnection.sendKeyEvent(KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_ENTER)) + } + ime.moveToIdleState() + if (!wordBeforeEnter.isNullOrEmpty()) { + ime.suggestionHandler.processLinguisticSuggestions(wordBeforeEnter) + } else { + ime.suggestionHandler.clearAllSuggestionsAndHideButtonUI() + } + } + + /** + * Commits the output of a Scribe command (like translation or pluralization) to the input field. + * + * @param commandModeOutput The string result of the command. + * @param inputConnection The current input connection. + */ + fun applyCommandOutput( + commandModeOutput: String, + inputConnection: InputConnection, + ) { + if (commandModeOutput.isNotEmpty()) { + val output = if (!commandModeOutput.endsWith(" ")) "$commandModeOutput " else commandModeOutput + inputConnection.commitText(output, COMMIT_TEXT_CURSOR_POSITION) + ime.suggestionHandler.processLinguisticSuggestions(output.trim()) + } + runCatching { + ime.uiManager.binding.commandBar + .setText("") + } + + ime.moveToIdleState() + } +} diff --git a/app/src/keyboards/java/be/scri/services/GeneralKeyboardIME.kt b/app/src/keyboards/java/be/scri/services/GeneralKeyboardIME.kt index a7bbee29..b5d45dd8 100644 --- a/app/src/keyboards/java/be/scri/services/GeneralKeyboardIME.kt +++ b/app/src/keyboards/java/be/scri/services/GeneralKeyboardIME.kt @@ -12,7 +12,6 @@ import android.text.InputType.TYPE_CLASS_DATETIME import android.text.InputType.TYPE_CLASS_NUMBER import android.text.InputType.TYPE_CLASS_PHONE import android.text.InputType.TYPE_MASK_CLASS -import android.view.KeyEvent import android.view.View import android.view.inputmethod.EditorInfo import android.view.inputmethod.EditorInfo.IME_ACTION_NONE @@ -30,6 +29,7 @@ import be.scri.helpers.AnnotationTextUtils.handleColorAndTextForNounType import be.scri.helpers.AnnotationTextUtils.handleTextForCaseAnnotation import be.scri.helpers.AutocompletionHandler import be.scri.helpers.BackspaceHandler +import be.scri.helpers.CommandHandler import be.scri.helpers.DatabaseManagers import be.scri.helpers.EmojiUtils.insertEmoji import be.scri.helpers.FloatingKeyboardHandler @@ -51,7 +51,6 @@ import be.scri.helpers.SHIFT_ON_PERMANENT import be.scri.helpers.SuggestionHandler import be.scri.helpers.clipboard.ClipboardHandler import be.scri.helpers.data.AutocompletionDataManager -import be.scri.helpers.english.ENInterfaceVariables.ALREADY_PLURAL_MSG import be.scri.helpers.recordRecentEmoji import be.scri.helpers.ui.KeyboardThemeManager import be.scri.helpers.ui.KeyboardUIManager @@ -152,6 +151,7 @@ abstract class GeneralKeyboardIME( internal lateinit var suggestionHandler: SuggestionHandler internal lateinit var autocompletionHandler: AutocompletionHandler internal val floatingKeyboardHandler by lazy { FloatingKeyboardHandler(this) } + internal val commandHandler by lazy { CommandHandler(this) } internal var dataContract: DataContract? get() = dataHandler.dataContract @@ -169,13 +169,13 @@ abstract class GeneralKeyboardIME( dataHandler.emojiKeywords = value } - private var conjugateOutput: MutableMap>>? + internal var conjugateOutput: MutableMap>>? get() = dataHandler.conjugateOutput set(value) { dataHandler.conjugateOutput = value } - private var conjugateLabels: Set + internal var conjugateLabels: Set get() = dataHandler.conjugateLabels set(value) { dataHandler.conjugateLabels = value @@ -251,7 +251,7 @@ abstract class GeneralKeyboardIME( // MARK: Conjugation State - private var currentVerbForConjugation: String? = null + internal var currentVerbForConjugation: String? = null private var selectedConjugationSubCategory: String? = null protected open fun isTablet(): Boolean = resources.configuration.smallestScreenWidthDp >= SMALLEST_SCREEN_WIDTH_TABLET @@ -704,7 +704,7 @@ abstract class GeneralKeyboardIME( */ internal fun updateUI() = refreshUI() - private fun refreshUI() { + internal fun refreshUI() { if (!this::uiManager.isInitialized) return uiManager.updateUI( @@ -860,156 +860,20 @@ abstract class GeneralKeyboardIME( // MARK: Input Logic /** - * Handles the logic for the Enter key press. This can either perform an editor action, - * commit a newline, or execute a Scribe command depending on the current state. + * Handles the logic for the Enter key press. + * Delegated to [CommandHandler]. */ - fun handleKeycodeEnter() { - val inputConnection = currentInputConnection ?: return - - if (currentState == ScribeState.INVALID || currentState == ScribeState.ALREADY_PLURAL) { - moveToIdleState() - return - } - - if (currentState == ScribeState.IDLE || currentState == ScribeState.SELECT_COMMAND) { - handleDefaultEnter(inputConnection) - return - } - - val rawInput = uiManager.getCommandBarTextWithoutCursor().trim().takeIf { it.isNotEmpty() } - - if (rawInput == null) { - moveToIdleState() - } else { - when (currentState) { - ScribeState.PLURAL, ScribeState.TRANSLATE -> handlePluralOrTranslateState(rawInput, inputConnection) - ScribeState.CONJUGATE -> handleConjugateState(rawInput) - else -> handleDefaultEnter(inputConnection) - } - } - } - - /** - * Handles the Enter key press when in the plural or translate state. - * - * @param rawInput The text from the command bar. - * @param inputConnection The current input connection. - */ - private fun handlePluralOrTranslateState( - rawInput: String, - inputConnection: InputConnection, - ) { - val isAllCaps = rawInput.isNotEmpty() && rawInput.all { !it.isLetter() || it.isUpperCase() } - - val commandModeOutput = - when (currentState) { - ScribeState.PLURAL -> { - when (val pluralResult = getPluralRepresentation(rawInput)) { - ALREADY_PLURAL_MSG -> { - currentState = ScribeState.ALREADY_PLURAL - refreshUI() - return - } - - null -> "" - else -> if (isAllCaps) pluralResult.uppercase() else pluralResult - } - } - - ScribeState.TRANSLATE -> { - val translation = getTranslation(language, rawInput) - if (isAllCaps) translation.uppercase() else translation - } - - else -> "" - } - - if (commandModeOutput.isEmpty()) { - stateManager.setInvalidState(currentState) - refreshUI() - } else { - applyCommandOutput(commandModeOutput, inputConnection) - } - } - /** - * Handles the Enter key press when in the `CONJUGATE` state. It fetches the - * conjugation data for the entered verb and transitions to the selection view. - * - * @param rawInput The verb entered in the command bar. - */ - private fun handleConjugateState(rawInput: String) { - val searchInput = rawInput.lowercase() - currentVerbForConjugation = rawInput - val languageAlias = getLanguageAlias(language) - - val tempOutput = dbManagers.conjugateDataManager.getTheConjugateLabels(languageAlias, dataContract, searchInput) - - val isAllCaps = rawInput.isNotEmpty() && rawInput.all { !it.isLetter() || it.isUpperCase() } - val isCapitalized = !isAllCaps && rawInput.firstOrNull()?.isUpperCase() == true - - conjugateOutput = - if (tempOutput?.isEmpty() == true || tempOutput?.values?.all { it.isEmpty() } == true) { - null - } else if ((isAllCaps || isCapitalized) && tempOutput != null) { - applyCapitalizationToConjugations(tempOutput, isAllCaps) - } else { - tempOutput - } - - conjugateLabels = dbManagers.conjugateDataManager.extractConjugateHeadings(dataContract, searchInput) - - if (conjugateOutput == null) { - stateManager.setInvalidState(ScribeState.CONJUGATE) - } else { - saveConjugateModeType(language) - stateManager.moveToState(ScribeState.SELECT_VERB_CONJUNCTION) - } - refreshUI() - } - - /** - * Handles the default behavior of the Enter key when not in a special Scribe command mode. - * - * It performs the editor action or sends a standard Enter key event. - * - * @param inputConnection The current input connection. - */ - private fun handleDefaultEnter(inputConnection: InputConnection) { - val wordBeforeEnter = getLastWordBeforeCursor() - val imeOptionsActionId = getImeOptionsActionId() - if (imeOptionsActionId != IME_ACTION_NONE) { - inputConnection.performEditorAction(imeOptionsActionId) - } else { - inputConnection.sendKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_ENTER)) - inputConnection.sendKeyEvent(KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_ENTER)) - } - moveToIdleState() - if (!wordBeforeEnter.isNullOrEmpty()) { - suggestionHandler.processLinguisticSuggestions(wordBeforeEnter) - } else { - suggestionHandler.clearAllSuggestionsAndHideButtonUI() - } - } + fun handleKeycodeEnter() = commandHandler.handleKeycodeEnter() /** * Commits the output of a Scribe command (like translation or pluralization) to the input field. - * - * @param commandModeOutput The string result of the command. - * @param inputConnection The current input connection. + * Delegated to [CommandHandler]. */ - private fun applyCommandOutput( + fun applyCommandOutput( commandModeOutput: String, inputConnection: InputConnection, - ) { - if (commandModeOutput.isNotEmpty()) { - val output = if (!commandModeOutput.endsWith(" ")) "$commandModeOutput " else commandModeOutput - inputConnection.commitText(output, COMMIT_TEXT_CURSOR_POSITION) - suggestionHandler.processLinguisticSuggestions(output.trim()) - } - uiManager.binding.commandBar.setText("") - moveToIdleState() - } + ) = commandHandler.applyCommandOutput(commandModeOutput, inputConnection) /** * Handles the input of any non-special character key (e.g., letters, numbers, punctuation). @@ -1179,7 +1043,8 @@ abstract class GeneralKeyboardIME( * * @return The IME action ID, or `IME_ACTION_NONE`. */ - private fun getImeOptionsActionId(): Int = + internal fun getImeOptionsActionId(): Int = + if (currentInputEditorInfo.imeOptions and IME_FLAG_NO_ENTER_ACTION != 0) { IME_ACTION_NONE } else { @@ -1193,31 +1058,25 @@ abstract class GeneralKeyboardIME( * * @return The plural form as a string, or null if not found. */ - private fun getPluralRepresentation(word: String?): String? = dataHandler.getPluralRepresentation(language, word) + internal fun getPluralRepresentation(word: String?): String? = dataHandler.getPluralRepresentation(language, word) /** - * Retrieves the translation for a given word. + * Retrieves the translation of a word or phrase from the database. * - * @param language The current keyboard language (destination language). - * @param commandBarInput The word to be translated (source word). + * @param language The target language code. + * @param commandBarInput The input text to translate. * - * @return The translated word as a string. + * @return The translated text. */ - private fun getTranslation( + internal fun getTranslation( language: String, commandBarInput: String, ): String = dataHandler.getTranslation(language, commandBarInput) /** - * Applies capitalization to all conjugated forms in the output map. - * Supports both standard capitalization (first letter) and all capital letters formatting. - * - * @param conjugations The original map of conjugations from the database. - * @param isAllCaps If true, applies all capital letters; if false, capitalizes only first letter. - * * @return A new map with properly formatted conjugations. */ - private fun applyCapitalizationToConjugations( + internal fun applyCapitalizationToConjugations( conjugations: MutableMap>>, isAllCaps: Boolean = false, ): MutableMap>> { diff --git a/app/src/testKeyboards/kotlin/be/scri/helpers/CommandHandlerTest.kt b/app/src/testKeyboards/kotlin/be/scri/helpers/CommandHandlerTest.kt new file mode 100644 index 00000000..a3eca794 --- /dev/null +++ b/app/src/testKeyboards/kotlin/be/scri/helpers/CommandHandlerTest.kt @@ -0,0 +1,66 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package be.scri.helpers + +import android.view.inputmethod.InputConnection +import be.scri.models.ScribeState +import be.scri.services.GeneralKeyboardIME +import io.mockk.every +import io.mockk.mockk +import io.mockk.unmockkAll +import io.mockk.verify +import org.junit.After +import org.junit.Before +import org.junit.Test + +class CommandHandlerTest { + private val ime = mockk(relaxed = true) + private val inputConnection = mockk(relaxed = true) + private lateinit var handler: CommandHandler + + @Before + fun setUp() { + every { ime.currentInputConnection } returns inputConnection + handler = CommandHandler(ime) + } + + @After + fun tearDown() { + unmockkAll() + } + + @Test + fun handleKeycodeEnter_nullInputConnection_doesNothing() { + every { ime.currentInputConnection } returns null + + handler.handleKeycodeEnter() + + verify(exactly = 0) { ime.moveToIdleState() } + } + + @Test + fun handleKeycodeEnter_invalidState_movesToIdle() { + every { ime.currentState } returns ScribeState.INVALID + + handler.handleKeycodeEnter() + + verify { ime.moveToIdleState() } + } + + @Test + fun handleKeycodeEnter_alreadyPluralState_movesToIdle() { + every { ime.currentState } returns ScribeState.ALREADY_PLURAL + + handler.handleKeycodeEnter() + + verify { ime.moveToIdleState() } + } + + @Test + fun applyCommandOutput_nonEmptyText_commitsTextAndMovesToIdle() { + handler.applyCommandOutput("translated text", inputConnection) + + verify { inputConnection.commitText("translated text ", 1) } + verify { ime.moveToIdleState() } + } +}