From e5ec7f6ff0accc7166bd406098f05418931568a4 Mon Sep 17 00:00:00 2001 From: Prince Yadav <66916296+prince-0408@users.noreply.github.com> Date: Tue, 25 Aug 2026 01:00:15 +0530 Subject: [PATCH] refactor: extract SuggestionUIHandler from GeneralKeyboardIME (Part 11) (#426) --- CHANGELOG.md | 2 + .../be/scri/helpers/ui/SuggestionUIHandler.kt | 454 ++++++++++++++++++ .../be/scri/services/GeneralKeyboardIME.kt | 436 +---------------- .../helpers/ui/SuggestionUIHandlerTest.kt | 103 ++++ 4 files changed, 571 insertions(+), 424 deletions(-) create mode 100644 app/src/keyboards/java/be/scri/helpers/ui/SuggestionUIHandler.kt create mode 100644 app/src/testKeyboards/kotlin/be/scri/helpers/ui/SuggestionUIHandlerTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index c40eff41..05fd0f27 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)). +- Extracted SuggestionUIHandler from GeneralKeyboardIME ([#426](https://github.com/scribe-org/Scribe-Android/issues/426)). + diff --git a/app/src/keyboards/java/be/scri/helpers/ui/SuggestionUIHandler.kt b/app/src/keyboards/java/be/scri/helpers/ui/SuggestionUIHandler.kt new file mode 100644 index 00000000..1c9acde3 --- /dev/null +++ b/app/src/keyboards/java/be/scri/helpers/ui/SuggestionUIHandler.kt @@ -0,0 +1,454 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package be.scri.helpers.ui + +import android.view.View +import android.widget.Button +import be.scri.R +import be.scri.helpers.AnnotationTextUtils.handleColorAndTextForNounType +import be.scri.helpers.AnnotationTextUtils.handleTextForCaseAnnotation +import be.scri.models.ScribeState +import be.scri.services.GeneralKeyboardIME +import be.scri.services.GeneralKeyboardIME.Companion.NOUN_TYPE_SIZE +import be.scri.services.GeneralKeyboardIME.Companion.SUGGESTION_SIZE + +/** + * Handles auto-suggestion and autocomplete UI layout rendering, button styling, + * visibility switching, and click listeners for [GeneralKeyboardIME]. + * + * @property ime The [GeneralKeyboardIME] instance associated with this handler. + */ +@Suppress("TooManyFunctions") +class SuggestionUIHandler( + private val ime: GeneralKeyboardIME, +) { + private val uiManager: KeyboardUIManager + get() = ime.uiManager + + private val themeManager: KeyboardThemeManager + get() = ime.themeManager + + /** + * The main dispatcher for displaying linguistic auto-suggestions (gender, case, plurality). + * + * @param nounTypeSuggestion The detected gender(s) of the last word. + * @param isPlural true if the last word is plural. + * @param caseAnnotationSuggestion The detected case(s) required by the last word. + * @param wordSuggestions The list of predicted words to display. + */ + fun updateAutoSuggestText( + nounTypeSuggestion: List? = null, + isPlural: Boolean = false, + caseAnnotationSuggestion: MutableList? = null, + wordSuggestions: List? = null, + ) { + ime.nounTypeSuggestion = nounTypeSuggestion + ime.checkIfPluralWord = isPlural + ime.caseAnnotationSuggestion = caseAnnotationSuggestion + ime.wordSuggestions = wordSuggestions + + if (ime.currentState != ScribeState.IDLE) { + if (ime.currentState != ScribeState.SELECT_COMMAND) { + uiManager.disableAutoSuggest(ime.language) + } + return + } + val hasLinguisticSuggestions = nounTypeSuggestion != null || isPlural || caseAnnotationSuggestion != null || ime.isSingularAndPlural + + val handled = + when { + (isPlural && nounTypeSuggestion != null) -> { + handleMultipleNounFormats(nounTypeSuggestion, "noun") + true + } + + ((nounTypeSuggestion?.size ?: 0) > 1) -> { + handleMultipleNounFormats(nounTypeSuggestion, "noun") + true + } + + handlePluralIfNeeded(isPlural) -> true + handleSingleNounSuggestion(nounTypeSuggestion) -> true + handleMultipleCases(caseAnnotationSuggestion) -> true + handleSingleCaseSuggestion(caseAnnotationSuggestion) -> true + handleFallbackSuggestions(nounTypeSuggestion, caseAnnotationSuggestion) -> true + else -> false + } + + if (!handled) uiManager.disableAutoSuggest(ime.language) + handleWordSuggestions(wordSuggestions, hasLinguisticSuggestions) + } + + /** + * A helper function to specifically trigger the plural suggestion UI if needed. + * + * @param isPlural true if the word is plural. + * @return true if the plural suggestion was handled, false otherwise. + */ + private fun handlePluralIfNeeded(isPlural: Boolean): Boolean { + if (isPlural) { + uiManager.genderSuggestionLeft?.visibility = View.INVISIBLE + uiManager.genderSuggestionRight?.visibility = View.INVISIBLE + themeManager.applySingleSuggestionStyle( + context = ime.applicationContext, + button = uiManager.binding.translateBtn, + colorRes = R.color.annotateOrange, + buttonText = "PL", + textSizeSp = NOUN_TYPE_SIZE, + ) + return true + } + return false + } + + /** + * A helper function to handle displaying a single noun gender suggestion. + * + * @param nounTypeSuggestion A list containing a single gender string. + * @return true if a suggestion was displayed, false otherwise. + */ + private fun handleSingleNounSuggestion(nounTypeSuggestion: List?): Boolean { + if (nounTypeSuggestion?.size == 1 && !ime.isSingularAndPlural) { + val (colorRes, text) = handleColorAndTextForNounType(nounTypeSuggestion[0], ime.language, ime.applicationContext) + if (text != "" || colorRes != R.color.transparent) { + handleSingleType(nounTypeSuggestion, "noun") + return true + } + } + return false + } + + /** + * A helper function to handle displaying a single preposition case suggestion. + * + * @param caseAnnotationSuggestion A list containing a single case annotation string. + * @return true if a suggestion was displayed, false otherwise. + */ + private fun handleSingleCaseSuggestion(caseAnnotationSuggestion: List?): Boolean { + if (caseAnnotationSuggestion?.size == 1) { + val (colorRes, text) = handleTextForCaseAnnotation(caseAnnotationSuggestion[0], ime.language, ime.applicationContext) + if (text != "" || colorRes != R.color.transparent) { + handleSingleType(caseAnnotationSuggestion, "preposition") + return true + } + } + return false + } + + /** + * A helper function to handle displaying multiple preposition case suggestions. + * + * @param caseAnnotationSuggestion A list containing multiple case annotation strings. + * @return true if suggestions were displayed, false otherwise. + */ + private fun handleMultipleCases(caseAnnotationSuggestion: List?): Boolean { + if ((caseAnnotationSuggestion?.size ?: 0) > 1) { + handleMultipleNounFormats(caseAnnotationSuggestion, "preposition") + return true + } + return false + } + + /** + * Handles fallback logic when multiple suggestions are available but only one can be shown, + * or when the primary suggestion type isn't displayable. + * + * @param nounTypeSuggestion The list of noun suggestions. + * @param caseAnnotationSuggestion The list of case suggestions. + * @return true if a fallback suggestion was applied, false otherwise. + */ + private fun handleFallbackSuggestions( + nounTypeSuggestion: List?, + caseAnnotationSuggestion: List?, + ): Boolean { + var appliedSomething = false + nounTypeSuggestion?.let { + handleSingleType(it, "noun") + val (_, text) = handleColorAndTextForNounType(it[0], ime.language, ime.applicationContext) + if (text != "") appliedSomething = true + } + if (!appliedSomething) { + caseAnnotationSuggestion?.let { + handleSingleType(it, "preposition") + val (_, text) = handleTextForCaseAnnotation(it[0], ime.language, ime.applicationContext) + if (text != "") appliedSomething = true + } + } + return appliedSomething + } + + /** + * Configures a single suggestion button with the appropriate text and color based on the suggestion type. + * + * @param singleTypeSuggestion The list containing the single suggestion to display. + * @param type The type of suggestion, either "noun" or "preposition". + */ + private fun handleSingleType( + singleTypeSuggestion: List?, + type: String? = null, + ) { + val suggestionText = singleTypeSuggestion?.getOrNull(0).toString() + val (colorRes, buttonText) = + when (type) { + "noun" -> handleColorAndTextForNounType(suggestionText, ime.language, ime.applicationContext) + "preposition" -> handleTextForCaseAnnotation(suggestionText, ime.language, ime.applicationContext) + else -> Pair(R.color.transparent, "") + } + + uiManager.genderSuggestionLeft?.visibility = View.INVISIBLE + uiManager.genderSuggestionRight?.visibility = View.INVISIBLE + + themeManager.applySingleSuggestionStyle( + context = ime.applicationContext, + button = uiManager.binding.translateBtn, + colorRes = colorRes, + buttonText = buttonText, + textSizeSp = NOUN_TYPE_SIZE, + ) + } + + /** + * Applies a specific style to a suggestion button, including text, color, and a custom background. + * + * @param button The Button to style. + * @param colorRes The color resource ID for the background. + * @param text The text to display on the button. + * @param backgroundRes The drawable resource ID for the button's background. + */ + private fun applyInformativeSuggestionStyle( + button: Button, + colorRes: Int, + text: String, + backgroundRes: Int, + ) { + themeManager.applyInformativeSuggestionStyle( + context = ime.applicationContext, + button = button, + colorRes = colorRes, + text = text, + backgroundRes = backgroundRes, + ) + } + + /** + * Handles the UI logic for displaying multiple suggestions simultaneously, + * typically for words with multiple genders. + * + * @param multipleTypeSuggestion The list of suggestions to display. + * @param type The type of suggestion, either "noun" or "preposition". + */ + private fun handleMultipleNounFormats( + multipleTypeSuggestion: List?, + type: String? = null, + ) { + val suggestionPairs = getSuggestionPairs(type, multipleTypeSuggestion) ?: return + val (leftSuggestion, rightSuggestion) = suggestionPairs + val suggestionText = "" + if (leftSuggestion.second == suggestionText || rightSuggestion.second == suggestionText) { + handleFallbackOrSingleSuggestion(multipleTypeSuggestion) + return + } + + uiManager.genderSuggestionLeft?.visibility = View.VISIBLE + uiManager.genderSuggestionRight?.visibility = View.VISIBLE + uiManager.binding.translateBtn.visibility = View.INVISIBLE + + uiManager.genderSuggestionLeft?.let { + applyInformativeSuggestionStyle( + it, + leftSuggestion.first, + leftSuggestion.second, + R.drawable.gender_suggestion_button_left_background, + ) + } + + uiManager.genderSuggestionRight?.let { + applyInformativeSuggestionStyle( + it, + rightSuggestion.first, + rightSuggestion.second, + R.drawable.gender_suggestion_button_right_background, + ) + } + } + + /** + * Creates pairs of (color, text) for dual suggestion buttons. + * + * @param type The suggestion type ("noun" or "preposition"). + * @param suggestions The list of suggestion strings. + * @return A pair of pairs, each containing a color resource ID and a text string, or null on failure. + */ + private fun getSuggestionPairs( + type: String?, + suggestions: List?, + ): Pair, Pair>? { + val (leftType, rightType) = + if (type == "noun" && ime.isSingularAndPlural) { + "PL" to (suggestions?.getOrNull(0).orEmpty()) + } else { + (suggestions?.getOrNull(0).orEmpty()) to (suggestions?.getOrNull(1).orEmpty()) + } + + return when (type) { + "noun" -> + handleColorAndTextForNounType(leftType, ime.language, ime.applicationContext) to + handleColorAndTextForNounType(rightType, ime.language, ime.applicationContext) + + "preposition" -> + handleTextForCaseAnnotation(leftType, ime.language, ime.applicationContext) to + handleTextForCaseAnnotation(rightType, ime.language, ime.applicationContext) + + else -> null + } + } + + /** + * Handles the logic when a word has multiple possible genders or + * cases but only one suggestion slot is available. + * + * @param multipleTypeSuggestion The list of noun suggestions. + */ + private fun handleFallbackOrSingleSuggestion(multipleTypeSuggestion: List?) { + val suggestionText = "" + val validNouns = multipleTypeSuggestion?.filter { handleColorAndTextForNounType(it, ime.language, ime.applicationContext).second != suggestionText } + val validCases = ime.caseAnnotationSuggestion?.filter { handleTextForCaseAnnotation(it, ime.language, ime.applicationContext).second != suggestionText } + if (!validNouns.isNullOrEmpty()) { + handleSingleType(validNouns, "noun") + } else if (!validCases.isNullOrEmpty()) { + handleSingleType(validCases, "preposition") + } else { + uiManager.disableAutoSuggest(ime.language) + } + } + + /** + * Displays word prediction suggestions on the command buttons. + * + * @param wordSuggestions The list of predicted words to display. + * @param hasLinguisticSuggestions Whether linguistic suggestions are also present. + */ + private fun handleWordSuggestions( + wordSuggestions: List?, + hasLinguisticSuggestions: Boolean, + ) { + if (wordSuggestions.isNullOrEmpty()) { + if (hasLinguisticSuggestions) { + val baseSuggestions = HintUtils.getBaseAutoSuggestions(ime.language) + val default1 = baseSuggestions.getOrNull(0).orEmpty() + val default2 = baseSuggestions.getOrNull(1).orEmpty() + setSuggestionButton(uiManager.binding.conjugateBtn, default1) + uiManager.pluralBtn?.let { setSuggestionButton(it, default2) } + } + return + } + + val suggestions = listOfNotNull(wordSuggestions.getOrNull(0), wordSuggestions.getOrNull(1), wordSuggestions.getOrNull(2)) + val suggestion1 = suggestions.getOrNull(0).orEmpty() + val suggestion2 = suggestions.getOrNull(1).orEmpty() + val suggestion3 = suggestions.getOrNull(2).orEmpty() + + val emojiCount = ime.autoSuggestEmojis?.size ?: 0 + setSuggestionButton(uiManager.binding.conjugateBtn, suggestion1) + + when { + hasLinguisticSuggestions && emojiCount != 0 -> { + uiManager.updateButtonVisibility(ime.currentState, true, ime.autoSuggestEmojis) + } + + hasLinguisticSuggestions && emojiCount == 0 -> { + uiManager.pluralBtn?.let { setSuggestionButton(it, suggestion2) } + } + + !hasLinguisticSuggestions && emojiCount != 0 -> { + setSuggestionButton(uiManager.binding.translateBtn, suggestion2) + uiManager.updateButtonVisibility(ime.currentState, true, ime.autoSuggestEmojis) + } + + else -> { + setSuggestionButton(uiManager.binding.translateBtn, suggestion2) + uiManager.pluralBtn?.let { setSuggestionButton(it, suggestion3) } + } + } + } + + private fun setSuggestionButton( + button: Button, + text: String, + ) { + button.text = text + button.isAllCaps = false + button.visibility = View.VISIBLE + button.textSize = SUGGESTION_SIZE + button.setOnClickListener(null) + button.background = null + button.foreground = null + button.setTextColor(themeManager.getSuggestionTextColor(ime.applicationContext)) + button.setOnClickListener { + ime.currentInputConnection?.commitText("$text ", 1) + ime.moveToIdleState() + } + } + + /** + * Updates autocomplete UI with a new list of suggestions. + * Clears it if not idle or no completions. + * + * @param completions The list of autocomplete completions to display. + */ + fun updateAutocompleteSuggestions(completions: List?) { + if (ime.currentState != ScribeState.IDLE) { + uiManager.disableAutoSuggest(ime.language) + return + } + if (completions.isNullOrEmpty()) { + uiManager.disableAutoSuggest(ime.language) + return + } + + val completion1 = completions.getOrNull(0).orEmpty() + val completion2 = completions.getOrNull(1).orEmpty() + val completion3 = completions.getOrNull(2).orEmpty() + + setAutocompleteButton(uiManager.binding.conjugateBtn, completion1) + setAutocompleteButton(uiManager.binding.translateBtn, completion2) + uiManager.pluralBtn?.let { setAutocompleteButton(it, completion3) } + + uiManager.binding.separator1.visibility = View.VISIBLE + uiManager.binding.separator2.visibility = View.VISIBLE + } + + /** + * Sets up an autocomplete button with the given suggestion text. + * When clicked, it replaces the current word with the suggestion. + */ + private fun setAutocompleteButton( + button: Button, + text: String, + ) { + setSuggestionButton(button, text) + if (text.isBlank()) { + button.setOnClickListener(null) + return + } + button.setOnClickListener { + val ic = ime.currentInputConnection ?: return@setOnClickListener + val beforeText = ic.getTextBeforeCursor(50, 0) ?: "" + val wordStartIndex = beforeText.lastIndexOfAny(charArrayOf(' ', '\n', '\t', '.', ',', '?', '!')) + 1 + val currentWord = beforeText.substring(wordStartIndex) + ic.deleteSurroundingText(currentWord.length, 0) + ic.commitText(text, 1) + ime.moveToIdleState() + } + } + + /** + * Clears autocomplete suggestions by resetting the suggestion strip + * to the default command buttons via the UI Manager. + */ + fun clearAutocomplete() { + if (ime.isUiManagerInitialized) { + uiManager.disableAutoSuggest(ime.language) + } + } +} diff --git a/app/src/keyboards/java/be/scri/services/GeneralKeyboardIME.kt b/app/src/keyboards/java/be/scri/services/GeneralKeyboardIME.kt index a7bbee29..7cc8e0c6 100644 --- a/app/src/keyboards/java/be/scri/services/GeneralKeyboardIME.kt +++ b/app/src/keyboards/java/be/scri/services/GeneralKeyboardIME.kt @@ -20,14 +20,11 @@ import android.view.inputmethod.EditorInfo.IME_FLAG_NO_ENTER_ACTION import android.view.inputmethod.EditorInfo.IME_MASK_ACTION import android.view.inputmethod.ExtractedTextRequest import android.view.inputmethod.InputConnection -import android.widget.Button import android.widget.TextView import androidx.core.content.edit import be.scri.R import be.scri.activities.MainActivity import be.scri.databinding.InputMethodViewBinding -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.DatabaseManagers @@ -55,6 +52,7 @@ 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 +import be.scri.helpers.ui.SuggestionUIHandler import be.scri.models.ScribeLanguage import be.scri.models.ScribeState import be.scri.views.KeyboardView @@ -152,6 +150,7 @@ abstract class GeneralKeyboardIME( internal lateinit var suggestionHandler: SuggestionHandler internal lateinit var autocompletionHandler: AutocompletionHandler internal val floatingKeyboardHandler by lazy { FloatingKeyboardHandler(this) } + internal val suggestionUIHandler by lazy { SuggestionUIHandler(this) } internal var dataContract: DataContract? get() = dataHandler.dataContract @@ -1419,440 +1418,38 @@ abstract class GeneralKeyboardIME( lastWord: String?, ) = lastWord?.let { caseAnnotation[it.lowercase()] } - // Logic for updating auto-suggest text and buttons. - // Since KeyboardUIManager doesn't have linguistic logic, we manipulate views here. + // MARK: Suggestion UI Logic /** * The main dispatcher for displaying linguistic auto-suggestions (gender, case, plurality). + * Delegated to [SuggestionUIHandler]. * * @param nounTypeSuggestion The detected gender(s) of the last word. * @param isPlural true if the last word is plural. * @param caseAnnotationSuggestion The detected case(s) required by the last word. + * @param wordSuggestions The list of predicted words to display. */ fun updateAutoSuggestText( nounTypeSuggestion: List? = null, isPlural: Boolean = false, caseAnnotationSuggestion: MutableList? = null, wordSuggestions: List? = null, - ) { - this.nounTypeSuggestion = nounTypeSuggestion - this.checkIfPluralWord = isPlural - this.caseAnnotationSuggestion = caseAnnotationSuggestion - this.wordSuggestions = wordSuggestions - - if (currentState != ScribeState.IDLE) { - if (currentState != ScribeState.SELECT_COMMAND) { - uiManager.disableAutoSuggest(language) - } - return - } - val hasLinguisticSuggestions = nounTypeSuggestion != null || isPlural || caseAnnotationSuggestion != null || isSingularAndPlural - - val handled = - when { - (isPlural && nounTypeSuggestion != null) -> { - handleMultipleNounFormats(nounTypeSuggestion, "noun") - true - } - - ((nounTypeSuggestion?.size ?: 0) > 1) -> { - handleMultipleNounFormats(nounTypeSuggestion, "noun") - true - } - - handlePluralIfNeeded(isPlural) -> true - handleSingleNounSuggestion(nounTypeSuggestion) -> true - handleMultipleCases(caseAnnotationSuggestion) -> true - handleSingleCaseSuggestion(caseAnnotationSuggestion) -> true - handleFallbackSuggestions(nounTypeSuggestion, caseAnnotationSuggestion) -> true - else -> false - } - - if (!handled) uiManager.disableAutoSuggest(language) - handleWordSuggestions(wordSuggestions, hasLinguisticSuggestions) - } - - // MARK: Linguistic Logic - - /** - * A helper function to specifically trigger the plural suggestion UI if needed. - * - * @param isPlural true if the word is plural. - * - * @return true if the plural suggestion was handled, false otherwise. - */ - private fun handlePluralIfNeeded(isPlural: Boolean): Boolean { - if (isPlural) { - uiManager.genderSuggestionLeft?.visibility = View.INVISIBLE - uiManager.genderSuggestionRight?.visibility = View.INVISIBLE - themeManager.applySingleSuggestionStyle( - context = applicationContext, - button = uiManager.binding.translateBtn, - colorRes = R.color.annotateOrange, - buttonText = "PL", - textSizeSp = NOUN_TYPE_SIZE, - ) - return true - } - return false - } - - /** - * A helper function to handle displaying a single noun gender suggestion. - * - * @param nounTypeSuggestion A list containing a single gender string. - * - * @return true if a suggestion was displayed, false otherwise. - */ - private fun handleSingleNounSuggestion(nounTypeSuggestion: List?): Boolean { - if (nounTypeSuggestion?.size == 1 && !isSingularAndPlural) { - val (colorRes, text) = handleColorAndTextForNounType(nounTypeSuggestion[0], language, applicationContext) - if (text != "" || colorRes != R.color.transparent) { - handleSingleType(nounTypeSuggestion, "noun") - return true - } - } - return false - } - - /** - * A helper function to handle displaying a single preposition case suggestion. - * - * @param caseAnnotationSuggestion A list containing a single case annotation string. - * - * @return true if a suggestion was displayed, false otherwise. - */ - private fun handleSingleCaseSuggestion(caseAnnotationSuggestion: List?): Boolean { - if (caseAnnotationSuggestion?.size == 1) { - val (colorRes, text) = handleTextForCaseAnnotation(caseAnnotationSuggestion[0], language, applicationContext) - if (text != "" || colorRes != R.color.transparent) { - handleSingleType(caseAnnotationSuggestion, "preposition") - return true - } - } - return false - } - - /** - * A helper function to handle displaying multiple preposition case suggestions. - * - * @param caseAnnotationSuggestion A list containing multiple case annotation strings. - * - * @return true if suggestions were displayed, false otherwise. - */ - private fun handleMultipleCases(caseAnnotationSuggestion: List?): Boolean { - if ((caseAnnotationSuggestion?.size ?: 0) > 1) { - handleMultipleNounFormats(caseAnnotationSuggestion, "preposition") - return true - } - return false - } - - /** - * Handles fallback logic when multiple suggestions are available but only one can be shown, - * or when the primary suggestion type isn't displayable. - * - * @param nounTypeSuggestion The list of noun suggestions. - * @param caseAnnotationSuggestion The list of case suggestions. - * - * @return true if a fallback suggestion was applied, false otherwise. - */ - private fun handleFallbackSuggestions( - nounTypeSuggestion: List?, - caseAnnotationSuggestion: List?, - ): Boolean { - var appliedSomething = false - nounTypeSuggestion?.let { - handleSingleType(it, "noun") - val (_, text) = handleColorAndTextForNounType(it[0], language, applicationContext) - if (text != "") appliedSomething = true - } - if (!appliedSomething) { - caseAnnotationSuggestion?.let { - handleSingleType(it, "preposition") - val (_, text) = handleTextForCaseAnnotation(it[0], language, applicationContext) - if (text != "") appliedSomething = true - } - } - return appliedSomething - } - - /** - * Configures a single suggestion button with the appropriate text and color based on the suggestion type. - * - * @param singleTypeSuggestion The list containing the single suggestion to display. - * @param type The type of suggestion, either "noun" or "preposition". - */ - private fun handleSingleType( - singleTypeSuggestion: List?, - type: String? = null, - ) { - val suggestionText = singleTypeSuggestion?.getOrNull(0).toString() - val (colorRes, buttonText) = - when (type) { - "noun" -> handleColorAndTextForNounType(suggestionText, language, applicationContext) - "preposition" -> handleTextForCaseAnnotation(suggestionText, language, applicationContext) - else -> Pair(R.color.transparent, "") - } - - uiManager.genderSuggestionLeft?.visibility = View.INVISIBLE - uiManager.genderSuggestionRight?.visibility = View.INVISIBLE - - themeManager.applySingleSuggestionStyle( - context = applicationContext, - button = uiManager.binding.translateBtn, - colorRes = colorRes, - buttonText = buttonText, - textSizeSp = NOUN_TYPE_SIZE, - ) - } - - /** - * Applies a specific style to a suggestion button, including text, color, and a custom background. - * - * @param button The Button to style. - * @param colorRes The color resource ID for the background. - * @param text The text to display on the button. - * @param backgroundRes The drawable resource ID for the button's background. - */ - private fun applyInformativeSuggestionStyle( - button: Button, - colorRes: Int, - text: String, - backgroundRes: Int, - ) { - themeManager.applyInformativeSuggestionStyle( - context = applicationContext, - button = button, - colorRes = colorRes, - text = text, - backgroundRes = backgroundRes, - ) - } - - /** - * Handles the UI logic for displaying multiple suggestions simultaneously, - * typically for words with multiple genders. - * - * @param multipleTypeSuggestion The list of suggestions to display. - * @param type The type of suggestion, either "noun" or "preposition". - */ - private fun handleMultipleNounFormats( - multipleTypeSuggestion: List?, - type: String? = null, - ) { - val suggestionPairs = getSuggestionPairs(type, multipleTypeSuggestion) ?: return - val (leftSuggestion, rightSuggestion) = suggestionPairs - val suggestionText = "" - if (leftSuggestion.second == suggestionText || rightSuggestion.second == suggestionText) { - handleFallbackOrSingleSuggestion(multipleTypeSuggestion) - return - } - - uiManager.genderSuggestionLeft?.visibility = View.VISIBLE - uiManager.genderSuggestionRight?.visibility = View.VISIBLE - uiManager.binding.translateBtn.visibility = View.INVISIBLE - - uiManager.genderSuggestionLeft?.let { - applyInformativeSuggestionStyle( - it, - leftSuggestion.first, - leftSuggestion.second, - be.scri.R.drawable.gender_suggestion_button_left_background, - ) - } - - uiManager.genderSuggestionRight?.let { - applyInformativeSuggestionStyle( - it, - rightSuggestion.first, - rightSuggestion.second, - be.scri.R.drawable.gender_suggestion_button_right_background, - ) - } - } - - /** - * Creates pairs of (color, text) for dual suggestion buttons. - * - * @param type The suggestion type ("noun" or "preposition"). - * @param suggestions The list of suggestion strings. - * - * @return A pair of pairs, each containing a color resource ID and a text string, or null on failure. - */ - private fun getSuggestionPairs( - type: String?, - suggestions: List?, - ): Pair, Pair>? { - val (leftType, rightType) = - if (type == "noun" && isSingularAndPlural) { - "PL" to (suggestions?.getOrNull(0) ?: "") - } else { - (suggestions?.getOrNull(0) ?: "") to (suggestions?.getOrNull(1) ?: "") - } - - return when (type) { - "noun" -> - handleColorAndTextForNounType(leftType, language, applicationContext) to - handleColorAndTextForNounType(rightType, language, applicationContext) - - "preposition" -> - handleTextForCaseAnnotation(leftType, language, applicationContext) to - handleTextForCaseAnnotation(rightType, language, applicationContext) - - else -> null - } - } - - /** - * Handles the logic when a word has multiple possible genders or - * cases but only one suggestion slot is available. - * - * It picks the first valid suggestion to display. - * @param multipleTypeSuggestion The list of noun suggestions. - */ - private fun handleFallbackOrSingleSuggestion(multipleTypeSuggestion: List?) { - val suggestionText = "" - val validNouns = multipleTypeSuggestion?.filter { handleColorAndTextForNounType(it, language, applicationContext).second != suggestionText } - val validCases = caseAnnotationSuggestion?.filter { handleTextForCaseAnnotation(it, language, applicationContext).second != suggestionText } - if (!validNouns.isNullOrEmpty()) { - handleSingleType(validNouns, "noun") - } else if (!validCases.isNullOrEmpty()) { - handleSingleType(validCases, "preposition") - } else { - uiManager.disableAutoSuggest(language) - } - } - - /** - * Displays word prediction suggestions on the command buttons. - * - * @param wordSuggestions The list of predicted words to display. - * @param hasLinguisticSuggestions Whether linguistic suggestions are also present. - */ - private fun handleWordSuggestions( - wordSuggestions: List?, - hasLinguisticSuggestions: Boolean, - ) { - if (wordSuggestions.isNullOrEmpty()) { - if (hasLinguisticSuggestions) { - val baseSuggestions = - be.scri.helpers.ui.HintUtils - .getBaseAutoSuggestions(language) - val default1 = baseSuggestions.getOrNull(0) ?: "" - val default2 = baseSuggestions.getOrNull(1) ?: "" - setSuggestionButton(uiManager.binding.conjugateBtn, default1) - uiManager.pluralBtn?.let { setSuggestionButton(it, default2) } - } - return - } - - val suggestions = listOfNotNull(wordSuggestions.getOrNull(0), wordSuggestions.getOrNull(1), wordSuggestions.getOrNull(2)) - val suggestion1 = suggestions.getOrNull(0) ?: "" - val suggestion2 = suggestions.getOrNull(1) ?: "" - val suggestion3 = suggestions.getOrNull(2) ?: "" - - val emojiCount = autoSuggestEmojis?.size ?: 0 - setSuggestionButton(uiManager.binding.conjugateBtn, suggestion1) - - when { - hasLinguisticSuggestions && emojiCount != 0 -> { - uiManager.updateButtonVisibility(currentState, true, autoSuggestEmojis) - } - - hasLinguisticSuggestions && emojiCount == 0 -> { - setSuggestionButton(uiManager.pluralBtn!!, suggestion2) - } - !hasLinguisticSuggestions && emojiCount != 0 -> { - setSuggestionButton(uiManager.binding.translateBtn, suggestion2) - uiManager.updateButtonVisibility(currentState, true, autoSuggestEmojis) - } - else -> { - setSuggestionButton(uiManager.binding.translateBtn, suggestion2) - setSuggestionButton(uiManager.pluralBtn!!, suggestion3) - } - } - } - - private fun setSuggestionButton( - button: Button, - text: String, - ) { - button.text = text - button.isAllCaps = false - button.visibility = View.VISIBLE - button.textSize = SUGGESTION_SIZE - button.setOnClickListener(null) - button.background = null - button.foreground = null - button.setTextColor(themeManager.getSuggestionTextColor(applicationContext)) - button.setOnClickListener { - currentInputConnection?.commitText("$text ", 1) - moveToIdleState() - } - } - - // MARK: Autocomplete + ) = suggestionUIHandler.updateAutoSuggestText(nounTypeSuggestion, isPlural, caseAnnotationSuggestion, wordSuggestions) /** * Updates autocomplete UI with a new list of suggestions. - * Clears it if not idle or no completions. - */ - fun updateAutocompleteSuggestions(completions: List?) { - if (currentState != ScribeState.IDLE) { - uiManager.disableAutoSuggest(language) - return - } - if (completions.isNullOrEmpty()) { - uiManager.disableAutoSuggest(language) - return - } - - val completion1 = completions.getOrNull(0) ?: "" - val completion2 = completions.getOrNull(1) ?: "" - val completion3 = completions.getOrNull(2) ?: "" - - setAutocompleteButton(uiManager.binding.conjugateBtn, completion1) - setAutocompleteButton(uiManager.binding.translateBtn, completion2) - setAutocompleteButton(uiManager.pluralBtn!!, completion3) - - uiManager.binding.separator1.visibility = View.VISIBLE - uiManager.binding.separator2.visibility = View.VISIBLE - } - - /** - * Sets up an autocomplete button with the given suggestion text. - * When clicked, it replaces the current word with the suggestion. + * Delegated to [SuggestionUIHandler]. + * + * @param completions The list of autocomplete completions to display. */ - private fun setAutocompleteButton( - button: Button, - text: String, - ) { - setSuggestionButton(button, text) - if (text.isBlank()) { - button.setOnClickListener(null) - return - } - button.setOnClickListener { - val ic = currentInputConnection ?: return@setOnClickListener - val beforeText = ic.getTextBeforeCursor(50, 0) ?: "" - val wordStartIndex = beforeText.lastIndexOfAny(charArrayOf(' ', '\n', '\t', '.', ',', '?', '!')) + 1 - val currentWord = beforeText.substring(wordStartIndex) - ic.deleteSurroundingText(currentWord.length, 0) - ic.commitText(text, 1) - moveToIdleState() - } - } + fun updateAutocompleteSuggestions(completions: List?) = suggestionUIHandler.updateAutocompleteSuggestions(completions) /** * Clears autocomplete suggestions by resetting the suggestion strip * to the default command buttons via the UI Manager. + * Delegated to [SuggestionUIHandler]. */ - fun clearAutocomplete() { - if (this::uiManager.isInitialized) { - uiManager.disableAutoSuggest(language) - } - } + fun clearAutocomplete() = suggestionUIHandler.clearAutocomplete() /** * Returns whether the current conjugation state requires a subsequent selection view. @@ -2049,12 +1646,3 @@ abstract class GeneralKeyboardIME( clipboardHandler.closeClipboardPanel() } } - -private fun Float.coerceInSafe( - bound1: Float, - bound2: Float, -): Float { - val minVal = if (bound1 < bound2) bound1 else bound2 - val maxVal = if (bound1 > bound2) bound1 else bound2 - return this.coerceIn(minVal, maxVal) -} diff --git a/app/src/testKeyboards/kotlin/be/scri/helpers/ui/SuggestionUIHandlerTest.kt b/app/src/testKeyboards/kotlin/be/scri/helpers/ui/SuggestionUIHandlerTest.kt new file mode 100644 index 00000000..1cb1dab8 --- /dev/null +++ b/app/src/testKeyboards/kotlin/be/scri/helpers/ui/SuggestionUIHandlerTest.kt @@ -0,0 +1,103 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package be.scri.helpers.ui + +import android.content.Context +import android.view.LayoutInflater +import androidx.test.core.app.ApplicationProvider +import be.scri.databinding.InputMethodViewBinding +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 +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class SuggestionUIHandlerTest { + private lateinit var context: Context + private lateinit var binding: InputMethodViewBinding + private val ime = mockk(relaxed = true) + private val uiManager = mockk(relaxed = true) + private val themeManager = mockk(relaxed = true) + private lateinit var handler: SuggestionUIHandler + + @Before + fun setUp() { + context = ApplicationProvider.getApplicationContext() + binding = InputMethodViewBinding.inflate(LayoutInflater.from(context)) + + every { uiManager.binding } returns binding + every { ime.uiManager } returns uiManager + every { ime.themeManager } returns themeManager + every { ime.applicationContext } returns context + every { ime.language } returns "English" + every { ime.isUiManagerInitialized } returns true + handler = SuggestionUIHandler(ime) + } + + @After + fun tearDown() { + unmockkAll() + } + + @Test + fun updateAutoSuggestText_whenNotIdle_disablesAutoSuggest() { + every { ime.currentState } returns ScribeState.PLURAL + + handler.updateAutoSuggestText(nounTypeSuggestion = listOf("masculine")) + + verify { uiManager.disableAutoSuggest("English") } + } + + @Test + fun updateAutoSuggestText_setsStatePropertiesOnIME() { + every { ime.currentState } returns ScribeState.IDLE + + handler.updateAutoSuggestText( + nounTypeSuggestion = listOf("masculine"), + isPlural = true, + caseAnnotationSuggestion = mutableListOf("accusative"), + wordSuggestions = listOf("word1", "word2"), + ) + + verify { ime.nounTypeSuggestion = listOf("masculine") } + verify { ime.checkIfPluralWord = true } + verify { ime.caseAnnotationSuggestion = mutableListOf("accusative") } + verify { ime.wordSuggestions = listOf("word1", "word2") } + } + + @Test + fun updateAutocompleteSuggestions_whenNullOrEmpty_disablesAutoSuggest() { + every { ime.currentState } returns ScribeState.IDLE + + handler.updateAutocompleteSuggestions(null) + verify { uiManager.disableAutoSuggest("English") } + + handler.updateAutocompleteSuggestions(emptyList()) + verify(exactly = 2) { uiManager.disableAutoSuggest("English") } + } + + @Test + fun updateAutocompleteSuggestions_whenNotIdle_disablesAutoSuggest() { + every { ime.currentState } returns ScribeState.PLURAL + + handler.updateAutocompleteSuggestions(listOf("completion1")) + + verify { uiManager.disableAutoSuggest("English") } + } + + @Test + fun clearAutocomplete_whenUiManagerInitialized_disablesAutoSuggest() { + handler.clearAutocomplete() + + verify { uiManager.disableAutoSuggest("English") } + } +}